Next release (#431)

* Fixed typos (#350)

* chore: Fix typo in render.ts (#347)

* Better vue link (#353)

* Better vue link

* add better React link

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>

* Enable NoScroll feature for the initial useFocusTrap hook (#356)

* enable NoScroll feature for the initial useFocusTrap hook

Once you are using Tab and Shift+Tab it does the scrolling.

Fixes: #345

* update changelog

* Revert "Enable NoScroll feature for the initial useFocusTrap hook (#356)"

This reverts commit 19590b07624d7e3d751cbf11de869dfb0ea432ba.

Solution is not 100% correct, so will revert for now!

* Improve search (#385)

* make search case insensitive for the listbox

* make search case insensitive for the menu

* update changelog

* add `disabled` prop to RadioGroup and RadioGroup Option (#401)

* add `disabled` prop to RadioGroup and RadioGroup Option

Also did some general cleanup which in turn fixed an issue where the
RadioGroup is unreachable when a value is used that doesn't exist in the
list of options.

Fixes: #378

* update changelog

* Fix type of `RadioGroupOption` (#400)

Match RadioGroupOption value types to match modelValue allowed types for RadioGroup

* update changelog

* fix typo's

* chore(CI): update main workflow (#395)

* chore(CI): update main workflow

* Update main.yml

* fix dialog event propagation (#422)

* re-export the `screen` utility for quick debugging purposes

* stop event propagation when clicking inside a Dialog

Fixes: #414

* improve dialog escape (#430)

* Make sure that `Escape` only closes the top most Dialog

* update changelog

* add defaultOpen prop to Disclosure component (#447)

* add defaultOpen prop to Disclosure component

* update changelog

Co-authored-by: Shuvro Roy <shuvro.roy@northsouth.edu>
Co-authored-by: Alex Nault <nault.alex@gmail.com>
Co-authored-by: Eugene Kopich <github@web2033.com>
Co-authored-by: Nathan Shoemark <n.shoemark@gmail.com>
Co-authored-by: Michaël De Boey <info@michaeldeboey.be>
This commit is contained in:
Robin Malfait
2021-04-26 15:44:10 +02:00
committed by GitHub
parent 6a01c54b15
commit ce23edeee4
42 changed files with 1067 additions and 139 deletions
+3 -3
View File
@@ -9,13 +9,13 @@ jobs:
uses: actions/checkout@v2
- name: Use Node 12
uses: actions/setup-node@v1
uses: actions/setup-node@v2
with:
node-version: 12.x
node-version: 12
# - name: Use cached node_modules
# id: cache
# uses: actions/cache@v1
# uses: actions/cache@v2
# with:
# path: node_modules
# key: nodeModules-${{ hashFiles('**/yarn.lock') }}
+21 -2
View File
@@ -7,11 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased - React]
- Nothing yet!
### Fixes
- Improve search, make searching case insensitive ([#385](https://github.com/tailwindlabs/headlessui/pull/385))
- Fix unreachable `RadioGroup` ([#401](https://github.com/tailwindlabs/headlessui/pull/401))
- Fix closing nested `Dialog` components when pressing `Escape` ([#430](https://github.com/tailwindlabs/headlessui/pull/430))
### Added
- Add `disabled` prop to `RadioGroup` and `RadioGroup.Option` ([#401](https://github.com/tailwindlabs/headlessui/pull/401))
- Add `defaultOpen` prop to the `Disclosure` component ([#447](https://github.com/tailwindlabs/headlessui/pull/447))
## [Unreleased - Vue]
- Nothing yet!
### Fixes
- Improve search, make searching case insensitive ([#385](https://github.com/tailwindlabs/headlessui/pull/385))
- Fix unreachable `RadioGroup` ([#401](https://github.com/tailwindlabs/headlessui/pull/401))
- Fix `RadioGroupOption` value type ([#400](https://github.com/tailwindlabs/headlessui/pull/400))
- Fix closing nested `Dialog` components when pressing `Escape` ([#430](https://github.com/tailwindlabs/headlessui/pull/430))
### Added
- Add `disabled` prop to `RadioGroup` and `RadioGroupOption` ([#401](https://github.com/tailwindlabs/headlessui/pull/401))
- Add `defaultOpen` prop to the `Disclosure` component ([#447](https://github.com/tailwindlabs/headlessui/pull/447))
## [@headlessui/react@v1.0.0] - 2021-04-14
+1 -1
View File
@@ -25,7 +25,7 @@ yarn add @headlessui/react
## Documentation
For full documentation, visit [headlessui.dev](https://headlessui.dev/react).
For full documentation, visit [headlessui.dev](https://headlessui.dev/react/menu).
## Community
@@ -19,10 +19,10 @@ function Nested({ onClose, level = 0 }) {
return (
<>
<Dialog open={true} onClose={onClose} className="fixed z-10 inset-0 pointer-events-none">
<Dialog open={true} onClose={onClose} className="fixed z-10 inset-0">
{true && <Dialog.Overlay className="fixed inset-0 bg-gray-500 opacity-25" />}
<div
className="z-10 fixed left-12 top-24 bg-white w-96 p-4 pointer-events-auto"
className="z-10 fixed left-12 top-24 bg-white w-96 p-4"
style={{
transform: `translate(calc(50px * ${level}), calc(50px * ${level}))`,
}}
@@ -64,7 +64,7 @@ it('should be possible to use a DescriptionProvider and a single Description, an
`)
})
it('should be possible to use a DescriptionProvider and multiple Description ocmponents, and have them linked', async () => {
it('should be possible to use a DescriptionProvider and multiple Description components, and have them linked', async () => {
function Component(props: { children: ReactNode }) {
let [describedby, DescriptionProvider] = useDescriptions()
@@ -13,6 +13,7 @@ import {
getDialogOverlay,
getByText,
assertActiveElement,
getDialogs,
} from '../../test-utils/accessibility-assertions'
import { click, press, Keys } from '../../test-utils/interactions'
import { PropsOf } from '../../types'
@@ -496,4 +497,152 @@ describe('Mouse interactions', () => {
assertActiveElement(getByText('Hello'))
})
)
it(
'should stop propagating click events when clicking on the Dialog.Overlay',
suppressConsoleLogs(async () => {
let wrapperFn = jest.fn()
function Example() {
let [isOpen, setIsOpen] = useState(true)
return (
<div onClick={wrapperFn}>
<Dialog open={isOpen} onClose={setIsOpen}>
Contents
<Dialog.Overlay />
<TabSentinel />
</Dialog>
</div>
)
}
render(<Example />)
// Verify it is open
assertDialog({ state: DialogState.Visible })
// Verify that the wrapper function has not been called yet
expect(wrapperFn).toHaveBeenCalledTimes(0)
// Click the Dialog.Overlay to close the Dialog
await click(getDialogOverlay())
// Verify it is closed
assertDialog({ state: DialogState.InvisibleUnmounted })
// Verify that the wrapper function has not been called yet
expect(wrapperFn).toHaveBeenCalledTimes(0)
})
)
it(
'should stop propagating click events when clicking on an element inside the Dialog',
suppressConsoleLogs(async () => {
let wrapperFn = jest.fn()
function Example() {
let [isOpen, setIsOpen] = useState(true)
return (
<div onClick={wrapperFn}>
<Dialog open={isOpen} onClose={setIsOpen}>
Contents
<button onClick={() => setIsOpen(false)}>Inside</button>
<TabSentinel />
</Dialog>
</div>
)
}
render(<Example />)
// Verify it is open
assertDialog({ state: DialogState.Visible })
// Verify that the wrapper function has not been called yet
expect(wrapperFn).toHaveBeenCalledTimes(0)
// Click the button inside the the Dialog
await click(getByText('Inside'))
// Verify it is closed
assertDialog({ state: DialogState.InvisibleUnmounted })
// Verify that the wrapper function has not been called yet
expect(wrapperFn).toHaveBeenCalledTimes(0)
})
)
})
describe('Nesting', () => {
it('should be possible to open nested Dialog components and close them with `Escape`', async () => {
function Nested({ onClose, level = 1 }: { onClose: (value: boolean) => void; level?: number }) {
let [showChild, setShowChild] = useState(false)
return (
<>
<Dialog open={true} onClose={onClose}>
<div>
<p>Level: {level}</p>
<button onClick={() => setShowChild(true)}>Open {level + 1}</button>
</div>
{showChild && <Nested onClose={setShowChild} level={level + 1} />}
</Dialog>
</>
)
}
function Example() {
let [open, setOpen] = useState(false)
return (
<>
<button onClick={() => setOpen(true)}>Open 1</button>
{open && <Nested onClose={setOpen} />}
</>
)
}
render(<Example />)
// Verify we have no open dialogs
expect(getDialogs()).toHaveLength(0)
// Open Dialog 1
await click(getByText('Open 1'))
// Verify that we have 1 open dialog
expect(getDialogs()).toHaveLength(1)
// Open Dialog 2
await click(getByText('Open 2'))
// Verify that we have 2 open dialogs
expect(getDialogs()).toHaveLength(2)
// Press escape to close the top most Dialog
await press(Keys.Escape)
// Verify that we have 1 open dialog
expect(getDialogs()).toHaveLength(1)
// Open Dialog 2
await click(getByText('Open 2'))
// Verify that we have 2 open dialogs
expect(getDialogs()).toHaveLength(2)
// Open Dialog 3
await click(getByText('Open 3'))
// Verify that we have 3 open dialogs
expect(getDialogs()).toHaveLength(3)
// Press escape to close the top most Dialog
await press(Keys.Escape)
// Verify that we have 2 open dialogs
expect(getDialogs()).toHaveLength(2)
// Press escape to close the top most Dialog
await press(Keys.Escape)
// Verify that we have 1 open dialog
expect(getDialogs()).toHaveLength(1)
})
})
@@ -12,6 +12,7 @@ import React, {
ContextType,
ElementType,
MouseEvent as ReactMouseEvent,
KeyboardEvent as ReactKeyboardEvent,
MutableRefObject,
Ref,
} from 'react'
@@ -92,7 +93,13 @@ let DEFAULT_DIALOG_TAG = 'div' as const
interface DialogRenderPropArg {
open: boolean
}
type DialogPropsWeControl = 'id' | 'role' | 'aria-modal' | 'aria-describedby' | 'aria-labelledby'
type DialogPropsWeControl =
| 'id'
| 'role'
| 'aria-modal'
| 'aria-describedby'
| 'aria-labelledby'
| 'onClick'
let DialogRenderFeatures = Features.RenderStrategy | Features.Static
@@ -171,14 +178,6 @@ let DialogRoot = forwardRefWithAs(function Dialog<
close()
})
// Handle `Escape` to close
useWindowEvent('keydown', event => {
if (event.key !== Keys.Escape) return
if (dialogState !== DialogStates.Open) return
if (containers.current.size > 1) return // 1 is myself, otherwise other elements in the Stack
close()
})
// Scroll lock
useEffect(() => {
if (dialogState !== DialogStates.Open) return
@@ -190,6 +189,7 @@ let DialogRoot = forwardRefWithAs(function Dialog<
document.documentElement.style.overflow = 'hidden'
document.documentElement.style.paddingRight = `${scrollbarWidth}px`
return () => {
document.documentElement.style.overflow = overflow
document.documentElement.style.paddingRight = paddingRight
@@ -243,6 +243,20 @@ let DialogRoot = forwardRefWithAs(function Dialog<
'aria-modal': dialogState === DialogStates.Open ? true : undefined,
'aria-labelledby': state.titleId,
'aria-describedby': describedby,
onClick(event: ReactMouseEvent) {
event.preventDefault()
event.stopPropagation()
},
// Handle `Escape` to close
onKeyDown(event: ReactKeyboardEvent) {
if (event.key !== Keys.Escape) return
if (dialogState !== DialogStates.Open) return
if (containers.current.size > 1) return // 1 is myself, otherwise other elements in the Stack
event.preventDefault()
event.stopPropagation()
close()
},
}
let passthroughProps = rest
@@ -302,6 +316,8 @@ let Overlay = forwardRefWithAs(function Overlay<
let handleClick = useCallback(
(event: ReactMouseEvent) => {
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
event.preventDefault()
event.stopPropagation()
close()
},
[close]
@@ -79,6 +79,29 @@ describe('Rendering', () => {
assertDisclosurePanel({ state: DisclosureState.Visible, textContent: 'Panel is: open' })
})
)
it('should be possible to render a Disclosure in an open state by default', async () => {
render(
<Disclosure defaultOpen>
{({ open }) => (
<>
<Disclosure.Button>Trigger</Disclosure.Button>
<Disclosure.Panel>Panel is: {open ? 'open' : 'closed'}</Disclosure.Panel>
</>
)}
</Disclosure>
)
assertDisclosureButton({
state: DisclosureState.Visible,
attributes: { id: 'headlessui-disclosure-button-1' },
})
assertDisclosurePanel({ state: DisclosureState.Visible, textContent: 'Panel is: open' })
await click(getDisclosureButton())
assertDisclosureButton({ state: DisclosureState.InvisibleUnmounted })
})
})
describe('Disclosure.Button', () => {
@@ -111,13 +111,16 @@ interface DisclosureRenderPropArg {
}
export function Disclosure<TTag extends ElementType = typeof DEFAULT_DISCLOSURE_TAG>(
props: Props<TTag, DisclosureRenderPropArg>
props: Props<TTag, DisclosureRenderPropArg> & {
defaultOpen?: boolean
}
) {
let { defaultOpen = false, ...passthroughProps } = props
let buttonId = `headlessui-disclosure-button-${useId()}`
let panelId = `headlessui-disclosure-panel-${useId()}`
let reducerBag = useReducer(stateReducer, {
disclosureState: DisclosureStates.Closed,
disclosureState: defaultOpen ? DisclosureStates.Open : DisclosureStates.Closed,
linkedPanel: false,
buttonId,
panelId,
@@ -135,7 +138,7 @@ export function Disclosure<TTag extends ElementType = typeof DEFAULT_DISCLOSURE_
return (
<DisclosureContext.Provider value={reducerBag}>
{render({
props,
props: passthroughProps,
slot,
defaultTag: DEFAULT_DISCLOSURE_TAG,
name: 'Disclosure',
@@ -98,13 +98,13 @@ it(
let [a, b, c, d] = Array.from(document.querySelectorAll('input'))
// Ensure that input-b is the active elememt
// Ensure that input-b is the active element
assertActiveElement(b)
// Tab to the next item
await press(Keys.Tab)
// Ensure that input-c is the active elememt
// Ensure that input-c is the active element
assertActiveElement(c)
// Try to move focus
@@ -64,7 +64,7 @@ it('should be possible to use a LabelProvider and a single Label, and have them
`)
})
it('should be possible to use a LabelProvider and multiple Label ocmponents, and have them linked', async () => {
it('should be possible to use a LabelProvider and multiple Label components, and have them linked', async () => {
function Component(props: { children: ReactNode }) {
let [labelledby, LabelProvider] = useLabels()
@@ -2764,6 +2764,39 @@ describe('Keyboard interactions', () => {
assertActiveListboxOption(options[2])
})
)
it(
'should be possible to search for a word (case insensitive)',
suppressConsoleLogs(async () => {
render(
<Listbox value={undefined} onChange={console.log}>
<Listbox.Button>Trigger</Listbox.Button>
<Listbox.Options>
<Listbox.Option value="alice">alice</Listbox.Option>
<Listbox.Option value="bob">bob</Listbox.Option>
<Listbox.Option value="charlie">charlie</Listbox.Option>
</Listbox.Options>
</Listbox>
)
// Focus the button
getListboxButton()?.focus()
// Open listbox
await press(Keys.ArrowUp)
let options = getListboxOptions()
// We should be on the last option
assertActiveListboxOption(options[2])
// Search for bob in a different casing
await type(word('BO'))
// We should be on `bob`
assertActiveListboxOption(options[1])
})
)
})
})
@@ -118,7 +118,7 @@ let reducers: {
if (state.disabled) return state
if (state.listboxState === ListboxStates.Closed) return state
let searchQuery = state.searchQuery + action.value
let searchQuery = state.searchQuery + action.value.toLowerCase()
let match = state.options.findIndex(
option =>
!option.dataRef.current.disabled &&
@@ -2408,6 +2408,38 @@ describe('Keyboard interactions', () => {
assertMenuLinkedWithMenuItem(items[2])
})
)
it(
'should be possible to search for a word (case insensitive)',
suppressConsoleLogs(async () => {
render(
<Menu>
<Menu.Button>Trigger</Menu.Button>
<Menu.Items>
<Menu.Item as="a">alice</Menu.Item>
<Menu.Item as="a">bob</Menu.Item>
<Menu.Item as="a">charlie</Menu.Item>
</Menu.Items>
</Menu>
)
// Focus the button
getMenuButton()?.focus()
// Open menu
await press(Keys.ArrowUp)
let items = getMenuItems()
// We should be on the last item
assertMenuLinkedWithMenuItem(items[2])
// Search for bob in a different casing
await type(word('BO'))
// We should be on `bob`
assertMenuLinkedWithMenuItem(items[1])
})
)
})
})
@@ -97,7 +97,7 @@ let reducers: {
return { ...state, searchQuery: '', activeItemIndex }
},
[ActionTypes.Search]: (state, action) => {
let searchQuery = state.searchQuery + action.value
let searchQuery = state.searchQuery + action.value.toLowerCase()
let match = state.items.findIndex(
item =>
item.dataRef.current.textValue?.startsWith(searchQuery) && !item.dataRef.current.disabled
@@ -570,7 +570,7 @@ let Panel = forwardRefWithAs(function Panel<TTag extends ElementType = typeof DE
// We will take-over the default tab behaviour so that we have a bit
// control over what is focused next. It will behave exactly the same,
// but it will also "fix" some issues based on wether you are using a
// but it will also "fix" some issues based on whether you are using a
// Portal or not.
event.preventDefault()
@@ -55,7 +55,7 @@ describe('Safe guards', () => {
})
describe('Rendering', () => {
it('should be possible to render a RadioGroup, where the first element is tabbable', async () => {
it('should be possible to render a RadioGroup, where the first element is tabbable (value is undefined)', async () => {
render(
<RadioGroup value={undefined} onChange={console.log}>
<RadioGroup.Label>Pizza Delivery</RadioGroup.Label>
@@ -72,6 +72,23 @@ describe('Rendering', () => {
assertNotFocusable(getByText('Dine in'))
})
it('should be possible to render a RadioGroup, where the first element is tabbable (value is null)', async () => {
render(
<RadioGroup value={null} onChange={console.log}>
<RadioGroup.Label>Pizza Delivery</RadioGroup.Label>
<RadioGroup.Option value="pickup">Pickup</RadioGroup.Option>
<RadioGroup.Option value="home-delivery">Home delivery</RadioGroup.Option>
<RadioGroup.Option value="dine-in">Dine in</RadioGroup.Option>
</RadioGroup>
)
expect(getRadioGroupOptions()).toHaveLength(3)
assertFocusable(getByText('Pickup'))
assertNotFocusable(getByText('Home delivery'))
assertNotFocusable(getByText('Dine in'))
})
it('should be possible to render a RadioGroup with an active value', async () => {
render(
<RadioGroup value="home-delivery" onChange={console.log}>
@@ -120,6 +137,121 @@ describe('Rendering', () => {
await press(Keys.ArrowUp) // Up again
assertActiveElement(getByText('Home delivery'))
})
it('should be possible to disable a RadioGroup', async () => {
let changeFn = jest.fn()
function Example() {
let [disabled, setDisabled] = useState(true)
return (
<>
<button onClick={() => setDisabled(v => !v)}>Toggle</button>
<RadioGroup value={undefined} onChange={changeFn} disabled={disabled}>
<RadioGroup.Label>Pizza Delivery</RadioGroup.Label>
<RadioGroup.Option value="pickup">Pickup</RadioGroup.Option>
<RadioGroup.Option value="home-delivery">Home delivery</RadioGroup.Option>
<RadioGroup.Option value="dine-in">Dine in</RadioGroup.Option>
<RadioGroup.Option value="render-prop" data-value="render-prop">
{JSON.stringify}
</RadioGroup.Option>
</RadioGroup>
</>
)
}
render(<Example />)
// Try to click one a few options
await click(getByText('Pickup'))
await click(getByText('Dine in'))
// Verify that the RadioGroup.Option gets the disabled state
expect(document.querySelector('[data-value="render-prop"]')).toHaveTextContent(
JSON.stringify({
checked: false,
disabled: true,
active: false,
})
)
// Make sure that the onChange handler never got called
expect(changeFn).toHaveBeenCalledTimes(0)
// Toggle the disabled state
await click(getByText('Toggle'))
// Verify that the RadioGroup.Option gets the disabled state
expect(document.querySelector('[data-value="render-prop"]')).toHaveTextContent(
JSON.stringify({
checked: false,
disabled: false,
active: false,
})
)
// Try to click one a few options
await click(getByText('Pickup'))
// Make sure that the onChange handler got called
expect(changeFn).toHaveBeenCalledTimes(1)
})
it('should be possible to disable a RadioGroup.Option', async () => {
let changeFn = jest.fn()
function Example() {
let [disabled, setDisabled] = useState(true)
return (
<>
<button onClick={() => setDisabled(v => !v)}>Toggle</button>
<RadioGroup value={undefined} onChange={changeFn}>
<RadioGroup.Label>Pizza Delivery</RadioGroup.Label>
<RadioGroup.Option value="pickup">Pickup</RadioGroup.Option>
<RadioGroup.Option value="home-delivery">Home delivery</RadioGroup.Option>
<RadioGroup.Option value="dine-in">Dine in</RadioGroup.Option>
<RadioGroup.Option value="render-prop" disabled={disabled} data-value="render-prop">
{JSON.stringify}
</RadioGroup.Option>
</RadioGroup>
</>
)
}
render(<Example />)
// Try to click the disabled option
await click(document.querySelector('[data-value="render-prop"]'))
// Verify that the RadioGroup.Option gets the disabled state
expect(document.querySelector('[data-value="render-prop"]')).toHaveTextContent(
JSON.stringify({
checked: false,
disabled: true,
active: false,
})
)
// Make sure that the onChange handler never got called
expect(changeFn).toHaveBeenCalledTimes(0)
// Toggle the disabled state
await click(getByText('Toggle'))
// Verify that the RadioGroup.Option gets the disabled state
expect(document.querySelector('[data-value="render-prop"]')).toHaveTextContent(
JSON.stringify({
checked: false,
disabled: false,
active: false,
})
)
// Try to click one a few options
await click(document.querySelector('[data-value="render-prop"]'))
// Make sure that the onChange handler got called
expect(changeFn).toHaveBeenCalledTimes(1)
})
})
describe('Keyboard interactions', () => {
@@ -7,10 +7,10 @@ import React, {
useRef,
// Types
Dispatch,
ElementType,
MutableRefObject,
KeyboardEvent as ReactKeyboardEvent,
ContextType,
} from 'react'
import { Props, Expand } from '../../types'
@@ -28,11 +28,10 @@ import { useTreeWalker } from '../../hooks/use-tree-walker'
interface Option {
id: string
element: MutableRefObject<HTMLElement | null>
propsRef: MutableRefObject<{ value: unknown }>
propsRef: MutableRefObject<{ value: unknown; disabled: boolean }>
}
interface StateDefinition {
propsRef: MutableRefObject<{ value: unknown; onChange(value: unknown): void }>
options: Option[]
}
@@ -69,7 +68,14 @@ let reducers: {
},
}
let RadioGroupContext = createContext<[StateDefinition, Dispatch<Actions>] | null>(null)
let RadioGroupContext = createContext<{
registerOption(option: Option): () => void
change(value: unknown): boolean
value: unknown
firstOption?: Option
containsCheckedOption: boolean
disabled: boolean
} | null>(null)
RadioGroupContext.displayName = 'RadioGroupContext'
function useRadioGroupContext(component: string) {
@@ -106,30 +112,40 @@ export function RadioGroup<
disabled?: boolean
}
) {
let { value, onChange, ...passThroughProps } = props
let reducerBag = useReducer(stateReducer, {
propsRef: { current: { value, onChange } },
let { value, onChange, disabled = false, ...passThroughProps } = props
let [{ options }, dispatch] = useReducer(stateReducer, {
options: [],
} as StateDefinition)
let [{ propsRef, options }] = reducerBag
let [labelledby, LabelProvider] = useLabels()
let [describedby, DescriptionProvider] = useDescriptions()
let id = `headlessui-radiogroup-${useId()}`
let radioGroupRef = useRef<HTMLElement | null>(null)
useIsoMorphicEffect(() => {
propsRef.current.value = value
}, [value, propsRef])
useIsoMorphicEffect(() => {
propsRef.current.onChange = onChange
}, [onChange, propsRef])
let firstOption = useMemo(
() =>
options.find(option => {
if (option.propsRef.current.disabled) return false
return true
}),
[options]
)
let containsCheckedOption = useMemo(
() => options.some(option => option.propsRef.current.value === value),
[options, value]
)
let triggerChange = useCallback(
nextValue => {
if (nextValue === value) return
return onChange(nextValue)
if (disabled) return false
if (nextValue === value) return false
let nextOption = options.find(option => option.propsRef.current.value === nextValue)?.propsRef
.current
if (nextOption?.disabled) return false
onChange(nextValue)
return true
},
[onChange, value]
[onChange, value, disabled, options]
)
useTreeWalker({
@@ -149,6 +165,10 @@ export function RadioGroup<
let container = radioGroupRef.current
if (!container) return
let all = options
.filter(option => option.propsRef.current.disabled === false)
.map(radio => radio.element.current) as HTMLElement[]
switch (event.key) {
case Keys.ArrowLeft:
case Keys.ArrowUp:
@@ -156,10 +176,7 @@ export function RadioGroup<
event.preventDefault()
event.stopPropagation()
let result = focusIn(
options.map(radio => radio.element.current) as HTMLElement[],
Focus.Previous | Focus.WrapAround
)
let result = focusIn(all, Focus.Previous | Focus.WrapAround)
if (result === FocusResult.Success) {
let activeOption = options.find(
@@ -176,10 +193,7 @@ export function RadioGroup<
event.preventDefault()
event.stopPropagation()
let result = focusIn(
options.map(option => option.element.current) as HTMLElement[],
Focus.Next | Focus.WrapAround
)
let result = focusIn(all, Focus.Next | Focus.WrapAround)
if (result === FocusResult.Success) {
let activeOption = options.find(
@@ -206,6 +220,26 @@ export function RadioGroup<
[radioGroupRef, options, triggerChange]
)
let registerOption = useCallback(
(option: Option) => {
dispatch({ type: ActionTypes.RegisterOption, ...option })
return () => dispatch({ type: ActionTypes.UnregisterOption, id: option.id })
},
[dispatch]
)
let api = useMemo<ContextType<typeof RadioGroupContext>>(
() => ({
registerOption,
firstOption,
containsCheckedOption,
change: triggerChange,
disabled,
value,
}),
[registerOption, firstOption, containsCheckedOption, triggerChange, disabled, value]
)
let propsWeControl = {
ref: radioGroupRef,
id,
@@ -218,7 +252,7 @@ export function RadioGroup<
return (
<DescriptionProvider name="RadioGroup.Description">
<LabelProvider name="RadioGroup.Label">
<RadioGroupContext.Provider value={reducerBag}>
<RadioGroupContext.Provider value={api}>
{render({
props: { ...passThroughProps, ...propsWeControl },
defaultTag: DEFAULT_RADIO_GROUP_TAG,
@@ -241,6 +275,7 @@ let DEFAULT_OPTION_TAG = 'div' as const
interface OptionRenderPropArg {
checked: boolean
active: boolean
disabled: boolean
}
type RadioPropsWeControl =
| 'aria-checked'
@@ -258,8 +293,9 @@ function Option<
// But today is not that day..
TType = Parameters<typeof RadioGroup>[0]['value']
>(
props: Props<TTag, OptionRenderPropArg, RadioPropsWeControl | 'value'> & {
props: Props<TTag, OptionRenderPropArg, RadioPropsWeControl | 'value' | 'disabled'> & {
value: TType
disabled?: boolean
}
) {
let optionRef = useRef<HTMLElement | null>(null)
@@ -269,35 +305,46 @@ function Option<
let [describedby, DescriptionProvider] = useDescriptions()
let { addFlag, removeFlag, hasFlag } = useFlags(OptionState.Empty)
let { value, ...passThroughProps } = props
let propsRef = useRef({ value })
let { value, disabled = false, ...passThroughProps } = props
let propsRef = useRef({ value, disabled })
useIsoMorphicEffect(() => {
propsRef.current.value = value
}, [value, propsRef])
let [{ propsRef: radioGroupPropsRef, options }, dispatch] = useRadioGroupContext(
[RadioGroup.name, Option.name].join('.')
)
useIsoMorphicEffect(() => {
dispatch({ type: ActionTypes.RegisterOption, id, element: optionRef, propsRef })
return () => dispatch({ type: ActionTypes.UnregisterOption, id })
}, [id, dispatch, optionRef, props])
propsRef.current.disabled = disabled
}, [disabled, propsRef])
let {
registerOption,
disabled: radioGroupDisabled,
change,
firstOption,
containsCheckedOption,
value: radioGroupValue,
} = useRadioGroupContext([RadioGroup.name, Option.name].join('.'))
useIsoMorphicEffect(() => registerOption({ id, element: optionRef, propsRef }), [
id,
registerOption,
optionRef,
props,
])
let handleClick = useCallback(() => {
if (radioGroupPropsRef.current.value === value) return
if (!change(value)) return
addFlag(OptionState.Active)
radioGroupPropsRef.current.onChange(value)
optionRef.current?.focus()
}, [addFlag, radioGroupPropsRef, value])
}, [addFlag, change, value])
let handleFocus = useCallback(() => addFlag(OptionState.Active), [addFlag])
let handleBlur = useCallback(() => removeFlag(OptionState.Active), [removeFlag])
let firstRadio = options?.[0]?.id === id
let checked = radioGroupPropsRef.current.value === value
let isFirstOption = firstOption?.id === id
let isDisabled = radioGroupDisabled || disabled
let checked = radioGroupValue === value
let propsWeControl = {
ref: optionRef,
id,
@@ -305,14 +352,19 @@ function Option<
'aria-checked': checked ? 'true' : 'false',
'aria-labelledby': labelledby,
'aria-describedby': describedby,
tabIndex: checked ? 0 : radioGroupPropsRef.current.value === undefined && firstRadio ? 0 : -1,
onClick: handleClick,
onFocus: handleFocus,
onBlur: handleBlur,
tabIndex: (() => {
if (isDisabled) return -1
if (checked) return 0
if (!containsCheckedOption && isFirstOption) return 0
return -1
})(),
onClick: isDisabled ? undefined : handleClick,
onFocus: isDisabled ? undefined : handleFocus,
onBlur: isDisabled ? undefined : handleBlur,
}
let slot = useMemo<OptionRenderPropArg>(
() => ({ checked, active: hasFlag(OptionState.Active) }),
[checked, hasFlag]
() => ({ checked, disabled: isDisabled, active: hasFlag(OptionState.Active) }),
[checked, isDisabled, hasFlag]
)
return (
@@ -219,7 +219,7 @@ function TransitionChild<TTag extends ElementType = typeof DEFAULT_TRANSITION_CH
let nesting = useNesting(() => {
// When all children have been unmounted we can only hide ourselves if and only if we are not
// transitioning ourserlves. Otherwise we would unmount before the transitions are finished.
// transitioning ourselves. Otherwise we would unmount before the transitions are finished.
if (!isTransitioning.current) {
setState(TreeStates.Hidden)
unregister(id)
@@ -39,7 +39,7 @@ it('should be possible to transition', async () => {
expect(snapshots[1].content).toEqual('<div class="enter enterFrom"></div>')
// NOTE: There is no `enter enterTo`, because we didn't define a duration. Therefore it is not
// necessary to put the classes on the element and immediatley remove them.
// necessary to put the classes on the element and immediately remove them.
// Cleanup phase
expect(snapshots[2].content).toEqual('<div class=""></div>')
@@ -25,7 +25,7 @@ function waitForTransition(node: HTMLElement, done: (reason: Reason) => void) {
let [durationMs, delaysMs] = [transitionDuration, transitionDelay].map(value => {
let [resolvedValue = 0] = value
.split(',')
// Remove falseys we can't work with
// Remove falsy we can't work with
.filter(Boolean)
// Values are returned as `0.3s` or `75ms`
.map(v => (v.includes('ms') ? parseFloat(v) : parseFloat(v) * 1000))
@@ -864,6 +864,10 @@ export function getDialog(): HTMLElement | null {
return document.querySelector('[role="dialog"]')
}
export function getDialogs(): HTMLElement[] {
return Array.from(document.querySelectorAll('[role="dialog"]'))
}
export function getDialogTitle(): HTMLElement | null {
return document.querySelector('[id^="headlessui-dialog-title-"]')
}
@@ -144,7 +144,7 @@ export function focusIn(container: HTMLElement | HTMLElement[], focus: Focus) {
// This is a little weird, but let me try and explain: There are a few scenario's
// in chrome for example where a focused `<a>` tag does not get the default focus
// styles and sometimes they do. This highly depends on wether you started by
// styles and sometimes they do. This highly depends on whether you started by
// clicking or by using your keyboard. When you programmatically add focus `anchor.focus()`
// then the active element (document.activeElement) is this anchor, which is expected.
// However in that case the default focus styles are not applied *unless* you
@@ -107,7 +107,7 @@ function _render<TTag extends ElementType, TSlot>(
'static',
])
// This allows us to use `<HeadlessUIComponent as={MyComopnent} refName="innerRef" />`
// This allows us to use `<HeadlessUIComponent as={MyComponent} refName="innerRef" />`
let refRelatedProps = props.ref !== undefined ? { [refName]: props.ref } : {}
let resolvedChildren = (typeof children === 'function' ? children(slot) : children) as
+1 -1
View File
@@ -27,7 +27,7 @@ yarn add @headlessui/vue
## Documentation
For full documentation, visit [headlessui.dev](https://headlessui.dev/vue).
For full documentation, visit [headlessui.dev](https://headlessui.dev/vue/menu).
## Community
@@ -1,4 +1,4 @@
import { defineComponent, ref, nextTick } from 'vue'
import { defineComponent, ref, nextTick, h } from 'vue'
import { render } from '../../test-utils/vue-testing-library'
import { Dialog, DialogOverlay, DialogTitle, DialogDescription } from './dialog'
@@ -13,6 +13,7 @@ import {
getDialogOverlay,
getByText,
assertActiveElement,
getDialogs,
} from '../../test-utils/accessibility-assertions'
import { click, press, Keys } from '../../test-utils/interactions'
import { html } from '../../test-utils/html'
@@ -607,4 +608,191 @@ describe('Mouse interactions', () => {
assertActiveElement(getByText('Hello'))
})
)
it(
'should stop propagating click events when clicking on the Dialog.Overlay',
suppressConsoleLogs(async () => {
let wrapperFn = jest.fn()
renderTemplate({
template: `
<div @click="wrapperFn">
<Dialog v-if="true" :open="isOpen" @close="setIsOpen">
Contents
<DialogOverlay />
<TabSentinel />
</Dialog>
</div>
`,
setup() {
let isOpen = ref(true)
return {
isOpen,
wrapperFn,
setIsOpen(value: boolean) {
isOpen.value = value
},
}
},
})
// Verify it is open
assertDialog({ state: DialogState.Visible })
// Verify that the wrapper function has not been called yet
expect(wrapperFn).toHaveBeenCalledTimes(0)
// Click the Dialog.Overlay to close the Dialog
await click(getDialogOverlay())
// Verify it is closed
assertDialog({ state: DialogState.InvisibleUnmounted })
// Verify that the wrapper function has not been called yet
expect(wrapperFn).toHaveBeenCalledTimes(0)
})
)
it(
'should stop propagating click events when clicking on an element inside the Dialog',
suppressConsoleLogs(async () => {
let wrapperFn = jest.fn()
renderTemplate({
template: `
<div @click="wrapperFn">
<Dialog v-if="true" :open="isOpen" @close="setIsOpen">
Contents
<button @click="setIsOpen(false)">Inside</button>
<TabSentinel />
</Dialog>
</div>
`,
setup() {
let isOpen = ref(true)
return {
isOpen,
wrapperFn,
setIsOpen(value: boolean) {
isOpen.value = value
},
}
},
})
// Verify it is open
assertDialog({ state: DialogState.Visible })
// Verify that the wrapper function has not been called yet
expect(wrapperFn).toHaveBeenCalledTimes(0)
// Click the button inside the the Dialog
await click(getByText('Inside'))
// Verify it is closed
assertDialog({ state: DialogState.InvisibleUnmounted })
// Verify that the wrapper function has not been called yet
expect(wrapperFn).toHaveBeenCalledTimes(0)
})
)
})
describe('Nesting', () => {
it('should be possible to open nested Dialog components and close them with `Escape`', async () => {
let Nested = defineComponent({
components: { Dialog },
emits: ['close'],
props: ['level'],
render() {
let level = this.$props.level ?? 1
return h(Dialog, { open: true, onClose: this.onClose }, () => [
h('div', [
h('p', `Level: ${level}`),
h(
'button',
{
onClick: () => {
this.showChild = true
},
},
`Open ${level + 1}`
),
]),
this.showChild &&
h(Nested, {
onClose: () => {
this.showChild = false
},
level: level + 1,
}),
])
},
setup(_props, { emit }) {
let showChild = ref(false)
return {
showChild,
onClose() {
emit('close', false)
},
}
},
})
renderTemplate({
components: { Nested },
template: `
<button @click="isOpen = true">Open 1</button>
<Nested v-if="isOpen" @close="isOpen = false" />
`,
setup() {
let isOpen = ref(false)
return { isOpen }
},
})
// Verify we have no open dialogs
expect(getDialogs()).toHaveLength(0)
// Open Dialog 1
await click(getByText('Open 1'))
// Verify that we have 1 open dialog
expect(getDialogs()).toHaveLength(1)
// Open Dialog 2
await click(getByText('Open 2'))
// Verify that we have 2 open dialogs
expect(getDialogs()).toHaveLength(2)
// Press escape to close the top most Dialog
await press(Keys.Escape)
// Verify that we have 1 open dialog
expect(getDialogs()).toHaveLength(1)
// Open Dialog 2
await click(getByText('Open 2'))
// Verify that we have 2 open dialogs
expect(getDialogs()).toHaveLength(2)
// Open Dialog 3
await click(getByText('Open 3'))
// Verify that we have 3 open dialogs
expect(getDialogs()).toHaveLength(3)
// Press escape to close the top most Dialog
await press(Keys.Escape)
// Verify that we have 2 open dialogs
expect(getDialogs()).toHaveLength(2)
// Press escape to close the top most Dialog
await press(Keys.Escape)
// Verify that we have 1 open dialog
expect(getDialogs()).toHaveLength(1)
})
})
@@ -85,6 +85,8 @@ export let Dialog = defineComponent({
'aria-modal': this.dialogState === DialogStates.Open ? true : undefined,
'aria-labelledby': this.titleId,
'aria-describedby': this.describedby,
onClick: this.handleClick,
onKeydown: this.handleKeyDown,
}
let { open, initialFocus, ...passThroughProps } = this.$props
let slot = { open: this.dialogState === DialogStates.Open }
@@ -183,14 +185,6 @@ export let Dialog = defineComponent({
nextTick(() => target?.focus())
})
// Handle `Escape` to close
useWindowEvent('keydown', event => {
if (event.key !== Keys.Escape) return
if (dialogState.value !== DialogStates.Open) return
if (containers.value.size > 1) return // 1 is myself, otherwise other elements in the Stack
api.close()
})
// Scroll lock
watchEffect(onInvalidate => {
if (dialogState.value !== DialogStates.Open) return
@@ -241,6 +235,20 @@ export let Dialog = defineComponent({
dialogState,
titleId,
describedby,
handleClick(event: MouseEvent) {
event.preventDefault()
event.stopPropagation()
},
// Handle `Escape` to close
handleKeyDown(event: KeyboardEvent) {
if (event.key !== Keys.Escape) return
if (dialogState.value !== DialogStates.Open) return
if (containers.value.size > 1) return // 1 is myself, otherwise other elements in the Stack
event.preventDefault()
event.stopPropagation()
api.close()
},
}
},
})
@@ -276,7 +284,9 @@ export let DialogOverlay = defineComponent({
return {
id,
handleClick() {
handleClick(event: MouseEvent) {
event.preventDefault()
event.stopPropagation()
api.close()
},
}
@@ -100,6 +100,29 @@ describe('Rendering', () => {
assertDisclosurePanel({ state: DisclosureState.Visible, textContent: 'Panel is: open' })
})
)
it('should be possible to render a Disclosure in an open state by default', async () => {
renderTemplate(
html`
<Disclosure v-slot="{ open }" defaultOpen>
<DisclosureButton>Trigger</DisclosureButton>
<DisclosurePanel>Panel is: {{open ? 'open' : 'closed'}}</DisclosurePanel>
</Disclosure>
`
)
await new Promise<void>(nextTick)
assertDisclosureButton({
state: DisclosureState.Visible,
attributes: { id: 'headlessui-disclosure-button-1' },
})
assertDisclosurePanel({ state: DisclosureState.Visible, textContent: 'Panel is: open' })
await click(getDisclosureButton())
assertDisclosureButton({ state: DisclosureState.InvisibleUnmounted })
})
})
describe('DisclosureButton', () => {
@@ -41,11 +41,12 @@ export let Disclosure = defineComponent({
name: 'Disclosure',
props: {
as: { type: [Object, String], default: 'template' },
defaultOpen: { type: [Boolean], default: false },
},
setup(props, { slots, attrs }) {
let { ...passThroughProps } = props
let disclosureState = ref<StateDefinition['disclosureState']['value']>(DisclosureStates.Closed)
let disclosureState = ref<StateDefinition['disclosureState']['value']>(
props.defaultOpen ? DisclosureStates.Open : DisclosureStates.Closed
)
let panelRef = ref<StateDefinition['panelRef']['value']>(null)
let api = {
@@ -62,6 +63,7 @@ export let Disclosure = defineComponent({
provide(DisclosureContext, api)
return () => {
let { defaultOpen: _, ...passThroughProps } = props
let slot = { open: disclosureState.value === DisclosureStates.Open }
return render({ props: passThroughProps, slot, slots, attrs, name: 'Disclosure' })
}
@@ -153,13 +153,13 @@ it(
let [a, b, c, d] = Array.from(document.querySelectorAll('input'))
// Ensure that input-b is the active elememt
// Ensure that input-b is the active element
assertActiveElement(b)
// Tab to the next item
await press(Keys.Tab)
// Ensure that input-c is the active elememt
// Ensure that input-c is the active element
assertActiveElement(c)
// Try to move focus
@@ -2965,6 +2965,42 @@ describe('Keyboard interactions', () => {
assertActiveListboxOption(options[2])
})
)
it(
'should be possible to search for a word (case insensitive)',
suppressConsoleLogs(async () => {
renderTemplate({
template: html`
<Listbox v-model="value">
<ListboxButton>Trigger</ListboxButton>
<ListboxOptions>
<ListboxOption value="alice">alice</ListboxOption>
<ListboxOption value="bob">bob</ListboxOption>
<ListboxOption value="charlie">charlie</ListboxOption>
</ListboxOptions>
</Listbox>
`,
setup: () => ({ value: ref(null) }),
})
// Focus the button
getListboxButton()?.focus()
// Open listbox
await press(Keys.ArrowUp)
let options = getListboxOptions()
// We should be on the last option
assertActiveListboxOption(options[2])
// Search for bob in a different casing
await type(word('BO'))
// We should be on `bob`
assertActiveListboxOption(options[1])
})
)
})
})
@@ -137,7 +137,7 @@ export let Listbox = defineComponent({
if (disabled) return
if (listboxState.value === ListboxStates.Closed) return
searchQuery.value += value
searchQuery.value += value.toLowerCase()
let match = options.value.findIndex(
option =>
@@ -2454,6 +2454,36 @@ describe('Keyboard interactions', () => {
// We should still be on the last item
assertMenuLinkedWithMenuItem(items[2])
})
it('should be possible to search for a word (case insensitive)', async () => {
renderTemplate(jsx`
<Menu>
<MenuButton>Trigger</MenuButton>
<MenuItems>
<MenuItem as="a">alice</MenuItem>
<MenuItem as="a">bob</MenuItem>
<MenuItem as="a">charlie</MenuItem>
</MenuItems>
</Menu>
`)
// Focus the button
getMenuButton()?.focus()
// Open menu
await press(Keys.ArrowUp)
let items = getMenuItems()
// We should be on the last item
assertMenuLinkedWithMenuItem(items[2])
// Search for bob in a different casing
await type(word('BO'))
// We should be on `bob`
assertMenuLinkedWithMenuItem(items[1])
})
})
})
@@ -104,7 +104,7 @@ export let Menu = defineComponent({
activeItemIndex.value = nextActiveItemIndex
},
search(value: string) {
searchQuery.value += value
searchQuery.value += value.toLowerCase()
let match = items.value.findIndex(
item => item.dataRef.textValue.startsWith(searchQuery.value) && !item.dataRef.disabled
@@ -456,7 +456,7 @@ export let PopoverPanel = defineComponent({
// We will take-over the default tab behaviour so that we have a bit
// control over what is focused next. It will behave exactly the same,
// but it will also "fix" some issues based on wether you are using a
// but it will also "fix" some issues based on whether you are using a
// Portal or not.
event.preventDefault()
@@ -88,7 +88,7 @@ describe('Safe guards', () => {
})
describe('Rendering', () => {
it('should be possible to render a RadioGroup, where the first element is tabbable', async () => {
it('should be possible to render a RadioGroup, where the first element is tabbable (value is undefined)', async () => {
renderTemplate({
template: html`
<RadioGroup v-model="deliveryMethod">
@@ -113,6 +113,31 @@ describe('Rendering', () => {
assertNotFocusable(getByText('Dine in'))
})
it('should be possible to render a RadioGroup, where the first element is tabbable (value is null)', async () => {
renderTemplate({
template: html`
<RadioGroup v-model="deliveryMethod">
<RadioGroupLabel>Pizza Delivery</RadioGroupLabel>
<RadioGroupOption value="pickup">Pickup</RadioGroupOption>
<RadioGroupOption value="home-delivery">Home delivery</RadioGroupOption>
<RadioGroupOption value="dine-in">Dine in</RadioGroupOption>
</RadioGroup>
`,
setup() {
let deliveryMethod = ref(null)
return { deliveryMethod }
},
})
await new Promise<void>(nextTick)
expect(getRadioGroupOptions()).toHaveLength(3)
assertFocusable(getByText('Pickup'))
assertNotFocusable(getByText('Home delivery'))
assertNotFocusable(getByText('Dine in'))
})
it('should be possible to render a RadioGroup with an active value', async () => {
renderTemplate({
template: html`
@@ -189,7 +214,7 @@ describe('Rendering', () => {
await new Promise<void>(nextTick)
expect(document.querySelector('[id^="headlessui-radiogroup-option-"]')).toHaveTextContent(
`Pickup - ${JSON.stringify({ checked: false, active: false })}`
`Pickup - ${JSON.stringify({ checked: false, disabled: false, active: false })}`
)
})
@@ -220,13 +245,13 @@ describe('Rendering', () => {
document.querySelectorAll('[id^="headlessui-radiogroup-option-"]')
)
expect(pickup).toHaveTextContent(
`Pickup - ${JSON.stringify({ checked: false, active: false })}`
`Pickup - ${JSON.stringify({ checked: false, disabled: false, active: false })}`
)
expect(homeDelivery).toHaveTextContent(
`Home delivery - ${JSON.stringify({ checked: false, active: false })}`
`Home delivery - ${JSON.stringify({ checked: false, disabled: false, active: false })}`
)
expect(dineIn).toHaveTextContent(
`Dine in - ${JSON.stringify({ checked: false, active: false })}`
`Dine in - ${JSON.stringify({ checked: false, disabled: false, active: false })}`
)
await click(homeDelivery)
@@ -235,13 +260,13 @@ describe('Rendering', () => {
)
expect(pickup).toHaveTextContent(
`Pickup - ${JSON.stringify({ checked: false, active: false })}`
`Pickup - ${JSON.stringify({ checked: false, disabled: false, active: false })}`
)
expect(homeDelivery).toHaveTextContent(
`Home delivery - ${JSON.stringify({ checked: true, active: true })}`
`Home delivery - ${JSON.stringify({ checked: true, disabled: false, active: true })}`
)
expect(dineIn).toHaveTextContent(
`Dine in - ${JSON.stringify({ checked: false, active: false })}`
`Dine in - ${JSON.stringify({ checked: false, disabled: false, active: false })}`
)
})
@@ -293,6 +318,126 @@ describe('Rendering', () => {
expect(getByText('Pickup')).toHaveClass('abc')
})
it('should be possible to disable a RadioGroup', async () => {
let changeFn = jest.fn()
renderTemplate({
template: html`
<button @click="disabled = !disabled">Toggle</button>
<RadioGroup v-model="deliveryMethod" :disabled="disabled">
<RadioGroupLabel>Pizza Delivery</RadioGroupLabel>
<RadioGroupOption value="pickup">Pickup</RadioGroupOption>
<RadioGroupOption value="home-delivery">Home delivery</RadioGroupOption>
<RadioGroupOption value="dine-in">Dine in</RadioGroupOption>
<RadioGroupOption value="render-prop" data-value="render-prop" v-slot="data">
{{JSON.stringify(data)}}
</RadioGroupOption>
</RadioGroup>
`,
setup() {
let deliveryMethod = ref(undefined)
let disabled = ref(true)
watch([deliveryMethod], () => changeFn(deliveryMethod.value))
return { deliveryMethod, disabled }
},
})
// Try to click one a few options
await click(getByText('Pickup'))
await click(getByText('Dine in'))
// Verify that the RadioGroup.Option gets the disabled state
expect(document.querySelector('[data-value="render-prop"]')).toHaveTextContent(
JSON.stringify({
checked: false,
disabled: true,
active: false,
})
)
// Make sure that the onChange handler never got called
expect(changeFn).toHaveBeenCalledTimes(0)
// Toggle the disabled state
await click(getByText('Toggle'))
// Verify that the RadioGroup.Option gets the disabled state
expect(document.querySelector('[data-value="render-prop"]')).toHaveTextContent(
JSON.stringify({
checked: false,
disabled: false,
active: false,
})
)
// Try to click one a few options
await click(getByText('Pickup'))
// Make sure that the onChange handler got called
expect(changeFn).toHaveBeenCalledTimes(1)
})
it('should be possible to disable a RadioGroup.Option', async () => {
let changeFn = jest.fn()
renderTemplate({
template: html`
<button @click="disabled = !disabled">Toggle</button>
<RadioGroup v-model="deliveryMethod">
<RadioGroupLabel>Pizza Delivery</RadioGroupLabel>
<RadioGroupOption value="pickup">Pickup</RadioGroupOption>
<RadioGroupOption value="home-delivery">Home delivery</RadioGroupOption>
<RadioGroupOption value="dine-in">Dine in</RadioGroupOption>
<RadioGroupOption
value="render-prop"
:disabled="disabled"
data-value="render-prop"
v-slot="data"
>
{{JSON.stringify(data)}}
</RadioGroupOption>
</RadioGroup>
`,
setup() {
let deliveryMethod = ref(undefined)
let disabled = ref(true)
watch([deliveryMethod], () => changeFn(deliveryMethod.value))
return { deliveryMethod, disabled }
},
})
// Try to click the disabled option
await click(document.querySelector('[data-value="render-prop"]'))
// Verify that the RadioGroup.Option gets the disabled state
expect(document.querySelector('[data-value="render-prop"]')).toHaveTextContent(
JSON.stringify({
checked: false,
disabled: true,
active: false,
})
)
// Make sure that the onChange handler never got called
expect(changeFn).toHaveBeenCalledTimes(0)
// Toggle the disabled state
await click(getByText('Toggle'))
// Verify that the RadioGroup.Option gets the disabled state
expect(document.querySelector('[data-value="render-prop"]')).toHaveTextContent(
JSON.stringify({
checked: false,
disabled: false,
active: false,
})
)
// Try to click one a few options
await click(document.querySelector('[data-value="render-prop"]'))
// Make sure that the onChange handler got called
expect(changeFn).toHaveBeenCalledTimes(1)
})
})
describe('Keyboard interactions', () => {
@@ -26,16 +26,19 @@ import { useTreeWalker } from '../../hooks/use-tree-walker'
interface Option {
id: string
element: Ref<HTMLElement | null>
propsRef: Ref<{ value: unknown }>
propsRef: Ref<{ value: unknown; disabled: boolean }>
}
interface StateDefinition {
// State
options: Ref<Option[]>
value: Ref<unknown>
disabled: Ref<boolean>
firstOption: Ref<Option | undefined>
containsCheckedOption: Ref<boolean>
// State mutators
change(nextValue: unknown): void
change(nextValue: unknown): boolean
registerOption(action: Option): void
unregisterOption(id: Option['id']): void
}
@@ -95,10 +98,25 @@ export let RadioGroup = defineComponent({
let api = {
options,
value,
disabled: computed(() => props.disabled),
firstOption: computed(() =>
options.value.find(option => {
if (option.propsRef.disabled) return false
return true
})
),
containsCheckedOption: computed(() =>
options.value.some(option => toRaw(option.propsRef.value) === toRaw(props.modelValue))
),
change(nextValue: unknown) {
if (props.disabled) return
if (value.value === nextValue) return
if (props.disabled) return false
if (value.value === nextValue) return false
let nextOption = options.value.find(
option => toRaw(option.propsRef.value) === toRaw(nextValue)
)?.propsRef
if (nextOption?.disabled) return false
emit('update:modelValue', nextValue)
return true
},
registerOption(action: UnwrapRef<Option>) {
let orderMap = Array.from(
@@ -137,6 +155,10 @@ export let RadioGroup = defineComponent({
if (!radioGroupRef.value) return
if (!radioGroupRef.value.contains(event.target as HTMLElement)) return
let all = options.value
.filter(option => option.propsRef.disabled === false)
.map(radio => radio.element) as HTMLElement[]
switch (event.key) {
case Keys.ArrowLeft:
case Keys.ArrowUp:
@@ -144,10 +166,7 @@ export let RadioGroup = defineComponent({
event.preventDefault()
event.stopPropagation()
let result = focusIn(
options.value.map(radio => radio.element) as HTMLElement[],
Focus.Previous | Focus.WrapAround
)
let result = focusIn(all, Focus.Previous | Focus.WrapAround)
if (result === FocusResult.Success) {
let activeOption = options.value.find(
@@ -164,10 +183,7 @@ export let RadioGroup = defineComponent({
event.preventDefault()
event.stopPropagation()
let result = focusIn(
options.value.map(option => option.element) as HTMLElement[],
Focus.Next | Focus.WrapAround
)
let result = focusIn(all, Focus.Next | Focus.WrapAround)
if (result === FocusResult.Success) {
let activeOption = options.value.find(
@@ -215,7 +231,7 @@ export let RadioGroupOption = defineComponent({
name: 'RadioGroupOption',
props: {
as: { type: [Object, String], default: 'div' },
value: { type: [Object, String] },
value: { type: [Object, String, Number, Boolean] },
disabled: { type: Boolean, default: false },
class: { type: [String, Function], required: false },
className: { type: [String, Function], required: false },
@@ -228,11 +244,13 @@ export let RadioGroupOption = defineComponent({
className = defaultClass,
...passThroughProps
} = this.$props
let api = useRadioGroupContext('RadioGroupOption')
let firstRadio = api.options.value?.[0]?.id === this.id
let slot = {
checked: this.checked,
disabled: this.disabled,
active: Boolean(this.state & OptionState.Active),
}
let slot = { checked: this.checked, active: Boolean(this.state & OptionState.Active) }
let propsWeControl = {
id: this.id,
ref: 'el',
@@ -241,10 +259,10 @@ export let RadioGroupOption = defineComponent({
'aria-checked': this.checked ? 'true' : 'false',
'aria-labelledby': this.labelledby,
'aria-describedby': this.describedby,
tabIndex: this.checked ? 0 : api.value.value === undefined && firstRadio ? 0 : -1,
onClick: this.handleClick,
onFocus: this.handleFocus,
onBlur: this.handleBlur,
tabIndex: this.tabIndex,
onClick: this.disabled ? undefined : this.handleClick,
onFocus: this.disabled ? undefined : this.handleFocus,
onBlur: this.disabled ? undefined : this.handleBlur,
}
return render({
@@ -262,26 +280,34 @@ export let RadioGroupOption = defineComponent({
let describedby = useDescriptions({ name: 'RadioGroupDescription' })
let optionRef = ref<HTMLElement | null>(null)
let propsRef = computed(() => ({ value: props.value }))
let propsRef = computed(() => ({ value: props.value, disabled: props.disabled }))
let state = ref(OptionState.Empty)
onMounted(() => api.registerOption({ id, element: optionRef, propsRef }))
onUnmounted(() => api.unregisterOption(id))
let isFirstOption = computed(() => api.firstOption.value?.id === id)
let disabled = computed(() => api.disabled.value || props.disabled)
let checked = computed(() => toRaw(api.value.value) === toRaw(props.value))
return {
id,
el: optionRef,
labelledby,
describedby,
state,
checked: computed(() => toRaw(api.value.value) === toRaw(props.value)),
disabled,
checked,
tabIndex: computed(() => {
if (disabled.value) return -1
if (checked.value) return 0
if (!api.containsCheckedOption.value && isFirstOption.value) return 0
return -1
}),
handleClick() {
let value = props.value
if (api.value.value === value) return
if (!api.change(props.value)) return
state.value |= OptionState.Active
api.change(value)
optionRef.value?.focus()
},
handleFocus() {
@@ -179,7 +179,7 @@ export let TransitionChild = defineComponent({
let nesting = useNesting(() => {
// When all children have been unmounted we can only hide ourselves if and only if we are not
// transitioning ourserlves. Otherwise we would unmount before the transitions are finished.
// transitioning ourselves. Otherwise we would unmount before the transitions are finished.
if (!isTransitioning.value) {
state.value = TreeStates.Hidden
unregister(id)
@@ -39,7 +39,7 @@ it('should be possible to transition', async () => {
expect(snapshots[1].content).toEqual('<div class="enter enterFrom"></div>')
// NOTE: There is no `enter enterTo`, because we didn't define a duration. Therefore it is not
// necessary to put the classes on the element and immediatley remove them.
// necessary to put the classes on the element and immediately remove them.
// Cleanup phase
expect(snapshots[2].content).toEqual('<div class=""></div>')
@@ -864,6 +864,10 @@ export function getDialog(): HTMLElement | null {
return document.querySelector('[role="dialog"]')
}
export function getDialogs(): HTMLElement[] {
return Array.from(document.querySelectorAll('[role="dialog"]'))
}
export function getDialogTitle(): HTMLElement | null {
return document.querySelector('[id^="headlessui-dialog-title-"]')
}
@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils'
import { logDOM, fireEvent } from '@testing-library/dom'
import { logDOM, fireEvent, screen } from '@testing-library/dom'
let mountedWrappers = new Set()
@@ -38,6 +38,7 @@ export function render(TestComponent: any, options?: Parameters<typeof mount>[1]
function cleanup() {
mountedWrappers.forEach(cleanupAtWrapper)
document.body.innerHTML = ''
}
function cleanupAtWrapper(wrapper: any) {
@@ -57,4 +58,4 @@ if (typeof afterEach === 'function') {
afterEach(() => cleanup())
}
export { fireEvent }
export { fireEvent, screen }
@@ -144,7 +144,7 @@ export function focusIn(container: HTMLElement | HTMLElement[], focus: Focus) {
// This is a little weird, but let me try and explain: There are a few scenario's
// in chrome for example where a focused `<a>` tag does not get the default focus
// styles and sometimes they do. This highly depends on wether you started by
// styles and sometimes they do. This highly depends on whether you started by
// clicking or by using your keyboard. When you programmatically add focus `anchor.focus()`
// then the active element (document.activeElement) is this anchor, which is expected.
// However in that case the default focus styles are not applied *unless* you