From 6139eb53dd92c4546e3a23ac2cc86768c04b61b4 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 15 Jul 2022 12:50:42 +0200 Subject: [PATCH] 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. --- .../src/components/tabs/tabs.test.tsx | 58 ++++++++++++++++++ .../src/components/tabs/tabs.tsx | 24 ++++++-- .../src/components/tabs/tabs.test.ts | 61 +++++++++++++++++++ .../src/components/tabs/tabs.ts | 24 +++++++- 4 files changed, 159 insertions(+), 8 deletions(-) diff --git a/packages/@headlessui-react/src/components/tabs/tabs.test.tsx b/packages/@headlessui-react/src/components/tabs/tabs.test.tsx index cb3a3e3b8b..f2d635f44f 100644 --- a/packages/@headlessui-react/src/components/tabs/tabs.test.tsx +++ b/packages/@headlessui-react/src/components/tabs/tabs.test.tsx @@ -543,6 +543,64 @@ describe('Rendering', () => { }) describe('`selectedIndex`', () => { + it( + 'should not change the tab in a controlled component if you do not respond to the onChange', + suppressConsoleLogs(async () => { + let handleChange = jest.fn() + + function ControlledTabs() { + let [selectedIndex, setSelectedIndex] = useState(0) + + return ( + <> + { + handleChange(value) + }} + > + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + + ) + } + + render() + + assertActiveElement(document.body) + + // test controlled behaviour + await click(getByText('setSelectedIndex')) + assertTabs({ active: 1 }) + await click(getByText('setSelectedIndex')) + assertTabs({ active: 2 }) + + // test uncontrolled behaviour again + await click(getByText('Tab 1')) + assertTabs({ active: 2 }) // Should still be Tab 3 because `selectedIndex` didn't update + await click(getByText('Tab 2')) + assertTabs({ active: 2 }) // Should still be Tab 3 because `selectedIndex` didn't update + await click(getByText('Tab 3')) + assertTabs({ active: 2 }) // Should still be Tab 3 because `selectedIndex` didn't update + await click(getByText('Tab 1')) + 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 + assertTabs({ active: 2 }) // Should still be Tab 3 because `selectedIndex` didn't update + }) + ) + it( 'should be possible to change active tab controlled and uncontrolled', suppressConsoleLogs(async () => { diff --git a/packages/@headlessui-react/src/components/tabs/tabs.tsx b/packages/@headlessui-react/src/components/tabs/tabs.tsx index 01ce338cab..730c023996 100644 --- a/packages/@headlessui-react/src/components/tabs/tabs.tsx +++ b/packages/@headlessui-react/src/components/tabs/tabs.tsx @@ -26,6 +26,7 @@ import { useResolveButtonType } from '../../hooks/use-resolve-button-type' import { useLatestValue } from '../../hooks/use-latest-value' import { FocusSentinel } from '../../internal/focus-sentinel' import { useEvent } from '../../hooks/use-event' +import { microTask } from '../../utils/micro-task' interface StateDefinition { selectedIndex: number @@ -196,6 +197,8 @@ let Tabs = forwardRefWithAs(function Tabs ({ registerTab(tab) { @@ -226,13 +229,16 @@ let Tabs = forwardRefWithAs(function Tabs { @@ -388,9 +394,17 @@ let TabRoot = forwardRefWithAs(function Tab { + if (ready.current) return + ready.current = true + internalTabRef.current?.focus() actions.change(myIndex) + + microTask(() => { + ready.current = false + }) }) // This is important because we want to only focus the tab when it gets focus diff --git a/packages/@headlessui-vue/src/components/tabs/tabs.test.ts b/packages/@headlessui-vue/src/components/tabs/tabs.test.ts index b9cef77772..29a6cb9374 100644 --- a/packages/@headlessui-vue/src/components/tabs/tabs.test.ts +++ b/packages/@headlessui-vue/src/components/tabs/tabs.test.ts @@ -515,6 +515,67 @@ describe('Rendering', () => { }) describe('`selectedIndex`', () => { + it( + 'should not change the tab in a controlled component if you do not respond to the @change', + suppressConsoleLogs(async () => { + let handleChange = jest.fn() + + renderTemplate({ + template: html` + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + `, + setup() { + let selectedIndex = ref(0) + + return { + selectedIndex, + handleChange(value: number) { + handleChange(value) + }, + next() { + selectedIndex.value += 1 + }, + } + }, + }) + + await new Promise(nextTick) + + assertActiveElement(document.body) + + // test controlled behaviour + await click(getByText('setSelectedIndex')) + assertTabs({ active: 1 }) + await click(getByText('setSelectedIndex')) + assertTabs({ active: 2 }) + + // test uncontrolled behaviour again + await click(getByText('Tab 1')) + assertTabs({ active: 2 }) // Should still be Tab 3 because `selectedIndex` didn't update + await click(getByText('Tab 2')) + assertTabs({ active: 2 }) // Should still be Tab 3 because `selectedIndex` didn't update + await click(getByText('Tab 3')) + assertTabs({ active: 2 }) // Should still be Tab 3 because `selectedIndex` didn't update + await click(getByText('Tab 1')) + 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 + assertTabs({ active: 2 }) // Should still be Tab 3 because `selectedIndex` didn't update + }) + ) + it('should be possible to change active tab controlled and uncontrolled', async () => { let handleChange = jest.fn() diff --git a/packages/@headlessui-vue/src/components/tabs/tabs.ts b/packages/@headlessui-vue/src/components/tabs/tabs.ts index ed9e5474eb..a9fb146dc9 100644 --- a/packages/@headlessui-vue/src/components/tabs/tabs.ts +++ b/packages/@headlessui-vue/src/components/tabs/tabs.ts @@ -23,6 +23,7 @@ import { match } from '../../utils/match' import { focusIn, Focus } from '../../utils/focus-management' import { useResolveButtonType } from '../../hooks/use-resolve-button-type' import { FocusSentinel } from '../../internal/focus-sentinel' +import { microTask } from '../../utils/micro-task' type StateDefinition = { // State @@ -75,6 +76,11 @@ export let TabGroup = defineComponent({ let tabs = ref([]) let panels = ref([]) + let isControlled = computed(() => props.selectedIndex !== null) + let realSelectedIndex = computed(() => + isControlled.value ? props.selectedIndex : selectedIndex.value + ) + let api = { selectedIndex, orientation: computed(() => (props.vertical ? 'vertical' : 'horizontal')), @@ -82,9 +88,13 @@ export let TabGroup = defineComponent({ tabs, panels, setSelectedIndex(index: number) { - if (selectedIndex.value === index) return - selectedIndex.value = index - emit('change', index) + if (realSelectedIndex.value !== index) { + emit('change', index) + } + + if (!isControlled.value) { + selectedIndex.value = index + } }, registerTab(tab: typeof tabs['value'][number]) { if (!tabs.value.includes(tab)) tabs.value.push(tab) @@ -272,11 +282,19 @@ export let Tab = defineComponent({ dom(internalTabRef)?.focus() } + let ready = ref(false) function handleSelection() { + if (ready.value) return + ready.value = true + if (props.disabled) return dom(internalTabRef)?.focus() api.setSelectedIndex(myIndex.value) + + microTask(() => { + ready.value = false + }) } // This is important because we want to only focus the tab when it gets focus