From 8335bee7688e5ea35b85111fe2879e2ad4f5d395 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 1 Dec 2021 15:20:14 +0100 Subject: [PATCH] Allow `Tabs` to be controllable (#970) * feat(react): Allow Tab Component to be controlled * fix falsy bug `selectedIndex || defaultIndex` would result in the `defaultIndex` if `selectedIndex` is set to 0. This means that if you have this code: ```js ``` That you will never be able to see the very first tab, unless you provided a negative value like `-1`. `selectedIndex ?? defaultIndex` fixes this, since it purely checkes for `undefined` and `null`. * implemented controllable Tabs for Vue * add dedicated test to ensure changing the defaultIndex has no effect * update changelog Co-authored-by: ChiefORZ --- CHANGELOG.md | 8 +- .../src/components/tabs/tabs.test.tsx | 245 ++++++++++++++++- .../src/components/tabs/tabs.tsx | 28 +- .../src/components/tabs/tabs.test.ts | 260 +++++++++++++++++- .../src/components/tabs/tabs.ts | 20 +- 5 files changed, 540 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 566cb5c70b..7a62564a61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Ensure portal root exists in the DOM ([#950](https://github.com/tailwindlabs/headlessui/pull/950)) +### Added + +- Allow for `Tab.Group` to be controllable ([#909](https://github.com/tailwindlabs/headlessui/pull/909), [#970](https://github.com/tailwindlabs/headlessui/pull/970)) + ## [Unreleased - Vue] -- Nothing yet! +### Added + +- Allow for `TabGroup` to be controllable ([#909](https://github.com/tailwindlabs/headlessui/pull/909), [#970](https://github.com/tailwindlabs/headlessui/pull/970)) ## [@headlessui/react@v1.4.2] - 2021-11-08 diff --git a/packages/@headlessui-react/src/components/tabs/tabs.test.tsx b/packages/@headlessui-react/src/components/tabs/tabs.test.tsx index 2125583c00..4f233892d0 100644 --- a/packages/@headlessui-react/src/components/tabs/tabs.test.tsx +++ b/packages/@headlessui-react/src/components/tabs/tabs.test.tsx @@ -1,4 +1,4 @@ -import React, { createElement } from 'react' +import React, { createElement, useState } from 'react' import { render } from '@testing-library/react' import { Tab } from './tabs' @@ -415,6 +415,249 @@ describe('Rendering', () => { assertTabs({ active: 0 }) assertActiveElement(getByText('Tab 1')) }) + + it('should not change the Tab if the defaultIndex changes', async () => { + function Example() { + let [defaultIndex, setDefaultIndex] = useState(1) + + return ( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + + ) + } + + render() + + assertActiveElement(document.body) + + await press(Keys.Tab) + + assertTabs({ active: 1 }) + assertActiveElement(getByText('Tab 2')) + + await click(getByText('Tab 3')) + + assertTabs({ active: 2 }) + assertActiveElement(getByText('Tab 3')) + + // Change default index + await click(getByText('change')) + + // Nothing should change... + assertTabs({ active: 2 }) + }) + }) + + describe('`selectedIndex`', () => { + it('should be possible to change active tab controlled and uncontrolled', async () => { + let handleChange = jest.fn() + + function ControlledTabs() { + let [selectedIndex, setSelectedIndex] = useState(0) + + return ( + <> + { + setSelectedIndex(value) + handleChange(value) + }} + > + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + + ) + } + + render() + + assertActiveElement(document.body) + + // test uncontrolled behaviour + await click(getByText('Tab 2')) + expect(handleChange).toHaveBeenCalledTimes(1) + expect(handleChange).toHaveBeenNthCalledWith(1, 1) + assertTabs({ active: 1 }) + + // test controlled behaviour + await click(getByText('setSelectedIndex')) + assertTabs({ active: 2 }) + }) + + it('should jump to the nearest tab when the selectedIndex is out of bounds (-2)', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + await press(Keys.Tab) + + assertTabs({ active: 0 }) + assertActiveElement(getByText('Tab 1')) + }) + + it('should jump to the nearest tab when the selectedIndex is out of bounds (+5)', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + await press(Keys.Tab) + + assertTabs({ active: 2 }) + assertActiveElement(getByText('Tab 3')) + }) + + it('should jump to the next available tab when the selectedIndex is a disabled tab', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + await press(Keys.Tab) + + assertTabs({ active: 1 }) + assertActiveElement(getByText('Tab 2')) + }) + + it('should jump to the next available tab when the selectedIndex is a disabled tab and wrap around', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + await press(Keys.Tab) + + assertTabs({ active: 0 }) + assertActiveElement(getByText('Tab 1')) + }) + + it('should prefer selectedIndex over defaultIndex', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + await press(Keys.Tab) + + assertTabs({ active: 0 }) + assertActiveElement(getByText('Tab 1')) + }) }) describe(`'Tab'`, () => { diff --git a/packages/@headlessui-react/src/components/tabs/tabs.tsx b/packages/@headlessui-react/src/components/tabs/tabs.tsx index d90cfe385a..7020f0e442 100644 --- a/packages/@headlessui-react/src/components/tabs/tabs.tsx +++ b/packages/@headlessui-react/src/components/tabs/tabs.tsx @@ -127,11 +127,19 @@ function Tabs( props: Props & { defaultIndex?: number onChange?: (index: number) => void + selectedIndex?: number vertical?: boolean manual?: boolean } ) { - let { defaultIndex = 0, vertical = false, manual = false, onChange, ...passThroughProps } = props + let { + defaultIndex = 0, + vertical = false, + manual = false, + onChange, + selectedIndex = null, + ...passThroughProps + } = props const orientation = vertical ? 'vertical' : 'horizontal' const activation = manual ? 'manual' : 'auto' @@ -161,18 +169,20 @@ function Tabs( useEffect(() => { if (state.tabs.length <= 0) return - if (state.selectedIndex !== null) return + if (selectedIndex === null && state.selectedIndex !== null) return let tabs = state.tabs.map(tab => tab.current).filter(Boolean) as HTMLElement[] let focusableTabs = tabs.filter(tab => !tab.hasAttribute('disabled')) + let indexToSet = selectedIndex ?? defaultIndex + // Underflow - if (defaultIndex < 0) { + if (indexToSet < 0) { dispatch({ type: ActionTypes.SetSelectedIndex, index: tabs.indexOf(focusableTabs[0]) }) } // Overflow - else if (defaultIndex > state.tabs.length) { + else if (indexToSet > state.tabs.length) { dispatch({ type: ActionTypes.SetSelectedIndex, index: tabs.indexOf(focusableTabs[focusableTabs.length - 1]), @@ -181,15 +191,15 @@ function Tabs( // Middle else { - let before = tabs.slice(0, defaultIndex) - let after = tabs.slice(defaultIndex) + let before = tabs.slice(0, indexToSet) + let after = tabs.slice(indexToSet) let next = [...after, ...before].find(tab => focusableTabs.includes(tab)) if (!next) return dispatch({ type: ActionTypes.SetSelectedIndex, index: tabs.indexOf(next) }) } - }, [defaultIndex, state.tabs, state.selectedIndex]) + }, [defaultIndex, selectedIndex, state.tabs, state.selectedIndex]) let lastChangedIndex = useRef(state.selectedIndex) let providerBag = useMemo>( @@ -349,7 +359,7 @@ export function Tab( let passThroughProps = props if (process.env.NODE_ENV === 'test') { - Object.assign(propsWeControl, { ['data-headlessui-index']: myIndex }) + Object.assign(propsWeControl, { 'data-headlessui-index': myIndex }) } return render({ @@ -424,7 +434,7 @@ function Panel( } if (process.env.NODE_ENV === 'test') { - Object.assign(propsWeControl, { ['data-headlessui-index']: myIndex }) + Object.assign(propsWeControl, { 'data-headlessui-index': myIndex }) } let passThroughProps = props diff --git a/packages/@headlessui-vue/src/components/tabs/tabs.test.ts b/packages/@headlessui-vue/src/components/tabs/tabs.test.ts index 74b12f42f8..4acc8794dd 100644 --- a/packages/@headlessui-vue/src/components/tabs/tabs.test.ts +++ b/packages/@headlessui-vue/src/components/tabs/tabs.test.ts @@ -1,4 +1,4 @@ -import { defineComponent, nextTick } from 'vue' +import { defineComponent, nextTick, ref } from 'vue' import { render } from '../../test-utils/vue-testing-library' import { TabGroup, TabList, Tab, TabPanels, TabPanel } from './tabs' import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs' @@ -433,6 +433,262 @@ describe('Rendering', () => { assertTabs({ active: 0 }) assertActiveElement(getByText('Tab 1')) }) + + it('should not change the Tab if the defaultIndex changes', async () => { + renderTemplate({ + template: html` + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + `, + setup() { + let defaultIndex = ref(1) + return { defaultIndex } + }, + }) + + await new Promise(nextTick) + + assertActiveElement(document.body) + + await press(Keys.Tab) + + assertTabs({ active: 1 }) + assertActiveElement(getByText('Tab 2')) + + await click(getByText('Tab 3')) + + assertTabs({ active: 2 }) + assertActiveElement(getByText('Tab 3')) + + // Change default index + await click(getByText('change')) + + // Nothing should change... + assertTabs({ active: 2 }) + }) + }) +}) + +describe('`selectedIndex`', () => { + it('should be possible to change active tab controlled and uncontrolled', 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) { + selectedIndex.value = value + handleChange(value) + }, + next() { + selectedIndex.value += 1 + }, + } + }, + }) + + await new Promise(nextTick) + + assertActiveElement(document.body) + + // test uncontrolled behaviour + await click(getByText('Tab 2')) + expect(handleChange).toHaveBeenCalledTimes(1) + expect(handleChange).toHaveBeenNthCalledWith(1, 1) + assertTabs({ active: 1 }) + + // test controlled behaviour + await click(getByText('setSelectedIndex')) + assertTabs({ active: 2 }) + }) + + it('should jump to the nearest tab when the selectedIndex is out of bounds (-2)', async () => { + renderTemplate( + html` + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + ` + ) + + await new Promise(nextTick) + + assertActiveElement(document.body) + + await press(Keys.Tab) + + assertTabs({ active: 0 }) + assertActiveElement(getByText('Tab 1')) + }) + + it('should jump to the nearest tab when the selectedIndex is out of bounds (+5)', async () => { + renderTemplate( + html` + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + ` + ) + + await new Promise(nextTick) + + assertActiveElement(document.body) + + await press(Keys.Tab) + + assertTabs({ active: 2 }) + assertActiveElement(getByText('Tab 3')) + }) + + it('should jump to the next available tab when the selectedIndex is a disabled tab', async () => { + renderTemplate( + html` + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + ` + ) + + await new Promise(nextTick) + + assertActiveElement(document.body) + + await press(Keys.Tab) + + assertTabs({ active: 1 }) + assertActiveElement(getByText('Tab 2')) + }) + + it('should jump to the next available tab when the selectedIndex is a disabled tab and wrap around', async () => { + renderTemplate( + html` + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + ` + ) + + await new Promise(nextTick) + + assertActiveElement(document.body) + + await press(Keys.Tab) + + assertTabs({ active: 0 }) + assertActiveElement(getByText('Tab 1')) + }) + + it('should prefer selectedIndex over defaultIndex', async () => { + renderTemplate( + html` + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + ` + ) + + await new Promise(nextTick) + + assertActiveElement(document.body) + + await press(Keys.Tab) + + assertTabs({ active: 0 }) + assertActiveElement(getByText('Tab 1')) }) }) @@ -1880,7 +2136,7 @@ describe('Mouse interactions', () => { }) }) -it('should trigger the `onChange` when the tab changes', async () => { +it('should trigger the `change` when the tab changes', async () => { let changes = jest.fn() renderTemplate({ diff --git a/packages/@headlessui-vue/src/components/tabs/tabs.ts b/packages/@headlessui-vue/src/components/tabs/tabs.ts index 3705e8145a..c72784d033 100644 --- a/packages/@headlessui-vue/src/components/tabs/tabs.ts +++ b/packages/@headlessui-vue/src/components/tabs/tabs.ts @@ -8,6 +8,7 @@ import { computed, InjectionKey, Ref, + watchEffect, } from 'vue' import { Features, render, omit } from '../../utils/render' @@ -58,6 +59,7 @@ export let TabGroup = defineComponent({ }, props: { as: { type: [Object, String], default: 'template' }, + selectedIndex: { type: [Number], default: null }, defaultIndex: { type: [Number], default: 0 }, vertical: { type: [Boolean], default: false }, manual: { type: [Boolean], default: false }, @@ -96,27 +98,29 @@ export let TabGroup = defineComponent({ provide(TabsContext, api) - onMounted(() => { - if (api.tabs.value.length <= 0) return console.log('bail') - if (selectedIndex.value !== null) return console.log('bail 2') + watchEffect(() => { + if (api.tabs.value.length <= 0) return + if (props.selectedIndex === null && selectedIndex.value !== null) return let tabs = api.tabs.value.map(tab => dom(tab)).filter(Boolean) as HTMLElement[] let focusableTabs = tabs.filter(tab => !tab.hasAttribute('disabled')) + let indexToSet = props.selectedIndex ?? props.defaultIndex + // Underflow - if (props.defaultIndex < 0) { + if (indexToSet < 0) { selectedIndex.value = tabs.indexOf(focusableTabs[0]) } // Overflow - else if (props.defaultIndex > api.tabs.value.length) { + else if (indexToSet > api.tabs.value.length) { selectedIndex.value = tabs.indexOf(focusableTabs[focusableTabs.length - 1]) } // Middle else { - let before = tabs.slice(0, props.defaultIndex) - let after = tabs.slice(props.defaultIndex) + let before = tabs.slice(0, indexToSet) + let after = tabs.slice(indexToSet) let next = [...after, ...before].find(tab => focusableTabs.includes(tab)) if (!next) return @@ -129,7 +133,7 @@ export let TabGroup = defineComponent({ let slot = { selectedIndex: selectedIndex.value } return render({ - props: omit(props, ['defaultIndex', 'manual', 'vertical']), + props: omit(props, ['selectedIndex', 'defaultIndex', 'manual', 'vertical', 'onChange']), slot, slots, attrs,