Repository: tailwindlabs/headlessui
Pull request #3037 (2024-03-15 10:56:55)

Respect `selectedIndex` for controlled `<Tab/>` components
This PR fixes an issue where the `selectedIndex` doesn't always match the visibly selected tab, when using a controlled `<Tab />` component.

The `<Tab />` component can be used in 2 ways:

- Uncontrolled: where the `selectedIndex` is managed internally by the component itself.
- Controlled: where the `selectedIndex` is managed by the parent component (or in other words, **you**).

When the `<Tab />` component is uncontrolled, and thus the state is managed internally, then we try to make sure that we maintain the active tab. For example, if you have the following tabs:

```
+---------+---------+---------+
| Tab foo | Tab bar | Tab baz |
|         | ------- |         |
+---------+---------+---------+
```

Right now `Tab bar` is the active one, if you now add or remove a tab _before_ `Tab bar` then we still want `Tab bar` to be the active one.

When `Tab foo` is deleted:
```
+---------+---------+
| Tab bar | Tab baz |
| ------- |         |
+---------+---------+
```

When `Tab qux` is added:
```
+---------+----------+---------+---------+
| Tab foo |  Tab qux | Tab bar | Tab baz |
|         |          | ------- |         |
+---------+----------+---------+---------+
```

This behaviour makes sense, because otherwise you are constantly changing the active tab, which is typically not what you want.

The problem is that we also did this when the component was controlled, but then the `selectedIndex` value is not in sync with what's actually visible. This means that the behaviour looks like this:

```
+---------+---------+---------+
| Tab foo | Tab bar | Tab baz |
|         | ------- |         |
+---------+---------+---------+

- Selected index: 1
- Visually selected: 1
```

When `Tab foo` is deleted:
```
+---------+---------+
| Tab bar | Tab baz |
| ------- |         |
+---------+---------+

- Selected index: 1
- Visually selected: 0
```

When `Tab qux` is added:
```
+---------+----------+---------+---------+
| Tab foo |  Tab qux | Tab bar | Tab baz |
|         |          | ------- |         |
+---------+----------+---------+---------+

- Selected index: 1
- Visually selected: 2
```

Instead, we will now always use the `selectedIndex` value to determine the active tab, and not try to maintain the active tab when the component is controlled.

```
+---------+---------+---------+
| Tab foo | Tab bar | Tab baz |
|         | ------- |         |
+---------+---------+---------+

- Selected index: 1
- Visually selected: 1
```


When `Tab foo` is deleted:
```
+---------+---------+
| Tab bar | Tab baz |
|         | ------- |
+---------+---------+

- Selected index: 1
- Visually selected: 1
```

When `Tab qux` is added:
```
+---------+----------+---------+---------+
| Tab foo |  Tab qux | Tab bar | Tab baz |
|         |  ------- |         |         |
+---------+----------+---------+---------+

- Selected index: 1
- Visually selected: 1
```

Since it is a controlled component, it is up to you to keep the `selectedIndex` in sync with what you want if you add or remove tabs.

Fixes: #2989



vercel[bot]: [vc]: #Nv8wIcR+wMIhUugseLVHFXXQY5EgfV2DeNu505ss1oA=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJoZWFkbGVzc3VpLXJlYWN0Iiwicm9vdERpcmVjdG9yeSI6InBsYXlncm91bmRzL3JlYWN0IiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3RhaWx3aW5kbGFicy9oZWFkbGVzc3VpLXJlYWN0L0Z4czV0dGNjNmpNZnU4azV1c2o1NWJTQ3poU3giLCJwcmV2aWV3VXJsIjoiaGVhZGxlc3N1aS1yZWFjdC1naXQtZml4LWlzc3VlLTI5ODktdGFpbHdpbmRsYWJzLnZlcmNlbC5hcHAiLCJuZXh0Q29tbWl0U3RhdHVzIjoiUEVORElORyIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJoZWFkbGVzc3VpLXJlYWN0LWdpdC1maXgtaXNzdWUtMjk4OS10YWlsd2luZGxhYnMudmVyY2VsLmFwcCJ9fV19
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **headlessui-react** | 🔄 Building ([Inspect](https://vercel.com/tailwindlabs/headlessui-react/Fxs5ttcc6jMfu8k5usj55bSCzhSx)) | [Visit Preview](https://vercel.live/open-feedback/headlessui-react-git-fix-issue-2989-tailwindlabs.vercel.app?via=pr-comment-visit-preview-link&passThrough=1) | 💬 [**Add feedback**](https://vercel.live/open-feedback/headlessui-react-git-fix-issue-2989-tailwindlabs.vercel.app?via=pr-comment-feedback-link) | Mar 15, 2024 10:56am |



Patch:
diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md
index 8ce1ce1..5541baa 100644
--- a/packages/@headlessui-react/CHANGELOG.md
+++ b/packages/@headlessui-react/CHANGELOG.md
@@ -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
 
diff --git a/packages/@headlessui-react/src/components/tabs/tabs.tsx b/packages/@headlessui-react/src/components/tabs/tabs.tsx
index e4de17f..f259050 100644
--- a/packages/@headlessui-react/src/components/tabs/tabs.tsx
+++ b/packages/@headlessui-react/src/components/tabs/tabs.tsx
@@ -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: [],
diff --git a/packages/@headlessui-vue/CHANGELOG.md b/packages/@headlessui-vue/CHANGELOG.md
index 044bbd1..4ce4372 100644
--- a/packages/@headlessui-vue/CHANGELOG.md
+++ b/packages/@headlessui-vue/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 `<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
 
diff --git a/packages/@headlessui-vue/src/components/tabs/tabs.ts b/packages/@headlessui-vue/src/components/tabs/tabs.ts
index 591c92c..4745662 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]) {


Test patch:
diff --git a/packages/@headlessui-react/src/components/tabs/tabs.test.tsx b/packages/@headlessui-react/src/components/tabs/tabs.test.tsx
index 9cb78f2..659140e 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<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 () => {
diff --git a/packages/@headlessui-vue/src/components/tabs/tabs.test.ts b/packages/@headlessui-vue/src/components/tabs/tabs.test.ts
index 9db9115..3400993 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`
+          <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 () => {