diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 00d1c95e67..15b6f7796e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -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') }} diff --git a/CHANGELOG.md b/CHANGELOG.md index d72772441b..85631ae70a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/@headlessui-react/README.md b/packages/@headlessui-react/README.md index 9ae24271ee..fa2a630f49 100644 --- a/packages/@headlessui-react/README.md +++ b/packages/@headlessui-react/README.md @@ -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 diff --git a/packages/@headlessui-react/pages/dialog/dialog.tsx b/packages/@headlessui-react/pages/dialog/dialog.tsx index d3da614542..bf1b2556e2 100644 --- a/packages/@headlessui-react/pages/dialog/dialog.tsx +++ b/packages/@headlessui-react/pages/dialog/dialog.tsx @@ -19,10 +19,10 @@ function Nested({ onClose, level = 0 }) { return ( <> - + {true && }
{ +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() diff --git a/packages/@headlessui-react/src/components/dialog/dialog.test.tsx b/packages/@headlessui-react/src/components/dialog/dialog.test.tsx index f9d4a6bcf6..fdb30bc4eb 100644 --- a/packages/@headlessui-react/src/components/dialog/dialog.test.tsx +++ b/packages/@headlessui-react/src/components/dialog/dialog.test.tsx @@ -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 ( +
+ + Contents + + + +
+ ) + } + render() + + // 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 ( +
+ + Contents + + + +
+ ) + } + render() + + // 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 ( + <> + +
+

Level: {level}

+ +
+ {showChild && } +
+ + ) + } + + function Example() { + let [open, setOpen] = useState(false) + + return ( + <> + + {open && } + + ) + } + + render() + + // 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) + }) }) diff --git a/packages/@headlessui-react/src/components/dialog/dialog.tsx b/packages/@headlessui-react/src/components/dialog/dialog.tsx index 32d2fcf9f1..fb476b4623 100644 --- a/packages/@headlessui-react/src/components/dialog/dialog.tsx +++ b/packages/@headlessui-react/src/components/dialog/dialog.tsx @@ -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] diff --git a/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx b/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx index 81757bb0dd..aea9edd29c 100644 --- a/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx +++ b/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx @@ -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( + + {({ open }) => ( + <> + Trigger + Panel is: {open ? 'open' : 'closed'} + + )} + + ) + + 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', () => { diff --git a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx index 3b811fe9ec..f633b78e76 100644 --- a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx +++ b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx @@ -111,13 +111,16 @@ interface DisclosureRenderPropArg { } export function Disclosure( - props: Props + props: Props & { + 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 {render({ - props, + props: passthroughProps, slot, defaultTag: DEFAULT_DISCLOSURE_TAG, name: 'Disclosure', diff --git a/packages/@headlessui-react/src/components/focus-trap/focus-trap.test.tsx b/packages/@headlessui-react/src/components/focus-trap/focus-trap.test.tsx index 1e1f0bc6d8..f0de0f330b 100644 --- a/packages/@headlessui-react/src/components/focus-trap/focus-trap.test.tsx +++ b/packages/@headlessui-react/src/components/focus-trap/focus-trap.test.tsx @@ -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 diff --git a/packages/@headlessui-react/src/components/label/label.test.tsx b/packages/@headlessui-react/src/components/label/label.test.tsx index bbc654b282..73140359b9 100644 --- a/packages/@headlessui-react/src/components/label/label.test.tsx +++ b/packages/@headlessui-react/src/components/label/label.test.tsx @@ -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() diff --git a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx index 2f7fac76f3..d70a6e1b89 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx @@ -2764,6 +2764,39 @@ describe('Keyboard interactions', () => { assertActiveListboxOption(options[2]) }) ) + + it( + 'should be possible to search for a word (case insensitive)', + suppressConsoleLogs(async () => { + render( + + Trigger + + alice + bob + charlie + + + ) + + // 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]) + }) + ) }) }) diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx index 894ccabc44..a4e9fc6ab0 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx @@ -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 && diff --git a/packages/@headlessui-react/src/components/menu/menu.test.tsx b/packages/@headlessui-react/src/components/menu/menu.test.tsx index 6ecdb96ef1..f8228747f7 100644 --- a/packages/@headlessui-react/src/components/menu/menu.test.tsx +++ b/packages/@headlessui-react/src/components/menu/menu.test.tsx @@ -2408,6 +2408,38 @@ describe('Keyboard interactions', () => { assertMenuLinkedWithMenuItem(items[2]) }) ) + it( + 'should be possible to search for a word (case insensitive)', + suppressConsoleLogs(async () => { + render( + + Trigger + + alice + bob + charlie + + + ) + + // 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]) + }) + ) }) }) diff --git a/packages/@headlessui-react/src/components/menu/menu.tsx b/packages/@headlessui-react/src/components/menu/menu.tsx index e8b65abeb9..d6a09e106c 100644 --- a/packages/@headlessui-react/src/components/menu/menu.tsx +++ b/packages/@headlessui-react/src/components/menu/menu.tsx @@ -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 diff --git a/packages/@headlessui-react/src/components/popover/popover.tsx b/packages/@headlessui-react/src/components/popover/popover.tsx index b92f3288d9..45504a4f70 100644 --- a/packages/@headlessui-react/src/components/popover/popover.tsx +++ b/packages/@headlessui-react/src/components/popover/popover.tsx @@ -570,7 +570,7 @@ let Panel = forwardRefWithAs(function Panel { }) 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( Pizza Delivery @@ -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( + + Pizza Delivery + Pickup + Home delivery + Dine in + + ) + + 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( @@ -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 ( + <> + + + Pizza Delivery + Pickup + Home delivery + Dine in + + {JSON.stringify} + + + + ) + } + + render() + + // 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 ( + <> + + + Pizza Delivery + Pickup + Home delivery + Dine in + + {JSON.stringify} + + + + ) + } + + render() + + // 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', () => { diff --git a/packages/@headlessui-react/src/components/radio-group/radio-group.tsx b/packages/@headlessui-react/src/components/radio-group/radio-group.tsx index d4934b6c43..f92891e8f1 100644 --- a/packages/@headlessui-react/src/components/radio-group/radio-group.tsx +++ b/packages/@headlessui-react/src/components/radio-group/radio-group.tsx @@ -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 - 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] | 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(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>( + () => ({ + 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 ( - + {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[0]['value'] >( - props: Props & { + props: Props & { value: TType + disabled?: boolean } ) { let optionRef = useRef(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( - () => ({ checked, active: hasFlag(OptionState.Active) }), - [checked, hasFlag] + () => ({ checked, disabled: isDisabled, active: hasFlag(OptionState.Active) }), + [checked, isDisabled, hasFlag] ) return ( diff --git a/packages/@headlessui-react/src/components/transitions/transition.tsx b/packages/@headlessui-react/src/components/transitions/transition.tsx index 9ac539788e..9e5310b0ba 100644 --- a/packages/@headlessui-react/src/components/transitions/transition.tsx +++ b/packages/@headlessui-react/src/components/transitions/transition.tsx @@ -219,7 +219,7 @@ function TransitionChild { // 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) diff --git a/packages/@headlessui-react/src/components/transitions/utils/transition.test.ts b/packages/@headlessui-react/src/components/transitions/utils/transition.test.ts index 0b0f51f45e..f5b2e0c01b 100644 --- a/packages/@headlessui-react/src/components/transitions/utils/transition.test.ts +++ b/packages/@headlessui-react/src/components/transitions/utils/transition.test.ts @@ -39,7 +39,7 @@ it('should be possible to transition', async () => { expect(snapshots[1].content).toEqual('
') // 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('
') diff --git a/packages/@headlessui-react/src/components/transitions/utils/transition.ts b/packages/@headlessui-react/src/components/transitions/utils/transition.ts index b74df00679..67f1ffb661 100644 --- a/packages/@headlessui-react/src/components/transitions/utils/transition.ts +++ b/packages/@headlessui-react/src/components/transitions/utils/transition.ts @@ -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)) diff --git a/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts b/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts index bb84e8ae19..1d34c57d80 100644 --- a/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts +++ b/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts @@ -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-"]') } diff --git a/packages/@headlessui-react/src/utils/focus-management.ts b/packages/@headlessui-react/src/utils/focus-management.ts index 12ffaf2cc2..f873373c7e 100644 --- a/packages/@headlessui-react/src/utils/focus-management.ts +++ b/packages/@headlessui-react/src/utils/focus-management.ts @@ -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 `` 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 diff --git a/packages/@headlessui-react/src/utils/render.ts b/packages/@headlessui-react/src/utils/render.ts index 56e22975b6..1fe004787b 100644 --- a/packages/@headlessui-react/src/utils/render.ts +++ b/packages/@headlessui-react/src/utils/render.ts @@ -107,7 +107,7 @@ function _render( 'static', ]) - // This allows us to use `` + // This allows us to use `` let refRelatedProps = props.ref !== undefined ? { [refName]: props.ref } : {} let resolvedChildren = (typeof children === 'function' ? children(slot) : children) as diff --git a/packages/@headlessui-vue/README.md b/packages/@headlessui-vue/README.md index ecb8ecb48f..8e905a390b 100644 --- a/packages/@headlessui-vue/README.md +++ b/packages/@headlessui-vue/README.md @@ -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 diff --git a/packages/@headlessui-vue/src/components/dialog/dialog.test.ts b/packages/@headlessui-vue/src/components/dialog/dialog.test.ts index 07bfe0d43f..9f092696c1 100644 --- a/packages/@headlessui-vue/src/components/dialog/dialog.test.ts +++ b/packages/@headlessui-vue/src/components/dialog/dialog.test.ts @@ -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: ` +
+ + Contents + + + +
+ `, + 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: ` +
+ + Contents + + + +
+ `, + 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: ` + + + `, + 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) + }) }) diff --git a/packages/@headlessui-vue/src/components/dialog/dialog.ts b/packages/@headlessui-vue/src/components/dialog/dialog.ts index 79c379ecc5..2466eb636c 100644 --- a/packages/@headlessui-vue/src/components/dialog/dialog.ts +++ b/packages/@headlessui-vue/src/components/dialog/dialog.ts @@ -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() }, } diff --git a/packages/@headlessui-vue/src/components/disclosure/disclosure.test.ts b/packages/@headlessui-vue/src/components/disclosure/disclosure.test.ts index 943b51cbe9..d5d726078d 100644 --- a/packages/@headlessui-vue/src/components/disclosure/disclosure.test.ts +++ b/packages/@headlessui-vue/src/components/disclosure/disclosure.test.ts @@ -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` + + Trigger + Panel is: {{open ? 'open' : 'closed'}} + + ` + ) + + await new Promise(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', () => { diff --git a/packages/@headlessui-vue/src/components/disclosure/disclosure.ts b/packages/@headlessui-vue/src/components/disclosure/disclosure.ts index e8de42577b..3aabf042d7 100644 --- a/packages/@headlessui-vue/src/components/disclosure/disclosure.ts +++ b/packages/@headlessui-vue/src/components/disclosure/disclosure.ts @@ -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(DisclosureStates.Closed) + let disclosureState = ref( + props.defaultOpen ? DisclosureStates.Open : DisclosureStates.Closed + ) let panelRef = ref(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' }) } diff --git a/packages/@headlessui-vue/src/components/focus-trap/focus-trap.test.ts b/packages/@headlessui-vue/src/components/focus-trap/focus-trap.test.ts index adb9f730f0..ee70eedf46 100644 --- a/packages/@headlessui-vue/src/components/focus-trap/focus-trap.test.ts +++ b/packages/@headlessui-vue/src/components/focus-trap/focus-trap.test.ts @@ -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 diff --git a/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx b/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx index 16b810186b..286580d929 100644 --- a/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx +++ b/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx @@ -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` + + Trigger + + alice + bob + charlie + + + `, + 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]) + }) + ) }) }) diff --git a/packages/@headlessui-vue/src/components/listbox/listbox.ts b/packages/@headlessui-vue/src/components/listbox/listbox.ts index 73264b3a62..ef1db2cb2b 100644 --- a/packages/@headlessui-vue/src/components/listbox/listbox.ts +++ b/packages/@headlessui-vue/src/components/listbox/listbox.ts @@ -78,7 +78,7 @@ export let Listbox = defineComponent({ props: { as: { type: [Object, String], default: 'template' }, disabled: { type: [Boolean], default: false }, - modelValue: { type: [Object, String, Number, Boolean], default: null }, + modelValue: { type: [Object, String, Number, Boolean] }, }, setup(props, { slots, attrs, emit }) { let { modelValue, disabled, ...passThroughProps } = props @@ -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 => @@ -445,7 +445,7 @@ export let ListboxOption = defineComponent({ name: 'ListboxOption', props: { as: { type: [Object, String], default: 'li' }, - value: { type: [Object, String], default: null }, + value: { type: [Object, String] }, disabled: { type: Boolean, default: false }, class: { type: [String, Function], required: false }, className: { type: [String, Function], required: false }, diff --git a/packages/@headlessui-vue/src/components/menu/menu.test.tsx b/packages/@headlessui-vue/src/components/menu/menu.test.tsx index b109344301..13a8d423bc 100644 --- a/packages/@headlessui-vue/src/components/menu/menu.test.tsx +++ b/packages/@headlessui-vue/src/components/menu/menu.test.tsx @@ -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` + + Trigger + + alice + bob + charlie + + + `) + + // 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]) + }) }) }) diff --git a/packages/@headlessui-vue/src/components/menu/menu.ts b/packages/@headlessui-vue/src/components/menu/menu.ts index 9b2b67394e..b2824ca8d6 100644 --- a/packages/@headlessui-vue/src/components/menu/menu.ts +++ b/packages/@headlessui-vue/src/components/menu/menu.ts @@ -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 diff --git a/packages/@headlessui-vue/src/components/popover/popover.ts b/packages/@headlessui-vue/src/components/popover/popover.ts index 4dba457a46..ca1384c3cd 100644 --- a/packages/@headlessui-vue/src/components/popover/popover.ts +++ b/packages/@headlessui-vue/src/components/popover/popover.ts @@ -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() diff --git a/packages/@headlessui-vue/src/components/radio-group/radio-group.test.ts b/packages/@headlessui-vue/src/components/radio-group/radio-group.test.ts index 29a6a304ca..88ea7c0268 100644 --- a/packages/@headlessui-vue/src/components/radio-group/radio-group.test.ts +++ b/packages/@headlessui-vue/src/components/radio-group/radio-group.test.ts @@ -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` @@ -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` + + Pizza Delivery + Pickup + Home delivery + Dine in + + `, + setup() { + let deliveryMethod = ref(null) + return { deliveryMethod } + }, + }) + + await new Promise(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(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` + + + Pizza Delivery + Pickup + Home delivery + Dine in + + {{JSON.stringify(data)}} + + + `, + 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` + + + Pizza Delivery + Pickup + Home delivery + Dine in + + {{JSON.stringify(data)}} + + + `, + 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', () => { diff --git a/packages/@headlessui-vue/src/components/radio-group/radio-group.ts b/packages/@headlessui-vue/src/components/radio-group/radio-group.ts index 0286705b41..0bbaf2acbb 100644 --- a/packages/@headlessui-vue/src/components/radio-group/radio-group.ts +++ b/packages/@headlessui-vue/src/components/radio-group/radio-group.ts @@ -26,16 +26,19 @@ import { useTreeWalker } from '../../hooks/use-tree-walker' interface Option { id: string element: Ref - propsRef: Ref<{ value: unknown }> + propsRef: Ref<{ value: unknown; disabled: boolean }> } interface StateDefinition { // State options: Ref value: Ref + disabled: Ref + firstOption: Ref
` 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