diff --git a/CHANGELOG.md b/CHANGELOG.md
index dc173a8..9f1533b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,11 +7,21 @@ 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), [#698](https://github.com/tailwindlabs/headlessui/pull/698))
+- Make `Disclosure.Button` close the disclosure inside a `Disclosure.Panel` ([#682](https://github.com/tailwindlabs/headlessui/pull/682))
+- Add `aria-orientation` to `Listbox`, which swaps Up/Down with Left/Right keys ([#683](https://github.com/tailwindlabs/headlessui/pull/683))
+- Expose `close` function from the render prop for `Disclosure`, `Disclosure.Panel`, `Popover` and `Popover.Panel` ([#697](https://github.com/tailwindlabs/headlessui/pull/697))
## [Unreleased - Vue]
-- Nothing yet!
+### Added
+
+- Add new `Tabs` component ([#674](https://github.com/tailwindlabs/headlessui/pull/674), [#698](https://github.com/tailwindlabs/headlessui/pull/698))
+- Make `DisclosureButton` close the disclosure inside a `DisclosurePanel` ([#682](https://github.com/tailwindlabs/headlessui/pull/682))
+- Add `aria-orientation` to `Listbox`, which swaps Up/Down with Left/Right keys ([#683](https://github.com/tailwindlabs/headlessui/pull/683))
+- Expose `close` function from the scoped slot for `Disclosure`, `DisclosurePanel`, `Popover` and `PopoverPanel` ([#697](https://github.com/tailwindlabs/headlessui/pull/697))
## [@headlessui/react@v1.3.0] - 2021-06-21
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 0000000..c6c7f84
--- /dev/null
+++ b/packages/@headlessui-react/pages/tabs/tabs-with-pure-tailwind.tsx
@@ -0,0 +1,86 @@
+import React, { useState } from 'react'
+import { Tab, 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(false)
+
+ 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/disclosure/disclosure.test.tsx b/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx
index 06f432b..a6528a2 100644
--- a/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx
+++ b/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx
@@ -1,4 +1,4 @@
-import React, { createElement, useEffect } from 'react'
+import React, { createElement, useEffect, useRef } from 'react'
import { render } from '@testing-library/react'
import { Disclosure } from './disclosure'
@@ -9,6 +9,8 @@ import {
assertDisclosureButton,
getDisclosureButton,
getDisclosurePanel,
+ assertActiveElement,
+ getByText,
} from '../../test-utils/accessibility-assertions'
import { click, press, Keys, MouseButton } from '../../test-utils/interactions'
import { Transition } from '../transitions/transition'
@@ -113,6 +115,127 @@ describe('Rendering', () => {
assertDisclosureButton({ state: DisclosureState.InvisibleUnmounted })
})
+
+ it(
+ 'should expose a close function that closes the disclosure',
+ suppressConsoleLogs(async () => {
+ render(
+
+ {({ close }) => (
+ <>
+ Trigger
+
+
+
+ >
+ )}
+
+ )
+
+ // Focus the button
+ getDisclosureButton()?.focus()
+
+ // Ensure the button is focused
+ assertActiveElement(getDisclosureButton())
+
+ // Open the disclosure
+ await click(getDisclosureButton())
+
+ // Ensure we can click the close button
+ await click(getByText('Close me'))
+
+ // Ensure the disclosure is closed
+ assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
+
+ // Ensure the Disclosure.Button got the restored focus
+ assertActiveElement(getByText('Trigger'))
+ })
+ )
+
+ it(
+ 'should expose a close function that closes the disclosure and restores to a specific element',
+ suppressConsoleLogs(async () => {
+ render(
+ <>
+
+
+ {({ close }) => (
+ <>
+ Trigger
+
+
+
+ >
+ )}
+
+ >
+ )
+
+ // Focus the button
+ getDisclosureButton()?.focus()
+
+ // Ensure the button is focused
+ assertActiveElement(getDisclosureButton())
+
+ // Open the disclosure
+ await click(getDisclosureButton())
+
+ // Ensure we can click the close button
+ await click(getByText('Close me'))
+
+ // Ensure the disclosure is closed
+ assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
+
+ // Ensure the restoreable button got the restored focus
+ assertActiveElement(getByText('restoreable'))
+ })
+ )
+
+ it(
+ 'should expose a close function that closes the disclosure and restores to a ref',
+ suppressConsoleLogs(async () => {
+ function Example() {
+ let elementRef = useRef(null)
+ return (
+ <>
+
+
+ {({ close }) => (
+ <>
+ Trigger
+
+
+
+ >
+ )}
+
+ >
+ )
+ }
+
+ render()
+
+ // Focus the button
+ getDisclosureButton()?.focus()
+
+ // Ensure the button is focused
+ assertActiveElement(getDisclosureButton())
+
+ // Open the disclosure
+ await click(getDisclosureButton())
+
+ // Ensure we can click the close button
+ await click(getByText('Close me'))
+
+ // Ensure the disclosure is closed
+ assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
+
+ // Ensure the restoreable button got the restored focus
+ assertActiveElement(getByText('restoreable'))
+ })
+ )
})
describe('Disclosure.Button', () => {
@@ -240,6 +363,115 @@ describe('Rendering', () => {
assertDisclosureButton({ state: DisclosureState.InvisibleHidden })
assertDisclosurePanel({ state: DisclosureState.InvisibleHidden })
})
+
+ it(
+ 'should expose a close function that closes the disclosure',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+
+ {({ close }) => }
+
+
+ )
+
+ // Focus the button
+ getDisclosureButton()?.focus()
+
+ // Ensure the button is focused
+ assertActiveElement(getDisclosureButton())
+
+ // Open the disclosure
+ await click(getDisclosureButton())
+
+ // Ensure we can click the close button
+ await click(getByText('Close me'))
+
+ // Ensure the disclosure is closed
+ assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
+
+ // Ensure the Disclosure.Button got the restored focus
+ assertActiveElement(getByText('Trigger'))
+ })
+ )
+
+ it(
+ 'should expose a close function that closes the disclosure and restores to a specific element',
+ suppressConsoleLogs(async () => {
+ render(
+ <>
+
+
+ Trigger
+
+ {({ close }) => (
+
+ )}
+
+
+ >
+ )
+
+ // Focus the button
+ getDisclosureButton()?.focus()
+
+ // Ensure the button is focused
+ assertActiveElement(getDisclosureButton())
+
+ // Open the disclosure
+ await click(getDisclosureButton())
+
+ // Ensure we can click the close button
+ await click(getByText('Close me'))
+
+ // Ensure the disclosure is closed
+ assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
+
+ // Ensure the restoreable button got the restored focus
+ assertActiveElement(getByText('restoreable'))
+ })
+ )
+
+ it(
+ 'should expose a close function that closes the disclosure and restores to a ref',
+ suppressConsoleLogs(async () => {
+ function Example() {
+ let elementRef = useRef(null)
+ return (
+ <>
+
+
+ Trigger
+
+ {({ close }) => }
+
+
+ >
+ )
+ }
+
+ render()
+
+ // Focus the button
+ getDisclosureButton()?.focus()
+
+ // Ensure the button is focused
+ assertActiveElement(getDisclosureButton())
+
+ // Open the disclosure
+ await click(getDisclosureButton())
+
+ // Ensure we can click the close button
+ await click(getByText('Close me'))
+
+ // Ensure the disclosure is closed
+ assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
+
+ // Ensure the restoreable button got the restored focus
+ assertActiveElement(getByText('restoreable'))
+ })
+ )
})
})
@@ -619,4 +851,36 @@ describe('Mouse interactions', () => {
assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
})
)
+
+ it(
+ 'should be possible to close the Disclosure by clicking on a Disclosure.Button inside a Disclosure.Panel',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Open
+
+ Close
+
+
+ )
+
+ // Open the disclosure
+ await click(getDisclosureButton())
+
+ let closeBtn = getByText('Close')
+
+ expect(closeBtn).not.toHaveAttribute('id')
+ expect(closeBtn).not.toHaveAttribute('aria-controls')
+ expect(closeBtn).not.toHaveAttribute('aria-expanded')
+
+ // The close button should close the disclosure
+ await click(closeBtn)
+
+ // Verify it is closed
+ assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
+
+ // Verify we restored the Open button
+ assertActiveElement(getDisclosureButton())
+ })
+ )
})
diff --git a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx
index 0f2f287..9033ee6 100644
--- a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx
+++ b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx
@@ -14,6 +14,8 @@ import React, {
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
Ref,
+ MutableRefObject,
+ ContextType,
} from 'react'
import { Props } from '../../types'
@@ -41,6 +43,7 @@ interface StateDefinition {
enum ActionTypes {
ToggleDisclosure,
+ CloseDisclosure,
SetButtonId,
SetPanelId,
@@ -51,6 +54,7 @@ enum ActionTypes {
type Actions =
| { type: ActionTypes.ToggleDisclosure }
+ | { type: ActionTypes.CloseDisclosure }
| { type: ActionTypes.SetButtonId; buttonId: string }
| { type: ActionTypes.SetPanelId; panelId: string }
| { type: ActionTypes.LinkPanel }
@@ -69,6 +73,10 @@ let reducers: {
[DisclosureStates.Closed]: DisclosureStates.Open,
}),
}),
+ [ActionTypes.CloseDisclosure]: state => {
+ if (state.disclosureState === DisclosureStates.Closed) return state
+ return { ...state, disclosureState: DisclosureStates.Closed }
+ },
[ActionTypes.LinkPanel](state) {
if (state.linkedPanel === true) return state
return { ...state, linkedPanel: true }
@@ -100,6 +108,28 @@ function useDisclosureContext(component: string) {
return context
}
+let DisclosureAPIContext = createContext<{
+ close(focusableElement?: HTMLElement | MutableRefObject): void
+} | null>(null)
+DisclosureAPIContext.displayName = 'DisclosureAPIContext'
+
+function useDisclosureAPIContext(component: string) {
+ let context = useContext(DisclosureAPIContext)
+ if (context === null) {
+ let err = new Error(`<${component} /> is missing a parent <${Disclosure.name} /> component.`)
+ if (Error.captureStackTrace) Error.captureStackTrace(err, useDisclosureAPIContext)
+ throw err
+ }
+ return context
+}
+
+let DisclosurePanelContext = createContext(null)
+DisclosurePanelContext.displayName = 'DisclosurePanelContext'
+
+function useDisclosurePanelContext() {
+ return useContext(DisclosurePanelContext)
+}
+
function stateReducer(state: StateDefinition, action: Actions) {
return match(action.type, reducers, state, action)
}
@@ -109,6 +139,7 @@ function stateReducer(state: StateDefinition, action: Actions) {
let DEFAULT_DISCLOSURE_TAG = Fragment
interface DisclosureRenderPropArg {
open: boolean
+ close(focusableElement?: HTMLElement | MutableRefObject): void
}
export function Disclosure(
@@ -131,26 +162,47 @@ export function Disclosure dispatch({ type: ActionTypes.SetButtonId, buttonId }), [buttonId, dispatch])
useEffect(() => dispatch({ type: ActionTypes.SetPanelId, panelId }), [panelId, dispatch])
+ let close = useCallback(
+ (focusableElement?: HTMLElement | MutableRefObject) => {
+ dispatch({ type: ActionTypes.CloseDisclosure })
+
+ let restoreElement = (() => {
+ if (!focusableElement) return document.getElementById(buttonId)
+ if (focusableElement instanceof HTMLElement) return focusableElement
+ if (focusableElement.current instanceof HTMLElement) return focusableElement.current
+
+ return document.getElementById(buttonId)
+ })()
+
+ restoreElement?.focus()
+ },
+ [dispatch, buttonId]
+ )
+
+ let api = useMemo>(() => ({ close }), [close])
+
let slot = useMemo(
- () => ({ open: disclosureState === DisclosureStates.Open }),
- [disclosureState]
+ () => ({ open: disclosureState === DisclosureStates.Open, close }),
+ [disclosureState, close]
)
return (
-
- {render({
- props: passthroughProps,
- slot,
- defaultTag: DEFAULT_DISCLOSURE_TAG,
- name: 'Disclosure',
- })}
-
+
+
+ {render({
+ props: passthroughProps,
+ slot,
+ defaultTag: DEFAULT_DISCLOSURE_TAG,
+ name: 'Disclosure',
+ })}
+
+
)
}
@@ -176,18 +228,35 @@ let Button = forwardRefWithAs(function Button) => {
- switch (event.key) {
- case Keys.Space:
- case Keys.Enter:
- event.preventDefault()
- event.stopPropagation()
- dispatch({ type: ActionTypes.ToggleDisclosure })
- break
+ if (isWithinPanel) {
+ if (state.disclosureState === DisclosureStates.Closed) return
+
+ switch (event.key) {
+ case Keys.Space:
+ case Keys.Enter:
+ event.preventDefault()
+ event.stopPropagation()
+ dispatch({ type: ActionTypes.ToggleDisclosure })
+ document.getElementById(state.buttonId)?.focus()
+ break
+ }
+ } else {
+ switch (event.key) {
+ case Keys.Space:
+ case Keys.Enter:
+ event.preventDefault()
+ event.stopPropagation()
+ dispatch({ type: ActionTypes.ToggleDisclosure })
+ break
+ }
}
},
- [dispatch]
+ [dispatch, isWithinPanel, state.disclosureState]
)
let handleKeyUp = useCallback((event: ReactKeyboardEvent) => {
@@ -205,9 +274,15 @@ let Button = forwardRefWithAs(function Button {
if (isDisabledReactIssue7711(event.currentTarget)) return
if (props.disabled) return
- dispatch({ type: ActionTypes.ToggleDisclosure })
+
+ if (isWithinPanel) {
+ dispatch({ type: ActionTypes.ToggleDisclosure })
+ document.getElementById(state.buttonId)?.focus()
+ } else {
+ dispatch({ type: ActionTypes.ToggleDisclosure })
+ }
},
- [dispatch, props.disabled]
+ [dispatch, props.disabled, state.buttonId, isWithinPanel]
)
let slot = useMemo(
@@ -216,16 +291,20 @@ let Button = forwardRefWithAs(function Button) => void
}
type PanelPropsWeControl = 'id'
@@ -251,6 +331,8 @@ let Panel = forwardRefWithAs(function Panel
) {
let [state, dispatch] = useDisclosureContext([Disclosure.name, Panel.name].join('.'))
+ let { close } = useDisclosureAPIContext([Disclosure.name, Panel.name].join('.'))
+
let panelRef = useSyncRefs(ref, () => {
if (state.linkedPanel) return
dispatch({ type: ActionTypes.LinkPanel })
@@ -276,8 +358,8 @@ let Panel = forwardRefWithAs(function Panel(
- () => ({ open: state.disclosureState === DisclosureStates.Open }),
- [state]
+ () => ({ open: state.disclosureState === DisclosureStates.Open, close }),
+ [state, close]
)
let propsWeControl = {
ref: panelRef,
@@ -285,14 +367,18 @@ let Panel = forwardRefWithAs(function Panel
+ {render({
+ props: { ...passthroughProps, ...propsWeControl },
+ slot,
+ defaultTag: DEFAULT_PANEL_TAG,
+ features: PanelRenderFeatures,
+ visible,
+ name: 'Disclosure.Panel',
+ })}
+
+ )
})
// ---
diff --git a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx
index 6e75cf0..bb232a0 100644
--- a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx
+++ b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx
@@ -1837,6 +1837,54 @@ describe('Keyboard interactions', () => {
)
})
+ describe('`ArrowRight` key', () => {
+ it(
+ 'should be possible to use ArrowRight to navigate the listbox options',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.Enter)
+
+ // Verify we have listbox options
+ let options = getListboxOptions()
+ expect(options).toHaveLength(3)
+ options.forEach(option => assertListboxOption(option))
+ assertActiveListboxOption(options[0])
+
+ // We should be able to go right once
+ await press(Keys.ArrowRight)
+ assertActiveListboxOption(options[1])
+
+ // We should be able to go right again
+ await press(Keys.ArrowRight)
+ assertActiveListboxOption(options[2])
+
+ // We should NOT be able to go right again (because last option). Current implementation won't go around.
+ await press(Keys.ArrowRight)
+ assertActiveListboxOption(options[2])
+ })
+ )
+ })
+
describe('`ArrowUp` key', () => {
it(
'should be possible to open the listbox with ArrowUp and the last option should be active',
@@ -2127,6 +2175,64 @@ describe('Keyboard interactions', () => {
)
})
+ describe('`ArrowLeft` key', () => {
+ it(
+ 'should be possible to use ArrowLeft to navigate the listbox options',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.ArrowUp)
+
+ // Verify it is visible
+ assertListboxButton({ state: ListboxState.Visible })
+ assertListbox({
+ state: ListboxState.Visible,
+ attributes: { id: 'headlessui-listbox-options-2' },
+ orientation: 'horizontal',
+ })
+ assertActiveElement(getListbox())
+ assertListboxButtonLinkedWithListbox()
+
+ // Verify we have listbox options
+ let options = getListboxOptions()
+ expect(options).toHaveLength(3)
+ options.forEach(option => assertListboxOption(option))
+ assertActiveListboxOption(options[2])
+
+ // We should be able to go left once
+ await press(Keys.ArrowLeft)
+ assertActiveListboxOption(options[1])
+
+ // We should be able to go left again
+ await press(Keys.ArrowLeft)
+ assertActiveListboxOption(options[0])
+
+ // We should NOT be able to go left again (because first option). Current implementation won't go around.
+ await press(Keys.ArrowLeft)
+ assertActiveListboxOption(options[0])
+ })
+ )
+ })
+
describe('`End` key', () => {
it(
'should be possible to use the End key to go to the last listbox option',
diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx
index 3f8b2ce..10b0e39 100644
--- a/packages/@headlessui-react/src/components/listbox/listbox.tsx
+++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx
@@ -46,10 +46,14 @@ type ListboxOptionDataRef = MutableRefObject<{
interface StateDefinition {
listboxState: ListboxStates
+
+ orientation: 'horizontal' | 'vertical'
+
propsRef: MutableRefObject<{ value: unknown; onChange(value: unknown): void }>
labelRef: MutableRefObject
buttonRef: MutableRefObject
optionsRef: MutableRefObject
+
disabled: boolean
options: { id: string; dataRef: ListboxOptionDataRef }[]
searchQuery: string
@@ -61,6 +65,7 @@ enum ActionTypes {
CloseListbox,
SetDisabled,
+ SetOrientation,
GoToOption,
Search,
@@ -74,6 +79,7 @@ type Actions =
| { type: ActionTypes.CloseListbox }
| { type: ActionTypes.OpenListbox }
| { type: ActionTypes.SetDisabled; disabled: boolean }
+ | { type: ActionTypes.SetOrientation; orientation: StateDefinition['orientation'] }
| { type: ActionTypes.GoToOption; focus: Focus.Specific; id: string }
| { type: ActionTypes.GoToOption; focus: Exclude }
| { type: ActionTypes.Search; value: string }
@@ -101,6 +107,10 @@ let reducers: {
if (state.disabled === action.disabled) return state
return { ...state, disabled: action.disabled }
},
+ [ActionTypes.SetOrientation](state, action) {
+ if (state.orientation === action.orientation) return state
+ return { ...state, orientation: action.orientation }
+ },
[ActionTypes.GoToOption](state, action) {
if (state.disabled) return state
if (state.listboxState === ListboxStates.Closed) return state
@@ -193,9 +203,12 @@ export function Listbox dispatch({ type: ActionTypes.SetDisabled, disabled }), [disabled])
+ useIsoMorphicEffect(() => dispatch({ type: ActionTypes.SetOrientation, orientation }), [
+ orientation,
+ ])
// Handle outside click
useWindowEvent('mousedown', event => {
@@ -413,6 +430,7 @@ interface OptionsRenderPropArg {
type OptionsPropsWeControl =
| 'aria-activedescendant'
| 'aria-labelledby'
+ | 'aria-orientation'
| 'id'
| 'onKeyDown'
| 'role'
@@ -478,12 +496,12 @@ let Options = forwardRefWithAs(function Options<
disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true }))
break
- case Keys.ArrowDown:
+ case match(state.orientation, { vertical: Keys.ArrowDown, horizontal: Keys.ArrowRight }):
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Next })
- case Keys.ArrowUp:
+ case match(state.orientation, { vertical: Keys.ArrowUp, horizontal: Keys.ArrowLeft }):
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Previous })
@@ -535,6 +553,7 @@ let Options = forwardRefWithAs(function Options<
'aria-activedescendant':
state.activeOptionIndex === null ? undefined : state.options[state.activeOptionIndex]?.id,
'aria-labelledby': labelledby,
+ 'aria-orientation': state.orientation,
id,
onKeyDown: handleKeyDown,
role: 'listbox',
diff --git a/packages/@headlessui-react/src/components/popover/popover.test.tsx b/packages/@headlessui-react/src/components/popover/popover.test.tsx
index 4938828..a5979ea 100644
--- a/packages/@headlessui-react/src/components/popover/popover.test.tsx
+++ b/packages/@headlessui-react/src/components/popover/popover.test.tsx
@@ -1,4 +1,4 @@
-import React, { createElement, useEffect } from 'react'
+import React, { createElement, useEffect, useRef } from 'react'
import { render } from '@testing-library/react'
import { Popover } from './popover'
@@ -138,6 +138,127 @@ describe('Rendering', () => {
assertPopoverPanel({ state: PopoverState.Visible, textContent: 'Panel is: open' })
})
)
+
+ it(
+ 'should expose a close function that closes the popover',
+ suppressConsoleLogs(async () => {
+ render(
+
+ {({ close }) => (
+ <>
+ Trigger
+
+
+
+ >
+ )}
+
+ )
+
+ // Focus the button
+ getPopoverButton()?.focus()
+
+ // Ensure the button is focused
+ assertActiveElement(getPopoverButton())
+
+ // Open the popover
+ await click(getPopoverButton())
+
+ // Ensure we can click the close button
+ await click(getByText('Close me'))
+
+ // Ensure the popover is closed
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+
+ // Ensure the Popover.Button got the restored focus
+ assertActiveElement(getByText('Trigger'))
+ })
+ )
+
+ it(
+ 'should expose a close function that closes the popover and restores to a specific element',
+ suppressConsoleLogs(async () => {
+ render(
+ <>
+
+
+ {({ close }) => (
+ <>
+ Trigger
+
+
+
+ >
+ )}
+
+ >
+ )
+
+ // Focus the button
+ getPopoverButton()?.focus()
+
+ // Ensure the button is focused
+ assertActiveElement(getPopoverButton())
+
+ // Open the popover
+ await click(getPopoverButton())
+
+ // Ensure we can click the close button
+ await click(getByText('Close me'))
+
+ // Ensure the popover is closed
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+
+ // Ensure the restoreable button got the restored focus
+ assertActiveElement(getByText('restoreable'))
+ })
+ )
+
+ it(
+ 'should expose a close function that closes the popover and restores to a ref',
+ suppressConsoleLogs(async () => {
+ function Example() {
+ let elementRef = useRef(null)
+ return (
+ <>
+
+
+ {({ close }) => (
+ <>
+ Trigger
+
+
+
+ >
+ )}
+
+ >
+ )
+ }
+
+ render()
+
+ // Focus the button
+ getPopoverButton()?.focus()
+
+ // Ensure the button is focused
+ assertActiveElement(getPopoverButton())
+
+ // Open the popover
+ await click(getPopoverButton())
+
+ // Ensure we can click the close button
+ await click(getByText('Close me'))
+
+ // Ensure the popover is closed
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+
+ // Ensure the restoreable button got the restored focus
+ assertActiveElement(getByText('restoreable'))
+ })
+ )
})
describe('Popover.Button', () => {
@@ -384,6 +505,115 @@ describe('Rendering', () => {
assertActiveElement(getByText('Link 1'))
})
)
+
+ it(
+ 'should expose a close function that closes the popover',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+
+ {({ close }) => }
+
+
+ )
+
+ // Focus the button
+ getPopoverButton()?.focus()
+
+ // Ensure the button is focused
+ assertActiveElement(getPopoverButton())
+
+ // Open the popover
+ await click(getPopoverButton())
+
+ // Ensure we can click the close button
+ await click(getByText('Close me'))
+
+ // Ensure the popover is closed
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+
+ // Ensure the Popover.Button got the restored focus
+ assertActiveElement(getByText('Trigger'))
+ })
+ )
+
+ it(
+ 'should expose a close function that closes the popover and restores to a specific element',
+ suppressConsoleLogs(async () => {
+ render(
+ <>
+
+
+ Trigger
+
+ {({ close }) => (
+
+ )}
+
+
+ >
+ )
+
+ // Focus the button
+ getPopoverButton()?.focus()
+
+ // Ensure the button is focused
+ assertActiveElement(getPopoverButton())
+
+ // Open the popover
+ await click(getPopoverButton())
+
+ // Ensure we can click the close button
+ await click(getByText('Close me'))
+
+ // Ensure the popover is closed
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+
+ // Ensure the restoreable button got the restored focus
+ assertActiveElement(getByText('restoreable'))
+ })
+ )
+
+ it(
+ 'should expose a close function that closes the popover and restores to a ref',
+ suppressConsoleLogs(async () => {
+ function Example() {
+ let elementRef = useRef(null)
+ return (
+ <>
+
+
+ Trigger
+
+ {({ close }) => }
+
+
+ >
+ )
+ }
+
+ render()
+
+ // Focus the button
+ getPopoverButton()?.focus()
+
+ // Ensure the button is focused
+ assertActiveElement(getPopoverButton())
+
+ // Open the popover
+ await click(getPopoverButton())
+
+ // Ensure we can click the close button
+ await click(getByText('Close me'))
+
+ // Ensure the popover is closed
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+
+ // Ensure the restoreable button got the restored focus
+ assertActiveElement(getByText('restoreable'))
+ })
+ )
})
})
diff --git a/packages/@headlessui-react/src/components/popover/popover.tsx b/packages/@headlessui-react/src/components/popover/popover.tsx
index 0baa62c..15bebf2 100644
--- a/packages/@headlessui-react/src/components/popover/popover.tsx
+++ b/packages/@headlessui-react/src/components/popover/popover.tsx
@@ -15,6 +15,7 @@ import React, {
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
Ref,
+ MutableRefObject,
} from 'react'
import { Props } from '../../types'
@@ -115,6 +116,21 @@ function usePopoverContext(component: string) {
return context
}
+let PopoverAPIContext = createContext<{
+ close(focusableElement?: HTMLElement | MutableRefObject): void
+} | null>(null)
+PopoverAPIContext.displayName = 'PopoverAPIContext'
+
+function usePopoverAPIContext(component: string) {
+ let context = useContext(PopoverAPIContext)
+ if (context === null) {
+ let err = new Error(`<${component} /> is missing a parent <${Popover.name} /> component.`)
+ if (Error.captureStackTrace) Error.captureStackTrace(err, usePopoverAPIContext)
+ throw err
+ }
+ return context
+}
+
let PopoverGroupContext = createContext<{
registerPopover(registerbag: PopoverRegisterBag): void
unregisterPopover(registerbag: PopoverRegisterBag): void
@@ -148,6 +164,7 @@ function stateReducer(state: StateDefinition, action: Actions) {
let DEFAULT_POPOVER_TAG = 'div' as const
interface PopoverRenderPropArg {
open: boolean
+ close(focusableElement?: HTMLElement | MutableRefObject): void
}
export function Popover(
@@ -215,25 +232,47 @@ export function Popover(
}
})
- let slot = useMemo(() => ({ open: popoverState === PopoverStates.Open }), [
- popoverState,
- ])
+ let close = useCallback(
+ (focusableElement?: HTMLElement | MutableRefObject) => {
+ dispatch({ type: ActionTypes.ClosePopover })
+
+ let restoreElement = (() => {
+ if (!focusableElement) return button
+ if (focusableElement instanceof HTMLElement) return focusableElement
+ if (focusableElement.current instanceof HTMLElement) return focusableElement.current
+
+ return button
+ })()
+
+ restoreElement?.focus()
+ },
+ [dispatch, button]
+ )
+
+ let api = useMemo>(() => ({ close }), [close])
+
+ let slot = useMemo(
+ () => ({ open: popoverState === PopoverStates.Open, close }),
+ [popoverState, close]
+ )
return (
-
- {render({
- props,
- slot,
- defaultTag: DEFAULT_POPOVER_TAG,
- name: 'Popover',
- })}
-
+
+
+ {render({
+ props,
+ slot,
+ defaultTag: DEFAULT_POPOVER_TAG,
+ name: 'Popover',
+ })}
+
+
)
}
@@ -520,6 +559,7 @@ let Overlay = forwardRefWithAs(function Overlay<
let DEFAULT_PANEL_TAG = 'div' as const
interface PanelRenderPropArg {
open: boolean
+ close: (focusableElement?: HTMLElement | MutableRefObject) => void
}
type PanelPropsWeControl = 'id' | 'onKeyDown'
@@ -527,12 +567,16 @@ let PanelRenderFeatures = Features.RenderStrategy | Features.Static
let Panel = forwardRefWithAs(function Panel(
props: Props &
- PropsForFeatures & { focus?: boolean },
+ PropsForFeatures & {
+ focus?: boolean
+ },
ref: Ref
) {
let { focus = false, ...passthroughProps } = props
let [state, dispatch] = usePopoverContext([Popover.name, Panel.name].join('.'))
+ let { close } = usePopoverAPIContext([Popover.name, Panel.name].join('.'))
+
let internalPanelRef = useRef(null)
let panelRef = useSyncRefs(internalPanelRef, ref, panel => {
dispatch({ type: ActionTypes.SetPanel, panel })
@@ -640,8 +684,8 @@ let Panel = forwardRefWithAs(function Panel(
- () => ({ open: state.popoverState === PopoverStates.Open }),
- [state]
+ () => ({ open: state.popoverState === PopoverStates.Open, close }),
+ [state, close]
)
let propsWeControl = {
ref: panelRef,
diff --git a/packages/@headlessui-react/src/components/tabs/tabs.test.tsx b/packages/@headlessui-react/src/components/tabs/tabs.test.tsx
new file mode 100644
index 0000000..c9214ab
--- /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 { Tab } 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([
+ ['Tab.List', Tab.List],
+ ['Tab', Tab],
+ ['Tab.Panels', Tab.Panels],
+ ['Tab.Panel', Tab.Panel],
+ ])(
+ 'should error when we are using a <%s /> without a parent component',
+ suppressConsoleLogs((name, Component) => {
+ expect(() => render(createElement(Component))).toThrowError(
+ `<${name} /> is missing a parent component.`
+ )
+ })
+ )
+
+ it('should be possible to render Tab.Group 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 Tab.Panels first, then the Tab.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 `Tab.Group` 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 `Tab.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 `Tab.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 `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 `Tab.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 0000000..d5c2e83
--- /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 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
+}
+
+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([Tab.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'
+
+export function Tab(
+ props: Props
+) {
+ let id = `headlessui-tabs-tab-${useId()}`
+
+ let [
+ { selectedIndex, tabs, panels, orientation, activation },
+ { dispatch, change },
+ ] = useTabsContext(Tab.name)
+
+ 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([Tab.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(
+ [Tab.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',
+ })
+}
+
+// ---
+
+Tab.Group = Tabs
+Tab.List = List
+Tab.Panels = Panels
+Tab.Panel = Panel
diff --git a/packages/@headlessui-react/src/index.test.ts b/packages/@headlessui-react/src/index.test.ts
index 1fc2d33..145b355 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',
+ 'Tab',
'Transition',
])
})
diff --git a/packages/@headlessui-react/src/index.ts b/packages/@headlessui-react/src/index.ts
index cce0dd2..2ef29db 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 ade9941..5a6fb38 100644
--- a/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts
+++ b/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts
@@ -263,9 +263,12 @@ export function assertListbox(
attributes?: Record
textContent?: string
state: ListboxState
+ orientation?: 'horizontal' | 'vertical'
},
listbox = getListbox()
) {
+ let { orientation = 'vertical' } = options
+
try {
switch (options.state) {
case ListboxState.InvisibleHidden:
@@ -274,6 +277,7 @@ export function assertListbox(
assertHidden(listbox)
expect(listbox).toHaveAttribute('aria-labelledby')
+ expect(listbox).toHaveAttribute('aria-orientation', orientation)
expect(listbox).toHaveAttribute('role', 'listbox')
if (options.textContent) expect(listbox).toHaveTextContent(options.textContent)
@@ -289,6 +293,7 @@ export function assertListbox(
assertVisible(listbox)
expect(listbox).toHaveAttribute('aria-labelledby')
+ expect(listbox).toHaveAttribute('aria-orientation', orientation)
expect(listbox).toHaveAttribute('role', 'listbox')
if (options.textContent) expect(listbox).toHaveTextContent(options.textContent)
@@ -1187,6 +1192,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/disclosure/disclosure.test.ts b/packages/@headlessui-vue/src/components/disclosure/disclosure.test.ts
index e0fd49d..fc3df40 100644
--- a/packages/@headlessui-vue/src/components/disclosure/disclosure.test.ts
+++ b/packages/@headlessui-vue/src/components/disclosure/disclosure.test.ts
@@ -8,6 +8,8 @@ import {
assertDisclosureButton,
getDisclosureButton,
getDisclosurePanel,
+ getByText,
+ assertActiveElement,
} from '../../test-utils/accessibility-assertions'
import { click, press, Keys, MouseButton } from '../../test-utils/interactions'
import { html } from '../../test-utils/html'
@@ -119,6 +121,112 @@ describe('Rendering', () => {
assertDisclosureButton({ state: DisclosureState.InvisibleUnmounted })
})
+
+ it(
+ 'should expose a close function that closes the disclosure',
+ suppressConsoleLogs(async () => {
+ renderTemplate(
+ html`
+
+ Trigger
+
+
+
+
+ `
+ )
+
+ // Focus the button
+ getDisclosureButton()?.focus()
+
+ // Ensure the button is focused
+ assertActiveElement(getDisclosureButton())
+
+ // Open the disclosure
+ await click(getDisclosureButton())
+
+ // Ensure we can click the close button
+ await click(getByText('Close me'))
+
+ // Ensure the disclosure is closed
+ assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
+
+ // Ensure the Disclosure.Button got the restored focus
+ assertActiveElement(getByText('Trigger'))
+ })
+ )
+
+ it(
+ 'should expose a close function that closes the disclosure and restores to a specific element',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+
+ Trigger
+
+
+
+
+ `,
+ setup: () => ({ document }),
+ })
+
+ // Focus the button
+ getDisclosureButton()?.focus()
+
+ // Ensure the button is focused
+ assertActiveElement(getDisclosureButton())
+
+ // Open the disclosure
+ await click(getDisclosureButton())
+
+ // Ensure we can click the close button
+ await click(getByText('Close me'))
+
+ // Ensure the disclosure is closed
+ assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
+
+ // Ensure the restoreable button got the restored focus
+ assertActiveElement(getByText('restoreable'))
+ })
+ )
+
+ it(
+ 'should expose a close function that closes the disclosure and restores to a ref',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+
+ Trigger
+
+ }
+
+
+ `,
+ setup: () => ({ elementRef: ref() }),
+ })
+
+ // Focus the button
+ getDisclosureButton()?.focus()
+
+ // Ensure the button is focused
+ assertActiveElement(getDisclosureButton())
+
+ // Open the disclosure
+ await click(getDisclosureButton())
+
+ // Ensure we can click the close button
+ await click(getByText('Close me'))
+
+ // Ensure the disclosure is closed
+ assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
+
+ // Ensure the restoreable button got the restored focus
+ assertActiveElement(getByText('restoreable'))
+ })
+ )
})
describe('DisclosureButton', () => {
@@ -259,6 +367,112 @@ describe('Rendering', () => {
assertDisclosureButton({ state: DisclosureState.InvisibleHidden })
assertDisclosurePanel({ state: DisclosureState.InvisibleHidden })
})
+
+ it(
+ 'should expose a close function that closes the disclosure',
+ suppressConsoleLogs(async () => {
+ renderTemplate(
+ html`
+
+ Trigger
+
+
+
+
+ `
+ )
+
+ // Focus the button
+ getDisclosureButton()?.focus()
+
+ // Ensure the button is focused
+ assertActiveElement(getDisclosureButton())
+
+ // Open the disclosure
+ await click(getDisclosureButton())
+
+ // Ensure we can click the close button
+ await click(getByText('Close me'))
+
+ // Ensure the disclosure is closed
+ assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
+
+ // Ensure the Disclosure.Button got the restored focus
+ assertActiveElement(getByText('Trigger'))
+ })
+ )
+
+ it(
+ 'should expose a close function that closes the disclosure and restores to a specific element',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+
+ Trigger
+
+
+
+
+ `,
+ setup: () => ({ document }),
+ })
+
+ // Focus the button
+ getDisclosureButton()?.focus()
+
+ // Ensure the button is focused
+ assertActiveElement(getDisclosureButton())
+
+ // Open the disclosure
+ await click(getDisclosureButton())
+
+ // Ensure we can click the close button
+ await click(getByText('Close me'))
+
+ // Ensure the disclosure is closed
+ assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
+
+ // Ensure the restoreable button got the restored focus
+ assertActiveElement(getByText('restoreable'))
+ })
+ )
+
+ it(
+ 'should expose a close function that closes the disclosure and restores to a ref',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+
+ Trigger
+
+ }
+
+
+ `,
+ setup: () => ({ elementRef: ref() }),
+ })
+
+ // Focus the button
+ getDisclosureButton()?.focus()
+
+ // Ensure the button is focused
+ assertActiveElement(getDisclosureButton())
+
+ // Open the disclosure
+ await click(getDisclosureButton())
+
+ // Ensure we can click the close button
+ await click(getByText('Close me'))
+
+ // Ensure the disclosure is closed
+ assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
+
+ // Ensure the restoreable button got the restored focus
+ assertActiveElement(getByText('restoreable'))
+ })
+ )
})
})
@@ -715,4 +929,38 @@ describe('Mouse interactions', () => {
assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
})
)
+
+ it(
+ 'should be possible to close the Disclosure by clicking on a DisclosureButton inside a DisclosurePanel',
+ suppressConsoleLogs(async () => {
+ renderTemplate(
+ html`
+
+ Open
+
+ Close
+
+
+ `
+ )
+
+ // Open the disclosure
+ await click(getDisclosureButton())
+
+ let closeBtn = getByText('Close')
+
+ expect(closeBtn).not.toHaveAttribute('id')
+ expect(closeBtn).not.toHaveAttribute('aria-controls')
+ expect(closeBtn).not.toHaveAttribute('aria-expanded')
+
+ // The close button should close the disclosure
+ await click(closeBtn)
+
+ // Verify it is closed
+ assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
+
+ // Verify we restored the Open button
+ assertActiveElement(getDisclosureButton())
+ })
+ )
})
diff --git a/packages/@headlessui-vue/src/components/disclosure/disclosure.ts b/packages/@headlessui-vue/src/components/disclosure/disclosure.ts
index 7d67dec..e8cc87d 100644
--- a/packages/@headlessui-vue/src/components/disclosure/disclosure.ts
+++ b/packages/@headlessui-vue/src/components/disclosure/disclosure.ts
@@ -16,10 +16,17 @@ enum DisclosureStates {
interface StateDefinition {
// State
disclosureState: Ref
- panelRef: Ref
+ panel: Ref
+ panelId: string
+ button: Ref
+ buttonId: string
// State mutators
toggleDisclosure(): void
+ closeDisclosure(): void
+
+ // Exposed functions
+ close(focusableElement: HTMLElement | Ref): void
}
let DisclosureContext = Symbol('DisclosureContext') as InjectionKey
@@ -36,6 +43,11 @@ function useDisclosureContext(component: string) {
return context
}
+let DisclosurePanelContext = Symbol('DisclosurePanelContext') as InjectionKey
+function useDisclosurePanelContext() {
+ return inject(DisclosurePanelContext, null)
+}
+
// ---
export let Disclosure = defineComponent({
@@ -45,20 +57,44 @@ export let Disclosure = defineComponent({
defaultOpen: { type: [Boolean], default: false },
},
setup(props, { slots, attrs }) {
+ let buttonId = `headlessui-disclosure-button-${useId()}`
+ let panelId = `headlessui-disclosure-panel-${useId()}`
+
let disclosureState = ref(
props.defaultOpen ? DisclosureStates.Open : DisclosureStates.Closed
)
- let panelRef = ref(null)
+ let panelRef = ref(null)
+ let buttonRef = ref(null)
let api = {
+ buttonId,
+ panelId,
disclosureState,
- panelRef,
+ panel: panelRef,
+ button: buttonRef,
toggleDisclosure() {
disclosureState.value = match(disclosureState.value, {
[DisclosureStates.Open]: DisclosureStates.Closed,
[DisclosureStates.Closed]: DisclosureStates.Open,
})
},
+ closeDisclosure() {
+ if (disclosureState.value === DisclosureStates.Closed) return
+ disclosureState.value = DisclosureStates.Closed
+ },
+ close(focusableElement: HTMLElement | Ref) {
+ api.closeDisclosure()
+
+ let restoreElement = (() => {
+ if (!focusableElement) return dom(api.button)
+ if (focusableElement instanceof HTMLElement) return focusableElement
+ if (focusableElement.value instanceof HTMLElement) return dom(focusableElement)
+
+ return dom(api.button)
+ })()
+
+ restoreElement?.focus()
+ },
} as StateDefinition
provide(DisclosureContext, api)
@@ -73,7 +109,7 @@ export let Disclosure = defineComponent({
return () => {
let { defaultOpen: _, ...passThroughProps } = props
- let slot = { open: disclosureState.value === DisclosureStates.Open }
+ let slot = { open: disclosureState.value === DisclosureStates.Open, close: api.close }
return render({ props: passThroughProps, slot, slots, attrs, name: 'Disclosure' })
}
},
@@ -91,18 +127,25 @@ export let DisclosureButton = defineComponent({
let api = useDisclosureContext('DisclosureButton')
let slot = { open: api.disclosureState.value === DisclosureStates.Open }
- let propsWeControl = {
- id: this.id,
- type: 'button',
- 'aria-expanded': this.$props.disabled
- ? undefined
- : api.disclosureState.value === DisclosureStates.Open,
- 'aria-controls': this.ariaControls,
- disabled: this.$props.disabled ? true : undefined,
- onClick: this.handleClick,
- onKeydown: this.handleKeyDown,
- onKeyup: this.handleKeyUp,
- }
+ let propsWeControl = this.isWithinPanel
+ ? {
+ type: 'button',
+ onClick: this.handleClick,
+ onKeydown: this.handleKeyDown,
+ }
+ : {
+ id: this.id,
+ ref: 'el',
+ type: 'button',
+ 'aria-expanded': this.$props.disabled
+ ? undefined
+ : api.disclosureState.value === DisclosureStates.Open,
+ 'aria-controls': dom(api.panel) ? api.panelId : undefined,
+ disabled: this.$props.disabled ? true : undefined,
+ onClick: this.handleClick,
+ onKeydown: this.handleKeyDown,
+ onKeyup: this.handleKeyUp,
+ }
return render({
props: { ...this.$props, ...propsWeControl },
@@ -114,26 +157,46 @@ export let DisclosureButton = defineComponent({
},
setup(props) {
let api = useDisclosureContext('DisclosureButton')
- let buttonId = `headlessui-disclosure-button-${useId()}`
- let ariaControls = computed(() => dom(api.panelRef)?.id ?? undefined)
+
+ let panelContext = useDisclosurePanelContext()
+ let isWithinPanel = panelContext === null ? false : panelContext === api.panelId
return {
- id: buttonId,
- ariaControls,
+ isWithinPanel,
+ id: api.buttonId,
+ el: isWithinPanel ? undefined : api.button,
handleClick() {
if (props.disabled) return
- api.toggleDisclosure()
+
+ if (isWithinPanel) {
+ api.toggleDisclosure()
+ dom(api.button)?.focus()
+ } else {
+ api.toggleDisclosure()
+ }
},
handleKeyDown(event: KeyboardEvent) {
if (props.disabled) return
- switch (event.key) {
- case Keys.Space:
- case Keys.Enter:
- event.preventDefault()
- event.stopPropagation()
- api.toggleDisclosure()
- break
+ if (isWithinPanel) {
+ switch (event.key) {
+ case Keys.Space:
+ case Keys.Enter:
+ event.preventDefault()
+ event.stopPropagation()
+ api.toggleDisclosure()
+ dom(api.button)?.focus()
+ break
+ }
+ } else {
+ switch (event.key) {
+ case Keys.Space:
+ case Keys.Enter:
+ event.preventDefault()
+ event.stopPropagation()
+ api.toggleDisclosure()
+ break
+ }
}
},
handleKeyUp(event: KeyboardEvent) {
@@ -162,7 +225,7 @@ export let DisclosurePanel = defineComponent({
render() {
let api = useDisclosureContext('DisclosurePanel')
- let slot = { open: api.disclosureState.value === DisclosureStates.Open }
+ let slot = { open: api.disclosureState.value === DisclosureStates.Open, close: api.close }
let propsWeControl = { id: this.id, ref: 'el' }
return render({
@@ -177,7 +240,8 @@ export let DisclosurePanel = defineComponent({
},
setup() {
let api = useDisclosureContext('DisclosurePanel')
- let panelId = `headlessui-disclosure-panel-${useId()}`
+
+ provide(DisclosurePanelContext, api.panelId)
let usesOpenClosedState = useOpenClosed()
let visible = computed(() => {
@@ -188,6 +252,10 @@ export let DisclosurePanel = defineComponent({
return api.disclosureState.value === DisclosureStates.Open
})
- return { id: panelId, el: api.panelRef, visible }
+ return {
+ id: api.panelId,
+ el: api.panel,
+ visible,
+ }
},
})
diff --git a/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx b/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx
index 398a4ba..a67db26 100644
--- a/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx
+++ b/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx
@@ -1933,6 +1933,57 @@ describe('Keyboard interactions', () => {
)
})
+ describe('`ArrowRight` key', () => {
+ it(
+ 'should be possible to use ArrowRight to navigate the listbox options',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ `,
+ setup: () => ({ value: ref(null) }),
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.Enter)
+
+ // Verify we have listbox options
+ let options = getListboxOptions()
+ expect(options).toHaveLength(3)
+ options.forEach(option => assertListboxOption(option))
+ assertActiveListboxOption(options[0])
+
+ // We should be able to go right once
+ await press(Keys.ArrowRight)
+ assertActiveListboxOption(options[1])
+
+ // We should be able to go right again
+ await press(Keys.ArrowRight)
+ assertActiveListboxOption(options[2])
+
+ // We should NOT be able to go right again (because last option). Current implementation won't go around.
+ await press(Keys.ArrowRight)
+ assertActiveListboxOption(options[2])
+ })
+ )
+ })
+
describe('`ArrowUp` key', () => {
it(
'should be possible to open the listbox with ArrowUp and the last option should be active',
@@ -2244,6 +2295,67 @@ describe('Keyboard interactions', () => {
)
})
+ describe('`ArrowLeft` key', () => {
+ it(
+ 'should be possible to use ArrowLeft to navigate the listbox options',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ `,
+ setup: () => ({ value: ref(null) }),
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.ArrowUp)
+
+ // Verify it is visible
+ assertListboxButton({ state: ListboxState.Visible })
+ assertListbox({
+ state: ListboxState.Visible,
+ attributes: { id: 'headlessui-listbox-options-2' },
+ orientation: 'horizontal',
+ })
+ assertActiveElement(getListbox())
+ assertListboxButtonLinkedWithListbox()
+
+ // Verify we have listbox options
+ let options = getListboxOptions()
+ expect(options).toHaveLength(3)
+ options.forEach(option => assertListboxOption(option))
+ assertActiveListboxOption(options[2])
+
+ // We should be able to go left once
+ await press(Keys.ArrowLeft)
+ assertActiveListboxOption(options[1])
+
+ // We should be able to go left again
+ await press(Keys.ArrowLeft)
+ assertActiveListboxOption(options[0])
+
+ // We should NOT be able to go left again (because first option). Current implementation won't go around.
+ await press(Keys.ArrowLeft)
+ assertActiveListboxOption(options[0])
+ })
+ )
+ })
+
describe('`End` key', () => {
it(
'should be possible to use the End key to go to the last listbox option',
diff --git a/packages/@headlessui-vue/src/components/listbox/listbox.ts b/packages/@headlessui-vue/src/components/listbox/listbox.ts
index 00173b8..49b5d28 100644
--- a/packages/@headlessui-vue/src/components/listbox/listbox.ts
+++ b/packages/@headlessui-vue/src/components/listbox/listbox.ts
@@ -38,9 +38,12 @@ type StateDefinition = {
// State
listboxState: Ref
value: ComputedRef
+ orientation: Ref<'vertical' | 'horizontal'>
+
labelRef: Ref
buttonRef: Ref
optionsRef: Ref
+
disabled: Ref
options: Ref<{ id: string; dataRef: ListboxOptionDataRef }[]>
searchQuery: Ref
@@ -79,6 +82,7 @@ export let Listbox = defineComponent({
props: {
as: { type: [Object, String], default: 'template' },
disabled: { type: [Boolean], default: false },
+ horizontal: { type: [Boolean], default: false },
modelValue: { type: [Object, String, Number, Boolean] },
},
setup(props, { slots, attrs, emit }) {
@@ -95,6 +99,7 @@ export let Listbox = defineComponent({
let api = {
listboxState,
value,
+ orientation: computed(() => (props.horizontal ? 'horizontal' : 'vertical')),
labelRef,
buttonRef,
optionsRef,
@@ -206,7 +211,7 @@ export let Listbox = defineComponent({
return () => {
let slot = { open: listboxState.value === ListboxStates.Open, disabled: props.disabled }
return render({
- props: omit(props, ['modelValue', 'onUpdate:modelValue', 'disabled']),
+ props: omit(props, ['modelValue', 'onUpdate:modelValue', 'disabled', 'horizontal']),
slot,
slots,
attrs,
@@ -362,6 +367,7 @@ export let ListboxOptions = defineComponent({
? undefined
: api.options.value[api.activeOptionIndex.value]?.id,
'aria-labelledby': dom(api.labelRef)?.id ?? dom(api.buttonRef)?.id,
+ 'aria-orientation': api.orientation.value,
id: this.id,
onKeydown: this.handleKeyDown,
role: 'listbox',
@@ -410,12 +416,15 @@ export let ListboxOptions = defineComponent({
nextTick(() => dom(api.buttonRef)?.focus({ preventScroll: true }))
break
- case Keys.ArrowDown:
+ case match(api.orientation.value, {
+ vertical: Keys.ArrowDown,
+ horizontal: Keys.ArrowRight,
+ }):
event.preventDefault()
event.stopPropagation()
return api.goToOption(Focus.Next)
- case Keys.ArrowUp:
+ case match(api.orientation.value, { vertical: Keys.ArrowUp, horizontal: Keys.ArrowLeft }):
event.preventDefault()
event.stopPropagation()
return api.goToOption(Focus.Previous)
diff --git a/packages/@headlessui-vue/src/components/popover/popover.test.ts b/packages/@headlessui-vue/src/components/popover/popover.test.ts
index 538257e..758b732 100644
--- a/packages/@headlessui-vue/src/components/popover/popover.test.ts
+++ b/packages/@headlessui-vue/src/components/popover/popover.test.ts
@@ -159,6 +159,110 @@ describe('Rendering', () => {
assertPopoverPanel({ state: PopoverState.Visible, textContent: 'Panel is: open' })
})
)
+
+ it(
+ 'should expose a close function that closes the popover',
+ suppressConsoleLogs(async () => {
+ renderTemplate(
+ html`
+
+ Trigger
+
+
+
+
+ `
+ )
+
+ // Focus the button
+ getPopoverButton()?.focus()
+
+ // Ensure the button is focused
+ assertActiveElement(getPopoverButton())
+
+ // Open the popover
+ await click(getPopoverButton())
+
+ // Ensure we can click the close button
+ await click(getByText('Close me'))
+
+ // Ensure the popover is closed
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+
+ // Ensure the Popover.Button got the restored focus
+ assertActiveElement(getByText('Trigger'))
+ })
+ )
+
+ it(
+ 'should expose a close function that closes the popover and restores to a specific element',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+
+ Trigger
+
+
+
+
+ `,
+ setup: () => ({ document }),
+ })
+
+ // Focus the button
+ getPopoverButton()?.focus()
+
+ // Ensure the button is focused
+ assertActiveElement(getPopoverButton())
+
+ // Open the popover
+ await click(getPopoverButton())
+
+ // Ensure we can click the close button
+ await click(getByText('Close me'))
+
+ // Ensure the popover is closed
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+
+ // Ensure the restoreable button got the restored focus
+ assertActiveElement(getByText('restoreable'))
+ })
+ )
+
+ it(
+ 'should expose a close function that closes the popover and restores to a ref',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+
+ Trigger
+ }
+
+ `,
+ setup: () => ({ elementRef: ref() }),
+ })
+
+ // Focus the button
+ getPopoverButton()?.focus()
+
+ // Ensure the button is focused
+ assertActiveElement(getPopoverButton())
+
+ // Open the popover
+ await click(getPopoverButton())
+
+ // Ensure we can click the close button
+ await click(getByText('Close me'))
+
+ // Ensure the popover is closed
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+
+ // Ensure the restoreable button got the restored focus
+ assertActiveElement(getByText('restoreable'))
+ })
+ )
})
describe('PopoverButton', () => {
@@ -427,6 +531,112 @@ describe('Rendering', () => {
assertActiveElement(getByText('Link 1'))
})
)
+
+ it(
+ 'should expose a close function that closes the popover',
+ suppressConsoleLogs(async () => {
+ renderTemplate(
+ html`
+
+ Trigger
+
+
+
+
+ `
+ )
+
+ // Focus the button
+ getPopoverButton()?.focus()
+
+ // Ensure the button is focused
+ assertActiveElement(getPopoverButton())
+
+ // Open the popover
+ await click(getPopoverButton())
+
+ // Ensure we can click the close button
+ await click(getByText('Close me'))
+
+ // Ensure the popover is closed
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+
+ // Ensure the Popover.Button got the restored focus
+ assertActiveElement(getByText('Trigger'))
+ })
+ )
+
+ it(
+ 'should expose a close function that closes the popover and restores to a specific element',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+
+ Trigger
+
+
+
+
+ `,
+ setup: () => ({ document }),
+ })
+
+ // Focus the button
+ getPopoverButton()?.focus()
+
+ // Ensure the button is focused
+ assertActiveElement(getPopoverButton())
+
+ // Open the popover
+ await click(getPopoverButton())
+
+ // Ensure we can click the close button
+ await click(getByText('Close me'))
+
+ // Ensure the popover is closed
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+
+ // Ensure the restoreable button got the restored focus
+ assertActiveElement(getByText('restoreable'))
+ })
+ )
+
+ it(
+ 'should expose a close function that closes the popover and restores to a ref',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+
+ Trigger
+
+ }
+
+
+ `,
+ setup: () => ({ elementRef: ref() }),
+ })
+
+ // Focus the button
+ getPopoverButton()?.focus()
+
+ // Ensure the button is focused
+ assertActiveElement(getPopoverButton())
+
+ // Open the popover
+ await click(getPopoverButton())
+
+ // Ensure we can click the close button
+ await click(getByText('Close me'))
+
+ // Ensure the popover is closed
+ assertPopoverPanel({ state: PopoverState.InvisibleUnmounted })
+
+ // Ensure the restoreable button got the restored focus
+ assertActiveElement(getByText('restoreable'))
+ })
+ )
})
})
diff --git a/packages/@headlessui-vue/src/components/popover/popover.ts b/packages/@headlessui-vue/src/components/popover/popover.ts
index 244327e..ef6fdd3 100644
--- a/packages/@headlessui-vue/src/components/popover/popover.ts
+++ b/packages/@headlessui-vue/src/components/popover/popover.ts
@@ -44,6 +44,9 @@ interface StateDefinition {
// State mutators
togglePopover(): void
closePopover(): void
+
+ // Exposed functions
+ close(focusableElement: HTMLElement | Ref): void
}
let PopoverContext = Symbol('PopoverContext') as InjectionKey
@@ -110,6 +113,19 @@ export let Popover = defineComponent({
if (popoverState.value === PopoverStates.Closed) return
popoverState.value = PopoverStates.Closed
},
+ close(focusableElement: HTMLElement | Ref) {
+ api.closePopover()
+
+ let restoreElement = (() => {
+ if (!focusableElement) return dom(api.button)
+ if (focusableElement instanceof HTMLElement) return focusableElement
+ if (focusableElement.value instanceof HTMLElement) return dom(focusableElement)
+
+ return dom(api.button)
+ })()
+
+ restoreElement?.focus()
+ },
} as StateDefinition
provide(PopoverContext, api)
@@ -175,7 +191,7 @@ export let Popover = defineComponent({
})
return () => {
- let slot = { open: popoverState.value === PopoverStates.Open }
+ let slot = { open: popoverState.value === PopoverStates.Open, close: api.close }
return render({ props, slot, slots, attrs, name: 'Popover' })
}
},
@@ -427,7 +443,11 @@ export let PopoverPanel = defineComponent({
render() {
let api = usePopoverContext('PopoverPanel')
- let slot = { open: api.popoverState.value === PopoverStates.Open }
+ let slot = {
+ open: api.popoverState.value === PopoverStates.Open,
+ close: api.close,
+ }
+
let propsWeControl = {
ref: 'el',
id: this.id,
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 0000000..74b12f4
--- /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 { TabGroup, TabList, Tab, TabPanels, TabPanel } 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 = { TabGroup, TabList, Tab, TabPanels, TabPanel }
+
+ 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([
+ ['TabList', TabList],
+ ['Tab', Tab],
+ ['TabPanels', TabPanels],
+ ['TabPanel', TabPanel],
+ ])(
+ '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 TabGroup 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 TabPanels first, then the TabList', 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 `TabList` 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 `TabPanels` 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 `Tab` components', async () => {
+ renderTemplate(
+ html`
+
+
+
+ {{JSON.stringify(data)}}
+ Tab 1
+
+
+ {{JSON.stringify(data)}}
+ Tab 2
+
+
+ {{JSON.stringify(data)}}
+ Tab 3
+
+
+
+
+ Content 1
+ Content 2
+ Content 3
+
+
+ `
+ )
+
+ 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 `TabPanel` 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 0000000..b13361e
--- /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 TabGroup = defineComponent({
+ name: 'TabGroup',
+ 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: 'TabGroup',
+ })
+ }
+ },
+})
+
+// ---
+
+export let TabList = defineComponent({
+ name: 'TabList',
+ props: {
+ as: { type: [Object, String], default: 'div' },
+ },
+ setup(props, { attrs, slots }) {
+ let api = useTabsContext('TabList')
+
+ 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: 'TabList',
+ })
+ }
+ },
+})
+
+// ---
+
+export let Tab = defineComponent({
+ name: 'Tab',
+ props: {
+ as: { type: [Object, String], default: 'button' },
+ disabled: { type: [Boolean], default: false },
+ },
+ render() {
+ let api = useTabsContext('Tab')
+
+ 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: 'Tab',
+ })
+ },
+ setup(props, { attrs }) {
+ let api = useTabsContext('Tab')
+ 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 TabPanels = defineComponent({
+ name: 'TabPanels',
+ props: {
+ as: { type: [Object, String], default: 'div' },
+ },
+ setup(props, { slots, attrs }) {
+ let api = useTabsContext('TabPanels')
+
+ return () => {
+ let slot = { selectedIndex: api.selectedIndex.value }
+
+ return render({
+ props,
+ slot,
+ attrs,
+ slots,
+ name: 'TabPanels',
+ })
+ }
+ },
+})
+
+export let TabPanel = defineComponent({
+ name: 'TabPanel',
+ props: {
+ as: { type: [Object, String], default: 'div' },
+ static: { type: Boolean, default: false },
+ unmount: { type: Boolean, default: true },
+ },
+ render() {
+ let api = useTabsContext('TabPanel')
+
+ 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: 'TabPanel',
+ })
+ },
+ setup() {
+ let api = useTabsContext('TabPanel')
+ 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 376eb98..97ddbee 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
+ 'TabGroup',
+ 'TabList',
+ 'Tab',
+ 'TabPanels',
+ 'TabPanel',
+
// Transition
'TransitionChild',
'TransitionRoot',
diff --git a/packages/@headlessui-vue/src/index.ts b/packages/@headlessui-vue/src/index.ts
index cce0dd2..2ef29db 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 ade9941..5a6fb38 100644
--- a/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts
+++ b/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts
@@ -263,9 +263,12 @@ export function assertListbox(
attributes?: Record
textContent?: string
state: ListboxState
+ orientation?: 'horizontal' | 'vertical'
},
listbox = getListbox()
) {
+ let { orientation = 'vertical' } = options
+
try {
switch (options.state) {
case ListboxState.InvisibleHidden:
@@ -274,6 +277,7 @@ export function assertListbox(
assertHidden(listbox)
expect(listbox).toHaveAttribute('aria-labelledby')
+ expect(listbox).toHaveAttribute('aria-orientation', orientation)
expect(listbox).toHaveAttribute('role', 'listbox')
if (options.textContent) expect(listbox).toHaveTextContent(options.textContent)
@@ -289,6 +293,7 @@ export function assertListbox(
assertVisible(listbox)
expect(listbox).toHaveAttribute('aria-labelledby')
+ expect(listbox).toHaveAttribute('aria-orientation', orientation)
expect(listbox).toHaveAttribute('role', 'listbox')
if (options.textContent) expect(listbox).toHaveTextContent(options.textContent)
@@ -1187,6 +1192,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)
]