Skip to content

Commit 6784a73

Browse files
committed
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
1 parent 2fd9d1c commit 6784a73

File tree

6 files changed

+116
-5
lines changed

6 files changed

+116
-5
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2020
- Add `hidden` attribute to internal `<Hidden />` component when the `Features.Hidden` feature is used ([#2955](https://github.com/tailwindlabs/headlessui/pull/2955))
2121
- Allow setting custom `tabIndex` on the `<Switch />` component ([#2966](https://github.com/tailwindlabs/headlessui/pull/2966))
2222
- Forward `disabled` state to hidden inputs in form-like components ([#3004](https://github.com/tailwindlabs/headlessui/pull/3004))
23+
- Respect `selectedIndex` for controlled `<Tab/>` components ([#3037](https://github.com/tailwindlabs/headlessui/pull/3037))
2324

2425
## [1.7.18] - 2024-01-08
2526

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,53 @@ describe('Rendering', () => {
167167
})
168168
)
169169

170+
it(
171+
'should use the `selectedIndex` when injecting new tabs dynamically',
172+
suppressConsoleLogs(async () => {
173+
function Example() {
174+
let [tabs, setTabs] = useState<string[]>(['A', 'B', 'C'])
175+
176+
return (
177+
<>
178+
<Tab.Group selectedIndex={1}>
179+
<Tab.List>
180+
{tabs.map((t) => (
181+
<Tab key={t}>Tab {t}</Tab>
182+
))}
183+
</Tab.List>
184+
<Tab.Panels>
185+
{tabs.map((t) => (
186+
<Tab.Panel key={t}>Panel {t}</Tab.Panel>
187+
))}
188+
</Tab.Panels>
189+
</Tab.Group>
190+
<button
191+
onClick={() => {
192+
setTabs((old) => {
193+
let copy = old.slice()
194+
copy.splice(1, 0, 'D')
195+
return copy
196+
})
197+
}}
198+
>
199+
Insert
200+
</button>
201+
</>
202+
)
203+
}
204+
205+
render(<Example />)
206+
207+
assertTabs({ active: 1, tabContents: 'Tab B', panelContents: 'Panel B' })
208+
209+
// Add some new tabs
210+
await click(getByText('Insert'))
211+
212+
// We should still be at the same tab position, but the tab itself changed
213+
assertTabs({ active: 1, tabContents: 'Tab D', panelContents: 'Panel D' })
214+
})
215+
)
216+
170217
it(
171218
'should guarantee the order of DOM nodes when reversing the tabs and panels themselves, then performing actions (controlled component)',
172219
suppressConsoleLogs(async () => {

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ enum Ordering {
4747
}
4848

4949
interface StateDefinition {
50+
info: MutableRefObject<{ isControlled: boolean }>
5051
selectedIndex: number
5152

5253
tabs: MutableRefObject<HTMLElement | null>[]
@@ -139,8 +140,18 @@ let reducers: {
139140
let activeTab = state.tabs[state.selectedIndex]
140141

141142
let adjustedTabs = sortByDomNode([...state.tabs, action.tab], (tab) => tab.current)
142-
let selectedIndex = adjustedTabs.indexOf(activeTab) ?? state.selectedIndex
143-
if (selectedIndex === -1) selectedIndex = state.selectedIndex
143+
let selectedIndex = state.selectedIndex
144+
145+
// When the component is uncontrolled, then we want to maintain the actively
146+
// selected tab even if new tabs are inserted or removed before the active
147+
// tab.
148+
//
149+
// When the component is controlled, then we don't want to do this and
150+
// instead we want to select the tab based on the `selectedIndex` prop.
151+
if (!state.info.current.isControlled) {
152+
selectedIndex = adjustedTabs.indexOf(activeTab)
153+
if (selectedIndex === -1) selectedIndex = state.selectedIndex
154+
}
144155

145156
return { ...state, tabs: adjustedTabs, selectedIndex }
146157
},
@@ -238,8 +249,11 @@ function GroupFn<TTag extends ElementType = typeof DEFAULT_TABS_TAG>(
238249

239250
let isControlled = selectedIndex !== null
240251

252+
let info = useLatestValue({ isControlled })
253+
241254
let tabsRef = useSyncRefs(ref)
242255
let [state, dispatch] = useReducer(stateReducer, {
256+
info,
243257
selectedIndex: selectedIndex ?? defaultIndex,
244258
tabs: [],
245259
panels: [],

packages/@headlessui-vue/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
- Add `hidden` attribute to internal `<Hidden />` component when the `Features.Hidden` feature is used ([#2955](https://github.com/tailwindlabs/headlessui/pull/2955))
1616
- Allow setting custom `tabIndex` on the `<Switch />` component ([#2966](https://github.com/tailwindlabs/headlessui/pull/2966))
1717
- Forward `disabled` state to hidden inputs in form-like components ([#3004](https://github.com/tailwindlabs/headlessui/pull/3004))
18+
- Respect `selectedIndex` for controlled `<Tab/>` components ([#3037](https://github.com/tailwindlabs/headlessui/pull/3037))
1819

1920
## [1.7.19] - 2024-02-07
2021

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,45 @@ describe('Rendering', () => {
171171
})
172172
)
173173

174+
it(
175+
'should use the `selectedIndex` when injecting new tabs dynamically',
176+
suppressConsoleLogs(async () => {
177+
renderTemplate({
178+
template: html`
179+
<TabGroup :selectedIndex="1">
180+
<TabList>
181+
<Tab v-for="t in tabs" :key="t">Tab {{ t }}</Tab>
182+
</TabList>
183+
<TabPanels>
184+
<TabPanel v-for="t in tabs" :key="t">Panel {{ t }}</TabPanel>
185+
</TabPanels>
186+
</TabGroup>
187+
<button @click="add">Insert</button>
188+
`,
189+
setup() {
190+
let tabs = ref<string[]>(['A', 'B', 'C'])
191+
192+
return {
193+
tabs,
194+
add() {
195+
tabs.value.splice(1, 0, 'D')
196+
},
197+
}
198+
},
199+
})
200+
201+
await new Promise<void>(nextTick)
202+
203+
assertTabs({ active: 1, tabContents: 'Tab B', panelContents: 'Panel B' })
204+
205+
// Add some new tabs
206+
await click(getByText('Insert'))
207+
208+
// We should still be at the same tab position, but the tab itself changed
209+
assertTabs({ active: 1, tabContents: 'Tab D', panelContents: 'Panel D' })
210+
})
211+
)
212+
174213
it(
175214
'should guarantee the order of DOM nodes when reversing the tabs and panels themselves, then performing actions (controlled component)',
176215
suppressConsoleLogs(async () => {

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

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -177,9 +177,18 @@ export let TabGroup = defineComponent({
177177
tabs.value.push(tab)
178178
tabs.value = sortByDomNode(tabs.value, dom)
179179

180-
let localSelectedIndex = tabs.value.indexOf(activeTab) ?? selectedIndex.value
181-
if (localSelectedIndex !== -1) {
182-
selectedIndex.value = localSelectedIndex
180+
// When the component is uncontrolled, then we want to maintain the
181+
// actively selected tab even if new tabs are inserted or removed before
182+
// the active tab.
183+
//
184+
// When the component is controlled, then we don't want to do this and
185+
// instead we want to select the tab based on the `selectedIndex` prop.
186+
if (!isControlled.value) {
187+
let localSelectedIndex = tabs.value.indexOf(activeTab) ?? selectedIndex.value
188+
189+
if (localSelectedIndex !== -1) {
190+
selectedIndex.value = localSelectedIndex
191+
}
183192
}
184193
},
185194
unregisterTab(tab: typeof tabs['value'][number]) {

0 commit comments

Comments
 (0)