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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user