Skip to content

Commit

Permalink
Respect selectedIndex for controlled <Tab/> components (#3037)
Browse files Browse the repository at this point in the history
* ensure controlled `<Tab>` components respect the `selectedIndex`

* update changelog

* use older syntax in tests

* run prettier to fix lint step
  • Loading branch information
RobinMalfait committed Mar 15, 2024
1 parent 8c1c42b commit 834dbf4
Show file tree
Hide file tree
Showing 6 changed files with 116 additions and 5 deletions.
1 change: 1 addition & 0 deletions packages/@headlessui-react/CHANGELOG.md
Expand Up @@ -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

Expand Down
47 changes: 47 additions & 0 deletions packages/@headlessui-react/src/components/tabs/tabs.test.tsx
Expand Up @@ -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 () => {
Expand Down
18 changes: 16 additions & 2 deletions packages/@headlessui-react/src/components/tabs/tabs.tsx
Expand Up @@ -53,6 +53,7 @@ enum Ordering {
}

interface StateDefinition {
info: MutableRefObject<{ isControlled: boolean }>
selectedIndex: number

tabs: MutableRefObject<HTMLElement | null>[]
Expand Down Expand Up @@ -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 }
},
Expand Down Expand Up @@ -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: [],
Expand Down
1 change: 1 addition & 0 deletions packages/@headlessui-vue/CHANGELOG.md
Expand Up @@ -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

Expand Down
39 changes: 39 additions & 0 deletions packages/@headlessui-vue/src/components/tabs/tabs.test.ts
Expand Up @@ -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 () => {
Expand Down
15 changes: 12 additions & 3 deletions packages/@headlessui-vue/src/components/tabs/tabs.ts
Expand Up @@ -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]) {
Expand Down

0 comments on commit 834dbf4

Please sign in to comment.