Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Respect selectedIndex for controlled <Tab/> components #3037

Merged
merged 4 commits into from
Mar 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/@headlessui-react/CHANGELOG.md
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Loading