+12
-2
@@ -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',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user