From be29eb46205ba56add214121398a6b975bfa943e Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 7 Jul 2021 14:00:14 +0200 Subject: [PATCH 1/6] add `Tabs` component (React) --- .../src/components/tabs/tabs.test.tsx | 1823 +++++++++++++++++ .../src/components/tabs/tabs.tsx | 448 ++++ .../test-utils/accessibility-assertions.ts | 82 + 3 files changed, 2353 insertions(+) create mode 100644 packages/@headlessui-react/src/components/tabs/tabs.test.tsx create mode 100644 packages/@headlessui-react/src/components/tabs/tabs.tsx 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/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) From 17274347cd7b95bdf647d7ffc7a88ece722c6536 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 7 Jul 2021 14:00:43 +0200 Subject: [PATCH 2/6] expose `Tabs` component (React) --- packages/@headlessui-react/src/index.test.ts | 1 + packages/@headlessui-react/src/index.ts | 1 + 2 files changed, 2 insertions(+) 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' From f829eb14a86492c268ac7f684d3a6230420db298 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 7 Jul 2021 14:00:58 +0200 Subject: [PATCH 3/6] add `Tabs` example (React) --- .../pages/tabs/tabs-with-pure-tailwind.tsx | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 packages/@headlessui-react/pages/tabs/tabs-with-pure-tailwind.tsx 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} + + ))} + + +
+ ) +} From 8a5d24b1488f8627ba32b0e7f5b696df54cc8eeb Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 8 Jul 2021 01:08:47 +0200 Subject: [PATCH 4/6] add `Tabs` component (Vue) --- .../src/components/tabs/tabs.test.ts | 1920 +++++++++++++++++ .../src/components/tabs/tabs.ts | 356 +++ .../test-utils/accessibility-assertions.ts | 82 + 3 files changed, 2358 insertions(+) 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/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/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 d4fa505c05b04306756db63b9aec1f98d119f72e Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 8 Jul 2021 01:10:34 +0200 Subject: [PATCH 5/6] expose `Tabs` component (Vue) --- packages/@headlessui-vue/src/index.test.ts | 7 +++++++ packages/@headlessui-vue/src/index.ts | 1 + 2 files changed, 8 insertions(+) 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' From e81e160416dc48da03fb18791362da0a61c80531 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 8 Jul 2021 11:25:19 +0200 Subject: [PATCH 6/6] update changelog --- CHANGELOG.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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