Merge pull request #702 from tailwindlabs/develop

Next release
This commit is contained in:
Robin Malfait
2021-07-29 12:53:17 +02:00
committed by GitHub
24 changed files with 6350 additions and 107 deletions
+12 -2
View File
@@ -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
@@ -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 (
<div className="flex flex-col items-start w-screen h-full p-12 bg-gray-50 space-y-12">
<Switch.Group as="div" className="flex items-center space-x-4">
<Switch.Label>Manual keyboard activation</Switch.Label>
<Switch
as="button"
checked={manual}
onChange={setManual}
className={({ checked }) =>
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 }) => (
<span
className={classNames(
'inline-block w-5 h-5 bg-white rounded-full transform transition ease-in-out duration-200',
checked ? 'translate-x-5' : 'translate-x-0'
)}
/>
)}
</Switch>
</Switch.Group>
<Tab.Group className="flex flex-col max-w-3xl w-full" as="div" manual={manual}>
<Tab.List className="relative z-0 rounded-lg shadow flex divide-x divide-gray-200">
{tabs.map((tab, tabIdx) => (
<Tab
key={tab.name}
disabled={tab.disabled}
className={({ selected }) =>
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 }) => (
<>
<span>{tab.name}</span>
{tab.disabled && <small className="inline-block px-4 text-xs">(disabled)</small>}
<span
aria-hidden="true"
className={classNames(
selected ? 'bg-indigo-500' : 'bg-transparent',
'absolute inset-x-0 bottom-0 h-0.5'
)}
/>
</>
)}
</Tab>
))}
</Tab.List>
<Tab.Panels className="mt-4">
{tabs.map(tab => (
<Tab.Panel className="bg-white rounded-lg p-4 shadow" key={tab.name}>
{tab.content}
</Tab.Panel>
))}
</Tab.Panels>
</Tab.Group>
</div>
)
}
@@ -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(
<Disclosure>
{({ close }) => (
<>
<Disclosure.Button>Trigger</Disclosure.Button>
<Disclosure.Panel>
<button onClick={() => close()}>Close me</button>
</Disclosure.Panel>
</>
)}
</Disclosure>
)
// 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(
<>
<button id="test">restoreable</button>
<Disclosure>
{({ close }) => (
<>
<Disclosure.Button>Trigger</Disclosure.Button>
<Disclosure.Panel>
<button onClick={() => close(document.getElementById('test')!)}>
Close me
</button>
</Disclosure.Panel>
</>
)}
</Disclosure>
</>
)
// 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 (
<>
<button ref={elementRef}>restoreable</button>
<Disclosure>
{({ close }) => (
<>
<Disclosure.Button>Trigger</Disclosure.Button>
<Disclosure.Panel>
<button onClick={() => close(elementRef)}>Close me</button>
</Disclosure.Panel>
</>
)}
</Disclosure>
</>
)
}
render(<Example />)
// 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(
<Disclosure>
<Disclosure.Button>Trigger</Disclosure.Button>
<Disclosure.Panel>
{({ close }) => <button onClick={() => close()}>Close me</button>}
</Disclosure.Panel>
</Disclosure>
)
// 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(
<>
<button id="test">restoreable</button>
<Disclosure>
<Disclosure.Button>Trigger</Disclosure.Button>
<Disclosure.Panel>
{({ close }) => (
<button onClick={() => close(document.getElementById('test')!)}>Close me</button>
)}
</Disclosure.Panel>
</Disclosure>
</>
)
// 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 (
<>
<button ref={elementRef}>restoreable</button>
<Disclosure>
<Disclosure.Button>Trigger</Disclosure.Button>
<Disclosure.Panel>
{({ close }) => <button onClick={() => close(elementRef)}>Close me</button>}
</Disclosure.Panel>
</Disclosure>
</>
)
}
render(<Example />)
// 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(
<Disclosure>
<Disclosure.Button>Open</Disclosure.Button>
<Disclosure.Panel>
<Disclosure.Button>Close</Disclosure.Button>
</Disclosure.Panel>
</Disclosure>
)
// 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())
})
)
})
@@ -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<HTMLElement | null>): 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<string | null>(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<HTMLElement | null>): void
}
export function Disclosure<TTag extends ElementType = typeof DEFAULT_DISCLOSURE_TAG>(
@@ -131,26 +162,47 @@ export function Disclosure<TTag extends ElementType = typeof DEFAULT_DISCLOSURE_
useEffect(() => dispatch({ type: ActionTypes.SetButtonId, buttonId }), [buttonId, dispatch])
useEffect(() => dispatch({ type: ActionTypes.SetPanelId, panelId }), [panelId, dispatch])
let close = useCallback(
(focusableElement?: HTMLElement | MutableRefObject<HTMLElement | null>) => {
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<ContextType<typeof DisclosureAPIContext>>(() => ({ close }), [close])
let slot = useMemo<DisclosureRenderPropArg>(
() => ({ open: disclosureState === DisclosureStates.Open }),
[disclosureState]
() => ({ open: disclosureState === DisclosureStates.Open, close }),
[disclosureState, close]
)
return (
<DisclosureContext.Provider value={reducerBag}>
<OpenClosedProvider
value={match(disclosureState, {
[DisclosureStates.Open]: State.Open,
[DisclosureStates.Closed]: State.Closed,
})}
>
{render({
props: passthroughProps,
slot,
defaultTag: DEFAULT_DISCLOSURE_TAG,
name: 'Disclosure',
})}
</OpenClosedProvider>
<DisclosureAPIContext.Provider value={api}>
<OpenClosedProvider
value={match(disclosureState, {
[DisclosureStates.Open]: State.Open,
[DisclosureStates.Closed]: State.Closed,
})}
>
{render({
props: passthroughProps,
slot,
defaultTag: DEFAULT_DISCLOSURE_TAG,
name: 'Disclosure',
})}
</OpenClosedProvider>
</DisclosureAPIContext.Provider>
</DisclosureContext.Provider>
)
}
@@ -176,18 +228,35 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
let [state, dispatch] = useDisclosureContext([Disclosure.name, Button.name].join('.'))
let buttonRef = useSyncRefs(ref)
let panelContext = useDisclosurePanelContext()
let isWithinPanel = panelContext === null ? false : panelContext === state.panelId
let handleKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
@@ -205,9 +274,15 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
(event: ReactMouseEvent) => {
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<ButtonRenderPropArg>(
@@ -216,16 +291,20 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
)
let passthroughProps = props
let propsWeControl = {
ref: buttonRef,
id: state.buttonId,
type: 'button',
'aria-expanded': props.disabled ? undefined : state.disclosureState === DisclosureStates.Open,
'aria-controls': state.linkedPanel ? state.panelId : undefined,
onKeyDown: handleKeyDown,
onKeyUp: handleKeyUp,
onClick: handleClick,
}
let propsWeControl = isWithinPanel
? { type: 'button', onKeyDown: handleKeyDown, onClick: handleClick }
: {
ref: buttonRef,
id: state.buttonId,
type: 'button',
'aria-expanded': props.disabled
? undefined
: state.disclosureState === DisclosureStates.Open,
'aria-controls': state.linkedPanel ? state.panelId : undefined,
onKeyDown: handleKeyDown,
onKeyUp: handleKeyUp,
onClick: handleClick,
}
return render({
props: { ...passthroughProps, ...propsWeControl },
@@ -240,6 +319,7 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
let DEFAULT_PANEL_TAG = 'div' as const
interface PanelRenderPropArg {
open: boolean
close: (focusableElement?: HTMLElement | MutableRefObject<HTMLElement | null>) => void
}
type PanelPropsWeControl = 'id'
@@ -251,6 +331,8 @@ let Panel = forwardRefWithAs(function Panel<TTag extends ElementType = typeof DE
ref: Ref<HTMLDivElement>
) {
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<TTag extends ElementType = typeof DE
}, [state.disclosureState, props.unmount, dispatch])
let slot = useMemo<PanelRenderPropArg>(
() => ({ 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<TTag extends ElementType = typeof DE
}
let passthroughProps = props
return render({
props: { ...passthroughProps, ...propsWeControl },
slot,
defaultTag: DEFAULT_PANEL_TAG,
features: PanelRenderFeatures,
visible,
name: 'Disclosure.Panel',
})
return (
<DisclosurePanelContext.Provider value={state.panelId}>
{render({
props: { ...passthroughProps, ...propsWeControl },
slot,
defaultTag: DEFAULT_PANEL_TAG,
features: PanelRenderFeatures,
visible,
name: 'Disclosure.Panel',
})}
</DisclosurePanelContext.Provider>
)
})
// ---
@@ -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(
<Listbox value={undefined} onChange={console.log} horizontal>
<Listbox.Button>Trigger</Listbox.Button>
<Listbox.Options>
<Listbox.Option value="a">Option A</Listbox.Option>
<Listbox.Option value="b">Option B</Listbox.Option>
<Listbox.Option value="c">Option C</Listbox.Option>
</Listbox.Options>
</Listbox>
)
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(
<Listbox value={undefined} onChange={console.log} horizontal>
<Listbox.Button>Trigger</Listbox.Button>
<Listbox.Options>
<Listbox.Option value="a">Option A</Listbox.Option>
<Listbox.Option value="b">Option B</Listbox.Option>
<Listbox.Option value="c">Option C</Listbox.Option>
</Listbox.Options>
</Listbox>
)
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',
@@ -46,10 +46,14 @@ type ListboxOptionDataRef = MutableRefObject<{
interface StateDefinition {
listboxState: ListboxStates
orientation: 'horizontal' | 'vertical'
propsRef: MutableRefObject<{ value: unknown; onChange(value: unknown): void }>
labelRef: MutableRefObject<HTMLLabelElement | null>
buttonRef: MutableRefObject<HTMLButtonElement | null>
optionsRef: MutableRefObject<HTMLUListElement | null>
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<Focus, Focus.Specific> }
| { 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<TTag extends ElementType = typeof DEFAULT_LISTBOX_TAG, T
value: TType
onChange(value: TType): void
disabled?: boolean
horizontal?: boolean
}
) {
let { value, onChange, disabled = false, ...passThroughProps } = props
let { value, onChange, disabled = false, horizontal = false, ...passThroughProps } = props
const orientation = horizontal ? 'horizontal' : 'vertical'
let reducerBag = useReducer(stateReducer, {
listboxState: ListboxStates.Closed,
propsRef: { current: { value, onChange } },
@@ -203,6 +216,7 @@ export function Listbox<TTag extends ElementType = typeof DEFAULT_LISTBOX_TAG, T
buttonRef: createRef(),
optionsRef: createRef(),
disabled,
orientation,
options: [],
searchQuery: '',
activeOptionIndex: null,
@@ -216,6 +230,9 @@ export function Listbox<TTag extends ElementType = typeof DEFAULT_LISTBOX_TAG, T
propsRef.current.onChange = onChange
}, [onChange, propsRef])
useIsoMorphicEffect(() => 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',
@@ -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(
<Popover>
{({ close }) => (
<>
<Popover.Button>Trigger</Popover.Button>
<Popover.Panel>
<button onClick={() => close()}>Close me</button>
</Popover.Panel>
</>
)}
</Popover>
)
// 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(
<>
<button id="test">restoreable</button>
<Popover>
{({ close }) => (
<>
<Popover.Button>Trigger</Popover.Button>
<Popover.Panel>
<button onClick={() => close(document.getElementById('test')!)}>
Close me
</button>
</Popover.Panel>
</>
)}
</Popover>
</>
)
// 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 (
<>
<button ref={elementRef}>restoreable</button>
<Popover>
{({ close }) => (
<>
<Popover.Button>Trigger</Popover.Button>
<Popover.Panel>
<button onClick={() => close(elementRef)}>Close me</button>
</Popover.Panel>
</>
)}
</Popover>
</>
)
}
render(<Example />)
// 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(
<Popover>
<Popover.Button>Trigger</Popover.Button>
<Popover.Panel>
{({ close }) => <button onClick={() => close()}>Close me</button>}
</Popover.Panel>
</Popover>
)
// 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(
<>
<button id="test">restoreable</button>
<Popover>
<Popover.Button>Trigger</Popover.Button>
<Popover.Panel>
{({ close }) => (
<button onClick={() => close(document.getElementById('test')!)}>Close me</button>
)}
</Popover.Panel>
</Popover>
</>
)
// 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 (
<>
<button ref={elementRef}>restoreable</button>
<Popover>
<Popover.Button>Trigger</Popover.Button>
<Popover.Panel>
{({ close }) => <button onClick={() => close(elementRef)}>Close me</button>}
</Popover.Panel>
</Popover>
</>
)
}
render(<Example />)
// 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'))
})
)
})
})
@@ -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<HTMLElement | null>): 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<HTMLElement | null>): void
}
export function Popover<TTag extends ElementType = typeof DEFAULT_POPOVER_TAG>(
@@ -215,25 +232,47 @@ export function Popover<TTag extends ElementType = typeof DEFAULT_POPOVER_TAG>(
}
})
let slot = useMemo<PopoverRenderPropArg>(() => ({ open: popoverState === PopoverStates.Open }), [
popoverState,
])
let close = useCallback(
(focusableElement?: HTMLElement | MutableRefObject<HTMLElement | null>) => {
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<ContextType<typeof PopoverAPIContext>>(() => ({ close }), [close])
let slot = useMemo<PopoverRenderPropArg>(
() => ({ open: popoverState === PopoverStates.Open, close }),
[popoverState, close]
)
return (
<PopoverContext.Provider value={reducerBag}>
<OpenClosedProvider
value={match(popoverState, {
[PopoverStates.Open]: State.Open,
[PopoverStates.Closed]: State.Closed,
})}
>
{render({
props,
slot,
defaultTag: DEFAULT_POPOVER_TAG,
name: 'Popover',
})}
</OpenClosedProvider>
<PopoverAPIContext.Provider value={api}>
<OpenClosedProvider
value={match(popoverState, {
[PopoverStates.Open]: State.Open,
[PopoverStates.Closed]: State.Closed,
})}
>
{render({
props,
slot,
defaultTag: DEFAULT_POPOVER_TAG,
name: 'Popover',
})}
</OpenClosedProvider>
</PopoverAPIContext.Provider>
</PopoverContext.Provider>
)
}
@@ -520,6 +559,7 @@ let Overlay = forwardRefWithAs(function Overlay<
let DEFAULT_PANEL_TAG = 'div' as const
interface PanelRenderPropArg {
open: boolean
close: (focusableElement?: HTMLElement | MutableRefObject<HTMLElement | null>) => void
}
type PanelPropsWeControl = 'id' | 'onKeyDown'
@@ -527,12 +567,16 @@ let PanelRenderFeatures = Features.RenderStrategy | Features.Static
let Panel = forwardRefWithAs(function Panel<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
props: Props<TTag, PanelRenderPropArg, PanelPropsWeControl> &
PropsForFeatures<typeof PanelRenderFeatures> & { focus?: boolean },
PropsForFeatures<typeof PanelRenderFeatures> & {
focus?: boolean
},
ref: Ref<HTMLDivElement>
) {
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<HTMLDivElement | null>(null)
let panelRef = useSyncRefs(internalPanelRef, ref, panel => {
dispatch({ type: ActionTypes.SetPanel, panel })
@@ -640,8 +684,8 @@ let Panel = forwardRefWithAs(function Panel<TTag extends ElementType = typeof DE
)
let slot = useMemo<PanelRenderPropArg>(
() => ({ open: state.popoverState === PopoverStates.Open }),
[state]
() => ({ open: state.popoverState === PopoverStates.Open, close }),
[state, close]
)
let propsWeControl = {
ref: panelRef,
File diff suppressed because it is too large Load Diff
@@ -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<HTMLElement | null>[]
panels: MutableRefObject<HTMLElement | null>[]
}
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<HTMLElement | null> }
| { type: ActionTypes.UnregisterTab; tab: MutableRefObject<HTMLElement | null> }
| { type: ActionTypes.RegisterPanel; panel: MutableRefObject<HTMLElement | null> }
| { type: ActionTypes.UnregisterPanel; panel: MutableRefObject<HTMLElement | null> }
| { type: ActionTypes.ForceRerender }
let reducers: {
[P in ActionTypes]: (
state: StateDefinition,
action: Extract<Actions, { type: P }>
) => 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<Actions> }] | 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 <Tab.Group /> 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<TTag extends ElementType = typeof DEFAULT_TABS_TAG>(
props: Props<TTag, TabsRenderPropArg> & {
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<ContextType<typeof TabsContext>>(
() => [
state,
{
dispatch,
change(index: number) {
if (lastChangedIndex.current !== index) onChangeRef.current(index)
lastChangedIndex.current = index
dispatch({ type: ActionTypes.SetSelectedIndex, index })
},
},
],
[state, dispatch]
)
return (
<TabsContext.Provider value={providerBag}>
{render({
props: { ...passThroughProps },
slot,
defaultTag: DEFAULT_TABS_TAG,
name: 'Tabs',
})}
</TabsContext.Provider>
)
}
// ---
let DEFAULT_LIST_TAG = 'div' as const
interface ListRenderPropArg {
selectedIndex: number
}
type ListPropsWeControl = 'role' | 'aria-orientation'
function List<TTag extends ElementType = typeof DEFAULT_LIST_TAG>(
props: Props<TTag, ListRenderPropArg, ListPropsWeControl> & {}
) {
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<TTag extends ElementType = typeof DEFAULT_TAB_TAG>(
props: Props<TTag, TabRenderPropArg, TabPropsWeControl>
) {
let id = `headlessui-tabs-tab-${useId()}`
let [
{ selectedIndex, tabs, panels, orientation, activation },
{ dispatch, change },
] = useTabsContext(Tab.name)
let internalTabRef = useRef<HTMLElement>(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<HTMLElement>) => {
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<TTag extends ElementType = typeof DEFAULT_PANELS_TAG>(
props: Props<TTag, PanelsRenderPropArg>
) {
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<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
props: Props<TTag, PanelRenderPropArg, PanelPropsWeControl> &
PropsForFeatures<typeof PanelRenderFeatures>
) {
let [{ selectedIndex, tabs, panels }, { dispatch }] = useTabsContext(
[Tab.name, Panel.name].join('.')
)
let id = `headlessui-tabs-panel-${useId()}`
let internalPanelRef = useRef<HTMLElement>(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
@@ -15,6 +15,7 @@ it('should expose the correct components', () => {
'Portal',
'RadioGroup',
'Switch',
'Tab',
'Transition',
])
})
+1
View File
@@ -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'
@@ -263,9 +263,12 @@ export function assertListbox(
attributes?: Record<string, string | null>
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)
@@ -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`
<Disclosure v-slot="{ close }">
<DisclosureButton>Trigger</DisclosureButton>
<DisclosurePanel>
<button @click="close()">Close me</button>
</DisclosurePanel>
</Disclosure>
`
)
// 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`
<button id="test">restoreable</button>
<Disclosure v-slot="{ close }">
<DisclosureButton>Trigger</DisclosureButton>
<DisclosurePanel>
<button @click="close(document.getElementById('test'))">Close me</button>
</DisclosurePanel>
</Disclosure>
`,
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`
<button ref="elementRef">restoreable</button>
<Disclosure v-slot="{ close }">
<DisclosureButton>Trigger</DisclosureButton>
<DisclosurePanel>
<button @click="close(elementRef)">Close me</button>}
</DisclosurePanel>
</Disclosure>
`,
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`
<Disclosure>
<DisclosureButton>Trigger</DisclosureButton>
<DisclosurePanel v-slot="{ close }">
<button @click="close()">Close me</button>
</DisclosurePanel>
</Disclosure>
`
)
// 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`
<button id="test">restoreable</button>
<Disclosure>
<DisclosureButton>Trigger</DisclosureButton>
<DisclosurePanel v-slot="{ close }">
<button @click="close(document.getElementById('test'))">Close me</button>
</DisclosurePanel>
</Disclosure>
`,
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`
<button ref="elementRef">restoreable</button>
<Disclosure>
<DisclosureButton>Trigger</DisclosureButton>
<DisclosurePanel v-slot="{ close }">
<button @click="close(elementRef)">Close me</button>}
</DisclosurePanel>
</Disclosure>
`,
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`
<Disclosure>
<DisclosureButton>Open</DisclosureButton>
<DisclosurePanel>
<DisclosureButton>Close</DisclosureButton>
</DisclosurePanel>
</Disclosure>
`
)
// 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())
})
)
})
@@ -16,10 +16,17 @@ enum DisclosureStates {
interface StateDefinition {
// State
disclosureState: Ref<DisclosureStates>
panelRef: Ref<HTMLElement | null>
panel: Ref<HTMLElement | null>
panelId: string
button: Ref<HTMLButtonElement | null>
buttonId: string
// State mutators
toggleDisclosure(): void
closeDisclosure(): void
// Exposed functions
close(focusableElement: HTMLElement | Ref<HTMLElement | null>): void
}
let DisclosureContext = Symbol('DisclosureContext') as InjectionKey<StateDefinition>
@@ -36,6 +43,11 @@ function useDisclosureContext(component: string) {
return context
}
let DisclosurePanelContext = Symbol('DisclosurePanelContext') as InjectionKey<string | null>
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<StateDefinition['disclosureState']['value']>(
props.defaultOpen ? DisclosureStates.Open : DisclosureStates.Closed
)
let panelRef = ref<StateDefinition['panelRef']['value']>(null)
let panelRef = ref<StateDefinition['panel']['value']>(null)
let buttonRef = ref<StateDefinition['button']['value']>(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<HTMLElement | null>) {
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,
}
},
})
@@ -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`
<Listbox v-model="value" horizontal>
<ListboxButton>Trigger</ListboxButton>
<ListboxOptions>
<ListboxOption value="a">Option A</ListboxOption>
<ListboxOption value="b">Option B</ListboxOption>
<ListboxOption value="c">Option C</ListboxOption>
</ListboxOptions>
</Listbox>
`,
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`
<Listbox v-model="value" horizontal>
<ListboxButton>Trigger</ListboxButton>
<ListboxOptions>
<ListboxOption value="a">Option A</ListboxOption>
<ListboxOption value="b">Option B</ListboxOption>
<ListboxOption value="c">Option C</ListboxOption>
</ListboxOptions>
</Listbox>
`,
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',
@@ -38,9 +38,12 @@ type StateDefinition = {
// State
listboxState: Ref<ListboxStates>
value: ComputedRef<unknown>
orientation: Ref<'vertical' | 'horizontal'>
labelRef: Ref<HTMLLabelElement | null>
buttonRef: Ref<HTMLButtonElement | null>
optionsRef: Ref<HTMLDivElement | null>
disabled: Ref<boolean>
options: Ref<{ id: string; dataRef: ListboxOptionDataRef }[]>
searchQuery: Ref<string>
@@ -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)
@@ -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`
<Popover v-slot="{ close }">
<PopoverButton>Trigger</PopoverButton>
<PopoverPanel>
<button @click="close()">Close me</button>
</PopoverPanel>
</Popover>
`
)
// 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`
<button id="test">restoreable</button>
<Popover v-slot="{ close }">
<PopoverButton>Trigger</PopoverButton>
<PopoverPanel>
<button @click="close(document.getElementById('test'))">Close me</button>
</PopoverPanel>
</Popover>
`,
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`
<button ref="elementRef">restoreable</button>
<Popover v-slot="{ close }">
<PopoverButton>Trigger</PopoverButton>
<PopoverPanel> <button @click="close(elementRef)">Close me</button>} </PopoverPanel>
</Popover>
`,
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`
<Popover>
<PopoverButton>Trigger</PopoverButton>
<PopoverPanel v-slot="{ close }">
<button @click="close()">Close me</button>
</PopoverPanel>
</Popover>
`
)
// 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`
<button id="test">restoreable</button>
<Popover>
<PopoverButton>Trigger</PopoverButton>
<PopoverPanel v-slot="{ close }">
<button @click="close(document.getElementById('test'))">Close me</button>
</PopoverPanel>
</Popover>
`,
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`
<button ref="elementRef">restoreable</button>
<Popover>
<PopoverButton>Trigger</PopoverButton>
<PopoverPanel v-slot="{ close }">
<button @click="close(elementRef)">Close me</button>}
</PopoverPanel>
</Popover>
`,
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'))
})
)
})
})
@@ -44,6 +44,9 @@ interface StateDefinition {
// State mutators
togglePopover(): void
closePopover(): void
// Exposed functions
close(focusableElement: HTMLElement | Ref<HTMLElement | null>): void
}
let PopoverContext = Symbol('PopoverContext') as InjectionKey<StateDefinition>
@@ -110,6 +113,19 @@ export let Popover = defineComponent({
if (popoverState.value === PopoverStates.Closed) return
popoverState.value = PopoverStates.Closed
},
close(focusableElement: HTMLElement | Ref<HTMLElement | null>) {
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,
File diff suppressed because it is too large Load Diff
@@ -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<number | null>
orientation: Ref<'vertical' | 'horizontal'>
activation: Ref<'auto' | 'manual'>
tabs: Ref<Ref<HTMLElement | null>[]>
panels: Ref<Ref<HTMLElement | null>[]>
// State mutators
setSelectedIndex(index: number): void
registerTab(tab: Ref<HTMLElement | null>): void
unregisterTab(tab: Ref<HTMLElement | null>): void
registerPanel(panel: Ref<HTMLElement | null>): void
unregisterPanel(panel: Ref<HTMLElement | null>): void
}
let TabsContext = Symbol('TabsContext') as InjectionKey<StateDefinition>
function useTabsContext(component: string) {
let context = inject(TabsContext, null)
if (context === null) {
let err = new Error(`<${component} /> is missing a parent <TabGroup /> 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<StateDefinition['selectedIndex']['value']>(null)
let tabs = ref<StateDefinition['tabs']['value']>([])
let panels = ref<StateDefinition['panels']['value']>([])
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 }
},
})
@@ -56,6 +56,13 @@ it('should expose the correct components', () => {
'SwitchLabel',
'SwitchDescription',
// Tabs
'TabGroup',
'TabList',
'Tab',
'TabPanels',
'TabPanel',
// Transition
'TransitionChild',
'TransitionRoot',
+1
View File
@@ -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'
@@ -263,9 +263,12 @@ export function assertListbox(
attributes?: Record<string, string | null>
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)