Skip to content

Commit 6d13e79

Browse files
authored
Ensure controlled Tabs don't change automagically (#1680)
* fix controlled tabs should not switch tabs When the `Tabs` component is used ina a controlled way, then clicking on a tab should call the `onChange` callback, but it should not change the actual tab internally. * update changelog
1 parent cf78d97 commit 6d13e79

File tree

6 files changed

+161
-8
lines changed

6 files changed

+161
-8
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
- Fix outside clicks to close dialog when nested, unopened dialogs are present ([#1667](https://github.com/tailwindlabs/headlessui/pull/1667))
2121
- Close `Menu` component when using `tab` key ([#1673](https://github.com/tailwindlabs/headlessui/pull/1673))
2222
- Resync input when display value changes ([#1679](https://github.com/tailwindlabs/headlessui/pull/1679))
23+
- Ensure controlled `Tabs` don't change automagically ([#1680](https://github.com/tailwindlabs/headlessui/pull/1680))
2324

2425
## [1.6.6] - 2022-07-07
2526

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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,64 @@ describe('Rendering', () => {
543543
})
544544

545545
describe('`selectedIndex`', () => {
546+
it(
547+
'should not change the tab in a controlled component if you do not respond to the onChange',
548+
suppressConsoleLogs(async () => {
549+
let handleChange = jest.fn()
550+
551+
function ControlledTabs() {
552+
let [selectedIndex, setSelectedIndex] = useState(0)
553+
554+
return (
555+
<>
556+
<Tab.Group
557+
selectedIndex={selectedIndex}
558+
onChange={(value) => {
559+
handleChange(value)
560+
}}
561+
>
562+
<Tab.List>
563+
<Tab>Tab 1</Tab>
564+
<Tab>Tab 2</Tab>
565+
<Tab>Tab 3</Tab>
566+
</Tab.List>
567+
568+
<Tab.Panels>
569+
<Tab.Panel>Content 1</Tab.Panel>
570+
<Tab.Panel>Content 2</Tab.Panel>
571+
<Tab.Panel>Content 3</Tab.Panel>
572+
</Tab.Panels>
573+
</Tab.Group>
574+
575+
<button>after</button>
576+
<button onClick={() => setSelectedIndex((prev) => prev + 1)}>setSelectedIndex</button>
577+
</>
578+
)
579+
}
580+
581+
render(<ControlledTabs />)
582+
583+
assertActiveElement(document.body)
584+
585+
// test controlled behaviour
586+
await click(getByText('setSelectedIndex'))
587+
assertTabs({ active: 1 })
588+
await click(getByText('setSelectedIndex'))
589+
assertTabs({ active: 2 })
590+
591+
// test uncontrolled behaviour again
592+
await click(getByText('Tab 1'))
593+
assertTabs({ active: 2 }) // Should still be Tab 3 because `selectedIndex` didn't update
594+
await click(getByText('Tab 2'))
595+
assertTabs({ active: 2 }) // Should still be Tab 3 because `selectedIndex` didn't update
596+
await click(getByText('Tab 3'))
597+
assertTabs({ active: 2 }) // Should still be Tab 3 because `selectedIndex` didn't update
598+
await click(getByText('Tab 1'))
599+
expect(handleChange).toHaveBeenCalledTimes(3) // We did see the 'onChange' calls, but only 3 because clicking Tab 3 is already the active one which means that this doesn't trigger the onChange
600+
assertTabs({ active: 2 }) // Should still be Tab 3 because `selectedIndex` didn't update
601+
})
602+
)
603+
546604
it(
547605
'should be possible to change active tab controlled and uncontrolled',
548606
suppressConsoleLogs(async () => {

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

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
2626
import { useLatestValue } from '../../hooks/use-latest-value'
2727
import { FocusSentinel } from '../../internal/focus-sentinel'
2828
import { useEvent } from '../../hooks/use-event'
29+
import { microTask } from '../../utils/micro-task'
2930

3031
interface StateDefinition {
3132
selectedIndex: number
@@ -196,6 +197,8 @@ let Tabs = forwardRefWithAs(function Tabs<TTag extends ElementType = typeof DEFA
196197
const orientation = vertical ? 'vertical' : 'horizontal'
197198
const activation = manual ? 'manual' : 'auto'
198199

200+
let isControlled = selectedIndex !== null
201+
199202
let tabsRef = useSyncRefs(ref)
200203
let [state, dispatch] = useReducer(stateReducer, {
201204
selectedIndex: selectedIndex ?? defaultIndex,
@@ -211,7 +214,7 @@ let Tabs = forwardRefWithAs(function Tabs<TTag extends ElementType = typeof DEFA
211214
[orientation, activation, state]
212215
)
213216

214-
let lastChangedIndex = useLatestValue(state.selectedIndex)
217+
let realSelectedIndex = useLatestValue(isControlled ? props.selectedIndex : state.selectedIndex)
215218
let tabsActions: _Actions = useMemo(
216219
() => ({
217220
registerTab(tab) {
@@ -226,13 +229,16 @@ let Tabs = forwardRefWithAs(function Tabs<TTag extends ElementType = typeof DEFA
226229
dispatch({ type: ActionTypes.ForceRerender })
227230
},
228231
change(index: number) {
229-
if (lastChangedIndex.current !== index) onChangeRef.current(index)
230-
lastChangedIndex.current = index
232+
if (realSelectedIndex.current !== index) {
233+
onChangeRef.current(index)
234+
}
231235

232-
dispatch({ type: ActionTypes.SetSelectedIndex, index })
236+
if (!isControlled) {
237+
dispatch({ type: ActionTypes.SetSelectedIndex, index })
238+
}
233239
},
234240
}),
235-
[dispatch]
241+
[dispatch, isControlled]
236242
)
237243

238244
useIsoMorphicEffect(() => {
@@ -388,9 +394,17 @@ let TabRoot = forwardRefWithAs(function Tab<TTag extends ElementType = typeof DE
388394
internalTabRef.current?.focus()
389395
})
390396

397+
let ready = useRef(false)
391398
let handleSelection = useEvent(() => {
399+
if (ready.current) return
400+
ready.current = true
401+
392402
internalTabRef.current?.focus()
393403
actions.change(myIndex)
404+
405+
microTask(() => {
406+
ready.current = false
407+
})
394408
})
395409

396410
// This is important because we want to only focus the tab when it gets focus

packages/@headlessui-vue/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
- Fix outside clicks to close dialog when nested, unopened dialogs are present ([#1667](https://github.com/tailwindlabs/headlessui/pull/1667))
2121
- Close `Menu` component when using `tab` key ([#1673](https://github.com/tailwindlabs/headlessui/pull/1673))
2222
- Resync input when display value changes ([#1679](https://github.com/tailwindlabs/headlessui/pull/1679))
23+
- Ensure controlled `Tabs` don't change automagically ([#1680](https://github.com/tailwindlabs/headlessui/pull/1680))
2324

2425
## [1.6.7] - 2022-07-12
2526

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,67 @@ describe('Rendering', () => {
515515
})
516516

517517
describe('`selectedIndex`', () => {
518+
it(
519+
'should not change the tab in a controlled component if you do not respond to the @change',
520+
suppressConsoleLogs(async () => {
521+
let handleChange = jest.fn()
522+
523+
renderTemplate({
524+
template: html`
525+
<TabGroup @change="handleChange" :selectedIndex="selectedIndex">
526+
<TabList>
527+
<Tab>Tab 1</Tab>
528+
<Tab>Tab 2</Tab>
529+
<Tab>Tab 3</Tab>
530+
</TabList>
531+
532+
<TabPanels>
533+
<TabPanel>Content 1</TabPanel>
534+
<TabPanel>Content 2</TabPanel>
535+
<TabPanel>Content 3</TabPanel>
536+
</TabPanels>
537+
</TabGroup>
538+
<button>after</button>
539+
<button @click="next">setSelectedIndex</button>
540+
`,
541+
setup() {
542+
let selectedIndex = ref(0)
543+
544+
return {
545+
selectedIndex,
546+
handleChange(value: number) {
547+
handleChange(value)
548+
},
549+
next() {
550+
selectedIndex.value += 1
551+
},
552+
}
553+
},
554+
})
555+
556+
await new Promise<void>(nextTick)
557+
558+
assertActiveElement(document.body)
559+
560+
// test controlled behaviour
561+
await click(getByText('setSelectedIndex'))
562+
assertTabs({ active: 1 })
563+
await click(getByText('setSelectedIndex'))
564+
assertTabs({ active: 2 })
565+
566+
// test uncontrolled behaviour again
567+
await click(getByText('Tab 1'))
568+
assertTabs({ active: 2 }) // Should still be Tab 3 because `selectedIndex` didn't update
569+
await click(getByText('Tab 2'))
570+
assertTabs({ active: 2 }) // Should still be Tab 3 because `selectedIndex` didn't update
571+
await click(getByText('Tab 3'))
572+
assertTabs({ active: 2 }) // Should still be Tab 3 because `selectedIndex` didn't update
573+
await click(getByText('Tab 1'))
574+
expect(handleChange).toHaveBeenCalledTimes(3) // We did see the '@change' calls, but only 3 because clicking Tab 3 is already the active one which means that this doesn't trigger the @change
575+
assertTabs({ active: 2 }) // Should still be Tab 3 because `selectedIndex` didn't update
576+
})
577+
)
578+
518579
it('should be possible to change active tab controlled and uncontrolled', async () => {
519580
let handleChange = jest.fn()
520581

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

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { match } from '../../utils/match'
2323
import { focusIn, Focus } from '../../utils/focus-management'
2424
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
2525
import { FocusSentinel } from '../../internal/focus-sentinel'
26+
import { microTask } from '../../utils/micro-task'
2627

2728
type StateDefinition = {
2829
// State
@@ -75,16 +76,25 @@ export let TabGroup = defineComponent({
7576
let tabs = ref<StateDefinition['tabs']['value']>([])
7677
let panels = ref<StateDefinition['panels']['value']>([])
7778

79+
let isControlled = computed(() => props.selectedIndex !== null)
80+
let realSelectedIndex = computed(() =>
81+
isControlled.value ? props.selectedIndex : selectedIndex.value
82+
)
83+
7884
let api = {
7985
selectedIndex,
8086
orientation: computed(() => (props.vertical ? 'vertical' : 'horizontal')),
8187
activation: computed(() => (props.manual ? 'manual' : 'auto')),
8288
tabs,
8389
panels,
8490
setSelectedIndex(index: number) {
85-
if (selectedIndex.value === index) return
86-
selectedIndex.value = index
87-
emit('change', index)
91+
if (realSelectedIndex.value !== index) {
92+
emit('change', index)
93+
}
94+
95+
if (!isControlled.value) {
96+
selectedIndex.value = index
97+
}
8898
},
8999
registerTab(tab: typeof tabs['value'][number]) {
90100
if (!tabs.value.includes(tab)) tabs.value.push(tab)
@@ -272,11 +282,19 @@ export let Tab = defineComponent({
272282
dom(internalTabRef)?.focus()
273283
}
274284

285+
let ready = ref(false)
275286
function handleSelection() {
287+
if (ready.value) return
288+
ready.value = true
289+
276290
if (props.disabled) return
277291

278292
dom(internalTabRef)?.focus()
279293
api.setSelectedIndex(myIndex.value)
294+
295+
microTask(() => {
296+
ready.value = false
297+
})
280298
}
281299

282300
// This is important because we want to only focus the tab when it gets focus

0 commit comments

Comments
 (0)