From 1ec5e746899be388e57180c62040b575de4adab5 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 22 Jun 2021 12:29:39 +0200 Subject: [PATCH 1/8] improve changelog --- CHANGELOG.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8ab227703..dc173a84db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,10 +18,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Ensure that you can use `Transition.Child` when using implicit Transitions ([#503](https://github.com/tailwindlabs/headlessui/pull/503)) -- Add `aria-disabled` on disabled `RadioGroup.Option` components ([#543](https://github.com/tailwindlabs/headlessui/pull/543)) +- Add new `entered` prop for `Transition` and `Transition.Child` components ([#504](https://github.com/tailwindlabs/headlessui/pull/504)) ### Fixes +- Add `aria-disabled` on disabled `RadioGroup.Option` components ([#543](https://github.com/tailwindlabs/headlessui/pull/543)) - Improve `disabled` and `tabindex` prop handling ([#512](https://github.com/tailwindlabs/headlessui/pull/512)) - Improve React peer dependency version range ([#544](https://github.com/tailwindlabs/headlessui/pull/544)) - Improve types for the `open` prop in the `Dialog` component ([#550](https://github.com/tailwindlabs/headlessui/pull/550)) @@ -37,10 +38,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Ensure that you can use `TransitionChild` when using implicit Transitions ([#503](https://github.com/tailwindlabs/headlessui/pull/503)) -- Add `aria-disabled` on disabled `RadioGroup.Option` components ([#543](https://github.com/tailwindlabs/headlessui/pull/543)) +- Add new `entered` prop for `Transition` and `TransitionChild` components ([#504](https://github.com/tailwindlabs/headlessui/pull/504)) ### Fixes +- Add `aria-disabled` on disabled `RadioGroup.Option` components ([#543](https://github.com/tailwindlabs/headlessui/pull/543)) - Improve `disabled` and `tabindex` prop handling ([#512](https://github.com/tailwindlabs/headlessui/pull/512)) - Improve reactivity when destructuring from props ([#512](https://github.com/tailwindlabs/headlessui/pull/512)) - Improve `aria-expanded` logic ([#592](https://github.com/tailwindlabs/headlessui/pull/592)) From 9af04a0a7ebdea2db37c21429fa9894363ad2158 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 13 Jul 2021 19:23:55 +0200 Subject: [PATCH 2/8] add `Tabs` component (#674) * add `Tabs` component (React) * expose `Tabs` component (React) * add `Tabs` example (React) * add `Tabs` component (Vue) * expose `Tabs` component (Vue) * update changelog --- CHANGELOG.md | 8 +- .../pages/tabs/tabs-with-pure-tailwind.tsx | 89 + .../src/components/tabs/tabs.test.tsx | 1823 ++++++++++++++++ .../src/components/tabs/tabs.tsx | 448 ++++ packages/@headlessui-react/src/index.test.ts | 1 + packages/@headlessui-react/src/index.ts | 1 + .../test-utils/accessibility-assertions.ts | 82 + .../src/components/tabs/tabs.test.ts | 1920 +++++++++++++++++ .../src/components/tabs/tabs.ts | 356 +++ packages/@headlessui-vue/src/index.test.ts | 7 + packages/@headlessui-vue/src/index.ts | 1 + .../test-utils/accessibility-assertions.ts | 82 + 12 files changed, 4816 insertions(+), 2 deletions(-) create mode 100644 packages/@headlessui-react/pages/tabs/tabs-with-pure-tailwind.tsx create mode 100644 packages/@headlessui-react/src/components/tabs/tabs.test.tsx create mode 100644 packages/@headlessui-react/src/components/tabs/tabs.tsx create mode 100644 packages/@headlessui-vue/src/components/tabs/tabs.test.ts create mode 100644 packages/@headlessui-vue/src/components/tabs/tabs.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index dc173a84db..4f97e89af6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased - React] -- Nothing yet! +### Added + +- Add new `Tabs` component ([#674](https://github.com/tailwindlabs/headlessui/pull/674)) ## [Unreleased - Vue] -- Nothing yet! +### Added + +- Add new `Tabs` component ([#674](https://github.com/tailwindlabs/headlessui/pull/674)) ## [@headlessui/react@v1.3.0] - 2021-06-21 diff --git a/packages/@headlessui-react/pages/tabs/tabs-with-pure-tailwind.tsx b/packages/@headlessui-react/pages/tabs/tabs-with-pure-tailwind.tsx new file mode 100644 index 0000000000..108aa82eae --- /dev/null +++ b/packages/@headlessui-react/pages/tabs/tabs-with-pure-tailwind.tsx @@ -0,0 +1,89 @@ +import React, { useState } from 'react' +import { Tabs, Switch } from '@headlessui/react' + +import { classNames } from '../../src/utils/class-names' + +export default function Home() { + let tabs = [ + { name: 'My Account', content: 'Tab content for my account' }, + { name: 'Company', content: 'Tab content for company', disabled: true }, + { name: 'Team Members', content: 'Tab content for team members' }, + { name: 'Billing', content: 'Tab content for billing' }, + ] + + let [manual, setManual] = useState(true) + + return ( +
+ + Manual keyboard activation + + + classNames( + 'relative inline-flex flex-shrink-0 h-6 border-2 border-transparent rounded-full cursor-pointer w-11 focus:outline-none focus:shadow-outline transition-colors ease-in-out duration-200', + checked ? 'bg-indigo-600' : 'bg-gray-200' + ) + } + > + {({ checked }) => ( + + )} + + + + + + {tabs.map((tab, tabIdx) => ( + + classNames( + selected ? 'text-gray-900' : 'text-gray-500 hover:text-gray-700', + tabIdx === 0 ? 'rounded-l-lg' : '', + tabIdx === tabs.length - 1 ? 'rounded-r-lg' : '', + tab.disabled && 'opacity-50', + 'group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-sm font-medium text-center hover:bg-gray-50 focus:z-10' + ) + } + > + {({ selected }) => ( + <> + {tab.name} + {tab.disabled && (disabled)} + + ))} + + + + {tabs.map(tab => ( + + {tab.content} + + ))} + + +
+ ) +} diff --git a/packages/@headlessui-react/src/components/tabs/tabs.test.tsx b/packages/@headlessui-react/src/components/tabs/tabs.test.tsx new file mode 100644 index 0000000000..bdee66bdd5 --- /dev/null +++ b/packages/@headlessui-react/src/components/tabs/tabs.test.tsx @@ -0,0 +1,1823 @@ +import React, { createElement } from 'react' +import { render } from '@testing-library/react' + +import { Tabs } from './tabs' +import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs' +import { + assertTabs, + assertActiveElement, + getByText, + getTabs, +} from '../../test-utils/accessibility-assertions' +import { press, Keys, shift, click } from '../../test-utils/interactions' + +jest.mock('../../hooks/use-id') + +beforeAll(() => { + jest.spyOn(window, 'requestAnimationFrame').mockImplementation(setImmediate as any) + jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(clearImmediate as any) +}) + +describe('safeguards', () => { + it.each([ + ['Tabs.List', Tabs.List], + ['Tabs.Tab', Tabs.Tab], + ['Tabs.Panels', Tabs.Panels], + ['Tabs.Panel', Tabs.Panel], + ])( + 'should error when we are using a <%s /> without a parent component', + suppressConsoleLogs((name, Component) => { + expect(() => render(createElement(Component))).toThrowError( + `<${name} /> is missing a parent component.` + ) + }) + ) + + it('should be possible to render Tabs without crashing', async () => { + render( + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + ) + + assertTabs({ active: 0 }) + }) +}) + +describe('Rendering', () => { + it('should be possible to render the Tabs.Panels first, then the Tabs.List', async () => { + render( + + + Content 1 + Content 2 + Content 3 + + + + Tab 1 + Tab 2 + Tab 3 + + + ) + + assertTabs({ active: 0 }) + }) + + describe('`renderProps`', () => { + it('should expose the `selectedIndex` on the `Tabs` component', async () => { + render( + + {data => ( + <> +
{JSON.stringify(data)}
+ + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + )} +
+ ) + + expect(document.getElementById('exposed')).toHaveTextContent( + JSON.stringify({ selectedIndex: 0 }) + ) + + await click(getByText('Tab 2')) + + expect(document.getElementById('exposed')).toHaveTextContent( + JSON.stringify({ selectedIndex: 1 }) + ) + }) + + it('should expose the `selectedIndex` on the `Tabs.List` component', async () => { + render( + + + {data => ( + <> +
{JSON.stringify(data)}
+ Tab 1 + Tab 2 + Tab 3 + + )} +
+ + + Content 1 + Content 2 + Content 3 + +
+ ) + + expect(document.getElementById('exposed')).toHaveTextContent( + JSON.stringify({ selectedIndex: 0 }) + ) + + await click(getByText('Tab 2')) + + expect(document.getElementById('exposed')).toHaveTextContent( + JSON.stringify({ selectedIndex: 1 }) + ) + }) + + it('should expose the `selectedIndex` on the `Tabs.Panels` component', async () => { + render( + + + Tab 1 + Tab 2 + Tab 3 + + + + {data => ( + <> +
{JSON.stringify(data)}
+ Content 1 + Content 2 + Content 3 + + )} +
+
+ ) + + expect(document.getElementById('exposed')).toHaveTextContent( + JSON.stringify({ selectedIndex: 0 }) + ) + + await click(getByText('Tab 2')) + + expect(document.getElementById('exposed')).toHaveTextContent( + JSON.stringify({ selectedIndex: 1 }) + ) + }) + + it('should expose the `selected` state on the `Tabs.Tab` components', async () => { + render( + + + + {data => ( + <> +
{JSON.stringify(data)}
+ Tab 1 + + )} +
+ + {data => ( + <> +
{JSON.stringify(data)}
+ Tab 2 + + )} +
+ + {data => ( + <> +
{JSON.stringify(data)}
+ Tab 3 + + )} +
+
+ + + Content 1 + Content 2 + Content 3 + +
+ ) + + expect(document.querySelector('[data-tab="0"]')).toHaveTextContent( + JSON.stringify({ selected: true }) + ) + expect(document.querySelector('[data-tab="1"]')).toHaveTextContent( + JSON.stringify({ selected: false }) + ) + expect(document.querySelector('[data-tab="2"]')).toHaveTextContent( + JSON.stringify({ selected: false }) + ) + + await click(getTabs()[1]) + + expect(document.querySelector('[data-tab="0"]')).toHaveTextContent( + JSON.stringify({ selected: false }) + ) + expect(document.querySelector('[data-tab="1"]')).toHaveTextContent( + JSON.stringify({ selected: true }) + ) + expect(document.querySelector('[data-tab="2"]')).toHaveTextContent( + JSON.stringify({ selected: false }) + ) + }) + + it('should expose the `selected` state on the `Tabs.Panel` components', async () => { + render( + + + Tab 1 + Tab 2 + Tab 3 + + + + + {data => ( + <> +
{JSON.stringify(data)}
+ Content 1 + + )} +
+ + {data => ( + <> +
{JSON.stringify(data)}
+ Content 2 + + )} +
+ + {data => ( + <> +
{JSON.stringify(data)}
+ Content 3 + + )} +
+
+
+ ) + + expect(document.querySelector('[data-panel="0"]')).toHaveTextContent( + JSON.stringify({ selected: true }) + ) + expect(document.querySelector('[data-panel="1"]')).toHaveTextContent( + JSON.stringify({ selected: false }) + ) + expect(document.querySelector('[data-panel="2"]')).toHaveTextContent( + JSON.stringify({ selected: false }) + ) + + await click(getByText('Tab 2')) + + expect(document.querySelector('[data-panel="0"]')).toHaveTextContent( + JSON.stringify({ selected: false }) + ) + expect(document.querySelector('[data-panel="1"]')).toHaveTextContent( + JSON.stringify({ selected: true }) + ) + expect(document.querySelector('[data-panel="2"]')).toHaveTextContent( + JSON.stringify({ selected: false }) + ) + }) + }) + + describe('`defaultIndex`', () => { + it('should jump to the nearest tab when the defaultIndex 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 defaultIndex 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 defaultIndex 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 defaultIndex 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')) + }) + }) +}) + +describe('Keyboard interactions', () => { + describe('`Tab` key', () => { + it('should be possible to tab to the default initial first tab', 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')) + + await press(Keys.Tab) + assertActiveElement(getByText('Content 1')) + + await press(Keys.Tab) + assertActiveElement(getByText('after')) + + await press(shift(Keys.Tab)) + assertActiveElement(getByText('Content 1')) + + await press(shift(Keys.Tab)) + assertActiveElement(getByText('Tab 1')) + }) + + it('should be possible to tab to the default index 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')) + + await press(Keys.Tab) + assertActiveElement(getByText('Content 2')) + + await press(Keys.Tab) + assertActiveElement(getByText('after')) + + await press(shift(Keys.Tab)) + assertActiveElement(getByText('Content 2')) + + await press(shift(Keys.Tab)) + assertActiveElement(getByText('Tab 2')) + }) + }) + + describe('`ArrowRight` key', () => { + it('should be possible to go to the next item (activation = `auto`)', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + await press(Keys.Tab) + assertTabs({ active: 0 }) + + await press(Keys.ArrowRight) + assertTabs({ active: 1 }) + + await press(Keys.ArrowRight) + assertTabs({ active: 2 }) + }) + + it('should be possible to go to the next item (activation = `manual`)', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + await press(Keys.Tab) + assertTabs({ active: 0 }) + + await press(Keys.ArrowRight) + assertTabs({ active: 0 }) + await press(Keys.Enter) + assertTabs({ active: 1 }) + + await press(Keys.ArrowRight) + assertTabs({ active: 1 }) + await press(Keys.Enter) + assertTabs({ active: 2 }) + }) + + it('should wrap around at the end (activation = `auto`)', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + await press(Keys.Tab) + assertTabs({ active: 0 }) + + await press(Keys.ArrowRight) + assertTabs({ active: 1 }) + + await press(Keys.ArrowRight) + assertTabs({ active: 2 }) + + await press(Keys.ArrowRight) + assertTabs({ active: 0 }) + + await press(Keys.ArrowRight) + assertTabs({ active: 1 }) + }) + + it('should wrap around at the end (activation = `manual`)', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + await press(Keys.Tab) + assertTabs({ active: 0 }) + + await press(Keys.ArrowRight) + assertTabs({ active: 0 }) + await press(Keys.Enter) + assertTabs({ active: 1 }) + + await press(Keys.ArrowRight) + assertTabs({ active: 1 }) + await press(Keys.Enter) + assertTabs({ active: 2 }) + + await press(Keys.ArrowRight) + assertTabs({ active: 2 }) + await press(Keys.Enter) + assertTabs({ active: 0 }) + + await press(Keys.ArrowRight) + assertTabs({ active: 0 }) + await press(Keys.Enter) + assertTabs({ active: 1 }) + }) + + it('should not be possible to go right when in vertical mode (activation = `auto`)', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + await press(Keys.Tab) + assertTabs({ active: 0, orientation: 'vertical' }) + + await press(Keys.ArrowRight) + // no-op + assertTabs({ active: 0, orientation: 'vertical' }) + }) + + it('should not be possible to go right when in vertical mode (activation = `manual`)', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + await press(Keys.Tab) + assertTabs({ active: 0, orientation: 'vertical' }) + + await press(Keys.ArrowRight) + assertTabs({ active: 0, orientation: 'vertical' }) + await press(Keys.Enter) + // no-op + assertTabs({ active: 0, orientation: 'vertical' }) + }) + }) + + describe('`ArrowLeft` key', () => { + it('should be possible to go to the previous item (activation = `auto`)', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + await press(Keys.Tab) + assertTabs({ active: 2 }) + + await press(Keys.ArrowLeft) + assertTabs({ active: 1 }) + + await press(Keys.ArrowLeft) + assertTabs({ active: 0 }) + }) + + it('should be possible to go to the previous item (activation = `manual`)', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + await press(Keys.Tab) + assertTabs({ active: 2 }) + + await press(Keys.ArrowLeft) + assertTabs({ active: 2 }) + await press(Keys.Enter) + assertTabs({ active: 1 }) + + await press(Keys.ArrowLeft) + assertTabs({ active: 1 }) + await press(Keys.Enter) + assertTabs({ active: 0 }) + }) + + it('should wrap around at the beginning (activation = `auto`)', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + await press(Keys.Tab) + assertTabs({ active: 2 }) + + await press(Keys.ArrowLeft) + assertTabs({ active: 1 }) + + await press(Keys.ArrowLeft) + assertTabs({ active: 0 }) + + await press(Keys.ArrowLeft) + assertTabs({ active: 2 }) + + await press(Keys.ArrowLeft) + assertTabs({ active: 1 }) + }) + + it('should wrap around at the beginning (activation = `manual`)', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + await press(Keys.Tab) + assertTabs({ active: 2 }) + + await press(Keys.ArrowLeft) + assertTabs({ active: 2 }) + await press(Keys.Enter) + assertTabs({ active: 1 }) + + await press(Keys.ArrowLeft) + assertTabs({ active: 1 }) + await press(Keys.Enter) + assertTabs({ active: 0 }) + + await press(Keys.ArrowLeft) + assertTabs({ active: 0 }) + await press(Keys.Enter) + assertTabs({ active: 2 }) + + await press(Keys.ArrowLeft) + assertTabs({ active: 2 }) + await press(Keys.Enter) + assertTabs({ active: 1 }) + }) + + it('should not be possible to go left when in vertical mode (activation = `auto`)', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + await press(Keys.Tab) + assertTabs({ active: 0, orientation: 'vertical' }) + + await press(Keys.ArrowLeft) + // no-op + assertTabs({ active: 0, orientation: 'vertical' }) + }) + + it('should not be possible to go left when in vertical mode (activation = `manual`)', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + await press(Keys.Tab) + assertTabs({ active: 0, orientation: 'vertical' }) + + await press(Keys.ArrowLeft) + assertTabs({ active: 0, orientation: 'vertical' }) + await press(Keys.Enter) + + // no-op + assertTabs({ active: 0, orientation: 'vertical' }) + }) + }) + + describe('`ArrowDown` key', () => { + it('should be possible to go to the next item (activation = `auto`)', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + await press(Keys.Tab) + assertTabs({ active: 0, orientation: 'vertical' }) + + await press(Keys.ArrowDown) + assertTabs({ active: 1, orientation: 'vertical' }) + + await press(Keys.ArrowDown) + assertTabs({ active: 2, orientation: 'vertical' }) + }) + + it('should be possible to go to the next item (activation = `manual`)', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + await press(Keys.Tab) + assertTabs({ active: 0, orientation: 'vertical' }) + + await press(Keys.ArrowDown) + assertTabs({ active: 0, orientation: 'vertical' }) + await press(Keys.Enter) + assertTabs({ active: 1, orientation: 'vertical' }) + + await press(Keys.ArrowDown) + assertTabs({ active: 1, orientation: 'vertical' }) + await press(Keys.Enter) + assertTabs({ active: 2, orientation: 'vertical' }) + }) + + it('should wrap around at the end (activation = `auto`)', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + await press(Keys.Tab) + assertTabs({ active: 0, orientation: 'vertical' }) + + await press(Keys.ArrowDown) + assertTabs({ active: 1, orientation: 'vertical' }) + + await press(Keys.ArrowDown) + assertTabs({ active: 2, orientation: 'vertical' }) + + await press(Keys.ArrowDown) + assertTabs({ active: 0, orientation: 'vertical' }) + + await press(Keys.ArrowDown) + assertTabs({ active: 1, orientation: 'vertical' }) + }) + + it('should wrap around at the end (activation = `manual`)', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + await press(Keys.Tab) + assertTabs({ active: 0, orientation: 'vertical' }) + + await press(Keys.ArrowDown) + assertTabs({ active: 0, orientation: 'vertical' }) + await press(Keys.Enter) + assertTabs({ active: 1, orientation: 'vertical' }) + + await press(Keys.ArrowDown) + assertTabs({ active: 1, orientation: 'vertical' }) + await press(Keys.Enter) + assertTabs({ active: 2, orientation: 'vertical' }) + + await press(Keys.ArrowDown) + assertTabs({ active: 2, orientation: 'vertical' }) + await press(Keys.Enter) + assertTabs({ active: 0, orientation: 'vertical' }) + + await press(Keys.ArrowDown) + assertTabs({ active: 0, orientation: 'vertical' }) + await press(Keys.Enter) + assertTabs({ active: 1, orientation: 'vertical' }) + }) + + it('should not be possible to go down when in horizontal mode (activation = `auto`)', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + await press(Keys.Tab) + assertTabs({ active: 0 }) + + await press(Keys.ArrowDown) + // no-op + assertTabs({ active: 0 }) + }) + + it('should not be possible to go down when in horizontal mode (activation = `manual`)', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + await press(Keys.Tab) + assertTabs({ active: 0 }) + + await press(Keys.ArrowDown) + assertTabs({ active: 0 }) + await press(Keys.Enter) + + // no-op + assertTabs({ active: 0 }) + }) + }) + + describe('`ArrowUp` key', () => { + it('should be possible to go to the previous item (activation = `auto`)', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + await press(Keys.Tab) + assertTabs({ active: 2, orientation: 'vertical' }) + + await press(Keys.ArrowUp) + assertTabs({ active: 1, orientation: 'vertical' }) + + await press(Keys.ArrowUp) + assertTabs({ active: 0, orientation: 'vertical' }) + }) + + it('should be possible to go to the previous item (activation = `manual`)', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + await press(Keys.Tab) + assertTabs({ active: 2, orientation: 'vertical' }) + + await press(Keys.ArrowUp) + assertTabs({ active: 2, orientation: 'vertical' }) + await press(Keys.Enter) + assertTabs({ active: 1, orientation: 'vertical' }) + + await press(Keys.ArrowUp) + assertTabs({ active: 1, orientation: 'vertical' }) + await press(Keys.Enter) + assertTabs({ active: 0, orientation: 'vertical' }) + }) + + it('should wrap around at the beginning (activation = `auto`)', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + await press(Keys.Tab) + assertTabs({ active: 2, orientation: 'vertical' }) + + await press(Keys.ArrowUp) + assertTabs({ active: 1, orientation: 'vertical' }) + + await press(Keys.ArrowUp) + assertTabs({ active: 0, orientation: 'vertical' }) + + await press(Keys.ArrowUp) + assertTabs({ active: 2, orientation: 'vertical' }) + + await press(Keys.ArrowUp) + assertTabs({ active: 1, orientation: 'vertical' }) + }) + + it('should wrap around at the beginning (activation = `manual`)', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + await press(Keys.Tab) + assertTabs({ active: 2, orientation: 'vertical' }) + + await press(Keys.ArrowUp) + assertTabs({ active: 2, orientation: 'vertical' }) + await press(Keys.Enter) + assertTabs({ active: 1, orientation: 'vertical' }) + + await press(Keys.ArrowUp) + assertTabs({ active: 1, orientation: 'vertical' }) + await press(Keys.Enter) + assertTabs({ active: 0, orientation: 'vertical' }) + + await press(Keys.ArrowUp) + assertTabs({ active: 0, orientation: 'vertical' }) + await press(Keys.Enter) + assertTabs({ active: 2, orientation: 'vertical' }) + + await press(Keys.ArrowUp) + assertTabs({ active: 2, orientation: 'vertical' }) + await press(Keys.Enter) + assertTabs({ active: 1, orientation: 'vertical' }) + }) + + it('should not be possible to go left when in vertical mode (activation = `auto`)', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + await press(Keys.Tab) + assertTabs({ active: 0 }) + + await press(Keys.ArrowUp) + // no-op + assertTabs({ active: 0 }) + }) + + it('should not be possible to go left when in vertical mode (activation = `manual`)', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + await press(Keys.Tab) + assertTabs({ active: 0 }) + + await press(Keys.ArrowUp) + assertTabs({ active: 0 }) + await press(Keys.Enter) + + // no-op + assertTabs({ active: 0 }) + }) + }) + + describe('`Home` key', () => { + it('should be possible to go to the first focusable item (activation = `auto`)', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + await press(Keys.Tab) + assertTabs({ active: 1 }) + + await press(Keys.Home) + assertTabs({ active: 0 }) + }) + + it('should be possible to go to the first focusable item (activation = `manual`)', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + await press(Keys.Tab) + assertTabs({ active: 1 }) + + await press(Keys.Home) + assertTabs({ active: 1 }) + await press(Keys.Enter) + assertTabs({ active: 0 }) + }) + }) + + describe('`PageUp` key', () => { + it('should be possible to go to the first focusable item (activation = `auto`)', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + await press(Keys.Tab) + assertTabs({ active: 1 }) + + await press(Keys.PageUp) + assertTabs({ active: 0 }) + }) + + it('should be possible to go to the first focusable item (activation = `manual`)', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + await press(Keys.Tab) + assertTabs({ active: 1 }) + + await press(Keys.PageUp) + assertTabs({ active: 1 }) + await press(Keys.Enter) + assertTabs({ active: 0 }) + }) + }) + + describe('`End` key', () => { + it('should be possible to go to the first focusable item (activation = `auto`)', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + await press(Keys.Tab) + assertTabs({ active: 1 }) + + await press(Keys.End) + assertTabs({ active: 2 }) + }) + + it('should be possible to go to the first focusable item (activation = `manual`)', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + await press(Keys.Tab) + assertTabs({ active: 1 }) + + await press(Keys.End) + assertTabs({ active: 1 }) + await press(Keys.Enter) + assertTabs({ active: 2 }) + }) + }) + + describe('`PageDown` key', () => { + it('should be possible to go to the first focusable item (activation = `auto`)', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + await press(Keys.Tab) + assertTabs({ active: 1 }) + + await press(Keys.PageDown) + assertTabs({ active: 2 }) + }) + + it('should be possible to go to the first focusable item (activation = `manual`)', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + await press(Keys.Tab) + assertTabs({ active: 1 }) + + await press(Keys.PageDown) + assertTabs({ active: 1 }) + await press(Keys.Enter) + assertTabs({ active: 2 }) + }) + }) + + describe('`Enter` key', () => { + it('should be possible to activate the focused tab', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + getByText('Tab 3')?.focus() + + assertActiveElement(getByText('Tab 3')) + assertTabs({ active: 0 }) + + await press(Keys.Enter) + assertTabs({ active: 2 }) + }) + }) + + describe('`Space` key', () => { + it('should be possible to activate the focused tab', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + + getByText('Tab 3')?.focus() + + assertActiveElement(getByText('Tab 3')) + assertTabs({ active: 0 }) + + await press(Keys.Space) + assertTabs({ active: 2 }) + }) + }) +}) + +describe('Mouse interactions', () => { + it('should be possible to click on a tab to focus it', async () => { + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + assertActiveElement(document.body) + await press(Keys.Tab) + assertTabs({ active: 1 }) + + await click(getByText('Tab 1')) + assertTabs({ active: 0 }) + + await click(getByText('Tab 3')) + assertTabs({ active: 2 }) + + await click(getByText('Tab 2')) + assertTabs({ active: 1 }) + }) + + it('should be a no-op when clicking on 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 }) + + await click(getByText('Tab 1')) + // No-op, Tab 2 is still active + assertTabs({ active: 1 }) + }) +}) + +it('should trigger the `onChange` when the tab changes', async () => { + let changes = jest.fn() + + render( + <> + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + + ) + + await click(getByText('Tab 2')) + await click(getByText('Tab 3')) + await click(getByText('Tab 2')) + await click(getByText('Tab 1')) + + expect(changes).toHaveBeenCalledTimes(4) + + expect(changes).toHaveBeenNthCalledWith(1, 1) + expect(changes).toHaveBeenNthCalledWith(2, 2) + expect(changes).toHaveBeenNthCalledWith(3, 1) + expect(changes).toHaveBeenNthCalledWith(4, 0) +}) diff --git a/packages/@headlessui-react/src/components/tabs/tabs.tsx b/packages/@headlessui-react/src/components/tabs/tabs.tsx new file mode 100644 index 0000000000..590a0779f4 --- /dev/null +++ b/packages/@headlessui-react/src/components/tabs/tabs.tsx @@ -0,0 +1,448 @@ +import React, { + Fragment, + createContext, + useCallback, + useContext, + useMemo, + useReducer, + useRef, + useEffect, + + // Types + ElementType, + MutableRefObject, + KeyboardEvent as ReactKeyboardEvent, + Dispatch, + ContextType, +} from 'react' + +import { Props } from '../../types' +import { render, Features, PropsForFeatures } from '../../utils/render' +import { useId } from '../../hooks/use-id' +import { match } from '../../utils/match' +import { Keys } from '../../components/keyboard' +import { focusIn, Focus } from '../../utils/focus-management' +import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect' +import { useSyncRefs } from '../../hooks/use-sync-refs' + +interface StateDefinition { + selectedIndex: number | null + + orientation: 'horizontal' | 'vertical' + activation: 'auto' | 'manual' + + tabs: MutableRefObject[] + panels: MutableRefObject[] +} + +enum ActionTypes { + SetSelectedIndex, + SetOrientation, + SetActivation, + + RegisterTab, + UnregisterTab, + + RegisterPanel, + UnregisterPanel, + + ForceRerender, +} + +type Actions = + | { type: ActionTypes.SetSelectedIndex; index: number } + | { type: ActionTypes.SetOrientation; orientation: StateDefinition['orientation'] } + | { type: ActionTypes.SetActivation; activation: StateDefinition['activation'] } + | { type: ActionTypes.RegisterTab; tab: MutableRefObject } + | { type: ActionTypes.UnregisterTab; tab: MutableRefObject } + | { type: ActionTypes.RegisterPanel; panel: MutableRefObject } + | { type: ActionTypes.UnregisterPanel; panel: MutableRefObject } + | { type: ActionTypes.ForceRerender } + +let reducers: { + [P in ActionTypes]: ( + state: StateDefinition, + action: Extract + ) => StateDefinition +} = { + [ActionTypes.SetSelectedIndex](state, action) { + if (state.selectedIndex === action.index) return state + return { ...state, selectedIndex: action.index } + }, + [ActionTypes.SetOrientation](state, action) { + if (state.orientation === action.orientation) return state + return { ...state, orientation: action.orientation } + }, + [ActionTypes.SetActivation](state, action) { + if (state.activation === action.activation) return state + return { ...state, activation: action.activation } + }, + [ActionTypes.RegisterTab](state, action) { + if (state.tabs.includes(action.tab)) return state + return { ...state, tabs: [...state.tabs, action.tab] } + }, + [ActionTypes.UnregisterTab](state, action) { + return { ...state, tabs: state.tabs.filter(tab => tab !== action.tab) } + }, + [ActionTypes.RegisterPanel](state, action) { + if (state.panels.includes(action.panel)) return state + return { ...state, panels: [...state.panels, action.panel] } + }, + [ActionTypes.UnregisterPanel](state, action) { + return { ...state, panels: state.panels.filter(panel => panel !== action.panel) } + }, + [ActionTypes.ForceRerender](state) { + return { ...state } + }, +} + +let TabsContext = createContext< + [StateDefinition, { change(index: number): void; dispatch: Dispatch }] | null +>(null) +TabsContext.displayName = 'TabsContext' + +function useTabsContext(component: string) { + let context = useContext(TabsContext) + if (context === null) { + let err = new Error(`<${component} /> is missing a parent <${Tabs.name} /> component.`) + if (Error.captureStackTrace) Error.captureStackTrace(err, useTabsContext) + throw err + } + return context +} + +function stateReducer(state: StateDefinition, action: Actions) { + return match(action.type, reducers, state, action) +} + +// --- + +let DEFAULT_TABS_TAG = Fragment +interface TabsRenderPropArg { + selectedIndex: number +} + +export function Tabs( + props: Props & { + defaultIndex?: number + onChange?: (index: number) => void + vertical?: boolean + manual?: boolean + } +) { + let { defaultIndex = 0, vertical = false, manual = false, onChange, ...passThroughProps } = props + const orientation = vertical ? 'vertical' : 'horizontal' + const activation = manual ? 'manual' : 'auto' + + let [state, dispatch] = useReducer(stateReducer, { + selectedIndex: null, + tabs: [], + panels: [], + orientation, + activation, + } as StateDefinition) + let slot = useMemo(() => ({ selectedIndex: state.selectedIndex }), [state.selectedIndex]) + let onChangeRef = useRef<(index: number) => void>(() => {}) + + useEffect(() => { + dispatch({ type: ActionTypes.SetOrientation, orientation }) + }, [orientation]) + + useEffect(() => { + dispatch({ type: ActionTypes.SetActivation, activation }) + }, [activation]) + + useEffect(() => { + if (typeof onChange === 'function') { + onChangeRef.current = onChange + } + }, [onChange]) + + useEffect(() => { + if (state.tabs.length <= 0) return + if (state.selectedIndex !== null) return + + let tabs = state.tabs.map(tab => tab.current).filter(Boolean) as HTMLElement[] + let focusableTabs = tabs.filter(tab => !tab.hasAttribute('disabled')) + + // Underflow + if (defaultIndex < 0) { + dispatch({ type: ActionTypes.SetSelectedIndex, index: tabs.indexOf(focusableTabs[0]) }) + } + + // Overflow + else if (defaultIndex > state.tabs.length) { + dispatch({ + type: ActionTypes.SetSelectedIndex, + index: tabs.indexOf(focusableTabs[focusableTabs.length - 1]), + }) + } + + // Middle + else { + let before = tabs.slice(0, defaultIndex) + let after = tabs.slice(defaultIndex) + + 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]) + + let lastChangedIndex = useRef(state.selectedIndex) + let providerBag = useMemo>( + () => [ + state, + { + dispatch, + change(index: number) { + if (lastChangedIndex.current !== index) onChangeRef.current(index) + lastChangedIndex.current = index + + dispatch({ type: ActionTypes.SetSelectedIndex, index }) + }, + }, + ], + [state, dispatch] + ) + + return ( + + {render({ + props: { ...passThroughProps }, + slot, + defaultTag: DEFAULT_TABS_TAG, + name: 'Tabs', + })} + + ) +} + +// --- + +let DEFAULT_LIST_TAG = 'div' as const +interface ListRenderPropArg { + selectedIndex: number +} +type ListPropsWeControl = 'role' | 'aria-orientation' + +function List( + props: Props & {} +) { + let [{ selectedIndex, orientation }] = useTabsContext([Tabs.name, List.name].join('.')) + + let slot = { selectedIndex } + let propsWeControl = { + role: 'tablist', + 'aria-orientation': orientation, + } + let passThroughProps = props + + return render({ + props: { ...passThroughProps, ...propsWeControl }, + slot, + defaultTag: DEFAULT_LIST_TAG, + name: 'Tabs.List', + }) +} + +// --- + +let DEFAULT_TAB_TAG = 'button' as const +interface TabRenderPropArg { + selected: boolean +} +type TabPropsWeControl = 'id' | 'role' | 'type' | 'aria-controls' | 'aria-selected' | 'tabIndex' + +function Tab( + props: Props +) { + let id = `headlessui-tabs-tab-${useId()}` + + let [ + { selectedIndex, tabs, panels, orientation, activation }, + { dispatch, change }, + ] = useTabsContext([Tabs.name, Tab.name].join('.')) + + let internalTabRef = useRef(null) + let tabRef = useSyncRefs(internalTabRef, element => { + if (!element) return + dispatch({ type: ActionTypes.ForceRerender }) + }) + + useIsoMorphicEffect(() => { + dispatch({ type: ActionTypes.RegisterTab, tab: internalTabRef }) + return () => dispatch({ type: ActionTypes.UnregisterTab, tab: internalTabRef }) + }, [dispatch, internalTabRef]) + + let myIndex = tabs.indexOf(internalTabRef) + let selected = myIndex === selectedIndex + + let handleKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + let list = tabs.map(tab => tab.current).filter(Boolean) as HTMLElement[] + + if (event.key === Keys.Space || event.key === Keys.Enter) { + event.preventDefault() + event.stopPropagation() + + change(myIndex) + return + } + + switch (event.key) { + case Keys.Home: + case Keys.PageUp: + event.preventDefault() + event.stopPropagation() + + return focusIn(list, Focus.First) + + case Keys.End: + case Keys.PageDown: + event.preventDefault() + event.stopPropagation() + + return focusIn(list, Focus.Last) + } + + return match(orientation, { + vertical() { + if (event.key === Keys.ArrowUp) return focusIn(list, Focus.Previous | Focus.WrapAround) + if (event.key === Keys.ArrowDown) return focusIn(list, Focus.Next | Focus.WrapAround) + return + }, + horizontal() { + if (event.key === Keys.ArrowLeft) return focusIn(list, Focus.Previous | Focus.WrapAround) + if (event.key === Keys.ArrowRight) return focusIn(list, Focus.Next | Focus.WrapAround) + return + }, + }) + }, + [tabs, orientation, myIndex, change] + ) + + let handleFocus = useCallback(() => { + internalTabRef.current?.focus() + }, [internalTabRef]) + + let handleSelection = useCallback(() => { + internalTabRef.current?.focus() + change(myIndex) + }, [change, myIndex, internalTabRef]) + + let type = props?.type ?? (props.as || DEFAULT_TAB_TAG) === 'button' ? 'button' : undefined + + let slot = useMemo(() => ({ selected }), [selected]) + let propsWeControl = { + ref: tabRef, + onKeyDown: handleKeyDown, + onFocus: activation === 'manual' ? handleFocus : handleSelection, + onClick: handleSelection, + id, + role: 'tab', + type, + 'aria-controls': panels[myIndex]?.current?.id, + 'aria-selected': selected, + tabIndex: selected ? 0 : -1, + } + let passThroughProps = props + + if (process.env.NODE_ENV === 'test') { + Object.assign(propsWeControl, { ['data-headlessui-index']: myIndex }) + } + + return render({ + props: { ...passThroughProps, ...propsWeControl }, + slot, + defaultTag: DEFAULT_TAB_TAG, + name: 'Tabs.Tab', + }) +} + +// --- + +let DEFAULT_PANELS_TAG = 'div' as const +interface PanelsRenderPropArg { + selectedIndex: number +} + +function Panels( + props: Props +) { + let [{ selectedIndex }] = useTabsContext([Tabs.name, Panels.name].join('.')) + + let slot = useMemo(() => ({ selectedIndex }), [selectedIndex]) + + return render({ + props, + slot, + defaultTag: DEFAULT_PANELS_TAG, + name: 'Tabs.Panels', + }) +} + +// --- + +let DEFAULT_PANEL_TAG = 'div' as const +interface PanelRenderPropArg { + selected: boolean +} +type PanelPropsWeControl = 'id' | 'role' | 'aria-labelledby' | 'tabIndex' +let PanelRenderFeatures = Features.RenderStrategy | Features.Static + +function Panel( + props: Props & + PropsForFeatures +) { + let [{ selectedIndex, tabs, panels }, { dispatch }] = useTabsContext( + [Tabs.name, Panel.name].join('.') + ) + + let id = `headlessui-tabs-panel-${useId()}` + let internalPanelRef = useRef(null) + let panelRef = useSyncRefs(internalPanelRef, element => { + if (!element) return + dispatch({ type: ActionTypes.ForceRerender }) + }) + + useIsoMorphicEffect(() => { + dispatch({ type: ActionTypes.RegisterPanel, panel: internalPanelRef }) + return () => dispatch({ type: ActionTypes.UnregisterPanel, panel: internalPanelRef }) + }, [dispatch, internalPanelRef]) + + let myIndex = panels.indexOf(internalPanelRef) + let selected = myIndex === selectedIndex + + let slot = useMemo(() => ({ selected }), [selected]) + let propsWeControl = { + ref: panelRef, + id, + role: 'tabpanel', + 'aria-labelledby': tabs[myIndex]?.current?.id, + tabIndex: selected ? 0 : -1, + } + + if (process.env.NODE_ENV === 'test') { + Object.assign(propsWeControl, { ['data-headlessui-index']: myIndex }) + } + + let passThroughProps = props + + return render({ + props: { ...passThroughProps, ...propsWeControl }, + slot, + defaultTag: DEFAULT_PANEL_TAG, + features: PanelRenderFeatures, + visible: selected, + name: 'Tabs.Panel', + }) +} + +// --- + +Tabs.List = List +Tabs.Tab = Tab +Tabs.Panels = Panels +Tabs.Panel = Panel diff --git a/packages/@headlessui-react/src/index.test.ts b/packages/@headlessui-react/src/index.test.ts index 1fc2d33682..2771e0f12a 100644 --- a/packages/@headlessui-react/src/index.test.ts +++ b/packages/@headlessui-react/src/index.test.ts @@ -15,6 +15,7 @@ it('should expose the correct components', () => { 'Portal', 'RadioGroup', 'Switch', + 'Tabs', 'Transition', ]) }) diff --git a/packages/@headlessui-react/src/index.ts b/packages/@headlessui-react/src/index.ts index cce0dd2a89..2ef29db23c 100644 --- a/packages/@headlessui-react/src/index.ts +++ b/packages/@headlessui-react/src/index.ts @@ -7,4 +7,5 @@ export * from './components/popover/popover' export * from './components/portal/portal' export * from './components/radio-group/radio-group' export * from './components/switch/switch' +export * from './components/tabs/tabs' export * from './components/transitions/transition' diff --git a/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts b/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts index ade99410e9..29ca3a8835 100644 --- a/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts +++ b/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts @@ -1187,6 +1187,88 @@ export function assertRadioGroupLabel( // --- +export function getTabList(): HTMLElement | null { + return document.querySelector('[role="tablist"]') +} + +export function getTabs(): HTMLElement[] { + return Array.from(document.querySelectorAll('[id^="headlessui-tabs-tab-"]')) +} + +export function getPanels(): HTMLElement[] { + return Array.from(document.querySelectorAll('[id^="headlessui-tabs-panel-"]')) +} + +// --- + +export function assertTabs( + { + active, + orientation = 'horizontal', + }: { + active: number + orientation?: 'vertical' | 'horizontal' + }, + list = getTabList(), + tabs = getTabs(), + panels = getPanels() +) { + try { + if (list === null) return expect(list).not.toBe(null) + + expect(list).toHaveAttribute('role', 'tablist') + expect(list).toHaveAttribute('aria-orientation', orientation) + + let activeTab = tabs.find(tab => tab.dataset.headlessuiIndex === '' + active) + let activePanel = panels.find(panel => panel.dataset.headlessuiIndex === '' + active) + + for (let tab of tabs) { + expect(tab).toHaveAttribute('id') + expect(tab).toHaveAttribute('role', 'tab') + expect(tab).toHaveAttribute('type', 'button') + + if (tab === activeTab) { + expect(tab).toHaveAttribute('aria-selected', 'true') + expect(tab).toHaveAttribute('tabindex', '0') + } else { + expect(tab).toHaveAttribute('aria-selected', 'false') + expect(tab).toHaveAttribute('tabindex', '-1') + } + + if (tab.hasAttribute('aria-controls')) { + let controlsId = tab.getAttribute('aria-controls')! + let panel = document.getElementById(controlsId) + + expect(panel).not.toBe(null) + expect(panels).toContain(panel) + expect(panel).toHaveAttribute('aria-labelledby', tab.id) + } + } + + for (let panel of panels) { + expect(panel).toHaveAttribute('id') + expect(panel).toHaveAttribute('role', 'tabpanel') + + let controlledById = panel.getAttribute('aria-labelledby')! + let tab = document.getElementById(controlledById) + + expect(tabs).toContain(tab) + expect(tab).toHaveAttribute('aria-controls', panel.id) + + if (panel === activePanel) { + expect(panel).toHaveAttribute('tabindex', '0') + } else { + expect(panel).toHaveAttribute('tabindex', '-1') + } + } + } catch (err) { + Error.captureStackTrace(err, assertTabs) + throw err + } +} + +// --- + export function assertActiveElement(element: HTMLElement | null) { try { if (element === null) return expect(element).not.toBe(null) diff --git a/packages/@headlessui-vue/src/components/tabs/tabs.test.ts b/packages/@headlessui-vue/src/components/tabs/tabs.test.ts new file mode 100644 index 0000000000..8d07b3e050 --- /dev/null +++ b/packages/@headlessui-vue/src/components/tabs/tabs.test.ts @@ -0,0 +1,1920 @@ +import { defineComponent, nextTick } from 'vue' +import { render } from '../../test-utils/vue-testing-library' +import { Tabs, TabsList, TabsTab, TabsPanels, TabsPanel } from './tabs' +import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs' +import { + assertActiveElement, + assertTabs, + getByText, + getTabs, +} from '../../test-utils/accessibility-assertions' +import { click, press, shift, Keys } from '../../test-utils/interactions' +import { html } from '../../test-utils/html' + +jest.mock('../../hooks/use-id') + +beforeAll(() => { + jest.spyOn(window, 'requestAnimationFrame').mockImplementation(setImmediate as any) + jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(clearImmediate as any) +}) + +afterAll(() => jest.restoreAllMocks()) + +function renderTemplate(input: string | Partial[0]>) { + let defaultComponents = { Tabs, TabsList, TabsTab, TabsPanels, TabsPanel } + + if (typeof input === 'string') { + return render(defineComponent({ template: input, components: defaultComponents })) + } + + return render( + defineComponent( + Object.assign({}, input, { + components: { ...defaultComponents, ...input.components }, + }) as Parameters[0] + ) + ) +} + +describe('safeguards', () => { + it.each([ + ['TabsList', TabsList], + ['TabsTab', TabsTab], + ['TabsPanels', TabsPanels], + ['TabsPanel', TabsPanel], + ])( + 'should error when we are using a <%s /> without a parent component', + suppressConsoleLogs((name, Component) => { + expect(() => render(Component)).toThrowError( + `<${name} /> is missing a parent component.` + ) + }) + ) + + it('should be possible to render Tabs without crashing', async () => { + renderTemplate( + html` + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + ` + ) + + await new Promise(nextTick) + + assertTabs({ active: 0 }) + }) +}) + +describe('Rendering', () => { + it('should be possible to render the TabsPanels first, then the TabsList', async () => { + renderTemplate( + html` + + + Content 1 + Content 2 + Content 3 + + + + Tab 1 + Tab 2 + Tab 3 + + + ` + ) + + await new Promise(nextTick) + + assertTabs({ active: 0 }) + }) + + describe('`renderProps`', () => { + it('should expose the `selectedIndex` on the `Tabs` component', async () => { + renderTemplate( + html` + +
{{JSON.stringify(data)}}
+ + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + +
+ ` + ) + + await new Promise(nextTick) + + expect(document.getElementById('exposed')).toHaveTextContent( + JSON.stringify({ selectedIndex: 0 }) + ) + + await click(getByText('Tab 2')) + + expect(document.getElementById('exposed')).toHaveTextContent( + JSON.stringify({ selectedIndex: 1 }) + ) + }) + + it('should expose the `selectedIndex` on the `TabsList` component', async () => { + renderTemplate( + html` + + +
{{JSON.stringify(data)}}
+ Tab 1 + Tab 2 + Tab 3 +
+ + + Content 1 + Content 2 + Content 3 + +
+ ` + ) + + await new Promise(nextTick) + + expect(document.getElementById('exposed')).toHaveTextContent( + JSON.stringify({ selectedIndex: 0 }) + ) + + await click(getByText('Tab 2')) + + expect(document.getElementById('exposed')).toHaveTextContent( + JSON.stringify({ selectedIndex: 1 }) + ) + }) + + it('should expose the `selectedIndex` on the `TabsPanels` component', async () => { + renderTemplate( + html` + + + Tab 1 + Tab 2 + Tab 3 + + + +
{{JSON.stringify(data)}}
+ Content 1 + Content 2 + Content 3 +
+
+ ` + ) + + await new Promise(nextTick) + + expect(document.getElementById('exposed')).toHaveTextContent( + JSON.stringify({ selectedIndex: 0 }) + ) + + await click(getByText('Tab 2')) + + expect(document.getElementById('exposed')).toHaveTextContent( + JSON.stringify({ selectedIndex: 1 }) + ) + }) + + it('should expose the `selected` state on the `TabsTab` components', async () => { + renderTemplate( + html` + + + +
{{JSON.stringify(data)}}
+ Tab 1 +
+ +
{{JSON.stringify(data)}}
+ Tab 2 +
+ +
{{JSON.stringify(data)}}
+ Tab 3 +
+
+ + + Content 1 + Content 2 + Content 3 + +
+ ` + ) + + await new Promise(nextTick) + + expect(document.querySelector('[data-tab="0"]')).toHaveTextContent( + JSON.stringify({ selected: true }) + ) + expect(document.querySelector('[data-tab="1"]')).toHaveTextContent( + JSON.stringify({ selected: false }) + ) + expect(document.querySelector('[data-tab="2"]')).toHaveTextContent( + JSON.stringify({ selected: false }) + ) + + await click(getTabs()[1]) + + expect(document.querySelector('[data-tab="0"]')).toHaveTextContent( + JSON.stringify({ selected: false }) + ) + expect(document.querySelector('[data-tab="1"]')).toHaveTextContent( + JSON.stringify({ selected: true }) + ) + expect(document.querySelector('[data-tab="2"]')).toHaveTextContent( + JSON.stringify({ selected: false }) + ) + }) + + it('should expose the `selected` state on the `TabsPanel` components', async () => { + renderTemplate( + html` + + + Tab 1 + Tab 2 + Tab 3 + + + + +
{{JSON.stringify(data)}}
+ Content 1 +
+ +
{{JSON.stringify(data)}}
+ Content 2 +
+ +
{{JSON.stringify(data)}}
+ Content 3 +
+
+
+ ` + ) + + await new Promise(nextTick) + + expect(document.querySelector('[data-panel="0"]')).toHaveTextContent( + JSON.stringify({ selected: true }) + ) + expect(document.querySelector('[data-panel="1"]')).toHaveTextContent( + JSON.stringify({ selected: false }) + ) + expect(document.querySelector('[data-panel="2"]')).toHaveTextContent( + JSON.stringify({ selected: false }) + ) + + await click(getByText('Tab 2')) + + expect(document.querySelector('[data-panel="0"]')).toHaveTextContent( + JSON.stringify({ selected: false }) + ) + expect(document.querySelector('[data-panel="1"]')).toHaveTextContent( + JSON.stringify({ selected: true }) + ) + expect(document.querySelector('[data-panel="2"]')).toHaveTextContent( + JSON.stringify({ selected: false }) + ) + }) + }) + + describe('`defaultIndex`', () => { + it('should jump to the nearest tab when the defaultIndex 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 defaultIndex 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 defaultIndex 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 defaultIndex 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')) + }) + }) +}) + +describe('Keyboard interactions', () => { + describe('`Tab` key', () => { + it('should be possible to tab to the default initial first 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: 0 }) + assertActiveElement(getByText('Tab 1')) + + await press(Keys.Tab) + assertActiveElement(getByText('Content 1')) + + await press(Keys.Tab) + assertActiveElement(getByText('after')) + + await press(shift(Keys.Tab)) + assertActiveElement(getByText('Content 1')) + + await press(shift(Keys.Tab)) + assertActiveElement(getByText('Tab 1')) + }) + + it('should be possible to tab to the default index 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')) + + await press(Keys.Tab) + assertActiveElement(getByText('Content 2')) + + await press(Keys.Tab) + assertActiveElement(getByText('after')) + + await press(shift(Keys.Tab)) + assertActiveElement(getByText('Content 2')) + + await press(shift(Keys.Tab)) + assertActiveElement(getByText('Tab 2')) + }) + }) + + describe('`ArrowRight` key', () => { + it('should be possible to go to the next item (activation = `auto`)', 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 }) + + await press(Keys.ArrowRight) + assertTabs({ active: 1 }) + + await press(Keys.ArrowRight) + assertTabs({ active: 2 }) + }) + + it('should be possible to go to the next item (activation = `manual`)', 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 }) + + await press(Keys.ArrowRight) + assertTabs({ active: 0 }) + await press(Keys.Enter) + assertTabs({ active: 1 }) + + await press(Keys.ArrowRight) + assertTabs({ active: 1 }) + await press(Keys.Enter) + assertTabs({ active: 2 }) + }) + + it('should wrap around at the end (activation = `auto`)', 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 }) + + await press(Keys.ArrowRight) + assertTabs({ active: 1 }) + + await press(Keys.ArrowRight) + assertTabs({ active: 2 }) + + await press(Keys.ArrowRight) + assertTabs({ active: 0 }) + + await press(Keys.ArrowRight) + assertTabs({ active: 1 }) + }) + + it('should wrap around at the end (activation = `manual`)', 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 }) + + await press(Keys.ArrowRight) + assertTabs({ active: 0 }) + await press(Keys.Enter) + assertTabs({ active: 1 }) + + await press(Keys.ArrowRight) + assertTabs({ active: 1 }) + await press(Keys.Enter) + assertTabs({ active: 2 }) + + await press(Keys.ArrowRight) + assertTabs({ active: 2 }) + await press(Keys.Enter) + assertTabs({ active: 0 }) + + await press(Keys.ArrowRight) + assertTabs({ active: 0 }) + await press(Keys.Enter) + assertTabs({ active: 1 }) + }) + + it('should not be possible to go right when in vertical mode (activation = `auto`)', 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, orientation: 'vertical' }) + + await press(Keys.ArrowRight) + // no-op + assertTabs({ active: 0, orientation: 'vertical' }) + }) + + it('should not be possible to go right when in vertical mode (activation = `manual`)', 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, orientation: 'vertical' }) + + await press(Keys.ArrowRight) + assertTabs({ active: 0, orientation: 'vertical' }) + await press(Keys.Enter) + // no-op + assertTabs({ active: 0, orientation: 'vertical' }) + }) + }) + + describe('`ArrowLeft` key', () => { + it('should be possible to go to the previous item (activation = `auto`)', 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 }) + + await press(Keys.ArrowLeft) + assertTabs({ active: 1 }) + + await press(Keys.ArrowLeft) + assertTabs({ active: 0 }) + }) + + it('should be possible to go to the previous item (activation = `manual`)', 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 }) + + await press(Keys.ArrowLeft) + assertTabs({ active: 2 }) + await press(Keys.Enter) + assertTabs({ active: 1 }) + + await press(Keys.ArrowLeft) + assertTabs({ active: 1 }) + await press(Keys.Enter) + assertTabs({ active: 0 }) + }) + + it('should wrap around at the beginning (activation = `auto`)', 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 }) + + await press(Keys.ArrowLeft) + assertTabs({ active: 1 }) + + await press(Keys.ArrowLeft) + assertTabs({ active: 0 }) + + await press(Keys.ArrowLeft) + assertTabs({ active: 2 }) + + await press(Keys.ArrowLeft) + assertTabs({ active: 1 }) + }) + + it('should wrap around at the beginning (activation = `manual`)', 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 }) + + await press(Keys.ArrowLeft) + assertTabs({ active: 2 }) + await press(Keys.Enter) + assertTabs({ active: 1 }) + + await press(Keys.ArrowLeft) + assertTabs({ active: 1 }) + await press(Keys.Enter) + assertTabs({ active: 0 }) + + await press(Keys.ArrowLeft) + assertTabs({ active: 0 }) + await press(Keys.Enter) + assertTabs({ active: 2 }) + + await press(Keys.ArrowLeft) + assertTabs({ active: 2 }) + await press(Keys.Enter) + assertTabs({ active: 1 }) + }) + + it('should not be possible to go left when in vertical mode (activation = `auto`)', 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, orientation: 'vertical' }) + + await press(Keys.ArrowLeft) + // no-op + assertTabs({ active: 0, orientation: 'vertical' }) + }) + + it('should not be possible to go left when in vertical mode (activation = `manual`)', 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, orientation: 'vertical' }) + + await press(Keys.ArrowLeft) + assertTabs({ active: 0, orientation: 'vertical' }) + await press(Keys.Enter) + + // no-op + assertTabs({ active: 0, orientation: 'vertical' }) + }) + }) + + describe('`ArrowDown` key', () => { + it('should be possible to go to the next item (activation = `auto`)', 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, orientation: 'vertical' }) + + await press(Keys.ArrowDown) + assertTabs({ active: 1, orientation: 'vertical' }) + + await press(Keys.ArrowDown) + assertTabs({ active: 2, orientation: 'vertical' }) + }) + + it('should be possible to go to the next item (activation = `manual`)', 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, orientation: 'vertical' }) + + await press(Keys.ArrowDown) + assertTabs({ active: 0, orientation: 'vertical' }) + await press(Keys.Enter) + assertTabs({ active: 1, orientation: 'vertical' }) + + await press(Keys.ArrowDown) + assertTabs({ active: 1, orientation: 'vertical' }) + await press(Keys.Enter) + assertTabs({ active: 2, orientation: 'vertical' }) + }) + + it('should wrap around at the end (activation = `auto`)', 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, orientation: 'vertical' }) + + await press(Keys.ArrowDown) + assertTabs({ active: 1, orientation: 'vertical' }) + + await press(Keys.ArrowDown) + assertTabs({ active: 2, orientation: 'vertical' }) + + await press(Keys.ArrowDown) + assertTabs({ active: 0, orientation: 'vertical' }) + + await press(Keys.ArrowDown) + assertTabs({ active: 1, orientation: 'vertical' }) + }) + + it('should wrap around at the end (activation = `manual`)', 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, orientation: 'vertical' }) + + await press(Keys.ArrowDown) + assertTabs({ active: 0, orientation: 'vertical' }) + await press(Keys.Enter) + assertTabs({ active: 1, orientation: 'vertical' }) + + await press(Keys.ArrowDown) + assertTabs({ active: 1, orientation: 'vertical' }) + await press(Keys.Enter) + assertTabs({ active: 2, orientation: 'vertical' }) + + await press(Keys.ArrowDown) + assertTabs({ active: 2, orientation: 'vertical' }) + await press(Keys.Enter) + assertTabs({ active: 0, orientation: 'vertical' }) + + await press(Keys.ArrowDown) + assertTabs({ active: 0, orientation: 'vertical' }) + await press(Keys.Enter) + assertTabs({ active: 1, orientation: 'vertical' }) + }) + + it('should not be possible to go down when in horizontal mode (activation = `auto`)', 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 }) + + await press(Keys.ArrowDown) + // no-op + assertTabs({ active: 0 }) + }) + + it('should not be possible to go down when in horizontal mode (activation = `manual`)', 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 }) + + await press(Keys.ArrowDown) + assertTabs({ active: 0 }) + await press(Keys.Enter) + + // no-op + assertTabs({ active: 0 }) + }) + }) + + describe('`ArrowUp` key', () => { + it('should be possible to go to the previous item (activation = `auto`)', 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, orientation: 'vertical' }) + + await press(Keys.ArrowUp) + assertTabs({ active: 1, orientation: 'vertical' }) + + await press(Keys.ArrowUp) + assertTabs({ active: 0, orientation: 'vertical' }) + }) + + it('should be possible to go to the previous item (activation = `manual`)', 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, orientation: 'vertical' }) + + await press(Keys.ArrowUp) + assertTabs({ active: 2, orientation: 'vertical' }) + await press(Keys.Enter) + assertTabs({ active: 1, orientation: 'vertical' }) + + await press(Keys.ArrowUp) + assertTabs({ active: 1, orientation: 'vertical' }) + await press(Keys.Enter) + assertTabs({ active: 0, orientation: 'vertical' }) + }) + + it('should wrap around at the beginning (activation = `auto`)', 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, orientation: 'vertical' }) + + await press(Keys.ArrowUp) + assertTabs({ active: 1, orientation: 'vertical' }) + + await press(Keys.ArrowUp) + assertTabs({ active: 0, orientation: 'vertical' }) + + await press(Keys.ArrowUp) + assertTabs({ active: 2, orientation: 'vertical' }) + + await press(Keys.ArrowUp) + assertTabs({ active: 1, orientation: 'vertical' }) + }) + + it('should wrap around at the beginning (activation = `manual`)', 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, orientation: 'vertical' }) + + await press(Keys.ArrowUp) + assertTabs({ active: 2, orientation: 'vertical' }) + await press(Keys.Enter) + assertTabs({ active: 1, orientation: 'vertical' }) + + await press(Keys.ArrowUp) + assertTabs({ active: 1, orientation: 'vertical' }) + await press(Keys.Enter) + assertTabs({ active: 0, orientation: 'vertical' }) + + await press(Keys.ArrowUp) + assertTabs({ active: 0, orientation: 'vertical' }) + await press(Keys.Enter) + assertTabs({ active: 2, orientation: 'vertical' }) + + await press(Keys.ArrowUp) + assertTabs({ active: 2, orientation: 'vertical' }) + await press(Keys.Enter) + assertTabs({ active: 1, orientation: 'vertical' }) + }) + + it('should not be possible to go left when in vertical mode (activation = `auto`)', 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 }) + + await press(Keys.ArrowUp) + // no-op + assertTabs({ active: 0 }) + }) + + it('should not be possible to go left when in vertical mode (activation = `manual`)', 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 }) + + await press(Keys.ArrowUp) + assertTabs({ active: 0 }) + await press(Keys.Enter) + + // no-op + assertTabs({ active: 0 }) + }) + }) + + describe('`Home` key', () => { + it('should be possible to go to the first focusable item (activation = `auto`)', 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 }) + + await press(Keys.Home) + assertTabs({ active: 0 }) + }) + + it('should be possible to go to the first focusable item (activation = `manual`)', 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 }) + + await press(Keys.Home) + assertTabs({ active: 1 }) + await press(Keys.Enter) + assertTabs({ active: 0 }) + }) + }) + + describe('`PageUp` key', () => { + it('should be possible to go to the first focusable item (activation = `auto`)', 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 }) + + await press(Keys.PageUp) + assertTabs({ active: 0 }) + }) + + it('should be possible to go to the first focusable item (activation = `manual`)', 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 }) + + await press(Keys.PageUp) + assertTabs({ active: 1 }) + await press(Keys.Enter) + assertTabs({ active: 0 }) + }) + }) + + describe('`End` key', () => { + it('should be possible to go to the first focusable item (activation = `auto`)', 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 }) + + await press(Keys.End) + assertTabs({ active: 2 }) + }) + + it('should be possible to go to the first focusable item (activation = `manual`)', 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 }) + + await press(Keys.End) + assertTabs({ active: 1 }) + await press(Keys.Enter) + assertTabs({ active: 2 }) + }) + }) + + describe('`PageDown` key', () => { + it('should be possible to go to the first focusable item (activation = `auto`)', 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 }) + + await press(Keys.PageDown) + assertTabs({ active: 2 }) + }) + + it('should be possible to go to the first focusable item (activation = `manual`)', 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 }) + + await press(Keys.PageDown) + assertTabs({ active: 1 }) + await press(Keys.Enter) + assertTabs({ active: 2 }) + }) + }) + + describe('`Enter` key', () => { + it('should be possible to activate the focused tab', async () => { + renderTemplate( + html` + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + ` + ) + + await new Promise(nextTick) + + assertActiveElement(document.body) + + getByText('Tab 3')?.focus() + + assertActiveElement(getByText('Tab 3')) + assertTabs({ active: 0 }) + + await press(Keys.Enter) + assertTabs({ active: 2 }) + }) + }) + + describe('`Space` key', () => { + it('should be possible to activate the focused tab', async () => { + renderTemplate( + html` + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + ` + ) + + await new Promise(nextTick) + + assertActiveElement(document.body) + + getByText('Tab 3')?.focus() + + assertActiveElement(getByText('Tab 3')) + assertTabs({ active: 0 }) + + await press(Keys.Space) + assertTabs({ active: 2 }) + }) + }) +}) + +describe('Mouse interactions', () => { + it('should be possible to click on a tab to focus it', 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 }) + + await click(getByText('Tab 1')) + assertTabs({ active: 0 }) + + await click(getByText('Tab 3')) + assertTabs({ active: 2 }) + + await click(getByText('Tab 2')) + assertTabs({ active: 1 }) + }) + + it('should be a no-op when clicking on 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 }) + + await click(getByText('Tab 1')) + // No-op, Tab 2 is still active + assertTabs({ active: 1 }) + }) +}) + +it('should trigger the `onChange` when the tab changes', async () => { + let changes = jest.fn() + + renderTemplate({ + template: html` + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + + + + `, + setup: () => ({ changes }), + }) + + await new Promise(nextTick) + + await click(getByText('Tab 2')) + await click(getByText('Tab 3')) + await click(getByText('Tab 2')) + await click(getByText('Tab 1')) + + expect(changes).toHaveBeenCalledTimes(4) + + expect(changes).toHaveBeenNthCalledWith(1, 1) + expect(changes).toHaveBeenNthCalledWith(2, 2) + expect(changes).toHaveBeenNthCalledWith(3, 1) + expect(changes).toHaveBeenNthCalledWith(4, 0) +}) diff --git a/packages/@headlessui-vue/src/components/tabs/tabs.ts b/packages/@headlessui-vue/src/components/tabs/tabs.ts new file mode 100644 index 0000000000..a37fd52aa7 --- /dev/null +++ b/packages/@headlessui-vue/src/components/tabs/tabs.ts @@ -0,0 +1,356 @@ +import { + defineComponent, + ref, + provide, + inject, + onMounted, + onUnmounted, + computed, + InjectionKey, + Ref, +} from 'vue' + +import { Features, render, omit } from '../../utils/render' +import { useId } from '../../hooks/use-id' +import { Keys } from '../../keyboard' +import { dom } from '../../utils/dom' +import { match } from '../../utils/match' +import { focusIn, Focus } from '../../utils/focus-management' + +type StateDefinition = { + // State + selectedIndex: Ref + orientation: Ref<'vertical' | 'horizontal'> + activation: Ref<'auto' | 'manual'> + + tabs: Ref[]> + panels: Ref[]> + + // State mutators + setSelectedIndex(index: number): void + registerTab(tab: Ref): void + unregisterTab(tab: Ref): void + registerPanel(panel: Ref): void + unregisterPanel(panel: Ref): void +} + +let TabsContext = Symbol('TabsContext') as InjectionKey + +function useTabsContext(component: string) { + let context = inject(TabsContext, null) + + if (context === null) { + let err = new Error(`<${component} /> is missing a parent component.`) + if (Error.captureStackTrace) Error.captureStackTrace(err, useTabsContext) + throw err + } + + return context +} + +// --- + +export let Tabs = defineComponent({ + name: 'Tabs', + emits: ['change'], + props: { + as: { type: [Object, String], default: 'template' }, + defaultIndex: { type: [Number], default: 0 }, + vertical: { type: [Boolean], default: false }, + manual: { type: [Boolean], default: false }, + }, + setup(props, { slots, attrs, emit }) { + let selectedIndex = ref(null) + let tabs = ref([]) + let panels = ref([]) + + 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) + }, + registerTab(tab: typeof tabs['value'][number]) { + if (!tabs.value.includes(tab)) tabs.value.push(tab) + }, + unregisterTab(tab: typeof tabs['value'][number]) { + let idx = tabs.value.indexOf(tab) + if (idx !== -1) tabs.value.slice(idx, 1) + }, + registerPanel(panel: typeof panels['value'][number]) { + if (!panels.value.includes(panel)) panels.value.push(panel) + }, + unregisterPanel(panel: typeof panels['value'][number]) { + let idx = panels.value.indexOf(panel) + if (idx !== -1) panels.value.slice(idx, 1) + }, + } + + provide(TabsContext, api) + + onMounted(() => { + if (api.tabs.value.length <= 0) return console.log('bail') + if (selectedIndex.value !== null) return console.log('bail 2') + + let tabs = api.tabs.value.map(tab => dom(tab)).filter(Boolean) as HTMLElement[] + let focusableTabs = tabs.filter(tab => !tab.hasAttribute('disabled')) + + // Underflow + if (props.defaultIndex < 0) { + selectedIndex.value = tabs.indexOf(focusableTabs[0]) + } + + // Overflow + else if (props.defaultIndex > 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 next = [...after, ...before].find(tab => focusableTabs.includes(tab)) + if (!next) return + + selectedIndex.value = tabs.indexOf(next) + } + }) + + return () => { + let slot = { selectedIndex: selectedIndex.value } + + return render({ + props: omit(props, ['defaultIndex', 'manual', 'vertical']), + slot, + slots, + attrs, + name: 'Tabs', + }) + } + }, +}) + +// --- + +export let TabsList = defineComponent({ + name: 'TabsList', + props: { + as: { type: [Object, String], default: 'div' }, + }, + setup(props, { attrs, slots }) { + let api = useTabsContext('TabsList') + + return () => { + let slot = { selectedIndex: api.selectedIndex.value } + + let propsWeControl = { + role: 'tablist', + 'aria-orientation': api.orientation.value, + } + let passThroughProps = props + + return render({ + props: { ...passThroughProps, ...propsWeControl }, + slot, + attrs, + slots, + name: 'TabsList', + }) + } + }, +}) + +// --- + +export let TabsTab = defineComponent({ + name: 'TabsTab', + props: { + as: { type: [Object, String], default: 'button' }, + disabled: { type: [Boolean], default: false }, + }, + render() { + let api = useTabsContext('TabsTab') + + let slot = { selected: this.selected } + let propsWeControl = { + ref: 'el', + onKeydown: this.handleKeyDown, + onFocus: api.activation.value === 'manual' ? this.handleFocus : this.handleSelection, + onClick: this.handleSelection, + id: this.id, + role: 'tab', + type: this.type, + 'aria-controls': api.panels.value[this.myIndex]?.value?.id, + 'aria-selected': this.selected, + tabIndex: this.selected ? 0 : -1, + disabled: this.$props.disabled ? true : undefined, + } + + if (process.env.NODE_ENV === 'test') { + Object.assign(propsWeControl, { ['data-headlessui-index']: this.myIndex }) + } + + return render({ + props: { ...this.$props, ...propsWeControl }, + slot, + attrs: this.$attrs, + slots: this.$slots, + name: 'TabsTab', + }) + }, + setup(props, { attrs }) { + let api = useTabsContext('TabsTab') + let id = `headlessui-tabs-tab-${useId()}` + + let tabRef = ref() + + onMounted(() => api.registerTab(tabRef)) + onUnmounted(() => api.unregisterTab(tabRef)) + + let myIndex = computed(() => api.tabs.value.indexOf(tabRef)) + let selected = computed(() => myIndex.value === api.selectedIndex.value) + let type = computed(() => attrs.type ?? (props.as === 'button' ? 'button' : undefined)) + + function handleKeyDown(event: KeyboardEvent) { + let list = api.tabs.value.map(tab => dom(tab)).filter(Boolean) as HTMLElement[] + + if (event.key === Keys.Space || event.key === Keys.Enter) { + event.preventDefault() + event.stopPropagation() + + api.setSelectedIndex(myIndex.value) + return + } + + switch (event.key) { + case Keys.Home: + case Keys.PageUp: + event.preventDefault() + event.stopPropagation() + + return focusIn(list, Focus.First) + + case Keys.End: + case Keys.PageDown: + event.preventDefault() + event.stopPropagation() + + return focusIn(list, Focus.Last) + } + + return match(api.orientation.value, { + vertical() { + if (event.key === Keys.ArrowUp) return focusIn(list, Focus.Previous | Focus.WrapAround) + if (event.key === Keys.ArrowDown) return focusIn(list, Focus.Next | Focus.WrapAround) + return + }, + horizontal() { + if (event.key === Keys.ArrowLeft) return focusIn(list, Focus.Previous | Focus.WrapAround) + if (event.key === Keys.ArrowRight) return focusIn(list, Focus.Next | Focus.WrapAround) + return + }, + }) + } + + function handleFocus() { + dom(tabRef)?.focus() + } + + function handleSelection() { + if (props.disabled) return + + dom(tabRef)?.focus() + api.setSelectedIndex(myIndex.value) + } + + return { + el: tabRef, + id, + selected, + myIndex, + type, + handleKeyDown, + handleFocus, + handleSelection, + } + }, +}) + +// --- + +export let TabsPanels = defineComponent({ + name: 'TabsPanels', + props: { + as: { type: [Object, String], default: 'div' }, + }, + setup(props, { slots, attrs }) { + let api = useTabsContext('TabsPanels') + + return () => { + let slot = { selectedIndex: api.selectedIndex.value } + + return render({ + props, + slot, + attrs, + slots, + name: 'TabsPanels', + }) + } + }, +}) + +export let TabsPanel = defineComponent({ + name: 'TabsPanel', + props: { + as: { type: [Object, String], default: 'div' }, + static: { type: Boolean, default: false }, + unmount: { type: Boolean, default: true }, + }, + render() { + let api = useTabsContext('TabsPanel') + + let slot = { selected: this.selected } + let propsWeControl = { + ref: 'el', + id: this.id, + role: 'tabpanel', + 'aria-labelledby': api.tabs.value[this.myIndex]?.value?.id, + tabIndex: this.selected ? 0 : -1, + } + + if (process.env.NODE_ENV === 'test') { + Object.assign(propsWeControl, { ['data-headlessui-index']: this.myIndex }) + } + + return render({ + props: { ...this.$props, ...propsWeControl }, + slot, + attrs: this.$attrs, + slots: this.$slots, + features: Features.Static | Features.RenderStrategy, + visible: this.selected, + name: 'TabsPanel', + }) + }, + setup() { + let api = useTabsContext('TabsPanel') + let id = `headlessui-tabs-panel-${useId()}` + + let panelRef = ref() + + onMounted(() => api.registerPanel(panelRef)) + onUnmounted(() => api.unregisterPanel(panelRef)) + + let myIndex = computed(() => api.panels.value.indexOf(panelRef)) + let selected = computed(() => myIndex.value === api.selectedIndex.value) + + return { id, el: panelRef, selected, myIndex } + }, +}) diff --git a/packages/@headlessui-vue/src/index.test.ts b/packages/@headlessui-vue/src/index.test.ts index 376eb9887f..efa7b8d5e5 100644 --- a/packages/@headlessui-vue/src/index.test.ts +++ b/packages/@headlessui-vue/src/index.test.ts @@ -56,6 +56,13 @@ it('should expose the correct components', () => { 'SwitchLabel', 'SwitchDescription', + // Tabs + 'Tabs', + 'TabsList', + 'TabsTab', + 'TabsPanels', + 'TabsPanel', + // Transition 'TransitionChild', 'TransitionRoot', diff --git a/packages/@headlessui-vue/src/index.ts b/packages/@headlessui-vue/src/index.ts index cce0dd2a89..2ef29db23c 100644 --- a/packages/@headlessui-vue/src/index.ts +++ b/packages/@headlessui-vue/src/index.ts @@ -7,4 +7,5 @@ export * from './components/popover/popover' export * from './components/portal/portal' export * from './components/radio-group/radio-group' export * from './components/switch/switch' +export * from './components/tabs/tabs' export * from './components/transitions/transition' diff --git a/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts b/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts index ade99410e9..29ca3a8835 100644 --- a/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts +++ b/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts @@ -1187,6 +1187,88 @@ export function assertRadioGroupLabel( // --- +export function getTabList(): HTMLElement | null { + return document.querySelector('[role="tablist"]') +} + +export function getTabs(): HTMLElement[] { + return Array.from(document.querySelectorAll('[id^="headlessui-tabs-tab-"]')) +} + +export function getPanels(): HTMLElement[] { + return Array.from(document.querySelectorAll('[id^="headlessui-tabs-panel-"]')) +} + +// --- + +export function assertTabs( + { + active, + orientation = 'horizontal', + }: { + active: number + orientation?: 'vertical' | 'horizontal' + }, + list = getTabList(), + tabs = getTabs(), + panels = getPanels() +) { + try { + if (list === null) return expect(list).not.toBe(null) + + expect(list).toHaveAttribute('role', 'tablist') + expect(list).toHaveAttribute('aria-orientation', orientation) + + let activeTab = tabs.find(tab => tab.dataset.headlessuiIndex === '' + active) + let activePanel = panels.find(panel => panel.dataset.headlessuiIndex === '' + active) + + for (let tab of tabs) { + expect(tab).toHaveAttribute('id') + expect(tab).toHaveAttribute('role', 'tab') + expect(tab).toHaveAttribute('type', 'button') + + if (tab === activeTab) { + expect(tab).toHaveAttribute('aria-selected', 'true') + expect(tab).toHaveAttribute('tabindex', '0') + } else { + expect(tab).toHaveAttribute('aria-selected', 'false') + expect(tab).toHaveAttribute('tabindex', '-1') + } + + if (tab.hasAttribute('aria-controls')) { + let controlsId = tab.getAttribute('aria-controls')! + let panel = document.getElementById(controlsId) + + expect(panel).not.toBe(null) + expect(panels).toContain(panel) + expect(panel).toHaveAttribute('aria-labelledby', tab.id) + } + } + + for (let panel of panels) { + expect(panel).toHaveAttribute('id') + expect(panel).toHaveAttribute('role', 'tabpanel') + + let controlledById = panel.getAttribute('aria-labelledby')! + let tab = document.getElementById(controlledById) + + expect(tabs).toContain(tab) + expect(tab).toHaveAttribute('aria-controls', panel.id) + + if (panel === activePanel) { + expect(panel).toHaveAttribute('tabindex', '0') + } else { + expect(panel).toHaveAttribute('tabindex', '-1') + } + } + } catch (err) { + Error.captureStackTrace(err, assertTabs) + throw err + } +} + +// --- + export function assertActiveElement(element: HTMLElement | null) { try { if (element === null) return expect(element).not.toBe(null) From 10110a928fd57afee458b231a74bc9c86d88bdb2 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 13 Jul 2021 19:29:29 +0200 Subject: [PATCH 3/8] Add ability to use `Disclosure.Button` inside a `Disclosure.Panel` (#682) * add ability to use `Disclosure.Button` inside a `Disclosure.Panel` If you do it this way, then the `Disclosure.Button` will function as a `close` button. This will make it consistent with the `Popover.Button` inside the `Popover.Panel` funcitonality. * update changelog --- CHANGELOG.md | 2 + .../components/disclosure/disclosure.test.tsx | 34 ++++++ .../src/components/disclosure/disclosure.tsx | 94 +++++++++++----- .../components/disclosure/disclosure.test.ts | 36 +++++++ .../src/components/disclosure/disclosure.ts | 101 +++++++++++++----- 5 files changed, 210 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f97e89af6..7ef38401e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add new `Tabs` component ([#674](https://github.com/tailwindlabs/headlessui/pull/674)) +- Make `Disclosure.Button` close the disclosure inside a `Disclosure.Panel` ([#682](https://github.com/tailwindlabs/headlessui/pull/682)) ## [Unreleased - Vue] ### Added - Add new `Tabs` component ([#674](https://github.com/tailwindlabs/headlessui/pull/674)) +- Make `DisclosureButton` close the disclosure inside a `DisclosurePanel` ([#682](https://github.com/tailwindlabs/headlessui/pull/682)) ## [@headlessui/react@v1.3.0] - 2021-06-21 diff --git a/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx b/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx index 06f432bf1b..ccbaa1e812 100644 --- a/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx +++ b/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx @@ -9,6 +9,8 @@ import { assertDisclosureButton, getDisclosureButton, getDisclosurePanel, + assertActiveElement, + getByText, } from '../../test-utils/accessibility-assertions' import { click, press, Keys, MouseButton } from '../../test-utils/interactions' import { Transition } from '../transitions/transition' @@ -619,4 +621,36 @@ describe('Mouse interactions', () => { assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted }) }) ) + + it( + 'should be possible to close the Disclosure by clicking on a Disclosure.Button inside a Disclosure.Panel', + suppressConsoleLogs(async () => { + render( + + Open + + Close + + + ) + + // Open the disclosure + await click(getDisclosureButton()) + + let closeBtn = getByText('Close') + + expect(closeBtn).not.toHaveAttribute('id') + expect(closeBtn).not.toHaveAttribute('aria-controls') + expect(closeBtn).not.toHaveAttribute('aria-expanded') + + // The close button should close the disclosure + await click(closeBtn) + + // Verify it is closed + assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted }) + + // Verify we restored the Open button + assertActiveElement(getDisclosureButton()) + }) + ) }) diff --git a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx index 0f2f28785b..3de8a85bb0 100644 --- a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx +++ b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx @@ -100,6 +100,13 @@ function useDisclosureContext(component: string) { return context } +let DisclosurePanelContext = createContext(null) +DisclosurePanelContext.displayName = 'DisclosurePanelContext' + +function useDisclosurePanelContext() { + return useContext(DisclosurePanelContext) +} + function stateReducer(state: StateDefinition, action: Actions) { return match(action.type, reducers, state, action) } @@ -176,18 +183,35 @@ let Button = forwardRefWithAs(function Button) => { - switch (event.key) { - case Keys.Space: - case Keys.Enter: - event.preventDefault() - event.stopPropagation() - dispatch({ type: ActionTypes.ToggleDisclosure }) - break + if (isWithinPanel) { + if (state.disclosureState === DisclosureStates.Closed) return + + switch (event.key) { + case Keys.Space: + case Keys.Enter: + event.preventDefault() + event.stopPropagation() + dispatch({ type: ActionTypes.ToggleDisclosure }) + document.getElementById(state.buttonId)?.focus() + break + } + } else { + switch (event.key) { + case Keys.Space: + case Keys.Enter: + event.preventDefault() + event.stopPropagation() + dispatch({ type: ActionTypes.ToggleDisclosure }) + break + } } }, - [dispatch] + [dispatch, isWithinPanel, state.disclosureState] ) let handleKeyUp = useCallback((event: ReactKeyboardEvent) => { @@ -205,9 +229,15 @@ let Button = forwardRefWithAs(function Button { if (isDisabledReactIssue7711(event.currentTarget)) return if (props.disabled) return - dispatch({ type: ActionTypes.ToggleDisclosure }) + + if (isWithinPanel) { + dispatch({ type: ActionTypes.ToggleDisclosure }) + document.getElementById(state.buttonId)?.focus() + } else { + dispatch({ type: ActionTypes.ToggleDisclosure }) + } }, - [dispatch, props.disabled] + [dispatch, props.disabled, state.buttonId, isWithinPanel] ) let slot = useMemo( @@ -216,16 +246,20 @@ let Button = forwardRefWithAs(function Button + {render({ + props: { ...passthroughProps, ...propsWeControl }, + slot, + defaultTag: DEFAULT_PANEL_TAG, + features: PanelRenderFeatures, + visible, + name: 'Disclosure.Panel', + })} + + ) }) // --- diff --git a/packages/@headlessui-vue/src/components/disclosure/disclosure.test.ts b/packages/@headlessui-vue/src/components/disclosure/disclosure.test.ts index e0fd49d1b4..29f5952a42 100644 --- a/packages/@headlessui-vue/src/components/disclosure/disclosure.test.ts +++ b/packages/@headlessui-vue/src/components/disclosure/disclosure.test.ts @@ -8,6 +8,8 @@ import { assertDisclosureButton, getDisclosureButton, getDisclosurePanel, + getByText, + assertActiveElement, } from '../../test-utils/accessibility-assertions' import { click, press, Keys, MouseButton } from '../../test-utils/interactions' import { html } from '../../test-utils/html' @@ -715,4 +717,38 @@ describe('Mouse interactions', () => { assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted }) }) ) + + it( + 'should be possible to close the Disclosure by clicking on a DisclosureButton inside a DisclosurePanel', + suppressConsoleLogs(async () => { + renderTemplate( + html` + + Open + + Close + + + ` + ) + + // Open the disclosure + await click(getDisclosureButton()) + + let closeBtn = getByText('Close') + + expect(closeBtn).not.toHaveAttribute('id') + expect(closeBtn).not.toHaveAttribute('aria-controls') + expect(closeBtn).not.toHaveAttribute('aria-expanded') + + // The close button should close the disclosure + await click(closeBtn) + + // Verify it is closed + assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted }) + + // Verify we restored the Open button + assertActiveElement(getDisclosureButton()) + }) + ) }) diff --git a/packages/@headlessui-vue/src/components/disclosure/disclosure.ts b/packages/@headlessui-vue/src/components/disclosure/disclosure.ts index 7d67deccb4..5b5a5ab702 100644 --- a/packages/@headlessui-vue/src/components/disclosure/disclosure.ts +++ b/packages/@headlessui-vue/src/components/disclosure/disclosure.ts @@ -16,7 +16,10 @@ enum DisclosureStates { interface StateDefinition { // State disclosureState: Ref - panelRef: Ref + panel: Ref + panelId: string + button: Ref + buttonId: string // State mutators toggleDisclosure(): void @@ -36,6 +39,11 @@ function useDisclosureContext(component: string) { return context } +let DisclosurePanelContext = Symbol('DisclosurePanelContext') as InjectionKey +function useDisclosurePanelContext() { + return inject(DisclosurePanelContext, null) +} + // --- export let Disclosure = defineComponent({ @@ -45,14 +53,21 @@ export let Disclosure = defineComponent({ defaultOpen: { type: [Boolean], default: false }, }, setup(props, { slots, attrs }) { + let buttonId = `headlessui-disclosure-button-${useId()}` + let panelId = `headlessui-disclosure-panel-${useId()}` + let disclosureState = ref( props.defaultOpen ? DisclosureStates.Open : DisclosureStates.Closed ) - let panelRef = ref(null) + let panelRef = ref(null) + let buttonRef = ref(null) let api = { + buttonId, + panelId, disclosureState, - panelRef, + panel: panelRef, + button: buttonRef, toggleDisclosure() { disclosureState.value = match(disclosureState.value, { [DisclosureStates.Open]: DisclosureStates.Closed, @@ -91,18 +106,25 @@ export let DisclosureButton = defineComponent({ let api = useDisclosureContext('DisclosureButton') let slot = { open: api.disclosureState.value === DisclosureStates.Open } - let propsWeControl = { - id: this.id, - type: 'button', - 'aria-expanded': this.$props.disabled - ? undefined - : api.disclosureState.value === DisclosureStates.Open, - 'aria-controls': this.ariaControls, - disabled: this.$props.disabled ? true : undefined, - onClick: this.handleClick, - onKeydown: this.handleKeyDown, - onKeyup: this.handleKeyUp, - } + let propsWeControl = this.isWithinPanel + ? { + type: 'button', + onClick: this.handleClick, + onKeydown: this.handleKeyDown, + } + : { + id: this.id, + ref: 'el', + type: 'button', + 'aria-expanded': this.$props.disabled + ? undefined + : api.disclosureState.value === DisclosureStates.Open, + 'aria-controls': dom(api.panel) ? api.panelId : undefined, + disabled: this.$props.disabled ? true : undefined, + onClick: this.handleClick, + onKeydown: this.handleKeyDown, + onKeyup: this.handleKeyUp, + } return render({ props: { ...this.$props, ...propsWeControl }, @@ -114,26 +136,46 @@ export let DisclosureButton = defineComponent({ }, setup(props) { let api = useDisclosureContext('DisclosureButton') - let buttonId = `headlessui-disclosure-button-${useId()}` - let ariaControls = computed(() => dom(api.panelRef)?.id ?? undefined) + + let panelContext = useDisclosurePanelContext() + let isWithinPanel = panelContext === null ? false : panelContext === api.panelId return { - id: buttonId, - ariaControls, + isWithinPanel, + id: api.buttonId, + el: isWithinPanel ? undefined : api.button, handleClick() { if (props.disabled) return - api.toggleDisclosure() + + if (isWithinPanel) { + api.toggleDisclosure() + dom(api.button)?.focus() + } else { + api.toggleDisclosure() + } }, handleKeyDown(event: KeyboardEvent) { if (props.disabled) return - switch (event.key) { - case Keys.Space: - case Keys.Enter: - event.preventDefault() - event.stopPropagation() - api.toggleDisclosure() - break + if (isWithinPanel) { + switch (event.key) { + case Keys.Space: + case Keys.Enter: + event.preventDefault() + event.stopPropagation() + api.toggleDisclosure() + dom(api.button)?.focus() + break + } + } else { + switch (event.key) { + case Keys.Space: + case Keys.Enter: + event.preventDefault() + event.stopPropagation() + api.toggleDisclosure() + break + } } }, handleKeyUp(event: KeyboardEvent) { @@ -177,7 +219,8 @@ export let DisclosurePanel = defineComponent({ }, setup() { let api = useDisclosureContext('DisclosurePanel') - let panelId = `headlessui-disclosure-panel-${useId()}` + + provide(DisclosurePanelContext, api.panelId) let usesOpenClosedState = useOpenClosed() let visible = computed(() => { @@ -188,6 +231,6 @@ export let DisclosurePanel = defineComponent({ return api.disclosureState.value === DisclosureStates.Open }) - return { id: panelId, el: api.panelRef, visible } + return { id: api.panelId, el: api.panel, visible } }, }) From 0cc9728694bccf20f78ac340e2e03723bc427238 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 13 Jul 2021 23:36:15 +0200 Subject: [PATCH 4/8] Add `aria-orientation` to the Listbox component (#683) * add `aria-orientation` to the Listbox component By default the `Listbox` will have an orientation of `vertical`. When you pass the `horizontal` prop to the `Listbox` component then the `aria-orientation` will be set to `horizontal`. Additionally, we swap the previous/next keys: - Vertical: ArrowUp/ArrowDown - Horizontal: ArrowLeft/ArrowRight * update changelog --- CHANGELOG.md | 2 + .../src/components/listbox/listbox.test.tsx | 106 +++++++++++++++++ .../src/components/listbox/listbox.tsx | 25 +++- .../test-utils/accessibility-assertions.ts | 5 + .../src/components/listbox/listbox.test.tsx | 112 ++++++++++++++++++ .../src/components/listbox/listbox.ts | 15 ++- .../test-utils/accessibility-assertions.ts | 5 + 7 files changed, 264 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ef38401e7..d1013637dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add new `Tabs` component ([#674](https://github.com/tailwindlabs/headlessui/pull/674)) - Make `Disclosure.Button` close the disclosure inside a `Disclosure.Panel` ([#682](https://github.com/tailwindlabs/headlessui/pull/682)) +- Add `aria-orientation` to `Listbox`, which swaps Up/Down with Left/Right keys ([#683](https://github.com/tailwindlabs/headlessui/pull/683)) ## [Unreleased - Vue] @@ -18,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add new `Tabs` component ([#674](https://github.com/tailwindlabs/headlessui/pull/674)) - Make `DisclosureButton` close the disclosure inside a `DisclosurePanel` ([#682](https://github.com/tailwindlabs/headlessui/pull/682)) +- Add `aria-orientation` to `Listbox`, which swaps Up/Down with Left/Right keys ([#683](https://github.com/tailwindlabs/headlessui/pull/683)) ## [@headlessui/react@v1.3.0] - 2021-06-21 diff --git a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx index 6e75cf05d0..bb232a0c63 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx @@ -1837,6 +1837,54 @@ describe('Keyboard interactions', () => { ) }) + describe('`ArrowRight` key', () => { + it( + 'should be possible to use ArrowRight to navigate the listbox options', + suppressConsoleLogs(async () => { + render( + + Trigger + + Option A + Option B + Option C + + + ) + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Enter) + + // Verify we have listbox options + let options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertListboxOption(option)) + assertActiveListboxOption(options[0]) + + // We should be able to go right once + await press(Keys.ArrowRight) + assertActiveListboxOption(options[1]) + + // We should be able to go right again + await press(Keys.ArrowRight) + assertActiveListboxOption(options[2]) + + // We should NOT be able to go right again (because last option). Current implementation won't go around. + await press(Keys.ArrowRight) + assertActiveListboxOption(options[2]) + }) + ) + }) + describe('`ArrowUp` key', () => { it( 'should be possible to open the listbox with ArrowUp and the last option should be active', @@ -2127,6 +2175,64 @@ describe('Keyboard interactions', () => { ) }) + describe('`ArrowLeft` key', () => { + it( + 'should be possible to use ArrowLeft to navigate the listbox options', + suppressConsoleLogs(async () => { + render( + + Trigger + + Option A + Option B + Option C + + + ) + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.ArrowUp) + + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }) + assertListbox({ + state: ListboxState.Visible, + attributes: { id: 'headlessui-listbox-options-2' }, + orientation: 'horizontal', + }) + assertActiveElement(getListbox()) + assertListboxButtonLinkedWithListbox() + + // Verify we have listbox options + let options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertListboxOption(option)) + assertActiveListboxOption(options[2]) + + // We should be able to go left once + await press(Keys.ArrowLeft) + assertActiveListboxOption(options[1]) + + // We should be able to go left again + await press(Keys.ArrowLeft) + assertActiveListboxOption(options[0]) + + // We should NOT be able to go left again (because first option). Current implementation won't go around. + await press(Keys.ArrowLeft) + assertActiveListboxOption(options[0]) + }) + ) + }) + describe('`End` key', () => { it( 'should be possible to use the End key to go to the last listbox option', diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx index 3f8b2ce60c..10b0e39b1c 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx @@ -46,10 +46,14 @@ type ListboxOptionDataRef = MutableRefObject<{ interface StateDefinition { listboxState: ListboxStates + + orientation: 'horizontal' | 'vertical' + propsRef: MutableRefObject<{ value: unknown; onChange(value: unknown): void }> labelRef: MutableRefObject buttonRef: MutableRefObject optionsRef: MutableRefObject + disabled: boolean options: { id: string; dataRef: ListboxOptionDataRef }[] searchQuery: string @@ -61,6 +65,7 @@ enum ActionTypes { CloseListbox, SetDisabled, + SetOrientation, GoToOption, Search, @@ -74,6 +79,7 @@ type Actions = | { type: ActionTypes.CloseListbox } | { type: ActionTypes.OpenListbox } | { type: ActionTypes.SetDisabled; disabled: boolean } + | { type: ActionTypes.SetOrientation; orientation: StateDefinition['orientation'] } | { type: ActionTypes.GoToOption; focus: Focus.Specific; id: string } | { type: ActionTypes.GoToOption; focus: Exclude } | { type: ActionTypes.Search; value: string } @@ -101,6 +107,10 @@ let reducers: { if (state.disabled === action.disabled) return state return { ...state, disabled: action.disabled } }, + [ActionTypes.SetOrientation](state, action) { + if (state.orientation === action.orientation) return state + return { ...state, orientation: action.orientation } + }, [ActionTypes.GoToOption](state, action) { if (state.disabled) return state if (state.listboxState === ListboxStates.Closed) return state @@ -193,9 +203,12 @@ export function Listbox dispatch({ type: ActionTypes.SetDisabled, disabled }), [disabled]) + useIsoMorphicEffect(() => dispatch({ type: ActionTypes.SetOrientation, orientation }), [ + orientation, + ]) // Handle outside click useWindowEvent('mousedown', event => { @@ -413,6 +430,7 @@ interface OptionsRenderPropArg { type OptionsPropsWeControl = | 'aria-activedescendant' | 'aria-labelledby' + | 'aria-orientation' | 'id' | 'onKeyDown' | 'role' @@ -478,12 +496,12 @@ let Options = forwardRefWithAs(function Options< disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true })) break - case Keys.ArrowDown: + case match(state.orientation, { vertical: Keys.ArrowDown, horizontal: Keys.ArrowRight }): event.preventDefault() event.stopPropagation() return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Next }) - case Keys.ArrowUp: + case match(state.orientation, { vertical: Keys.ArrowUp, horizontal: Keys.ArrowLeft }): event.preventDefault() event.stopPropagation() return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Previous }) @@ -535,6 +553,7 @@ let Options = forwardRefWithAs(function Options< 'aria-activedescendant': state.activeOptionIndex === null ? undefined : state.options[state.activeOptionIndex]?.id, 'aria-labelledby': labelledby, + 'aria-orientation': state.orientation, id, onKeyDown: handleKeyDown, role: 'listbox', diff --git a/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts b/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts index 29ca3a8835..5a6fb38a87 100644 --- a/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts +++ b/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts @@ -263,9 +263,12 @@ export function assertListbox( attributes?: Record textContent?: string state: ListboxState + orientation?: 'horizontal' | 'vertical' }, listbox = getListbox() ) { + let { orientation = 'vertical' } = options + try { switch (options.state) { case ListboxState.InvisibleHidden: @@ -274,6 +277,7 @@ export function assertListbox( assertHidden(listbox) expect(listbox).toHaveAttribute('aria-labelledby') + expect(listbox).toHaveAttribute('aria-orientation', orientation) expect(listbox).toHaveAttribute('role', 'listbox') if (options.textContent) expect(listbox).toHaveTextContent(options.textContent) @@ -289,6 +293,7 @@ export function assertListbox( assertVisible(listbox) expect(listbox).toHaveAttribute('aria-labelledby') + expect(listbox).toHaveAttribute('aria-orientation', orientation) expect(listbox).toHaveAttribute('role', 'listbox') if (options.textContent) expect(listbox).toHaveTextContent(options.textContent) diff --git a/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx b/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx index 398a4ba485..a67db2630f 100644 --- a/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx +++ b/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx @@ -1933,6 +1933,57 @@ describe('Keyboard interactions', () => { ) }) + describe('`ArrowRight` key', () => { + it( + 'should be possible to use ArrowRight to navigate the listbox options', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Enter) + + // Verify we have listbox options + let options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertListboxOption(option)) + assertActiveListboxOption(options[0]) + + // We should be able to go right once + await press(Keys.ArrowRight) + assertActiveListboxOption(options[1]) + + // We should be able to go right again + await press(Keys.ArrowRight) + assertActiveListboxOption(options[2]) + + // We should NOT be able to go right again (because last option). Current implementation won't go around. + await press(Keys.ArrowRight) + assertActiveListboxOption(options[2]) + }) + ) + }) + describe('`ArrowUp` key', () => { it( 'should be possible to open the listbox with ArrowUp and the last option should be active', @@ -2244,6 +2295,67 @@ describe('Keyboard interactions', () => { ) }) + describe('`ArrowLeft` key', () => { + it( + 'should be possible to use ArrowLeft to navigate the listbox options', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertListboxButton({ + state: ListboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.InvisibleUnmounted }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.ArrowUp) + + // Verify it is visible + assertListboxButton({ state: ListboxState.Visible }) + assertListbox({ + state: ListboxState.Visible, + attributes: { id: 'headlessui-listbox-options-2' }, + orientation: 'horizontal', + }) + assertActiveElement(getListbox()) + assertListboxButtonLinkedWithListbox() + + // Verify we have listbox options + let options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertListboxOption(option)) + assertActiveListboxOption(options[2]) + + // We should be able to go left once + await press(Keys.ArrowLeft) + assertActiveListboxOption(options[1]) + + // We should be able to go left again + await press(Keys.ArrowLeft) + assertActiveListboxOption(options[0]) + + // We should NOT be able to go left again (because first option). Current implementation won't go around. + await press(Keys.ArrowLeft) + assertActiveListboxOption(options[0]) + }) + ) + }) + describe('`End` key', () => { it( 'should be possible to use the End key to go to the last listbox option', diff --git a/packages/@headlessui-vue/src/components/listbox/listbox.ts b/packages/@headlessui-vue/src/components/listbox/listbox.ts index 00173b8309..49b5d28343 100644 --- a/packages/@headlessui-vue/src/components/listbox/listbox.ts +++ b/packages/@headlessui-vue/src/components/listbox/listbox.ts @@ -38,9 +38,12 @@ type StateDefinition = { // State listboxState: Ref value: ComputedRef + orientation: Ref<'vertical' | 'horizontal'> + labelRef: Ref buttonRef: Ref optionsRef: Ref + disabled: Ref options: Ref<{ id: string; dataRef: ListboxOptionDataRef }[]> searchQuery: Ref @@ -79,6 +82,7 @@ export let Listbox = defineComponent({ props: { as: { type: [Object, String], default: 'template' }, disabled: { type: [Boolean], default: false }, + horizontal: { type: [Boolean], default: false }, modelValue: { type: [Object, String, Number, Boolean] }, }, setup(props, { slots, attrs, emit }) { @@ -95,6 +99,7 @@ export let Listbox = defineComponent({ let api = { listboxState, value, + orientation: computed(() => (props.horizontal ? 'horizontal' : 'vertical')), labelRef, buttonRef, optionsRef, @@ -206,7 +211,7 @@ export let Listbox = defineComponent({ return () => { let slot = { open: listboxState.value === ListboxStates.Open, disabled: props.disabled } return render({ - props: omit(props, ['modelValue', 'onUpdate:modelValue', 'disabled']), + props: omit(props, ['modelValue', 'onUpdate:modelValue', 'disabled', 'horizontal']), slot, slots, attrs, @@ -362,6 +367,7 @@ export let ListboxOptions = defineComponent({ ? undefined : api.options.value[api.activeOptionIndex.value]?.id, 'aria-labelledby': dom(api.labelRef)?.id ?? dom(api.buttonRef)?.id, + 'aria-orientation': api.orientation.value, id: this.id, onKeydown: this.handleKeyDown, role: 'listbox', @@ -410,12 +416,15 @@ export let ListboxOptions = defineComponent({ nextTick(() => dom(api.buttonRef)?.focus({ preventScroll: true })) break - case Keys.ArrowDown: + case match(api.orientation.value, { + vertical: Keys.ArrowDown, + horizontal: Keys.ArrowRight, + }): event.preventDefault() event.stopPropagation() return api.goToOption(Focus.Next) - case Keys.ArrowUp: + case match(api.orientation.value, { vertical: Keys.ArrowUp, horizontal: Keys.ArrowLeft }): event.preventDefault() event.stopPropagation() return api.goToOption(Focus.Previous) diff --git a/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts b/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts index 29ca3a8835..5a6fb38a87 100644 --- a/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts +++ b/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts @@ -263,9 +263,12 @@ export function assertListbox( attributes?: Record textContent?: string state: ListboxState + orientation?: 'horizontal' | 'vertical' }, listbox = getListbox() ) { + let { orientation = 'vertical' } = options + try { switch (options.state) { case ListboxState.InvisibleHidden: @@ -274,6 +277,7 @@ export function assertListbox( assertHidden(listbox) expect(listbox).toHaveAttribute('aria-labelledby') + expect(listbox).toHaveAttribute('aria-orientation', orientation) expect(listbox).toHaveAttribute('role', 'listbox') if (options.textContent) expect(listbox).toHaveTextContent(options.textContent) @@ -289,6 +293,7 @@ export function assertListbox( assertVisible(listbox) expect(listbox).toHaveAttribute('aria-labelledby') + expect(listbox).toHaveAttribute('aria-orientation', orientation) expect(listbox).toHaveAttribute('role', 'listbox') if (options.textContent) expect(listbox).toHaveTextContent(options.textContent) From e8303382dd12302adb597296feb006767dbf39a1 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 15 Jul 2021 13:30:18 +0200 Subject: [PATCH 5/8] move manual prop to correct spot in demo --- .../pages/tabs/tabs-with-pure-tailwind.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/@headlessui-react/pages/tabs/tabs-with-pure-tailwind.tsx b/packages/@headlessui-react/pages/tabs/tabs-with-pure-tailwind.tsx index 108aa82eae..fefe911bf6 100644 --- a/packages/@headlessui-react/pages/tabs/tabs-with-pure-tailwind.tsx +++ b/packages/@headlessui-react/pages/tabs/tabs-with-pure-tailwind.tsx @@ -40,11 +40,8 @@ export default function Home() { - - + + {tabs.map((tab, tabIdx) => ( Date: Mon, 26 Jul 2021 16:07:47 +0200 Subject: [PATCH 6/8] Expose `close` functionality via render prop (#697) * expose a `close` function via the render prop for the `Popover` and `Popover.Panel` components (React) * expose a `close` function via the render prop for the `Disclosure` and `Disclosure.Panel` components (React) * expose a `close` function via the render prop for the `Popover` and `PopoverPanel` components (Vue) * expose a `close` function via the render prop for the `Disclosure` and `DisclosurePanel` components (Vue) --- .../components/disclosure/disclosure.test.tsx | 232 +++++++++++++++++- .../src/components/disclosure/disclosure.tsx | 82 +++++-- .../src/components/popover/popover.test.tsx | 232 +++++++++++++++++- .../src/components/popover/popover.tsx | 82 +++++-- .../components/disclosure/disclosure.test.ts | 212 ++++++++++++++++ .../src/components/disclosure/disclosure.ts | 31 ++- .../src/components/popover/popover.test.ts | 210 ++++++++++++++++ .../src/components/popover/popover.ts | 24 +- 8 files changed, 1062 insertions(+), 43 deletions(-) diff --git a/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx b/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx index ccbaa1e812..a6528a2d6d 100644 --- a/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx +++ b/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx @@ -1,4 +1,4 @@ -import React, { createElement, useEffect } from 'react' +import React, { createElement, useEffect, useRef } from 'react' import { render } from '@testing-library/react' import { Disclosure } from './disclosure' @@ -115,6 +115,127 @@ describe('Rendering', () => { assertDisclosureButton({ state: DisclosureState.InvisibleUnmounted }) }) + + it( + 'should expose a close function that closes the disclosure', + suppressConsoleLogs(async () => { + render( + + {({ close }) => ( + <> + Trigger + + + + + )} + + ) + + // Focus the button + getDisclosureButton()?.focus() + + // Ensure the button is focused + assertActiveElement(getDisclosureButton()) + + // Open the disclosure + await click(getDisclosureButton()) + + // Ensure we can click the close button + await click(getByText('Close me')) + + // Ensure the disclosure is closed + assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted }) + + // Ensure the Disclosure.Button got the restored focus + assertActiveElement(getByText('Trigger')) + }) + ) + + it( + 'should expose a close function that closes the disclosure and restores to a specific element', + suppressConsoleLogs(async () => { + render( + <> + + + {({ close }) => ( + <> + Trigger + + + + + )} + + + ) + + // Focus the button + getDisclosureButton()?.focus() + + // Ensure the button is focused + assertActiveElement(getDisclosureButton()) + + // Open the disclosure + await click(getDisclosureButton()) + + // Ensure we can click the close button + await click(getByText('Close me')) + + // Ensure the disclosure is closed + assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted }) + + // Ensure the restoreable button got the restored focus + assertActiveElement(getByText('restoreable')) + }) + ) + + it( + 'should expose a close function that closes the disclosure and restores to a ref', + suppressConsoleLogs(async () => { + function Example() { + let elementRef = useRef(null) + return ( + <> + + + {({ close }) => ( + <> + Trigger + + + + + )} + + + ) + } + + render() + + // Focus the button + getDisclosureButton()?.focus() + + // Ensure the button is focused + assertActiveElement(getDisclosureButton()) + + // Open the disclosure + await click(getDisclosureButton()) + + // Ensure we can click the close button + await click(getByText('Close me')) + + // Ensure the disclosure is closed + assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted }) + + // Ensure the restoreable button got the restored focus + assertActiveElement(getByText('restoreable')) + }) + ) }) describe('Disclosure.Button', () => { @@ -242,6 +363,115 @@ describe('Rendering', () => { assertDisclosureButton({ state: DisclosureState.InvisibleHidden }) assertDisclosurePanel({ state: DisclosureState.InvisibleHidden }) }) + + it( + 'should expose a close function that closes the disclosure', + suppressConsoleLogs(async () => { + render( + + Trigger + + {({ close }) => } + + + ) + + // Focus the button + getDisclosureButton()?.focus() + + // Ensure the button is focused + assertActiveElement(getDisclosureButton()) + + // Open the disclosure + await click(getDisclosureButton()) + + // Ensure we can click the close button + await click(getByText('Close me')) + + // Ensure the disclosure is closed + assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted }) + + // Ensure the Disclosure.Button got the restored focus + assertActiveElement(getByText('Trigger')) + }) + ) + + it( + 'should expose a close function that closes the disclosure and restores to a specific element', + suppressConsoleLogs(async () => { + render( + <> + + + Trigger + + {({ close }) => ( + + )} + + + + ) + + // Focus the button + getDisclosureButton()?.focus() + + // Ensure the button is focused + assertActiveElement(getDisclosureButton()) + + // Open the disclosure + await click(getDisclosureButton()) + + // Ensure we can click the close button + await click(getByText('Close me')) + + // Ensure the disclosure is closed + assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted }) + + // Ensure the restoreable button got the restored focus + assertActiveElement(getByText('restoreable')) + }) + ) + + it( + 'should expose a close function that closes the disclosure and restores to a ref', + suppressConsoleLogs(async () => { + function Example() { + let elementRef = useRef(null) + return ( + <> + + + Trigger + + {({ close }) => } + + + + ) + } + + render() + + // Focus the button + getDisclosureButton()?.focus() + + // Ensure the button is focused + assertActiveElement(getDisclosureButton()) + + // Open the disclosure + await click(getDisclosureButton()) + + // Ensure we can click the close button + await click(getByText('Close me')) + + // Ensure the disclosure is closed + assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted }) + + // Ensure the restoreable button got the restored focus + assertActiveElement(getByText('restoreable')) + }) + ) }) }) diff --git a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx index 3de8a85bb0..9033ee6734 100644 --- a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx +++ b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx @@ -14,6 +14,8 @@ import React, { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, Ref, + MutableRefObject, + ContextType, } from 'react' import { Props } from '../../types' @@ -41,6 +43,7 @@ interface StateDefinition { enum ActionTypes { ToggleDisclosure, + CloseDisclosure, SetButtonId, SetPanelId, @@ -51,6 +54,7 @@ enum ActionTypes { type Actions = | { type: ActionTypes.ToggleDisclosure } + | { type: ActionTypes.CloseDisclosure } | { type: ActionTypes.SetButtonId; buttonId: string } | { type: ActionTypes.SetPanelId; panelId: string } | { type: ActionTypes.LinkPanel } @@ -69,6 +73,10 @@ let reducers: { [DisclosureStates.Closed]: DisclosureStates.Open, }), }), + [ActionTypes.CloseDisclosure]: state => { + if (state.disclosureState === DisclosureStates.Closed) return state + return { ...state, disclosureState: DisclosureStates.Closed } + }, [ActionTypes.LinkPanel](state) { if (state.linkedPanel === true) return state return { ...state, linkedPanel: true } @@ -100,6 +108,21 @@ function useDisclosureContext(component: string) { return context } +let DisclosureAPIContext = createContext<{ + close(focusableElement?: HTMLElement | MutableRefObject): void +} | null>(null) +DisclosureAPIContext.displayName = 'DisclosureAPIContext' + +function useDisclosureAPIContext(component: string) { + let context = useContext(DisclosureAPIContext) + if (context === null) { + let err = new Error(`<${component} /> is missing a parent <${Disclosure.name} /> component.`) + if (Error.captureStackTrace) Error.captureStackTrace(err, useDisclosureAPIContext) + throw err + } + return context +} + let DisclosurePanelContext = createContext(null) DisclosurePanelContext.displayName = 'DisclosurePanelContext' @@ -116,6 +139,7 @@ function stateReducer(state: StateDefinition, action: Actions) { let DEFAULT_DISCLOSURE_TAG = Fragment interface DisclosureRenderPropArg { open: boolean + close(focusableElement?: HTMLElement | MutableRefObject): void } export function Disclosure( @@ -138,26 +162,47 @@ export function Disclosure dispatch({ type: ActionTypes.SetButtonId, buttonId }), [buttonId, dispatch]) useEffect(() => dispatch({ type: ActionTypes.SetPanelId, panelId }), [panelId, dispatch]) + let close = useCallback( + (focusableElement?: HTMLElement | MutableRefObject) => { + dispatch({ type: ActionTypes.CloseDisclosure }) + + let restoreElement = (() => { + if (!focusableElement) return document.getElementById(buttonId) + if (focusableElement instanceof HTMLElement) return focusableElement + if (focusableElement.current instanceof HTMLElement) return focusableElement.current + + return document.getElementById(buttonId) + })() + + restoreElement?.focus() + }, + [dispatch, buttonId] + ) + + let api = useMemo>(() => ({ close }), [close]) + let slot = useMemo( - () => ({ open: disclosureState === DisclosureStates.Open }), - [disclosureState] + () => ({ open: disclosureState === DisclosureStates.Open, close }), + [disclosureState, close] ) return ( - - {render({ - props: passthroughProps, - slot, - defaultTag: DEFAULT_DISCLOSURE_TAG, - name: 'Disclosure', - })} - + + + {render({ + props: passthroughProps, + slot, + defaultTag: DEFAULT_DISCLOSURE_TAG, + name: 'Disclosure', + })} + + ) } @@ -274,6 +319,7 @@ let Button = forwardRefWithAs(function Button) => void } type PanelPropsWeControl = 'id' @@ -285,6 +331,8 @@ let Panel = forwardRefWithAs(function Panel ) { let [state, dispatch] = useDisclosureContext([Disclosure.name, Panel.name].join('.')) + let { close } = useDisclosureAPIContext([Disclosure.name, Panel.name].join('.')) + let panelRef = useSyncRefs(ref, () => { if (state.linkedPanel) return dispatch({ type: ActionTypes.LinkPanel }) @@ -310,8 +358,8 @@ let Panel = forwardRefWithAs(function Panel( - () => ({ open: state.disclosureState === DisclosureStates.Open }), - [state] + () => ({ open: state.disclosureState === DisclosureStates.Open, close }), + [state, close] ) let propsWeControl = { ref: panelRef, diff --git a/packages/@headlessui-react/src/components/popover/popover.test.tsx b/packages/@headlessui-react/src/components/popover/popover.test.tsx index 4938828ad1..a5979ea44d 100644 --- a/packages/@headlessui-react/src/components/popover/popover.test.tsx +++ b/packages/@headlessui-react/src/components/popover/popover.test.tsx @@ -1,4 +1,4 @@ -import React, { createElement, useEffect } from 'react' +import React, { createElement, useEffect, useRef } from 'react' import { render } from '@testing-library/react' import { Popover } from './popover' @@ -138,6 +138,127 @@ describe('Rendering', () => { assertPopoverPanel({ state: PopoverState.Visible, textContent: 'Panel is: open' }) }) ) + + it( + 'should expose a close function that closes the popover', + suppressConsoleLogs(async () => { + render( + + {({ close }) => ( + <> + Trigger + + + + + )} + + ) + + // Focus the button + getPopoverButton()?.focus() + + // Ensure the button is focused + assertActiveElement(getPopoverButton()) + + // Open the popover + await click(getPopoverButton()) + + // Ensure we can click the close button + await click(getByText('Close me')) + + // Ensure the popover is closed + assertPopoverPanel({ state: PopoverState.InvisibleUnmounted }) + + // Ensure the Popover.Button got the restored focus + assertActiveElement(getByText('Trigger')) + }) + ) + + it( + 'should expose a close function that closes the popover and restores to a specific element', + suppressConsoleLogs(async () => { + render( + <> + + + {({ close }) => ( + <> + Trigger + + + + + )} + + + ) + + // Focus the button + getPopoverButton()?.focus() + + // Ensure the button is focused + assertActiveElement(getPopoverButton()) + + // Open the popover + await click(getPopoverButton()) + + // Ensure we can click the close button + await click(getByText('Close me')) + + // Ensure the popover is closed + assertPopoverPanel({ state: PopoverState.InvisibleUnmounted }) + + // Ensure the restoreable button got the restored focus + assertActiveElement(getByText('restoreable')) + }) + ) + + it( + 'should expose a close function that closes the popover and restores to a ref', + suppressConsoleLogs(async () => { + function Example() { + let elementRef = useRef(null) + return ( + <> + + + {({ close }) => ( + <> + Trigger + + + + + )} + + + ) + } + + render() + + // Focus the button + getPopoverButton()?.focus() + + // Ensure the button is focused + assertActiveElement(getPopoverButton()) + + // Open the popover + await click(getPopoverButton()) + + // Ensure we can click the close button + await click(getByText('Close me')) + + // Ensure the popover is closed + assertPopoverPanel({ state: PopoverState.InvisibleUnmounted }) + + // Ensure the restoreable button got the restored focus + assertActiveElement(getByText('restoreable')) + }) + ) }) describe('Popover.Button', () => { @@ -384,6 +505,115 @@ describe('Rendering', () => { assertActiveElement(getByText('Link 1')) }) ) + + it( + 'should expose a close function that closes the popover', + suppressConsoleLogs(async () => { + render( + + Trigger + + {({ close }) => } + + + ) + + // Focus the button + getPopoverButton()?.focus() + + // Ensure the button is focused + assertActiveElement(getPopoverButton()) + + // Open the popover + await click(getPopoverButton()) + + // Ensure we can click the close button + await click(getByText('Close me')) + + // Ensure the popover is closed + assertPopoverPanel({ state: PopoverState.InvisibleUnmounted }) + + // Ensure the Popover.Button got the restored focus + assertActiveElement(getByText('Trigger')) + }) + ) + + it( + 'should expose a close function that closes the popover and restores to a specific element', + suppressConsoleLogs(async () => { + render( + <> + + + Trigger + + {({ close }) => ( + + )} + + + + ) + + // Focus the button + getPopoverButton()?.focus() + + // Ensure the button is focused + assertActiveElement(getPopoverButton()) + + // Open the popover + await click(getPopoverButton()) + + // Ensure we can click the close button + await click(getByText('Close me')) + + // Ensure the popover is closed + assertPopoverPanel({ state: PopoverState.InvisibleUnmounted }) + + // Ensure the restoreable button got the restored focus + assertActiveElement(getByText('restoreable')) + }) + ) + + it( + 'should expose a close function that closes the popover and restores to a ref', + suppressConsoleLogs(async () => { + function Example() { + let elementRef = useRef(null) + return ( + <> + + + Trigger + + {({ close }) => } + + + + ) + } + + render() + + // Focus the button + getPopoverButton()?.focus() + + // Ensure the button is focused + assertActiveElement(getPopoverButton()) + + // Open the popover + await click(getPopoverButton()) + + // Ensure we can click the close button + await click(getByText('Close me')) + + // Ensure the popover is closed + assertPopoverPanel({ state: PopoverState.InvisibleUnmounted }) + + // Ensure the restoreable button got the restored focus + assertActiveElement(getByText('restoreable')) + }) + ) }) }) diff --git a/packages/@headlessui-react/src/components/popover/popover.tsx b/packages/@headlessui-react/src/components/popover/popover.tsx index 0baa62c66f..15bebf215d 100644 --- a/packages/@headlessui-react/src/components/popover/popover.tsx +++ b/packages/@headlessui-react/src/components/popover/popover.tsx @@ -15,6 +15,7 @@ import React, { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, Ref, + MutableRefObject, } from 'react' import { Props } from '../../types' @@ -115,6 +116,21 @@ function usePopoverContext(component: string) { return context } +let PopoverAPIContext = createContext<{ + close(focusableElement?: HTMLElement | MutableRefObject): void +} | null>(null) +PopoverAPIContext.displayName = 'PopoverAPIContext' + +function usePopoverAPIContext(component: string) { + let context = useContext(PopoverAPIContext) + if (context === null) { + let err = new Error(`<${component} /> is missing a parent <${Popover.name} /> component.`) + if (Error.captureStackTrace) Error.captureStackTrace(err, usePopoverAPIContext) + throw err + } + return context +} + let PopoverGroupContext = createContext<{ registerPopover(registerbag: PopoverRegisterBag): void unregisterPopover(registerbag: PopoverRegisterBag): void @@ -148,6 +164,7 @@ function stateReducer(state: StateDefinition, action: Actions) { let DEFAULT_POPOVER_TAG = 'div' as const interface PopoverRenderPropArg { open: boolean + close(focusableElement?: HTMLElement | MutableRefObject): void } export function Popover( @@ -215,25 +232,47 @@ export function Popover( } }) - let slot = useMemo(() => ({ open: popoverState === PopoverStates.Open }), [ - popoverState, - ]) + let close = useCallback( + (focusableElement?: HTMLElement | MutableRefObject) => { + dispatch({ type: ActionTypes.ClosePopover }) + + let restoreElement = (() => { + if (!focusableElement) return button + if (focusableElement instanceof HTMLElement) return focusableElement + if (focusableElement.current instanceof HTMLElement) return focusableElement.current + + return button + })() + + restoreElement?.focus() + }, + [dispatch, button] + ) + + let api = useMemo>(() => ({ close }), [close]) + + let slot = useMemo( + () => ({ open: popoverState === PopoverStates.Open, close }), + [popoverState, close] + ) return ( - - {render({ - props, - slot, - defaultTag: DEFAULT_POPOVER_TAG, - name: 'Popover', - })} - + + + {render({ + props, + slot, + defaultTag: DEFAULT_POPOVER_TAG, + name: 'Popover', + })} + + ) } @@ -520,6 +559,7 @@ let Overlay = forwardRefWithAs(function Overlay< let DEFAULT_PANEL_TAG = 'div' as const interface PanelRenderPropArg { open: boolean + close: (focusableElement?: HTMLElement | MutableRefObject) => void } type PanelPropsWeControl = 'id' | 'onKeyDown' @@ -527,12 +567,16 @@ let PanelRenderFeatures = Features.RenderStrategy | Features.Static let Panel = forwardRefWithAs(function Panel( props: Props & - PropsForFeatures & { focus?: boolean }, + PropsForFeatures & { + focus?: boolean + }, ref: Ref ) { let { focus = false, ...passthroughProps } = props let [state, dispatch] = usePopoverContext([Popover.name, Panel.name].join('.')) + let { close } = usePopoverAPIContext([Popover.name, Panel.name].join('.')) + let internalPanelRef = useRef(null) let panelRef = useSyncRefs(internalPanelRef, ref, panel => { dispatch({ type: ActionTypes.SetPanel, panel }) @@ -640,8 +684,8 @@ let Panel = forwardRefWithAs(function Panel( - () => ({ open: state.popoverState === PopoverStates.Open }), - [state] + () => ({ open: state.popoverState === PopoverStates.Open, close }), + [state, close] ) let propsWeControl = { ref: panelRef, diff --git a/packages/@headlessui-vue/src/components/disclosure/disclosure.test.ts b/packages/@headlessui-vue/src/components/disclosure/disclosure.test.ts index 29f5952a42..fc3df40a25 100644 --- a/packages/@headlessui-vue/src/components/disclosure/disclosure.test.ts +++ b/packages/@headlessui-vue/src/components/disclosure/disclosure.test.ts @@ -121,6 +121,112 @@ describe('Rendering', () => { assertDisclosureButton({ state: DisclosureState.InvisibleUnmounted }) }) + + it( + 'should expose a close function that closes the disclosure', + suppressConsoleLogs(async () => { + renderTemplate( + html` + + Trigger + + + + + ` + ) + + // Focus the button + getDisclosureButton()?.focus() + + // Ensure the button is focused + assertActiveElement(getDisclosureButton()) + + // Open the disclosure + await click(getDisclosureButton()) + + // Ensure we can click the close button + await click(getByText('Close me')) + + // Ensure the disclosure is closed + assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted }) + + // Ensure the Disclosure.Button got the restored focus + assertActiveElement(getByText('Trigger')) + }) + ) + + it( + 'should expose a close function that closes the disclosure and restores to a specific element', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + + + `, + setup: () => ({ document }), + }) + + // Focus the button + getDisclosureButton()?.focus() + + // Ensure the button is focused + assertActiveElement(getDisclosureButton()) + + // Open the disclosure + await click(getDisclosureButton()) + + // Ensure we can click the close button + await click(getByText('Close me')) + + // Ensure the disclosure is closed + assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted }) + + // Ensure the restoreable button got the restored focus + assertActiveElement(getByText('restoreable')) + }) + ) + + it( + 'should expose a close function that closes the disclosure and restores to a ref', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + } + + + `, + setup: () => ({ elementRef: ref() }), + }) + + // Focus the button + getDisclosureButton()?.focus() + + // Ensure the button is focused + assertActiveElement(getDisclosureButton()) + + // Open the disclosure + await click(getDisclosureButton()) + + // Ensure we can click the close button + await click(getByText('Close me')) + + // Ensure the disclosure is closed + assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted }) + + // Ensure the restoreable button got the restored focus + assertActiveElement(getByText('restoreable')) + }) + ) }) describe('DisclosureButton', () => { @@ -261,6 +367,112 @@ describe('Rendering', () => { assertDisclosureButton({ state: DisclosureState.InvisibleHidden }) assertDisclosurePanel({ state: DisclosureState.InvisibleHidden }) }) + + it( + 'should expose a close function that closes the disclosure', + suppressConsoleLogs(async () => { + renderTemplate( + html` + + Trigger + + + + + ` + ) + + // Focus the button + getDisclosureButton()?.focus() + + // Ensure the button is focused + assertActiveElement(getDisclosureButton()) + + // Open the disclosure + await click(getDisclosureButton()) + + // Ensure we can click the close button + await click(getByText('Close me')) + + // Ensure the disclosure is closed + assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted }) + + // Ensure the Disclosure.Button got the restored focus + assertActiveElement(getByText('Trigger')) + }) + ) + + it( + 'should expose a close function that closes the disclosure and restores to a specific element', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + + + `, + setup: () => ({ document }), + }) + + // Focus the button + getDisclosureButton()?.focus() + + // Ensure the button is focused + assertActiveElement(getDisclosureButton()) + + // Open the disclosure + await click(getDisclosureButton()) + + // Ensure we can click the close button + await click(getByText('Close me')) + + // Ensure the disclosure is closed + assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted }) + + // Ensure the restoreable button got the restored focus + assertActiveElement(getByText('restoreable')) + }) + ) + + it( + 'should expose a close function that closes the disclosure and restores to a ref', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + } + + + `, + setup: () => ({ elementRef: ref() }), + }) + + // Focus the button + getDisclosureButton()?.focus() + + // Ensure the button is focused + assertActiveElement(getDisclosureButton()) + + // Open the disclosure + await click(getDisclosureButton()) + + // Ensure we can click the close button + await click(getByText('Close me')) + + // Ensure the disclosure is closed + assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted }) + + // Ensure the restoreable button got the restored focus + assertActiveElement(getByText('restoreable')) + }) + ) }) }) diff --git a/packages/@headlessui-vue/src/components/disclosure/disclosure.ts b/packages/@headlessui-vue/src/components/disclosure/disclosure.ts index 5b5a5ab702..e8cc87da5f 100644 --- a/packages/@headlessui-vue/src/components/disclosure/disclosure.ts +++ b/packages/@headlessui-vue/src/components/disclosure/disclosure.ts @@ -23,6 +23,10 @@ interface StateDefinition { // State mutators toggleDisclosure(): void + closeDisclosure(): void + + // Exposed functions + close(focusableElement: HTMLElement | Ref): void } let DisclosureContext = Symbol('DisclosureContext') as InjectionKey @@ -74,6 +78,23 @@ export let Disclosure = defineComponent({ [DisclosureStates.Closed]: DisclosureStates.Open, }) }, + closeDisclosure() { + if (disclosureState.value === DisclosureStates.Closed) return + disclosureState.value = DisclosureStates.Closed + }, + close(focusableElement: HTMLElement | Ref) { + api.closeDisclosure() + + let restoreElement = (() => { + if (!focusableElement) return dom(api.button) + if (focusableElement instanceof HTMLElement) return focusableElement + if (focusableElement.value instanceof HTMLElement) return dom(focusableElement) + + return dom(api.button) + })() + + restoreElement?.focus() + }, } as StateDefinition provide(DisclosureContext, api) @@ -88,7 +109,7 @@ export let Disclosure = defineComponent({ return () => { let { defaultOpen: _, ...passThroughProps } = props - let slot = { open: disclosureState.value === DisclosureStates.Open } + let slot = { open: disclosureState.value === DisclosureStates.Open, close: api.close } return render({ props: passThroughProps, slot, slots, attrs, name: 'Disclosure' }) } }, @@ -204,7 +225,7 @@ export let DisclosurePanel = defineComponent({ render() { let api = useDisclosureContext('DisclosurePanel') - let slot = { open: api.disclosureState.value === DisclosureStates.Open } + let slot = { open: api.disclosureState.value === DisclosureStates.Open, close: api.close } let propsWeControl = { id: this.id, ref: 'el' } return render({ @@ -231,6 +252,10 @@ export let DisclosurePanel = defineComponent({ return api.disclosureState.value === DisclosureStates.Open }) - return { id: api.panelId, el: api.panel, visible } + return { + id: api.panelId, + el: api.panel, + visible, + } }, }) diff --git a/packages/@headlessui-vue/src/components/popover/popover.test.ts b/packages/@headlessui-vue/src/components/popover/popover.test.ts index 538257eb3e..758b732cb1 100644 --- a/packages/@headlessui-vue/src/components/popover/popover.test.ts +++ b/packages/@headlessui-vue/src/components/popover/popover.test.ts @@ -159,6 +159,110 @@ describe('Rendering', () => { assertPopoverPanel({ state: PopoverState.Visible, textContent: 'Panel is: open' }) }) ) + + it( + 'should expose a close function that closes the popover', + suppressConsoleLogs(async () => { + renderTemplate( + html` + + Trigger + + + + + ` + ) + + // Focus the button + getPopoverButton()?.focus() + + // Ensure the button is focused + assertActiveElement(getPopoverButton()) + + // Open the popover + await click(getPopoverButton()) + + // Ensure we can click the close button + await click(getByText('Close me')) + + // Ensure the popover is closed + assertPopoverPanel({ state: PopoverState.InvisibleUnmounted }) + + // Ensure the Popover.Button got the restored focus + assertActiveElement(getByText('Trigger')) + }) + ) + + it( + 'should expose a close function that closes the popover and restores to a specific element', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + + + `, + setup: () => ({ document }), + }) + + // Focus the button + getPopoverButton()?.focus() + + // Ensure the button is focused + assertActiveElement(getPopoverButton()) + + // Open the popover + await click(getPopoverButton()) + + // Ensure we can click the close button + await click(getByText('Close me')) + + // Ensure the popover is closed + assertPopoverPanel({ state: PopoverState.InvisibleUnmounted }) + + // Ensure the restoreable button got the restored focus + assertActiveElement(getByText('restoreable')) + }) + ) + + it( + 'should expose a close function that closes the popover and restores to a ref', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + } + + `, + setup: () => ({ elementRef: ref() }), + }) + + // Focus the button + getPopoverButton()?.focus() + + // Ensure the button is focused + assertActiveElement(getPopoverButton()) + + // Open the popover + await click(getPopoverButton()) + + // Ensure we can click the close button + await click(getByText('Close me')) + + // Ensure the popover is closed + assertPopoverPanel({ state: PopoverState.InvisibleUnmounted }) + + // Ensure the restoreable button got the restored focus + assertActiveElement(getByText('restoreable')) + }) + ) }) describe('PopoverButton', () => { @@ -427,6 +531,112 @@ describe('Rendering', () => { assertActiveElement(getByText('Link 1')) }) ) + + it( + 'should expose a close function that closes the popover', + suppressConsoleLogs(async () => { + renderTemplate( + html` + + Trigger + + + + + ` + ) + + // Focus the button + getPopoverButton()?.focus() + + // Ensure the button is focused + assertActiveElement(getPopoverButton()) + + // Open the popover + await click(getPopoverButton()) + + // Ensure we can click the close button + await click(getByText('Close me')) + + // Ensure the popover is closed + assertPopoverPanel({ state: PopoverState.InvisibleUnmounted }) + + // Ensure the Popover.Button got the restored focus + assertActiveElement(getByText('Trigger')) + }) + ) + + it( + 'should expose a close function that closes the popover and restores to a specific element', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + + + `, + setup: () => ({ document }), + }) + + // Focus the button + getPopoverButton()?.focus() + + // Ensure the button is focused + assertActiveElement(getPopoverButton()) + + // Open the popover + await click(getPopoverButton()) + + // Ensure we can click the close button + await click(getByText('Close me')) + + // Ensure the popover is closed + assertPopoverPanel({ state: PopoverState.InvisibleUnmounted }) + + // Ensure the restoreable button got the restored focus + assertActiveElement(getByText('restoreable')) + }) + ) + + it( + 'should expose a close function that closes the popover and restores to a ref', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + } + + + `, + setup: () => ({ elementRef: ref() }), + }) + + // Focus the button + getPopoverButton()?.focus() + + // Ensure the button is focused + assertActiveElement(getPopoverButton()) + + // Open the popover + await click(getPopoverButton()) + + // Ensure we can click the close button + await click(getByText('Close me')) + + // Ensure the popover is closed + assertPopoverPanel({ state: PopoverState.InvisibleUnmounted }) + + // Ensure the restoreable button got the restored focus + assertActiveElement(getByText('restoreable')) + }) + ) }) }) diff --git a/packages/@headlessui-vue/src/components/popover/popover.ts b/packages/@headlessui-vue/src/components/popover/popover.ts index 244327e148..ef6fdd3912 100644 --- a/packages/@headlessui-vue/src/components/popover/popover.ts +++ b/packages/@headlessui-vue/src/components/popover/popover.ts @@ -44,6 +44,9 @@ interface StateDefinition { // State mutators togglePopover(): void closePopover(): void + + // Exposed functions + close(focusableElement: HTMLElement | Ref): void } let PopoverContext = Symbol('PopoverContext') as InjectionKey @@ -110,6 +113,19 @@ export let Popover = defineComponent({ if (popoverState.value === PopoverStates.Closed) return popoverState.value = PopoverStates.Closed }, + close(focusableElement: HTMLElement | Ref) { + api.closePopover() + + let restoreElement = (() => { + if (!focusableElement) return dom(api.button) + if (focusableElement instanceof HTMLElement) return focusableElement + if (focusableElement.value instanceof HTMLElement) return dom(focusableElement) + + return dom(api.button) + })() + + restoreElement?.focus() + }, } as StateDefinition provide(PopoverContext, api) @@ -175,7 +191,7 @@ export let Popover = defineComponent({ }) return () => { - let slot = { open: popoverState.value === PopoverStates.Open } + let slot = { open: popoverState.value === PopoverStates.Open, close: api.close } return render({ props, slot, slots, attrs, name: 'Popover' }) } }, @@ -427,7 +443,11 @@ export let PopoverPanel = defineComponent({ render() { let api = usePopoverContext('PopoverPanel') - let slot = { open: api.popoverState.value === PopoverStates.Open } + let slot = { + open: api.popoverState.value === PopoverStates.Open, + close: api.close, + } + let propsWeControl = { ref: 'el', id: this.id, From 112270d206fb6735c6dc3e0697e358e8a3791540 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 28 Jul 2021 11:30:34 +0200 Subject: [PATCH 7/8] Change Tabs api (#698) * Change Tabs API (React) | Before | After | | ------------- | ------------ | | `Tabs` | `Tab.Group` | | `Tabs.List` | `Tab.List` | | `Tabs.Tab` | `Tab` | | `Tabs.Panels` | `Tab.Panels` | | `Tabs.Panel` | `Tab.Panel` | * Change Tabs API (Vue) | Before | After | | ------------ | ----------- | | `Tabs` | `TabGroup` | | `TabsList` | `TabList` | | `TabsTab` | `Tab` | | `TabsPanels` | `TabPanels` | | `TabsPanel` | `TabPanel` | * change playground example for Tabs (React) * update changelog --- CHANGELOG.md | 4 +- .../pages/tabs/tabs-with-pure-tailwind.tsx | 24 +- .../src/components/tabs/tabs.test.tsx | 1336 ++++++++-------- .../src/components/tabs/tabs.tsx | 22 +- packages/@headlessui-react/src/index.test.ts | 2 +- .../src/components/tabs/tabs.test.ts | 1340 ++++++++--------- .../src/components/tabs/tabs.ts | 44 +- packages/@headlessui-vue/src/index.test.ts | 10 +- 8 files changed, 1391 insertions(+), 1391 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1013637dc..5402551f12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add new `Tabs` component ([#674](https://github.com/tailwindlabs/headlessui/pull/674)) +- Add new `Tabs` component ([#674](https://github.com/tailwindlabs/headlessui/pull/674), [#698](https://github.com/tailwindlabs/headlessui/pull/698)) - Make `Disclosure.Button` close the disclosure inside a `Disclosure.Panel` ([#682](https://github.com/tailwindlabs/headlessui/pull/682)) - Add `aria-orientation` to `Listbox`, which swaps Up/Down with Left/Right keys ([#683](https://github.com/tailwindlabs/headlessui/pull/683)) @@ -17,7 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add new `Tabs` component ([#674](https://github.com/tailwindlabs/headlessui/pull/674)) +- Add new `Tabs` component ([#674](https://github.com/tailwindlabs/headlessui/pull/674), [#698](https://github.com/tailwindlabs/headlessui/pull/698)) - Make `DisclosureButton` close the disclosure inside a `DisclosurePanel` ([#682](https://github.com/tailwindlabs/headlessui/pull/682)) - Add `aria-orientation` to `Listbox`, which swaps Up/Down with Left/Right keys ([#683](https://github.com/tailwindlabs/headlessui/pull/683)) diff --git a/packages/@headlessui-react/pages/tabs/tabs-with-pure-tailwind.tsx b/packages/@headlessui-react/pages/tabs/tabs-with-pure-tailwind.tsx index fefe911bf6..c6c7f84b99 100644 --- a/packages/@headlessui-react/pages/tabs/tabs-with-pure-tailwind.tsx +++ b/packages/@headlessui-react/pages/tabs/tabs-with-pure-tailwind.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react' -import { Tabs, Switch } from '@headlessui/react' +import { Tab, Switch } from '@headlessui/react' import { classNames } from '../../src/utils/class-names' @@ -11,7 +11,7 @@ export default function Home() { { name: 'Billing', content: 'Tab content for billing' }, ] - let [manual, setManual] = useState(true) + let [manual, setManual] = useState(false) return (
@@ -40,10 +40,10 @@ export default function Home() { - - + + {tabs.map((tab, tabIdx) => ( - @@ -69,18 +69,18 @@ export default function Home() { /> )} - + ))} - + - + {tabs.map(tab => ( - + {tab.content} - + ))} - - + +
) } diff --git a/packages/@headlessui-react/src/components/tabs/tabs.test.tsx b/packages/@headlessui-react/src/components/tabs/tabs.test.tsx index bdee66bdd5..c9214ab3fd 100644 --- a/packages/@headlessui-react/src/components/tabs/tabs.test.tsx +++ b/packages/@headlessui-react/src/components/tabs/tabs.test.tsx @@ -1,7 +1,7 @@ import React, { createElement } from 'react' import { render } from '@testing-library/react' -import { Tabs } from './tabs' +import { Tab } from './tabs' import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs' import { assertTabs, @@ -20,34 +20,34 @@ beforeAll(() => { describe('safeguards', () => { it.each([ - ['Tabs.List', Tabs.List], - ['Tabs.Tab', Tabs.Tab], - ['Tabs.Panels', Tabs.Panels], - ['Tabs.Panel', Tabs.Panel], + ['Tab.List', Tab.List], + ['Tab', Tab], + ['Tab.Panels', Tab.Panels], + ['Tab.Panel', Tab.Panel], ])( - 'should error when we are using a <%s /> without a parent component', + 'should error when we are using a <%s /> without a parent component', suppressConsoleLogs((name, Component) => { expect(() => render(createElement(Component))).toThrowError( - `<${name} /> is missing a parent component.` + `<${name} /> is missing a parent component.` ) }) ) - it('should be possible to render Tabs without crashing', async () => { + it('should be possible to render Tab.Group without crashing', async () => { render( - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ) assertTabs({ active: 0 }) @@ -55,48 +55,48 @@ describe('safeguards', () => { }) describe('Rendering', () => { - it('should be possible to render the Tabs.Panels first, then the Tabs.List', async () => { + it('should be possible to render the Tab.Panels first, then the Tab.List', async () => { render( - - - Content 1 - Content 2 - Content 3 - - - - Tab 1 - Tab 2 - Tab 3 - - + + + Content 1 + Content 2 + Content 3 + + + + Tab 1 + Tab 2 + Tab 3 + + ) assertTabs({ active: 0 }) }) describe('`renderProps`', () => { - it('should expose the `selectedIndex` on the `Tabs` component', async () => { + it('should expose the `selectedIndex` on the `Tab.Group` component', async () => { render( - + {data => ( <>
{JSON.stringify(data)}
- - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + )} -
+
) expect(document.getElementById('exposed')).toHaveTextContent( @@ -110,26 +110,26 @@ describe('Rendering', () => { ) }) - it('should expose the `selectedIndex` on the `Tabs.List` component', async () => { + it('should expose the `selectedIndex` on the `Tab.List` component', async () => { render( - - + + {data => ( <>
{JSON.stringify(data)}
- Tab 1 - Tab 2 - Tab 3 + Tab 1 + Tab 2 + Tab 3 )} -
+ - - Content 1 - Content 2 - Content 3 - -
+ + Content 1 + Content 2 + Content 3 + +
) expect(document.getElementById('exposed')).toHaveTextContent( @@ -143,26 +143,26 @@ describe('Rendering', () => { ) }) - it('should expose the `selectedIndex` on the `Tabs.Panels` component', async () => { + it('should expose the `selectedIndex` on the `Tab.Panels` component', async () => { render( - - - Tab 1 - Tab 2 - Tab 3 - - - + + + Tab 1 + Tab 2 + Tab 3 + + + {data => ( <>
{JSON.stringify(data)}
- Content 1 - Content 2 - Content 3 + Content 1 + Content 2 + Content 3 )} -
-
+ + ) expect(document.getElementById('exposed')).toHaveTextContent( @@ -176,42 +176,42 @@ describe('Rendering', () => { ) }) - it('should expose the `selected` state on the `Tabs.Tab` components', async () => { + it('should expose the `selected` state on the `Tab` components', async () => { render( - - - + + + {data => ( <>
{JSON.stringify(data)}
Tab 1 )} -
- + + {data => ( <>
{JSON.stringify(data)}
Tab 2 )} -
- + + {data => ( <>
{JSON.stringify(data)}
Tab 3 )} -
-
+ + - - Content 1 - Content 2 - Content 3 - -
+ + Content 1 + Content 2 + Content 3 + + ) expect(document.querySelector('[data-tab="0"]')).toHaveTextContent( @@ -237,42 +237,42 @@ describe('Rendering', () => { ) }) - it('should expose the `selected` state on the `Tabs.Panel` components', async () => { + it('should expose the `selected` state on the `Tab.Panel` components', async () => { render( - - - Tab 1 - Tab 2 - Tab 3 - - - - + + + Tab 1 + Tab 2 + Tab 3 + + + + {data => ( <>
{JSON.stringify(data)}
Content 1 )} -
- + + {data => ( <>
{JSON.stringify(data)}
Content 2 )} -
- + + {data => ( <>
{JSON.stringify(data)}
Content 3 )} -
-
-
+ + + ) expect(document.querySelector('[data-panel="0"]')).toHaveTextContent( @@ -303,19 +303,19 @@ describe('Rendering', () => { it('should jump to the nearest tab when the defaultIndex is out of bounds (-2)', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -332,19 +332,19 @@ describe('Rendering', () => { it('should jump to the nearest tab when the defaultIndex is out of bounds (+5)', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -361,19 +361,19 @@ describe('Rendering', () => { it('should jump to the next available tab when the defaultIndex is a disabled tab', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -390,19 +390,19 @@ describe('Rendering', () => { it('should jump to the next available tab when the defaultIndex is a disabled tab and wrap around', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -423,19 +423,19 @@ describe('Keyboard interactions', () => { it('should be possible to tab to the default initial first tab', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -464,19 +464,19 @@ describe('Keyboard interactions', () => { it('should be possible to tab to the default index tab', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -507,19 +507,19 @@ describe('Keyboard interactions', () => { it('should be possible to go to the next item (activation = `auto`)', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -540,19 +540,19 @@ describe('Keyboard interactions', () => { it('should be possible to go to the next item (activation = `manual`)', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -577,19 +577,19 @@ describe('Keyboard interactions', () => { it('should wrap around at the end (activation = `auto`)', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -616,19 +616,19 @@ describe('Keyboard interactions', () => { it('should wrap around at the end (activation = `manual`)', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -663,19 +663,19 @@ describe('Keyboard interactions', () => { it('should not be possible to go right when in vertical mode (activation = `auto`)', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -694,19 +694,19 @@ describe('Keyboard interactions', () => { it('should not be possible to go right when in vertical mode (activation = `manual`)', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -729,19 +729,19 @@ describe('Keyboard interactions', () => { it('should be possible to go to the previous item (activation = `auto`)', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -762,19 +762,19 @@ describe('Keyboard interactions', () => { it('should be possible to go to the previous item (activation = `manual`)', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -799,19 +799,19 @@ describe('Keyboard interactions', () => { it('should wrap around at the beginning (activation = `auto`)', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -838,19 +838,19 @@ describe('Keyboard interactions', () => { it('should wrap around at the beginning (activation = `manual`)', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -885,19 +885,19 @@ describe('Keyboard interactions', () => { it('should not be possible to go left when in vertical mode (activation = `auto`)', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -916,19 +916,19 @@ describe('Keyboard interactions', () => { it('should not be possible to go left when in vertical mode (activation = `manual`)', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -952,19 +952,19 @@ describe('Keyboard interactions', () => { it('should be possible to go to the next item (activation = `auto`)', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -985,19 +985,19 @@ describe('Keyboard interactions', () => { it('should be possible to go to the next item (activation = `manual`)', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -1022,19 +1022,19 @@ describe('Keyboard interactions', () => { it('should wrap around at the end (activation = `auto`)', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -1061,19 +1061,19 @@ describe('Keyboard interactions', () => { it('should wrap around at the end (activation = `manual`)', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -1108,19 +1108,19 @@ describe('Keyboard interactions', () => { it('should not be possible to go down when in horizontal mode (activation = `auto`)', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -1139,19 +1139,19 @@ describe('Keyboard interactions', () => { it('should not be possible to go down when in horizontal mode (activation = `manual`)', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -1175,19 +1175,19 @@ describe('Keyboard interactions', () => { it('should be possible to go to the previous item (activation = `auto`)', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -1208,19 +1208,19 @@ describe('Keyboard interactions', () => { it('should be possible to go to the previous item (activation = `manual`)', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -1245,19 +1245,19 @@ describe('Keyboard interactions', () => { it('should wrap around at the beginning (activation = `auto`)', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -1284,19 +1284,19 @@ describe('Keyboard interactions', () => { it('should wrap around at the beginning (activation = `manual`)', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -1331,19 +1331,19 @@ describe('Keyboard interactions', () => { it('should not be possible to go left when in vertical mode (activation = `auto`)', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -1362,19 +1362,19 @@ describe('Keyboard interactions', () => { it('should not be possible to go left when in vertical mode (activation = `manual`)', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -1398,19 +1398,19 @@ describe('Keyboard interactions', () => { it('should be possible to go to the first focusable item (activation = `auto`)', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -1428,19 +1428,19 @@ describe('Keyboard interactions', () => { it('should be possible to go to the first focusable item (activation = `manual`)', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -1462,19 +1462,19 @@ describe('Keyboard interactions', () => { it('should be possible to go to the first focusable item (activation = `auto`)', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -1492,19 +1492,19 @@ describe('Keyboard interactions', () => { it('should be possible to go to the first focusable item (activation = `manual`)', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -1526,19 +1526,19 @@ describe('Keyboard interactions', () => { it('should be possible to go to the first focusable item (activation = `auto`)', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -1556,19 +1556,19 @@ describe('Keyboard interactions', () => { it('should be possible to go to the first focusable item (activation = `manual`)', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -1590,19 +1590,19 @@ describe('Keyboard interactions', () => { it('should be possible to go to the first focusable item (activation = `auto`)', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -1620,19 +1620,19 @@ describe('Keyboard interactions', () => { it('should be possible to go to the first focusable item (activation = `manual`)', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -1654,19 +1654,19 @@ describe('Keyboard interactions', () => { it('should be possible to activate the focused tab', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -1688,19 +1688,19 @@ describe('Keyboard interactions', () => { it('should be possible to activate the focused tab', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -1723,19 +1723,19 @@ describe('Mouse interactions', () => { it('should be possible to click on a tab to focus it', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -1758,19 +1758,19 @@ describe('Mouse interactions', () => { it('should be a no-op when clicking on a disabled tab', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + @@ -1791,19 +1791,19 @@ it('should trigger the `onChange` when the tab changes', async () => { render( <> - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + diff --git a/packages/@headlessui-react/src/components/tabs/tabs.tsx b/packages/@headlessui-react/src/components/tabs/tabs.tsx index 590a0779f4..d5c2e8322a 100644 --- a/packages/@headlessui-react/src/components/tabs/tabs.tsx +++ b/packages/@headlessui-react/src/components/tabs/tabs.tsx @@ -104,7 +104,7 @@ TabsContext.displayName = 'TabsContext' function useTabsContext(component: string) { let context = useContext(TabsContext) if (context === null) { - let err = new Error(`<${component} /> is missing a parent <${Tabs.name} /> component.`) + let err = new Error(`<${component} /> is missing a parent component.`) if (Error.captureStackTrace) Error.captureStackTrace(err, useTabsContext) throw err } @@ -122,7 +122,7 @@ interface TabsRenderPropArg { selectedIndex: number } -export function Tabs( +function Tabs( props: Props & { defaultIndex?: number onChange?: (index: number) => void @@ -230,7 +230,7 @@ type ListPropsWeControl = 'role' | 'aria-orientation' function List( props: Props & {} ) { - let [{ selectedIndex, orientation }] = useTabsContext([Tabs.name, List.name].join('.')) + let [{ selectedIndex, orientation }] = useTabsContext([Tab.name, List.name].join('.')) let slot = { selectedIndex } let propsWeControl = { @@ -255,7 +255,7 @@ interface TabRenderPropArg { } type TabPropsWeControl = 'id' | 'role' | 'type' | 'aria-controls' | 'aria-selected' | 'tabIndex' -function Tab( +export function Tab( props: Props ) { let id = `headlessui-tabs-tab-${useId()}` @@ -263,7 +263,7 @@ function Tab( let [ { selectedIndex, tabs, panels, orientation, activation }, { dispatch, change }, - ] = useTabsContext([Tabs.name, Tab.name].join('.')) + ] = useTabsContext(Tab.name) let internalTabRef = useRef(null) let tabRef = useSyncRefs(internalTabRef, element => { @@ -371,7 +371,7 @@ interface PanelsRenderPropArg { function Panels( props: Props ) { - let [{ selectedIndex }] = useTabsContext([Tabs.name, Panels.name].join('.')) + let [{ selectedIndex }] = useTabsContext([Tab.name, Panels.name].join('.')) let slot = useMemo(() => ({ selectedIndex }), [selectedIndex]) @@ -397,7 +397,7 @@ function Panel( PropsForFeatures ) { let [{ selectedIndex, tabs, panels }, { dispatch }] = useTabsContext( - [Tabs.name, Panel.name].join('.') + [Tab.name, Panel.name].join('.') ) let id = `headlessui-tabs-panel-${useId()}` @@ -442,7 +442,7 @@ function Panel( // --- -Tabs.List = List -Tabs.Tab = Tab -Tabs.Panels = Panels -Tabs.Panel = Panel +Tab.Group = Tabs +Tab.List = List +Tab.Panels = Panels +Tab.Panel = Panel diff --git a/packages/@headlessui-react/src/index.test.ts b/packages/@headlessui-react/src/index.test.ts index 2771e0f12a..145b355530 100644 --- a/packages/@headlessui-react/src/index.test.ts +++ b/packages/@headlessui-react/src/index.test.ts @@ -15,7 +15,7 @@ it('should expose the correct components', () => { 'Portal', 'RadioGroup', 'Switch', - 'Tabs', + 'Tab', 'Transition', ]) }) diff --git a/packages/@headlessui-vue/src/components/tabs/tabs.test.ts b/packages/@headlessui-vue/src/components/tabs/tabs.test.ts index 8d07b3e050..74b12f42f8 100644 --- a/packages/@headlessui-vue/src/components/tabs/tabs.test.ts +++ b/packages/@headlessui-vue/src/components/tabs/tabs.test.ts @@ -1,6 +1,6 @@ import { defineComponent, nextTick } from 'vue' import { render } from '../../test-utils/vue-testing-library' -import { Tabs, TabsList, TabsTab, TabsPanels, TabsPanel } from './tabs' +import { TabGroup, TabList, Tab, TabPanels, TabPanel } from './tabs' import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs' import { assertActiveElement, @@ -21,7 +21,7 @@ beforeAll(() => { afterAll(() => jest.restoreAllMocks()) function renderTemplate(input: string | Partial[0]>) { - let defaultComponents = { Tabs, TabsList, TabsTab, TabsPanels, TabsPanel } + let defaultComponents = { TabGroup, TabList, Tab, TabPanels, TabPanel } if (typeof input === 'string') { return render(defineComponent({ template: input, components: defaultComponents })) @@ -38,35 +38,35 @@ function renderTemplate(input: string | Partial { it.each([ - ['TabsList', TabsList], - ['TabsTab', TabsTab], - ['TabsPanels', TabsPanels], - ['TabsPanel', TabsPanel], + ['TabList', TabList], + ['Tab', Tab], + ['TabPanels', TabPanels], + ['TabPanel', TabPanel], ])( - 'should error when we are using a <%s /> without a parent component', + 'should error when we are using a <%s /> without a parent component', suppressConsoleLogs((name, Component) => { expect(() => render(Component)).toThrowError( - `<${name} /> is missing a parent component.` + `<${name} /> is missing a parent component.` ) }) ) - it('should be possible to render Tabs without crashing', async () => { + it('should be possible to render TabGroup without crashing', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` ) @@ -77,22 +77,22 @@ describe('safeguards', () => { }) describe('Rendering', () => { - it('should be possible to render the TabsPanels first, then the TabsList', async () => { + it('should be possible to render the TabPanels first, then the TabList', async () => { renderTemplate( html` - - - Content 1 - Content 2 - Content 3 - - - - Tab 1 - Tab 2 - Tab 3 - - + + + Content 1 + Content 2 + Content 3 + + + + Tab 1 + Tab 2 + Tab 3 + + ` ) @@ -105,21 +105,21 @@ describe('Rendering', () => { it('should expose the `selectedIndex` on the `Tabs` component', async () => { renderTemplate( html` - +
{{JSON.stringify(data)}}
- - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - -
+ + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + +
` ) @@ -136,23 +136,23 @@ describe('Rendering', () => { ) }) - it('should expose the `selectedIndex` on the `TabsList` component', async () => { + it('should expose the `selectedIndex` on the `TabList` component', async () => { renderTemplate( html` - - + +
{{JSON.stringify(data)}}
- Tab 1 - Tab 2 - Tab 3 -
- - - Content 1 - Content 2 - Content 3 - -
+ Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + +
` ) @@ -169,23 +169,23 @@ describe('Rendering', () => { ) }) - it('should expose the `selectedIndex` on the `TabsPanels` component', async () => { + it('should expose the `selectedIndex` on the `TabPanels` component', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - + + + Tab 1 + Tab 2 + Tab 3 + + +
{{JSON.stringify(data)}}
- Content 1 - Content 2 - Content 3 -
-
+ Content 1 + Content 2 + Content 3 + + ` ) @@ -202,31 +202,31 @@ describe('Rendering', () => { ) }) - it('should expose the `selected` state on the `TabsTab` components', async () => { + it('should expose the `selected` state on the `Tab` components', async () => { renderTemplate( html` - - - + + +
{{JSON.stringify(data)}}
Tab 1 -
- + +
{{JSON.stringify(data)}}
Tab 2 -
- + +
{{JSON.stringify(data)}}
Tab 3 -
-
- - - Content 1 - Content 2 - Content 3 - -
+ + + + + Content 1 + Content 2 + Content 3 + + ` ) @@ -255,31 +255,31 @@ describe('Rendering', () => { ) }) - it('should expose the `selected` state on the `TabsPanel` components', async () => { + it('should expose the `selected` state on the `TabPanel` components', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - + + + Tab 1 + Tab 2 + Tab 3 + + + +
{{JSON.stringify(data)}}
Content 1 -
- + +
{{JSON.stringify(data)}}
Content 2 -
- + +
{{JSON.stringify(data)}}
Content 3 -
-
-
+ + + ` ) @@ -313,19 +313,19 @@ describe('Rendering', () => { it('should jump to the nearest tab when the defaultIndex is out of bounds (-2)', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -344,19 +344,19 @@ describe('Rendering', () => { it('should jump to the nearest tab when the defaultIndex is out of bounds (+5)', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -375,19 +375,19 @@ describe('Rendering', () => { it('should jump to the next available tab when the defaultIndex is a disabled tab', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -406,19 +406,19 @@ describe('Rendering', () => { it('should jump to the next available tab when the defaultIndex is a disabled tab and wrap around', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -441,19 +441,19 @@ describe('Keyboard interactions', () => { it('should be possible to tab to the default initial first tab', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -484,19 +484,19 @@ describe('Keyboard interactions', () => { it('should be possible to tab to the default index tab', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -529,19 +529,19 @@ describe('Keyboard interactions', () => { it('should be possible to go to the next item (activation = `auto`)', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -564,19 +564,19 @@ describe('Keyboard interactions', () => { it('should be possible to go to the next item (activation = `manual`)', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -603,19 +603,19 @@ describe('Keyboard interactions', () => { it('should wrap around at the end (activation = `auto`)', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -644,19 +644,19 @@ describe('Keyboard interactions', () => { it('should wrap around at the end (activation = `manual`)', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -693,19 +693,19 @@ describe('Keyboard interactions', () => { it('should not be possible to go right when in vertical mode (activation = `auto`)', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -726,19 +726,19 @@ describe('Keyboard interactions', () => { it('should not be possible to go right when in vertical mode (activation = `manual`)', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -763,19 +763,19 @@ describe('Keyboard interactions', () => { it('should be possible to go to the previous item (activation = `auto`)', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -798,19 +798,19 @@ describe('Keyboard interactions', () => { it('should be possible to go to the previous item (activation = `manual`)', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -837,19 +837,19 @@ describe('Keyboard interactions', () => { it('should wrap around at the beginning (activation = `auto`)', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -878,19 +878,19 @@ describe('Keyboard interactions', () => { it('should wrap around at the beginning (activation = `manual`)', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -927,19 +927,19 @@ describe('Keyboard interactions', () => { it('should not be possible to go left when in vertical mode (activation = `auto`)', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -960,19 +960,19 @@ describe('Keyboard interactions', () => { it('should not be possible to go left when in vertical mode (activation = `manual`)', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -998,19 +998,19 @@ describe('Keyboard interactions', () => { it('should be possible to go to the next item (activation = `auto`)', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -1033,19 +1033,19 @@ describe('Keyboard interactions', () => { it('should be possible to go to the next item (activation = `manual`)', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -1072,19 +1072,19 @@ describe('Keyboard interactions', () => { it('should wrap around at the end (activation = `auto`)', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -1113,19 +1113,19 @@ describe('Keyboard interactions', () => { it('should wrap around at the end (activation = `manual`)', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -1162,19 +1162,19 @@ describe('Keyboard interactions', () => { it('should not be possible to go down when in horizontal mode (activation = `auto`)', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -1195,19 +1195,19 @@ describe('Keyboard interactions', () => { it('should not be possible to go down when in horizontal mode (activation = `manual`)', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -1233,19 +1233,19 @@ describe('Keyboard interactions', () => { it('should be possible to go to the previous item (activation = `auto`)', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -1268,19 +1268,19 @@ describe('Keyboard interactions', () => { it('should be possible to go to the previous item (activation = `manual`)', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -1307,19 +1307,19 @@ describe('Keyboard interactions', () => { it('should wrap around at the beginning (activation = `auto`)', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -1348,19 +1348,19 @@ describe('Keyboard interactions', () => { it('should wrap around at the beginning (activation = `manual`)', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -1397,19 +1397,19 @@ describe('Keyboard interactions', () => { it('should not be possible to go left when in vertical mode (activation = `auto`)', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -1430,19 +1430,19 @@ describe('Keyboard interactions', () => { it('should not be possible to go left when in vertical mode (activation = `manual`)', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -1468,19 +1468,19 @@ describe('Keyboard interactions', () => { it('should be possible to go to the first focusable item (activation = `auto`)', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -1500,19 +1500,19 @@ describe('Keyboard interactions', () => { it('should be possible to go to the first focusable item (activation = `manual`)', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -1536,19 +1536,19 @@ describe('Keyboard interactions', () => { it('should be possible to go to the first focusable item (activation = `auto`)', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -1568,19 +1568,19 @@ describe('Keyboard interactions', () => { it('should be possible to go to the first focusable item (activation = `manual`)', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -1604,19 +1604,19 @@ describe('Keyboard interactions', () => { it('should be possible to go to the first focusable item (activation = `auto`)', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -1636,19 +1636,19 @@ describe('Keyboard interactions', () => { it('should be possible to go to the first focusable item (activation = `manual`)', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -1672,19 +1672,19 @@ describe('Keyboard interactions', () => { it('should be possible to go to the first focusable item (activation = `auto`)', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -1704,19 +1704,19 @@ describe('Keyboard interactions', () => { it('should be possible to go to the first focusable item (activation = `manual`)', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -1740,19 +1740,19 @@ describe('Keyboard interactions', () => { it('should be possible to activate the focused tab', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -1776,19 +1776,19 @@ describe('Keyboard interactions', () => { it('should be possible to activate the focused tab', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -1813,19 +1813,19 @@ describe('Mouse interactions', () => { it('should be possible to click on a tab to focus it', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -1850,19 +1850,19 @@ describe('Mouse interactions', () => { it('should be a no-op when clicking on a disabled tab', async () => { renderTemplate( html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + ` @@ -1885,19 +1885,19 @@ it('should trigger the `onChange` when the tab changes', async () => { renderTemplate({ template: html` - - - Tab 1 - Tab 2 - Tab 3 - - - - Content 1 - Content 2 - Content 3 - - + + + Tab 1 + Tab 2 + Tab 3 + + + + Content 1 + Content 2 + Content 3 + + `, diff --git a/packages/@headlessui-vue/src/components/tabs/tabs.ts b/packages/@headlessui-vue/src/components/tabs/tabs.ts index a37fd52aa7..b13361e9b7 100644 --- a/packages/@headlessui-vue/src/components/tabs/tabs.ts +++ b/packages/@headlessui-vue/src/components/tabs/tabs.ts @@ -40,7 +40,7 @@ function useTabsContext(component: string) { let context = inject(TabsContext, null) if (context === null) { - let err = new Error(`<${component} /> is missing a parent component.`) + let err = new Error(`<${component} /> is missing a parent component.`) if (Error.captureStackTrace) Error.captureStackTrace(err, useTabsContext) throw err } @@ -50,8 +50,8 @@ function useTabsContext(component: string) { // --- -export let Tabs = defineComponent({ - name: 'Tabs', +export let TabGroup = defineComponent({ + name: 'TabGroup', emits: ['change'], props: { as: { type: [Object, String], default: 'template' }, @@ -130,7 +130,7 @@ export let Tabs = defineComponent({ slot, slots, attrs, - name: 'Tabs', + name: 'TabGroup', }) } }, @@ -138,13 +138,13 @@ export let Tabs = defineComponent({ // --- -export let TabsList = defineComponent({ - name: 'TabsList', +export let TabList = defineComponent({ + name: 'TabList', props: { as: { type: [Object, String], default: 'div' }, }, setup(props, { attrs, slots }) { - let api = useTabsContext('TabsList') + let api = useTabsContext('TabList') return () => { let slot = { selectedIndex: api.selectedIndex.value } @@ -160,7 +160,7 @@ export let TabsList = defineComponent({ slot, attrs, slots, - name: 'TabsList', + name: 'TabList', }) } }, @@ -168,14 +168,14 @@ export let TabsList = defineComponent({ // --- -export let TabsTab = defineComponent({ - name: 'TabsTab', +export let Tab = defineComponent({ + name: 'Tab', props: { as: { type: [Object, String], default: 'button' }, disabled: { type: [Boolean], default: false }, }, render() { - let api = useTabsContext('TabsTab') + let api = useTabsContext('Tab') let slot = { selected: this.selected } let propsWeControl = { @@ -201,11 +201,11 @@ export let TabsTab = defineComponent({ slot, attrs: this.$attrs, slots: this.$slots, - name: 'TabsTab', + name: 'Tab', }) }, setup(props, { attrs }) { - let api = useTabsContext('TabsTab') + let api = useTabsContext('Tab') let id = `headlessui-tabs-tab-${useId()}` let tabRef = ref() @@ -284,13 +284,13 @@ export let TabsTab = defineComponent({ // --- -export let TabsPanels = defineComponent({ - name: 'TabsPanels', +export let TabPanels = defineComponent({ + name: 'TabPanels', props: { as: { type: [Object, String], default: 'div' }, }, setup(props, { slots, attrs }) { - let api = useTabsContext('TabsPanels') + let api = useTabsContext('TabPanels') return () => { let slot = { selectedIndex: api.selectedIndex.value } @@ -300,21 +300,21 @@ export let TabsPanels = defineComponent({ slot, attrs, slots, - name: 'TabsPanels', + name: 'TabPanels', }) } }, }) -export let TabsPanel = defineComponent({ - name: 'TabsPanel', +export let TabPanel = defineComponent({ + name: 'TabPanel', props: { as: { type: [Object, String], default: 'div' }, static: { type: Boolean, default: false }, unmount: { type: Boolean, default: true }, }, render() { - let api = useTabsContext('TabsPanel') + let api = useTabsContext('TabPanel') let slot = { selected: this.selected } let propsWeControl = { @@ -336,11 +336,11 @@ export let TabsPanel = defineComponent({ slots: this.$slots, features: Features.Static | Features.RenderStrategy, visible: this.selected, - name: 'TabsPanel', + name: 'TabPanel', }) }, setup() { - let api = useTabsContext('TabsPanel') + let api = useTabsContext('TabPanel') let id = `headlessui-tabs-panel-${useId()}` let panelRef = ref() diff --git a/packages/@headlessui-vue/src/index.test.ts b/packages/@headlessui-vue/src/index.test.ts index efa7b8d5e5..97ddbee3e8 100644 --- a/packages/@headlessui-vue/src/index.test.ts +++ b/packages/@headlessui-vue/src/index.test.ts @@ -57,11 +57,11 @@ it('should expose the correct components', () => { 'SwitchDescription', // Tabs - 'Tabs', - 'TabsList', - 'TabsTab', - 'TabsPanels', - 'TabsPanel', + 'TabGroup', + 'TabList', + 'Tab', + 'TabPanels', + 'TabPanel', // Transition 'TransitionChild', From 4f3c5fa90b4c347724702539e852d16ba8d6a641 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 28 Jul 2021 14:42:54 +0200 Subject: [PATCH 8/8] update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5402551f12..9f1533ba5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add new `Tabs` component ([#674](https://github.com/tailwindlabs/headlessui/pull/674), [#698](https://github.com/tailwindlabs/headlessui/pull/698)) - Make `Disclosure.Button` close the disclosure inside a `Disclosure.Panel` ([#682](https://github.com/tailwindlabs/headlessui/pull/682)) - Add `aria-orientation` to `Listbox`, which swaps Up/Down with Left/Right keys ([#683](https://github.com/tailwindlabs/headlessui/pull/683)) +- Expose `close` function from the render prop for `Disclosure`, `Disclosure.Panel`, `Popover` and `Popover.Panel` ([#697](https://github.com/tailwindlabs/headlessui/pull/697)) ## [Unreleased - Vue] @@ -20,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add new `Tabs` component ([#674](https://github.com/tailwindlabs/headlessui/pull/674), [#698](https://github.com/tailwindlabs/headlessui/pull/698)) - Make `DisclosureButton` close the disclosure inside a `DisclosurePanel` ([#682](https://github.com/tailwindlabs/headlessui/pull/682)) - Add `aria-orientation` to `Listbox`, which swaps Up/Down with Left/Right keys ([#683](https://github.com/tailwindlabs/headlessui/pull/683)) +- Expose `close` function from the scoped slot for `Disclosure`, `DisclosurePanel`, `Popover` and `PopoverPanel` ([#697](https://github.com/tailwindlabs/headlessui/pull/697)) ## [@headlessui/react@v1.3.0] - 2021-06-21