Skip to content

Commit

Permalink
fix controlled tabs should not switch tabs
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
RobinMalfait committed Jul 15, 2022
1 parent 0e1599b commit 6139eb5
Show file tree
Hide file tree
Showing 4 changed files with 159 additions and 8 deletions.
58 changes: 58 additions & 0 deletions packages/@headlessui-react/src/components/tabs/tabs.test.tsx
Expand Up @@ -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 (
<>
<Tab.Group
selectedIndex={selectedIndex}
onChange={(value) => {
handleChange(value)
}}
>
<Tab.List>
<Tab>Tab 1</Tab>
<Tab>Tab 2</Tab>
<Tab>Tab 3</Tab>
</Tab.List>

<Tab.Panels>
<Tab.Panel>Content 1</Tab.Panel>
<Tab.Panel>Content 2</Tab.Panel>
<Tab.Panel>Content 3</Tab.Panel>
</Tab.Panels>
</Tab.Group>

<button>after</button>
<button onClick={() => setSelectedIndex((prev) => prev + 1)}>setSelectedIndex</button>
</>
)
}

render(<ControlledTabs />)

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 () => {
Expand Down
24 changes: 19 additions & 5 deletions packages/@headlessui-react/src/components/tabs/tabs.tsx
Expand Up @@ -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
Expand Down Expand Up @@ -196,6 +197,8 @@ let Tabs = forwardRefWithAs(function Tabs<TTag extends ElementType = typeof DEFA
const orientation = vertical ? 'vertical' : 'horizontal'
const activation = manual ? 'manual' : 'auto'

let isControlled = selectedIndex !== null

let tabsRef = useSyncRefs(ref)
let [state, dispatch] = useReducer(stateReducer, {
selectedIndex: selectedIndex ?? defaultIndex,
Expand All @@ -211,7 +214,7 @@ let Tabs = forwardRefWithAs(function Tabs<TTag extends ElementType = typeof DEFA
[orientation, activation, state]
)

let lastChangedIndex = useLatestValue(state.selectedIndex)
let realSelectedIndex = useLatestValue(isControlled ? props.selectedIndex : state.selectedIndex)
let tabsActions: _Actions = useMemo(
() => ({
registerTab(tab) {
Expand All @@ -226,13 +229,16 @@ let Tabs = forwardRefWithAs(function Tabs<TTag extends ElementType = typeof DEFA
dispatch({ type: ActionTypes.ForceRerender })
},
change(index: number) {
if (lastChangedIndex.current !== index) onChangeRef.current(index)
lastChangedIndex.current = index
if (realSelectedIndex.current !== index) {
onChangeRef.current(index)
}

dispatch({ type: ActionTypes.SetSelectedIndex, index })
if (!isControlled) {
dispatch({ type: ActionTypes.SetSelectedIndex, index })
}
},
}),
[dispatch]
[dispatch, isControlled]
)

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

let ready = useRef(false)
let handleSelection = useEvent(() => {
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
Expand Down
61 changes: 61 additions & 0 deletions packages/@headlessui-vue/src/components/tabs/tabs.test.ts
Expand Up @@ -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`
<TabGroup @change="handleChange" :selectedIndex="selectedIndex">
<TabList>
<Tab>Tab 1</Tab>
<Tab>Tab 2</Tab>
<Tab>Tab 3</Tab>
</TabList>
<TabPanels>
<TabPanel>Content 1</TabPanel>
<TabPanel>Content 2</TabPanel>
<TabPanel>Content 3</TabPanel>
</TabPanels>
</TabGroup>
<button>after</button>
<button @click="next">setSelectedIndex</button>
`,
setup() {
let selectedIndex = ref(0)

return {
selectedIndex,
handleChange(value: number) {
handleChange(value)
},
next() {
selectedIndex.value += 1
},
}
},
})

await new Promise<void>(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()

Expand Down
24 changes: 21 additions & 3 deletions packages/@headlessui-vue/src/components/tabs/tabs.ts
Expand Up @@ -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
Expand Down Expand Up @@ -75,16 +76,25 @@ export let TabGroup = defineComponent({
let tabs = ref<StateDefinition['tabs']['value']>([])
let panels = ref<StateDefinition['panels']['value']>([])

let isControlled = computed(() => props.selectedIndex !== null)
let realSelectedIndex = computed(() =>
isControlled.value ? props.selectedIndex : selectedIndex.value
)

let api = {
selectedIndex,
orientation: computed(() => (props.vertical ? 'vertical' : 'horizontal')),
activation: computed(() => (props.manual ? 'manual' : 'auto')),
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)
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit 6139eb5

Please sign in to comment.