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]) {