Skip to content

Commit b296b73

Browse files
authored
Ensure Tab order stays consistent, and the currently active Tab stays active (#1837)
* ensure tabs order stays consistent This ensures that whenever you insert or delete tabs before the current tab, that the current tab stays active with the proper panel. To do this we had to start rendering the non-visible panels as well, but we used the `Hidden` component already which is position fixed and completely hidden so this should not break layouts where using flexbox or grid. * update changelog * fix TypeScript issue
1 parent 10f932a commit b296b73

File tree

9 files changed

+134
-13
lines changed

9 files changed

+134
-13
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- Improve iOS scroll locking ([#1830](https://github.com/tailwindlabs/headlessui/pull/1830))
1313
- Add `<fieldset disabled>` check to radio group options in React ([#1835](https://github.com/tailwindlabs/headlessui/pull/1835))
14+
- Ensure `Tab` order stays consistent, and the currently active `Tab` stays active ([#1837](https://github.com/tailwindlabs/headlessui/pull/1837))
1415

1516
## [1.7.0] - 2022-09-06
1617

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1046,7 +1046,7 @@ describe('Rendering', () => {
10461046
handleSubmission(Object.fromEntries(new FormData(e.target as HTMLFormElement)))
10471047
}}
10481048
>
1049-
<Combobox name="assignee">
1049+
<Combobox<string> name="assignee">
10501050
{({ value }) => (
10511051
<>
10521052
<div data-testid="value">{value}</div>

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,50 @@ describe('Rendering', () => {
123123
})
124124
)
125125

126+
it(
127+
'should guarantee the order when injecting new tabs dynamically',
128+
suppressConsoleLogs(async () => {
129+
function Example() {
130+
let [tabs, setTabs] = useState<string[]>([])
131+
132+
return (
133+
<Tab.Group>
134+
<Tab.List>
135+
{tabs.map((t, i) => (
136+
<Tab key={t}>Tab {i + 1}</Tab>
137+
))}
138+
<Tab>Insert new</Tab>
139+
</Tab.List>
140+
<Tab.Panels>
141+
{tabs.map((t) => (
142+
<Tab.Panel key={t}>{t}</Tab.Panel>
143+
))}
144+
<Tab.Panel>
145+
<button
146+
onClick={() => {
147+
setTabs((old) => [...old, `Panel ${old.length + 1}`])
148+
}}
149+
>
150+
Insert
151+
</button>
152+
</Tab.Panel>
153+
</Tab.Panels>
154+
</Tab.Group>
155+
)
156+
}
157+
158+
render(<Example />)
159+
160+
assertTabs({ active: 0, tabContents: 'Insert new', panelContents: 'Insert' })
161+
162+
// Add some new tabs
163+
await click(getByText('Insert'))
164+
165+
// We should still be on the tab we were on
166+
assertTabs({ active: 1, tabContents: 'Insert new', panelContents: 'Insert' })
167+
})
168+
)
169+
126170
describe('`renderProps`', () => {
127171
it(
128172
'should expose the `selectedIndex` on the `Tab.Group` component',

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

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { useLatestValue } from '../../hooks/use-latest-value'
2727
import { FocusSentinel } from '../../internal/focus-sentinel'
2828
import { useEvent } from '../../hooks/use-event'
2929
import { microTask } from '../../utils/micro-task'
30+
import { Hidden } from '../../internal/hidden'
3031

3132
interface StateDefinition {
3233
selectedIndex: number
@@ -88,20 +89,23 @@ let reducers: {
8889
},
8990
[ActionTypes.RegisterTab](state, action) {
9091
if (state.tabs.includes(action.tab)) return state
91-
return { ...state, tabs: sortByDomNode([...state.tabs, action.tab], (tab) => tab.current) }
92+
let activeTab = state.tabs[state.selectedIndex]
93+
94+
let adjustedTabs = sortByDomNode([...state.tabs, action.tab], (tab) => tab.current)
95+
let selectedIndex = adjustedTabs.indexOf(activeTab) ?? state.selectedIndex
96+
if (selectedIndex === -1) selectedIndex = state.selectedIndex
97+
98+
return { ...state, tabs: adjustedTabs, selectedIndex }
9299
},
93100
[ActionTypes.UnregisterTab](state, action) {
94-
return {
95-
...state,
96-
tabs: sortByDomNode(
97-
state.tabs.filter((tab) => tab !== action.tab),
98-
(tab) => tab.current
99-
),
100-
}
101+
return { ...state, tabs: state.tabs.filter((tab) => tab !== action.tab) }
101102
},
102103
[ActionTypes.RegisterPanel](state, action) {
103104
if (state.panels.includes(action.panel)) return state
104-
return { ...state, panels: [...state.panels, action.panel] }
105+
return {
106+
...state,
107+
panels: sortByDomNode([...state.panels, action.panel], (panel) => panel.current),
108+
}
105109
},
106110
[ActionTypes.UnregisterPanel](state, action) {
107111
return { ...state, panels: state.panels.filter((panel) => panel !== action.panel) }
@@ -487,7 +491,7 @@ let Panel = forwardRefWithAs(function Panel<TTag extends ElementType = typeof DE
487491
let SSRContext = useSSRTabsCounter('Tab.Panel')
488492

489493
let id = `headlessui-tabs-panel-${useId()}`
490-
let internalPanelRef = useRef<HTMLElement>(null)
494+
let internalPanelRef = useRef<HTMLElement | null>(null)
491495
let panelRef = useSyncRefs(internalPanelRef, ref, (element) => {
492496
if (!element) return
493497
requestAnimationFrame(() => actions.forceRerender())
@@ -514,6 +518,10 @@ let Panel = forwardRefWithAs(function Panel<TTag extends ElementType = typeof DE
514518
tabIndex: selected ? 0 : -1,
515519
}
516520

521+
if (!selected && (props.unmount ?? true)) {
522+
return <Hidden as="span" {...ourProps} />
523+
}
524+
517525
return render({
518526
ourProps,
519527
theirProps,

packages/@headlessui-react/src/test-utils/accessibility-assertions.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1664,9 +1664,13 @@ export function assertTabs(
16641664
{
16651665
active,
16661666
orientation = 'horizontal',
1667+
tabContents = null,
1668+
panelContents = null,
16671669
}: {
16681670
active: number
16691671
orientation?: 'vertical' | 'horizontal'
1672+
tabContents?: string | null
1673+
panelContents?: string | null
16701674
},
16711675
list = getTabList(),
16721676
tabs = getTabs(),
@@ -1689,6 +1693,9 @@ export function assertTabs(
16891693
if (tab === activeTab) {
16901694
expect(tab).toHaveAttribute('aria-selected', 'true')
16911695
expect(tab).toHaveAttribute('tabindex', '0')
1696+
if (tabContents !== null) {
1697+
expect(tab.textContent).toBe(tabContents)
1698+
}
16921699
} else {
16931700
expect(tab).toHaveAttribute('aria-selected', 'false')
16941701
expect(tab).toHaveAttribute('tabindex', '-1')
@@ -1716,6 +1723,9 @@ export function assertTabs(
17161723

17171724
if (panel === activePanel) {
17181725
expect(panel).toHaveAttribute('tabindex', '0')
1726+
if (tabContents !== null) {
1727+
expect(panel.textContent).toBe(panelContents)
1728+
}
17191729
} else {
17201730
expect(panel).toHaveAttribute('tabindex', '-1')
17211731
}

packages/@headlessui-vue/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Fixed
1111

1212
- Improve iOS scroll locking ([#1830](https://github.com/tailwindlabs/headlessui/pull/1830))
13+
- Ensure `Tab` order stays consistent, and the currently active `Tab` stays active ([#1837](https://github.com/tailwindlabs/headlessui/pull/1837))
1314

1415
## [1.7.0] - 2022-09-06
1516

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,48 @@ describe('Rendering', () => {
132132
assertTabs({ active: 2 })
133133
})
134134

135+
it(
136+
'should guarantee the order when injecting new tabs dynamically',
137+
suppressConsoleLogs(async () => {
138+
renderTemplate({
139+
template: html`
140+
<TabGroup>
141+
<TabList>
142+
<Tab v-for="(t, i) in tabs" :key="t">Tab {{ i + 1 }}</Tab>
143+
<Tab>Insert new</Tab>
144+
</TabList>
145+
<TabPanels>
146+
<TabPanel v-for="t in tabs" :key="t">{{ t }}</TabPanel>
147+
<TabPanel>
148+
<button @click="add">Insert</button>
149+
</TabPanel>
150+
</TabPanels>
151+
</TabGroup>
152+
`,
153+
setup() {
154+
let tabs = ref<string[]>([])
155+
156+
return {
157+
tabs,
158+
add() {
159+
tabs.value.push(`Panel ${tabs.value.length + 1}`)
160+
},
161+
}
162+
},
163+
})
164+
165+
await new Promise<void>(nextTick)
166+
167+
assertTabs({ active: 0, tabContents: 'Insert new', panelContents: 'Insert' })
168+
169+
// Add some new tabs
170+
await click(getByText('Insert'))
171+
172+
// We should still be on the tab we were on
173+
assertTabs({ active: 1, tabContents: 'Insert new', panelContents: 'Insert' })
174+
})
175+
)
176+
135177
describe('`renderProps`', () => {
136178
it('should expose the `selectedIndex` on the `Tabs` component', async () => {
137179
renderTemplate(

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { focusIn, Focus } from '../../utils/focus-management'
2424
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
2525
import { FocusSentinel } from '../../internal/focus-sentinel'
2626
import { microTask } from '../../utils/micro-task'
27+
import { Hidden } from '../../internal/hidden'
2728

2829
type StateDefinition = {
2930
// State
@@ -320,7 +321,7 @@ export let Tab = defineComponent({
320321
id,
321322
role: 'tab',
322323
type: type.value,
323-
'aria-controls': api.panels.value[myIndex.value]?.value?.id,
324+
'aria-controls': dom(api.panels.value[myIndex.value])?.id,
324325
'aria-selected': selected.value,
325326
tabIndex: selected.value ? 0 : -1,
326327
disabled: props.disabled ? true : undefined,
@@ -390,10 +391,14 @@ export let TabPanel = defineComponent({
390391
ref: internalPanelRef,
391392
id,
392393
role: 'tabpanel',
393-
'aria-labelledby': api.tabs.value[myIndex.value]?.value?.id,
394+
'aria-labelledby': dom(api.tabs.value[myIndex.value])?.id,
394395
tabIndex: selected.value ? 0 : -1,
395396
}
396397

398+
if (!selected.value && props.unmount) {
399+
return h(Hidden, { as: 'span', ...ourProps })
400+
}
401+
397402
return render({
398403
ourProps,
399404
theirProps: props,

packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1664,9 +1664,13 @@ export function assertTabs(
16641664
{
16651665
active,
16661666
orientation = 'horizontal',
1667+
tabContents = null,
1668+
panelContents = null,
16671669
}: {
16681670
active: number
16691671
orientation?: 'vertical' | 'horizontal'
1672+
tabContents?: string | null
1673+
panelContents?: string | null
16701674
},
16711675
list = getTabList(),
16721676
tabs = getTabs(),
@@ -1689,6 +1693,9 @@ export function assertTabs(
16891693
if (tab === activeTab) {
16901694
expect(tab).toHaveAttribute('aria-selected', 'true')
16911695
expect(tab).toHaveAttribute('tabindex', '0')
1696+
if (tabContents !== null) {
1697+
expect(tab.textContent).toBe(tabContents)
1698+
}
16921699
} else {
16931700
expect(tab).toHaveAttribute('aria-selected', 'false')
16941701
expect(tab).toHaveAttribute('tabindex', '-1')
@@ -1716,6 +1723,9 @@ export function assertTabs(
17161723

17171724
if (panel === activePanel) {
17181725
expect(panel).toHaveAttribute('tabindex', '0')
1726+
if (tabContents !== null) {
1727+
expect(panel.textContent).toBe(panelContents)
1728+
}
17191729
} else {
17201730
expect(panel).toHaveAttribute('tabindex', '-1')
17211731
}

0 commit comments

Comments
 (0)