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)
]