From 6784a734a91bf15dffea25d28b42b0590b42188a Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 15 Mar 2024 14:37:16 +0100 Subject: [PATCH] Respect `selectedIndex` for controlled `` components (#3037) * ensure controlled `` components respect the `selectedIndex` * update changelog * use older syntax in tests * run prettier to fix lint step --- packages/@headlessui-react/CHANGELOG.md | 1 + .../src/components/tabs/tabs.test.tsx | 47 +++++++++++++++++++ .../src/components/tabs/tabs.tsx | 18 ++++++- packages/@headlessui-vue/CHANGELOG.md | 1 + .../src/components/tabs/tabs.test.ts | 39 +++++++++++++++ .../src/components/tabs/tabs.ts | 15 ++++-- 6 files changed, 116 insertions(+), 5 deletions(-) diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index a1bab7ab8..6ec8cacdf 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `hidden` attribute to internal `` component when the `Features.Hidden` feature is used ([#2955](https://github.com/tailwindlabs/headlessui/pull/2955)) - Allow setting custom `tabIndex` on the `` 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 `` components ([#3037](https://github.com/tailwindlabs/headlessui/pull/3037)) ## [1.7.18] - 2024-01-08 diff --git a/packages/@headlessui-react/src/components/tabs/tabs.test.tsx b/packages/@headlessui-react/src/components/tabs/tabs.test.tsx index 0af031b7f..e7724d36b 100644 --- a/packages/@headlessui-react/src/components/tabs/tabs.test.tsx +++ b/packages/@headlessui-react/src/components/tabs/tabs.test.tsx @@ -167,6 +167,53 @@ describe('Rendering', () => { }) ) + it( + 'should use the `selectedIndex` when injecting new tabs dynamically', + suppressConsoleLogs(async () => { + function Example() { + let [tabs, setTabs] = useState(['A', 'B', 'C']) + + return ( + <> + + + {tabs.map((t) => ( + Tab {t} + ))} + + + {tabs.map((t) => ( + Panel {t} + ))} + + + + + ) + } + + render() + + 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 () => { diff --git a/packages/@headlessui-react/src/components/tabs/tabs.tsx b/packages/@headlessui-react/src/components/tabs/tabs.tsx index 116f28d92..8e578c2b5 100644 --- a/packages/@headlessui-react/src/components/tabs/tabs.tsx +++ b/packages/@headlessui-react/src/components/tabs/tabs.tsx @@ -47,6 +47,7 @@ enum Ordering { } interface StateDefinition { + info: MutableRefObject<{ isControlled: boolean }> selectedIndex: number tabs: MutableRefObject[] @@ -139,8 +140,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 } }, @@ -238,8 +249,11 @@ function GroupFn( let isControlled = selectedIndex !== null + let info = useLatestValue({ isControlled }) + let tabsRef = useSyncRefs(ref) let [state, dispatch] = useReducer(stateReducer, { + info, selectedIndex: selectedIndex ?? defaultIndex, tabs: [], panels: [], diff --git a/packages/@headlessui-vue/CHANGELOG.md b/packages/@headlessui-vue/CHANGELOG.md index 06db4c9cb..4a3cc4ea2 100644 --- a/packages/@headlessui-vue/CHANGELOG.md +++ b/packages/@headlessui-vue/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `hidden` attribute to internal `` component when the `Features.Hidden` feature is used ([#2955](https://github.com/tailwindlabs/headlessui/pull/2955)) - Allow setting custom `tabIndex` on the `` 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 `` components ([#3037](https://github.com/tailwindlabs/headlessui/pull/3037)) ## [1.7.19] - 2024-02-07 diff --git a/packages/@headlessui-vue/src/components/tabs/tabs.test.ts b/packages/@headlessui-vue/src/components/tabs/tabs.test.ts index 798645cff..96e942d3a 100644 --- a/packages/@headlessui-vue/src/components/tabs/tabs.test.ts +++ b/packages/@headlessui-vue/src/components/tabs/tabs.test.ts @@ -171,6 +171,45 @@ describe('Rendering', () => { }) ) + it( + 'should use the `selectedIndex` when injecting new tabs dynamically', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Tab {{ t }} + + + Panel {{ t }} + + + + `, + setup() { + let tabs = ref(['A', 'B', 'C']) + + return { + tabs, + add() { + tabs.value.splice(1, 0, 'D') + }, + } + }, + }) + + await new Promise(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 () => { diff --git a/packages/@headlessui-vue/src/components/tabs/tabs.ts b/packages/@headlessui-vue/src/components/tabs/tabs.ts index 12b95d2c5..8295edab6 100644 --- a/packages/@headlessui-vue/src/components/tabs/tabs.ts +++ b/packages/@headlessui-vue/src/components/tabs/tabs.ts @@ -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]) {