Skip to content

Commit e2294f5

Browse files
authored
Improve Tabs wrapping around when controlling the component and overflowing the selectedIndex (#2213)
* ensure chaning the `selectedIndex` tabs properly wraps around We never want to use and index that doesn't map to a proper tab. This commit also makes the implementation similar for both React and Vue. * add tests to prove the underflow and overflow wrapping * drop updating the index manually This is already adjusted when tabs change internally. You can still manually change it of course, but for these tests that doesn't matter and cause different results. * update changelog
1 parent dbcfb23 commit e2294f5

File tree

6 files changed

+312
-47
lines changed

6 files changed

+312
-47
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1919
- Improve `Combobox` accessibility ([#2153](https://github.com/tailwindlabs/headlessui/pull/2153))
2020
- Fix crash when reading `headlessuiFocusGuard` of `relatedTarget` in the `FocusTrap` component ([#2203](https://github.com/tailwindlabs/headlessui/pull/2203))
2121
- Fix `FocusTrap` in `Dialog` when there is only 1 focusable element ([#2172](https://github.com/tailwindlabs/headlessui/pull/2172))
22+
- Improve `Tabs` wrapping around when controlling the component and overflowing the `selectedIndex` ([#2213](https://github.com/tailwindlabs/headlessui/pull/2213))
2223

2324
## [1.7.7] - 2022-12-16
2425

packages/@headlessui-react/src/components/tabs/tabs.test.tsx

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,6 @@ describe('Rendering', () => {
179179
<button
180180
onClick={() => {
181181
setTabs((tabs) => tabs.slice().reverse())
182-
setSelectedIndex((idx) => tabs.length - 1 - idx)
183182
}}
184183
>
185184
reverse
@@ -1083,6 +1082,94 @@ describe('Rendering', () => {
10831082
assertActiveElement(getByText('Tab 1'))
10841083
})
10851084
)
1085+
1086+
it(
1087+
'should wrap around when overflowing the index when using a controlled component',
1088+
suppressConsoleLogs(async () => {
1089+
function Example() {
1090+
let [selectedIndex, setSelectedIndex] = useState(0)
1091+
1092+
return (
1093+
<Tab.Group selectedIndex={selectedIndex} onChange={setSelectedIndex}>
1094+
{({ selectedIndex }) => (
1095+
<>
1096+
<Tab.List>
1097+
<Tab>Tab 1</Tab>
1098+
<Tab>Tab 2</Tab>
1099+
<Tab>Tab 3</Tab>
1100+
</Tab.List>
1101+
<Tab.Panels>
1102+
<Tab.Panel>Content 1</Tab.Panel>
1103+
<Tab.Panel>Content 2</Tab.Panel>
1104+
<Tab.Panel>Content 3</Tab.Panel>
1105+
</Tab.Panels>
1106+
<button onClick={() => setSelectedIndex(selectedIndex + 1)}>Next</button>
1107+
</>
1108+
)}
1109+
</Tab.Group>
1110+
)
1111+
}
1112+
render(<Example />)
1113+
1114+
assertActiveElement(document.body)
1115+
1116+
await click(getByText('Next'))
1117+
assertTabs({ active: 1 })
1118+
1119+
await click(getByText('Next'))
1120+
assertTabs({ active: 2 })
1121+
1122+
await click(getByText('Next'))
1123+
assertTabs({ active: 0 })
1124+
1125+
await click(getByText('Next'))
1126+
assertTabs({ active: 1 })
1127+
})
1128+
)
1129+
1130+
it(
1131+
'should wrap around when underflowing the index when using a controlled component',
1132+
suppressConsoleLogs(async () => {
1133+
function Example() {
1134+
let [selectedIndex, setSelectedIndex] = useState(0)
1135+
1136+
return (
1137+
<Tab.Group selectedIndex={selectedIndex} onChange={setSelectedIndex}>
1138+
{({ selectedIndex }) => (
1139+
<>
1140+
<Tab.List>
1141+
<Tab>Tab 1</Tab>
1142+
<Tab>Tab 2</Tab>
1143+
<Tab>Tab 3</Tab>
1144+
</Tab.List>
1145+
<Tab.Panels>
1146+
<Tab.Panel>Content 1</Tab.Panel>
1147+
<Tab.Panel>Content 2</Tab.Panel>
1148+
<Tab.Panel>Content 3</Tab.Panel>
1149+
</Tab.Panels>
1150+
<button onClick={() => setSelectedIndex(selectedIndex - 1)}>Previous</button>
1151+
</>
1152+
)}
1153+
</Tab.Group>
1154+
)
1155+
}
1156+
render(<Example />)
1157+
1158+
assertActiveElement(document.body)
1159+
1160+
await click(getByText('Previous'))
1161+
assertTabs({ active: 2 })
1162+
1163+
await click(getByText('Previous'))
1164+
assertTabs({ active: 1 })
1165+
1166+
await click(getByText('Previous'))
1167+
assertTabs({ active: 0 })
1168+
1169+
await click(getByText('Previous'))
1170+
assertTabs({ active: 2 })
1171+
})
1172+
)
10861173
})
10871174

10881175
describe(`'Tab'`, () => {

packages/@headlessui-react/src/components/tabs/tabs.tsx

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,17 @@ import { microTask } from '../../utils/micro-task'
3030
import { Hidden } from '../../internal/hidden'
3131
import { getOwnerDocument } from '../../utils/owner'
3232

33+
enum Direction {
34+
Forwards,
35+
Backwards,
36+
}
37+
38+
enum Ordering {
39+
Less = -1,
40+
Equal = 0,
41+
Greater = 1,
42+
}
43+
3344
interface StateDefinition {
3445
selectedIndex: number
3546

@@ -68,16 +79,30 @@ let reducers: {
6879

6980
let nextState = { ...state, tabs, panels }
7081

71-
// Underflow
72-
if (action.index < 0) {
73-
return { ...nextState, selectedIndex: tabs.indexOf(focusableTabs[0]) }
74-
}
82+
if (
83+
// Underflow
84+
action.index < 0 ||
85+
// Overflow
86+
action.index > tabs.length - 1
87+
) {
88+
let direction = match(Math.sign(action.index - state.selectedIndex), {
89+
[Ordering.Less]: () => Direction.Backwards,
90+
[Ordering.Equal]: () => {
91+
return match(Math.sign(action.index), {
92+
[Ordering.Less]: () => Direction.Forwards,
93+
[Ordering.Equal]: () => Direction.Forwards,
94+
[Ordering.Greater]: () => Direction.Backwards,
95+
})
96+
},
97+
[Ordering.Greater]: () => Direction.Forwards,
98+
})
7599

76-
// Overflow
77-
else if (action.index > tabs.length) {
78100
return {
79101
...nextState,
80-
selectedIndex: tabs.indexOf(focusableTabs[focusableTabs.length - 1]),
102+
selectedIndex: match(direction, {
103+
[Direction.Forwards]: () => tabs.indexOf(focusableTabs[0]),
104+
[Direction.Backwards]: () => tabs.indexOf(focusableTabs[focusableTabs.length - 1]),
105+
}),
81106
}
82107
}
83108

packages/@headlessui-vue/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2020
- Improve `Combobox` accessibility ([#2153](https://github.com/tailwindlabs/headlessui/pull/2153))
2121
- Fix crash when reading `headlessuiFocusGuard` of `relatedTarget` in the `FocusTrap` component ([#2203](https://github.com/tailwindlabs/headlessui/pull/2203))
2222
- Fix `FocusTrap` in `Dialog` when there is only 1 focusable element ([#2172](https://github.com/tailwindlabs/headlessui/pull/2172))
23+
- Improve `Tabs` wrapping around when controlling the component and overflowing the `selectedIndex` ([#2213](https://github.com/tailwindlabs/headlessui/pull/2213))
2324

2425
## [1.7.7] - 2022-12-16
2526

packages/@headlessui-vue/src/components/tabs/tabs.test.ts

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,6 @@ describe('Rendering', () => {
199199
selectedIndex,
200200
reverse() {
201201
tabs.value = tabs.value.slice().reverse()
202-
selectedIndex.value = tabs.value.length - 1 - selectedIndex.value
203202
},
204203
handleChange(value: number) {
205204
selectedIndex.value = value
@@ -999,6 +998,106 @@ describe('`selectedIndex`', () => {
999998
assertTabs({ active: 0 })
1000999
assertActiveElement(getByText('Tab 1'))
10011000
})
1001+
1002+
it(
1003+
'should wrap around when overflowing the index when using a controlled component',
1004+
suppressConsoleLogs(async () => {
1005+
renderTemplate({
1006+
template: html`
1007+
<TabGroup :selectedIndex="value" @change="set" v-slot="{ selectedIndex }">
1008+
<TabList>
1009+
<Tab>Tab 1</Tab>
1010+
<Tab>Tab 2</Tab>
1011+
<Tab>Tab 3</Tab>
1012+
</TabList>
1013+
1014+
<TabPanels>
1015+
<TabPanel>Content 1</TabPanel>
1016+
<TabPanel>Content 2</TabPanel>
1017+
<TabPanel>Content 3</TabPanel>
1018+
</TabPanels>
1019+
1020+
<button @click="set(selectedIndex + 1)">Next</button>
1021+
</TabGroup>
1022+
`,
1023+
setup() {
1024+
let value = ref(0)
1025+
return {
1026+
value,
1027+
set(v: number) {
1028+
value.value = v
1029+
},
1030+
}
1031+
},
1032+
})
1033+
1034+
await new Promise<void>(nextTick)
1035+
1036+
assertActiveElement(document.body)
1037+
1038+
await click(getByText('Next'))
1039+
assertTabs({ active: 1 })
1040+
1041+
await click(getByText('Next'))
1042+
assertTabs({ active: 2 })
1043+
1044+
await click(getByText('Next'))
1045+
assertTabs({ active: 0 })
1046+
1047+
await click(getByText('Next'))
1048+
assertTabs({ active: 1 })
1049+
})
1050+
)
1051+
1052+
it(
1053+
'should wrap around when underflowing the index when using a controlled component',
1054+
suppressConsoleLogs(async () => {
1055+
renderTemplate({
1056+
template: html`
1057+
<TabGroup :selectedIndex="value" @change="set" v-slot="{ selectedIndex }">
1058+
<TabList>
1059+
<Tab>Tab 1</Tab>
1060+
<Tab>Tab 2</Tab>
1061+
<Tab>Tab 3</Tab>
1062+
</TabList>
1063+
1064+
<TabPanels>
1065+
<TabPanel>Content 1</TabPanel>
1066+
<TabPanel>Content 2</TabPanel>
1067+
<TabPanel>Content 3</TabPanel>
1068+
</TabPanels>
1069+
1070+
<button @click="set(selectedIndex - 1)">Previous</button>
1071+
</TabGroup>
1072+
`,
1073+
setup() {
1074+
let value = ref(0)
1075+
return {
1076+
value,
1077+
set(v: number) {
1078+
value.value = v
1079+
},
1080+
}
1081+
},
1082+
})
1083+
1084+
await new Promise<void>(nextTick)
1085+
1086+
assertActiveElement(document.body)
1087+
1088+
await click(getByText('Previous'))
1089+
assertTabs({ active: 2 })
1090+
1091+
await click(getByText('Previous'))
1092+
assertTabs({ active: 1 })
1093+
1094+
await click(getByText('Previous'))
1095+
assertTabs({ active: 0 })
1096+
1097+
await click(getByText('Previous'))
1098+
assertTabs({ active: 2 })
1099+
})
1100+
)
10021101
})
10031102

10041103
describe('Keyboard interactions', () => {

0 commit comments

Comments
 (0)