Respect selectedIndex for controlled <Tab/> components (#3037)

* ensure controlled `<Tab>` components respect the `selectedIndex`

* update changelog

* use older syntax in tests

* run prettier to fix lint step
This commit is contained in:
Robin Malfait
2024-03-15 14:37:16 +01:00
committed by GitHub
parent 8c1c42bc5a
commit 834dbf423e
6 changed files with 116 additions and 5 deletions
+1
View File
@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Allow setting custom `tabIndex` on the `<Switch />` component ([#2966](https://github.com/tailwindlabs/headlessui/pull/2966))
- Forward `disabled` state to hidden inputs in form-like components ([#3004](https://github.com/tailwindlabs/headlessui/pull/3004))
- Prefer incoming `data-*` attributes, over the ones set by Headless UI ([#3035](https://github.com/tailwindlabs/headlessui/pull/3035))
- Respect `selectedIndex` for controlled `<Tab/>` components ([#3037](https://github.com/tailwindlabs/headlessui/pull/3037))
### Changed
@@ -167,6 +167,53 @@ describe('Rendering', () => {
})
)
it(
'should use the `selectedIndex` when injecting new tabs dynamically',
suppressConsoleLogs(async () => {
function Example() {
let [tabs, setTabs] = useState<string[]>(['A', 'B', 'C'])
return (
<>
<Tab.Group selectedIndex={1}>
<Tab.List>
{tabs.map((t) => (
<Tab key={t}>Tab {t}</Tab>
))}
</Tab.List>
<Tab.Panels>
{tabs.map((t) => (
<Tab.Panel key={t}>Panel {t}</Tab.Panel>
))}
</Tab.Panels>
</Tab.Group>
<button
onClick={() => {
setTabs((old) => {
let copy = old.slice()
copy.splice(1, 0, 'D')
return copy
})
}}
>
Insert
</button>
</>
)
}
render(<Example />)
assertTabs({ active: 1, tabContents: 'Tab B', panelContents: 'Panel B' })
// Add some new tabs
await click(getByText('Insert'))
// We should still be at the same tab position, but the tab itself changed
assertTabs({ active: 1, tabContents: 'Tab D', panelContents: 'Panel D' })
})
)
it(
'should guarantee the order of DOM nodes when reversing the tabs and panels themselves, then performing actions (controlled component)',
suppressConsoleLogs(async () => {
@@ -53,6 +53,7 @@ enum Ordering {
}
interface StateDefinition {
info: MutableRefObject<{ isControlled: boolean }>
selectedIndex: number
tabs: MutableRefObject<HTMLElement | null>[]
@@ -145,8 +146,18 @@ let reducers: {
let activeTab = state.tabs[state.selectedIndex]
let adjustedTabs = sortByDomNode([...state.tabs, action.tab], (tab) => tab.current)
let selectedIndex = adjustedTabs.indexOf(activeTab) ?? state.selectedIndex
if (selectedIndex === -1) selectedIndex = state.selectedIndex
let selectedIndex = state.selectedIndex
// When the component is uncontrolled, then we want to maintain the actively
// selected tab even if new tabs are inserted or removed before the active
// tab.
//
// When the component is controlled, then we don't want to do this and
// instead we want to select the tab based on the `selectedIndex` prop.
if (!state.info.current.isControlled) {
selectedIndex = adjustedTabs.indexOf(activeTab)
if (selectedIndex === -1) selectedIndex = state.selectedIndex
}
return { ...state, tabs: adjustedTabs, selectedIndex }
},
@@ -245,8 +256,11 @@ function GroupFn<TTag extends ElementType = typeof DEFAULT_TABS_TAG>(
let isControlled = selectedIndex !== null
let info = useLatestValue({ isControlled })
let tabsRef = useSyncRefs(ref)
let [state, dispatch] = useReducer(stateReducer, {
info,
selectedIndex: selectedIndex ?? defaultIndex,
tabs: [],
panels: [],
+1
View File
@@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add `hidden` attribute to internal `<Hidden />` component when the `Features.Hidden` feature is used ([#2955](https://github.com/tailwindlabs/headlessui/pull/2955))
- Allow setting custom `tabIndex` on the `<Switch />` component ([#2966](https://github.com/tailwindlabs/headlessui/pull/2966))
- Forward `disabled` state to hidden inputs in form-like components ([#3004](https://github.com/tailwindlabs/headlessui/pull/3004))
- Respect `selectedIndex` for controlled `<Tab/>` components ([#3037](https://github.com/tailwindlabs/headlessui/pull/3037))
## [1.7.19] - 2024-02-07
@@ -171,6 +171,45 @@ describe('Rendering', () => {
})
)
it(
'should use the `selectedIndex` when injecting new tabs dynamically',
suppressConsoleLogs(async () => {
renderTemplate({
template: html`
<TabGroup :selectedIndex="1">
<TabList>
<Tab v-for="t in tabs" :key="t">Tab {{ t }}</Tab>
</TabList>
<TabPanels>
<TabPanel v-for="t in tabs" :key="t">Panel {{ t }}</TabPanel>
</TabPanels>
</TabGroup>
<button @click="add">Insert</button>
`,
setup() {
let tabs = ref<string[]>(['A', 'B', 'C'])
return {
tabs,
add() {
tabs.value.splice(1, 0, 'D')
},
}
},
})
await new Promise<void>(nextTick)
assertTabs({ active: 1, tabContents: 'Tab B', panelContents: 'Panel B' })
// Add some new tabs
await click(getByText('Insert'))
// We should still be at the same tab position, but the tab itself changed
assertTabs({ active: 1, tabContents: 'Tab D', panelContents: 'Panel D' })
})
)
it(
'should guarantee the order of DOM nodes when reversing the tabs and panels themselves, then performing actions (controlled component)',
suppressConsoleLogs(async () => {
@@ -177,9 +177,18 @@ export let TabGroup = defineComponent({
tabs.value.push(tab)
tabs.value = sortByDomNode(tabs.value, dom)
let localSelectedIndex = tabs.value.indexOf(activeTab) ?? selectedIndex.value
if (localSelectedIndex !== -1) {
selectedIndex.value = localSelectedIndex
// When the component is uncontrolled, then we want to maintain the
// actively selected tab even if new tabs are inserted or removed before
// the active tab.
//
// When the component is controlled, then we don't want to do this and
// instead we want to select the tab based on the `selectedIndex` prop.
if (!isControlled.value) {
let localSelectedIndex = tabs.value.indexOf(activeTab) ?? selectedIndex.value
if (localSelectedIndex !== -1) {
selectedIndex.value = localSelectedIndex
}
}
},
unregisterTab(tab: (typeof tabs)['value'][number]) {