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)