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
@@ -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))