diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index e010ef6c5f..c8291c4ebc 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Prefer incoming `data-*` attributes, over the ones set by Headless UI ([#3035](https://github.com/tailwindlabs/headlessui/pull/3035)) - Respect `selectedIndex` for controlled `` components ([#3037](https://github.com/tailwindlabs/headlessui/pull/3037)) - Prevent unnecessary execution of the `displayValue` callback in the `ComboboxInput` component ([#3048](https://github.com/tailwindlabs/headlessui/pull/3048)) +- Expose missing `data-disabled` and `data-focus` attributes on the `TabsPanel`, `MenuButton`, `PopoverButton` and `DisclosureButton` components ([#3061](https://github.com/tailwindlabs/headlessui/pull/3061)) ### Changed diff --git a/packages/@headlessui-react/src/components/button/button.tsx b/packages/@headlessui-react/src/components/button/button.tsx index 031fef5220..daf3e9bfc6 100644 --- a/packages/@headlessui-react/src/components/button/button.tsx +++ b/packages/@headlessui-react/src/components/button/button.tsx @@ -41,34 +41,27 @@ function ButtonFn( ref: Ref ) { let providedDisabled = useDisabled() - let { disabled = providedDisabled || false, ...theirProps } = props + let { disabled = providedDisabled || false, autoFocus = false, ...theirProps } = props - let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus: props.autoFocus ?? false }) + let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus }) let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled }) let { pressed: active, pressProps } = useActivePress({ disabled }) let ourProps = mergeProps( { ref, - disabled: disabled || undefined, type: theirProps.type ?? 'button', + disabled: disabled || undefined, + autoFocus, }, focusProps, hoverProps, pressProps ) - let slot = useMemo( - () => - ({ - disabled, - hover, - focus, - active, - autofocus: props.autoFocus ?? false, - }) satisfies ButtonRenderPropArg, - [disabled, hover, focus, active, props.autoFocus] - ) + let slot = useMemo(() => { + return { disabled, hover, focus, active, autofocus: autoFocus } satisfies ButtonRenderPropArg + }, [disabled, hover, focus, active, autoFocus]) return render({ ourProps, diff --git a/packages/@headlessui-react/src/components/checkbox/checkbox.tsx b/packages/@headlessui-react/src/components/checkbox/checkbox.tsx index c67e9653ba..8ee45ba860 100644 --- a/packages/@headlessui-react/src/components/checkbox/checkbox.tsx +++ b/packages/@headlessui-react/src/components/checkbox/checkbox.tsx @@ -83,6 +83,7 @@ function CheckboxFn) => event.preventDefault()) - let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus: props.autoFocus ?? false }) - let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled ?? false }) - let { pressed: active, pressProps } = useActivePress({ disabled: disabled ?? false }) + let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus }) + let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled }) + let { pressed: active, pressProps } = useActivePress({ disabled }) let ourProps = mergeProps( { @@ -151,20 +152,18 @@ function CheckboxFn - ({ - checked, - disabled, - hover, - focus, - active, - indeterminate, - changing, - autofocus: props.autoFocus ?? false, - }) satisfies CheckboxRenderPropArg, - [checked, indeterminate, disabled, hover, focus, active, changing, props.autoFocus] - ) + let slot = useMemo(() => { + return { + checked, + disabled, + hover, + focus, + active, + indeterminate, + changing, + autofocus: autoFocus, + } satisfies CheckboxRenderPropArg + }, [checked, indeterminate, disabled, hover, focus, active, changing, autoFocus]) let reset = useCallback(() => { return onChange?.(defaultChecked) diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index f5752a0935..42bd0446fb 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -770,22 +770,20 @@ function ComboboxFn - ({ - open: data.comboboxState === ComboboxState.Open, - disabled, - activeIndex: data.activeOptionIndex, - activeOption: - data.activeOptionIndex === null - ? null - : data.virtual - ? data.virtual.options[data.activeOptionIndex ?? 0] - : (data.options[data.activeOptionIndex]?.dataRef.current.value as TValue) ?? null, - value, - }) satisfies ComboboxRenderPropArg, - [data, disabled, value] - ) + let slot = useMemo(() => { + return { + open: data.comboboxState === ComboboxState.Open, + disabled, + activeIndex: data.activeOptionIndex, + activeOption: + data.activeOptionIndex === null + ? null + : data.virtual + ? data.virtual.options[data.activeOptionIndex ?? 0] + : (data.options[data.activeOptionIndex]?.dataRef.current.value as TValue) ?? null, + value, + } satisfies ComboboxRenderPropArg + }, [data, disabled, value]) let selectActiveOption = useEvent(() => { if (data.activeOptionIndex === null) return @@ -958,6 +956,7 @@ export type ComboboxInputProps< InputPropsWeControl, { defaultValue?: TType + disabled?: boolean displayValue?(item: TType): string onChange?(event: React.ChangeEvent): void autoFocus?: boolean @@ -970,18 +969,21 @@ function InputFn< // But today is not that day.. TType = Parameters[0]['value'], >(props: ComboboxInputProps, ref: Ref) { + let data = useData('Combobox.Input') + let actions = useActions('Combobox.Input') + let internalId = useId() let providedId = useProvidedId() let { id = providedId || `headlessui-combobox-input-${internalId}`, onChange, displayValue, + disabled = data.disabled || false, + autoFocus = false, // @ts-ignore: We know this MAY NOT exist for a given tag but we only care when it _does_ exist. type = 'text', ...theirProps } = props - let data = useData('Combobox.Input') - let actions = useActions('Combobox.Input') let inputRef = useSyncRefs(data.inputRef, ref, useFloatingReference()) let ownerDocument = useOwnerDocument(data.inputRef) @@ -1320,20 +1322,18 @@ function InputFn< let labelledBy = useLabelledBy() let describedBy = useDescribedBy() - let { isFocused: focus, focusProps } = useFocusRing({ autoFocus: props.autoFocus ?? false }) - let { isHovered: hover, hoverProps } = useHover({ isDisabled: data.disabled ?? false }) + let { isFocused: focus, focusProps } = useFocusRing({ autoFocus }) + let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled }) - let slot = useMemo( - () => - ({ - open: data.comboboxState === ComboboxState.Open, - disabled: data.disabled, - hover, - focus, - autofocus: props.autoFocus ?? false, - }) satisfies InputRenderPropArg, - [data, hover, focus, props.autoFocus] - ) + let slot = useMemo(() => { + return { + open: data.comboboxState === ComboboxState.Open, + disabled, + hover, + focus, + autofocus: autoFocus, + } satisfies InputRenderPropArg + }, [data, hover, focus, autoFocus, disabled]) let ourProps = mergeProps( { @@ -1365,7 +1365,8 @@ function InputFn< ? displayValue?.(data.defaultValue as unknown as TType) : null) ?? data.defaultValue, - disabled: data.disabled, + disabled: disabled || undefined, + autoFocus, onCompositionStart: handleCompositionStart, onCompositionEnd: handleCompositionEnd, onKeyDown: handleKeyDown, @@ -1411,6 +1412,7 @@ export type ComboboxButtonProps @@ -1422,7 +1424,12 @@ function ButtonFn( let actions = useActions('Combobox.Button') let buttonRef = useSyncRefs(data.buttonRef, ref) let internalId = useId() - let { id = `headlessui-combobox-button-${internalId}`, ...theirProps } = props + let { + id = `headlessui-combobox-button-${internalId}`, + disabled = data.disabled || false, + autoFocus = false, + ...theirProps + } = props let d = useDisposables() let handleKeyDown = useEvent((event: ReactKeyboardEvent) => { @@ -1479,22 +1486,20 @@ function ButtonFn( let labelledBy = useLabelledBy([id]) - let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus: props.autoFocus ?? false }) - let { isHovered: hover, hoverProps } = useHover({ isDisabled: data.disabled ?? false }) - let { pressed: active, pressProps } = useActivePress({ disabled: data.disabled ?? false }) + let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus }) + let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled }) + let { pressed: active, pressProps } = useActivePress({ disabled }) - let slot = useMemo( - () => - ({ - open: data.comboboxState === ComboboxState.Open, - active: active || data.comboboxState === ComboboxState.Open, - disabled: data.disabled, - value: data.value, - hover, - focus, - }) satisfies ButtonRenderPropArg, - [data, hover, focus, active] - ) + let slot = useMemo(() => { + return { + open: data.comboboxState === ComboboxState.Open, + active: active || data.comboboxState === ComboboxState.Open, + disabled, + value: data.value, + hover, + focus, + } satisfies ButtonRenderPropArg + }, [data, hover, focus, active, disabled]) let ourProps = mergeProps( { ref: buttonRef, @@ -1505,7 +1510,8 @@ function ButtonFn( 'aria-controls': data.optionsRef.current?.id, 'aria-expanded': data.comboboxState === ComboboxState.Open, 'aria-labelledby': labelledBy, - disabled: data.disabled, + disabled: disabled || undefined, + autoFocus, onClick: handleClick, onKeyDown: handleKeyDown, }, @@ -1592,14 +1598,12 @@ function OptionsFn( let labelledBy = useLabelledBy([data.buttonRef.current?.id]) - let slot = useMemo( - () => - ({ - open: data.comboboxState === ComboboxState.Open, - option: undefined, - }) satisfies OptionsRenderPropArg, - [data] - ) + let slot = useMemo(() => { + return { + open: data.comboboxState === ComboboxState.Open, + option: undefined, + } satisfies OptionsRenderPropArg + }, [data]) let ourProps = mergeProps(anchor ? getFloatingPanelProps() : {}, { 'aria-labelledby': labelledBy, role: 'listbox', diff --git a/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx b/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx index 349c06e01d..2f9935513f 100644 --- a/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx +++ b/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx @@ -268,6 +268,7 @@ describe('Rendering', () => { open: false, hover: false, active: false, + disabled: false, focus: false, autofocus: false, }), @@ -283,6 +284,7 @@ describe('Rendering', () => { open: true, hover: false, active: false, + disabled: false, focus: false, autofocus: false, }), @@ -310,6 +312,7 @@ describe('Rendering', () => { open: false, hover: false, active: false, + disabled: false, focus: false, autofocus: false, }), @@ -325,6 +328,7 @@ describe('Rendering', () => { open: true, hover: false, active: false, + disabled: false, focus: false, autofocus: false, }), diff --git a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx index 70c01b1550..4c6d01db43 100644 --- a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx +++ b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx @@ -220,14 +220,12 @@ function DisclosureFn( let api = useMemo>(() => ({ close }), [close]) - let slot = useMemo( - () => - ({ - open: disclosureState === DisclosureStates.Open, - close, - }) satisfies DisclosureRenderPropArg, - [disclosureState, close] - ) + let slot = useMemo(() => { + return { + open: disclosureState === DisclosureStates.Open, + close, + } satisfies DisclosureRenderPropArg + }, [disclosureState, close]) let ourProps = { ref: disclosureRef, @@ -262,6 +260,7 @@ type ButtonRenderPropArg = { open: boolean hover: boolean active: boolean + disabled: boolean focus: boolean autofocus: boolean } @@ -282,7 +281,12 @@ function ButtonFn( ref: Ref ) { let internalId = useId() - let { id = `headlessui-disclosure-button-${internalId}`, ...theirProps } = props + let { + id = `headlessui-disclosure-button-${internalId}`, + disabled = false, + autoFocus = false, + ...theirProps + } = props let [state, dispatch] = useDisclosureContext('Disclosure.Button') let panelContext = useDisclosurePanelContext() let isWithinPanel = panelContext === null ? false : panelContext === state.panelId @@ -338,7 +342,7 @@ function ButtonFn( let handleClick = useEvent((event: ReactMouseEvent) => { if (isDisabledReactIssue7711(event.currentTarget)) return - if (props.disabled) return + if (disabled) return if (isWithinPanel) { dispatch({ type: ActionTypes.ToggleDisclosure }) @@ -348,21 +352,20 @@ function ButtonFn( } }) - let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus: props.autoFocus ?? false }) - let { isHovered: hover, hoverProps } = useHover({ isDisabled: props.disabled ?? false }) - let { pressed: active, pressProps } = useActivePress({ disabled: props.disabled ?? false }) - - let slot = useMemo( - () => - ({ - open: state.disclosureState === DisclosureStates.Open, - hover, - active, - focus, - autofocus: props.autoFocus ?? false, - }) satisfies ButtonRenderPropArg, - [state, hover, active, focus, props.autoFocus] - ) + let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus }) + let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled }) + let { pressed: active, pressProps } = useActivePress({ disabled }) + + let slot = useMemo(() => { + return { + open: state.disclosureState === DisclosureStates.Open, + hover, + active, + disabled, + focus, + autofocus: autoFocus, + } satisfies ButtonRenderPropArg + }, [state, hover, active, focus, disabled, autoFocus]) let type = useResolveButtonType(props, internalButtonRef) let ourProps = isWithinPanel @@ -370,6 +373,8 @@ function ButtonFn( { ref: buttonRef, type, + disabled: disabled || undefined, + autoFocus, onKeyDown: handleKeyDown, onClick: handleClick, }, @@ -384,6 +389,8 @@ function ButtonFn( type, 'aria-expanded': state.disclosureState === DisclosureStates.Open, 'aria-controls': state.linkedPanel ? state.panelId : undefined, + disabled: disabled || undefined, + autoFocus, onKeyDown: handleKeyDown, onKeyUp: handleKeyUp, onClick: handleClick, @@ -451,14 +458,12 @@ function PanelFn( return state.disclosureState === DisclosureStates.Open })() - let slot = useMemo( - () => - ({ - open: state.disclosureState === DisclosureStates.Open, - close, - }) satisfies PanelRenderPropArg, - [state, close] - ) + let slot = useMemo(() => { + return { + open: state.disclosureState === DisclosureStates.Open, + close, + } satisfies PanelRenderPropArg + }, [state, close]) let ourProps = { ref: panelRef, diff --git a/packages/@headlessui-react/src/components/field/field.tsx b/packages/@headlessui-react/src/components/field/field.tsx index d770c162dd..5225433fd9 100644 --- a/packages/@headlessui-react/src/components/field/field.tsx +++ b/packages/@headlessui-react/src/components/field/field.tsx @@ -40,7 +40,7 @@ function FieldFn( let ourProps = { ref, - disabled, + disabled: disabled || undefined, 'aria-disabled': disabled || undefined, } diff --git a/packages/@headlessui-react/src/components/input/input.tsx b/packages/@headlessui-react/src/components/input/input.tsx index 27b0d68063..2eac0d9b7c 100644 --- a/packages/@headlessui-react/src/components/input/input.tsx +++ b/packages/@headlessui-react/src/components/input/input.tsx @@ -49,6 +49,7 @@ function InputFn( let { id = providedId || `headlessui-input-${internalId}`, disabled = providedDisabled || false, + autoFocus = false, invalid = false, ...theirProps } = props @@ -56,10 +57,8 @@ function InputFn( let labelledBy = useLabelledBy() let describedBy = useDescribedBy() - let { isFocused: focus, focusProps } = useFocusRing({ - autoFocus: props.autoFocus ?? false, - }) - let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled ?? false }) + let { isFocused: focus, focusProps } = useFocusRing({ autoFocus }) + let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled }) let ourProps = mergeProps( { @@ -69,22 +68,15 @@ function InputFn( 'aria-describedby': describedBy, 'aria-invalid': invalid ? '' : undefined, disabled: disabled || undefined, + autoFocus, }, focusProps, hoverProps ) - let slot = useMemo( - () => - ({ - disabled, - invalid, - hover, - focus, - autofocus: props.autoFocus ?? false, - }) satisfies InputRenderPropArg, - [disabled, invalid, hover, focus, props.autoFocus] - ) + let slot = useMemo(() => { + return { disabled, invalid, hover, focus, autofocus: autoFocus } satisfies InputRenderPropArg + }, [disabled, invalid, hover, focus, autoFocus]) return render({ ourProps, diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx index 129dd7408f..d9d5ed6c8e 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx @@ -554,16 +554,14 @@ function ListboxFn< data.listboxState === ListboxStates.Open ) - let slot = useMemo( - () => - ({ - open: data.listboxState === ListboxStates.Open, - disabled, - invalid, - value, - }) satisfies ListboxRenderPropArg, - [data, disabled, value, invalid] - ) + let slot = useMemo(() => { + return { + open: data.listboxState === ListboxStates.Open, + disabled, + invalid, + value, + } satisfies ListboxRenderPropArg + }, [data, disabled, value, invalid]) let selectOption = useEvent((id: string) => { let option = data.options.find((item) => item.id === id) @@ -718,6 +716,7 @@ export type ListboxButtonProps @@ -725,11 +724,17 @@ function ButtonFn( props: ListboxButtonProps, ref: Ref ) { - let internalId = useId() - let providedId = useProvidedId() - let { id = providedId || `headlessui-listbox-button-${internalId}`, ...theirProps } = props let data = useData('Listbox.Button') let actions = useActions('Listbox.Button') + + let internalId = useId() + let providedId = useProvidedId() + let { + id = providedId || `headlessui-listbox-button-${internalId}`, + disabled = data.disabled || false, + autoFocus = false, + ...theirProps + } = props let buttonRef = useSyncRefs(data.buttonRef, ref, useFloatingReference()) let getFloatingReferenceProps = useFloatingReferenceProps() @@ -790,33 +795,22 @@ function ButtonFn( let labelledBy = useLabelledBy([id]) let describedBy = useDescribedBy() - let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus: props.autoFocus ?? false }) - let { isHovered: hover, hoverProps } = useHover({ isDisabled: data.disabled ?? false }) - let { pressed: active, pressProps } = useActivePress({ disabled: data.disabled ?? false }) + let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus }) + let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled }) + let { pressed: active, pressProps } = useActivePress({ disabled }) - let slot = useMemo( - () => - ({ - open: data.listboxState === ListboxStates.Open, - active: active || data.listboxState === ListboxStates.Open, - disabled: data.disabled, - invalid: data.invalid, - value: data.value, - hover, - focus, - autofocus: props.autoFocus ?? false, - }) satisfies ButtonRenderPropArg, - [ - data.listboxState, - data.disabled, - data.value, + let slot = useMemo(() => { + return { + open: data.listboxState === ListboxStates.Open, + active: active || data.listboxState === ListboxStates.Open, + disabled, + invalid: data.invalid, + value: data.value, hover, focus, - active, - data.invalid, - props.autoFocus, - ] - ) + autofocus: autoFocus, + } satisfies ButtonRenderPropArg + }, [data.listboxState, data.value, disabled, hover, focus, active, data.invalid, autoFocus]) let ourProps = mergeProps( getFloatingReferenceProps(), @@ -829,7 +823,8 @@ function ButtonFn( 'aria-expanded': data.listboxState === ListboxStates.Open, 'aria-labelledby': labelledBy, 'aria-describedby': describedBy, - disabled: data.disabled, + disabled: disabled || undefined, + autoFocus, onKeyDown: handleKeyDown, onKeyUp: handleKeyUp, onKeyPress: handleKeyPress, @@ -1225,17 +1220,15 @@ function OptionFn< actions.goToOption(Focus.Nothing) }) - let slot = useMemo( - () => - ({ - active, - focus: active, - selected, - disabled, - selectedOption: selected && usedInSelectedOption, - }) satisfies OptionRenderPropArg, - [active, selected, disabled, usedInSelectedOption] - ) + let slot = useMemo(() => { + return { + active, + focus: active, + selected, + disabled, + selectedOption: selected && usedInSelectedOption, + } satisfies OptionRenderPropArg + }, [active, selected, disabled, usedInSelectedOption]) let ourProps = !usedInSelectedOption ? { id, diff --git a/packages/@headlessui-react/src/components/menu/menu.test.tsx b/packages/@headlessui-react/src/components/menu/menu.test.tsx index 2a26ef11aa..3646ee4500 100644 --- a/packages/@headlessui-react/src/components/menu/menu.test.tsx +++ b/packages/@headlessui-react/src/components/menu/menu.test.tsx @@ -179,6 +179,7 @@ describe('Rendering', () => { textContent: JSON.stringify({ open: false, active: false, + disabled: false, hover: false, focus: false, autofocus: false, @@ -194,6 +195,7 @@ describe('Rendering', () => { textContent: JSON.stringify({ open: true, active: true, + disabled: false, hover: false, focus: false, autofocus: false, @@ -225,6 +227,7 @@ describe('Rendering', () => { textContent: JSON.stringify({ open: false, active: false, + disabled: false, hover: false, focus: false, autofocus: false, @@ -240,6 +243,7 @@ describe('Rendering', () => { textContent: JSON.stringify({ open: true, active: true, + disabled: false, hover: false, focus: false, autofocus: false, @@ -248,6 +252,7 @@ describe('Rendering', () => { assertMenu({ state: MenuState.Visible }) }) ) + describe('`type` attribute', () => { it('should set the `type` to "button" by default', async () => { render( diff --git a/packages/@headlessui-react/src/components/menu/menu.tsx b/packages/@headlessui-react/src/components/menu/menu.tsx index 359e67430d..9cf528ec38 100644 --- a/packages/@headlessui-react/src/components/menu/menu.tsx +++ b/packages/@headlessui-react/src/components/menu/menu.tsx @@ -436,6 +436,7 @@ type ButtonRenderPropArg = { active: boolean hover: boolean focus: boolean + disabled: boolean autofocus: boolean } type ButtonPropsWeControl = 'aria-controls' | 'aria-expanded' | 'aria-haspopup' @@ -455,7 +456,12 @@ function ButtonFn( ref: Ref ) { let internalId = useId() - let { id = `headlessui-menu-button-${internalId}`, ...theirProps } = props + let { + id = `headlessui-menu-button-${internalId}`, + disabled = false, + autoFocus = false, + ...theirProps + } = props let [state, dispatch] = useMenuContext('Menu.Button') let getFloatingReferenceProps = useFloatingReferenceProps() let buttonRef = useSyncRefs(state.buttonRef, ref, useFloatingReference()) @@ -497,7 +503,7 @@ function ButtonFn( let handleClick = useEvent((event: ReactMouseEvent) => { if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault() - if (props.disabled) return + if (disabled) return if (state.menuState === MenuStates.Open) { dispatch({ type: ActionTypes.CloseMenu }) d.nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true })) @@ -507,21 +513,20 @@ function ButtonFn( } }) - let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus: props.autoFocus ?? false }) - let { isHovered: hover, hoverProps } = useHover({ isDisabled: props.disabled ?? false }) - let { pressed: active, pressProps } = useActivePress({ disabled: props.disabled ?? false }) + let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus }) + let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled }) + let { pressed: active, pressProps } = useActivePress({ disabled }) - let slot = useMemo( - () => - ({ - open: state.menuState === MenuStates.Open, - active: active || state.menuState === MenuStates.Open, - hover, - focus, - autofocus: props.autoFocus ?? false, - }) satisfies ButtonRenderPropArg, - [state, hover, focus, active, props.autoFocus] - ) + let slot = useMemo(() => { + return { + open: state.menuState === MenuStates.Open, + active: active || state.menuState === MenuStates.Open, + disabled, + hover, + focus, + autofocus: autoFocus, + } satisfies ButtonRenderPropArg + }, [state, hover, focus, active, disabled, autoFocus]) let ourProps = mergeProps( getFloatingReferenceProps(), @@ -532,6 +537,8 @@ function ButtonFn( 'aria-haspopup': 'menu', 'aria-controls': state.itemsRef.current?.id, 'aria-expanded': state.menuState === MenuStates.Open, + disabled: disabled || undefined, + autoFocus, onKeyDown: handleKeyDown, onKeyUp: handleKeyUp, onClick: handleClick, diff --git a/packages/@headlessui-react/src/components/popover/popover.test.tsx b/packages/@headlessui-react/src/components/popover/popover.test.tsx index cdcc77525c..405778a173 100644 --- a/packages/@headlessui-react/src/components/popover/popover.test.tsx +++ b/packages/@headlessui-react/src/components/popover/popover.test.tsx @@ -387,6 +387,7 @@ describe('Rendering', () => { textContent: JSON.stringify({ open: false, active: false, + disabled: false, hover: false, focus: false, autofocus: false, @@ -402,6 +403,7 @@ describe('Rendering', () => { textContent: JSON.stringify({ open: true, active: true, + disabled: false, hover: false, focus: false, autofocus: false, @@ -429,6 +431,7 @@ describe('Rendering', () => { textContent: JSON.stringify({ open: false, active: false, + disabled: false, hover: false, focus: false, autofocus: false, @@ -444,6 +447,7 @@ describe('Rendering', () => { textContent: JSON.stringify({ open: true, active: true, + disabled: false, hover: false, focus: false, autofocus: false, diff --git a/packages/@headlessui-react/src/components/popover/popover.tsx b/packages/@headlessui-react/src/components/popover/popover.tsx index 6a26b2168a..9ff7687306 100644 --- a/packages/@headlessui-react/src/components/popover/popover.tsx +++ b/packages/@headlessui-react/src/components/popover/popover.tsx @@ -440,6 +440,7 @@ type ButtonRenderPropArg = { active: boolean hover: boolean focus: boolean + disabled: boolean autofocus: boolean } type ButtonPropsWeControl = 'aria-controls' | 'aria-expanded' @@ -459,7 +460,12 @@ function ButtonFn( ref: Ref ) { let internalId = useId() - let { id = `headlessui-popover-button-${internalId}`, ...theirProps } = props + let { + id = `headlessui-popover-button-${internalId}`, + disabled = false, + autoFocus = false, + ...theirProps + } = props let [state, dispatch] = usePopoverContext('Popover.Button') let { isPortalled } = usePopoverAPIContext('Popover.Button') let internalButtonRef = useRef(null) @@ -584,7 +590,7 @@ function ButtonFn( let handleClick = useEvent((event: ReactMouseEvent) => { if (isDisabledReactIssue7711(event.currentTarget)) return - if (props.disabled) return + if (disabled) return if (isWithinPanel) { dispatch({ type: ActionTypes.ClosePopover }) state.button?.focus() // Re-focus the original opening Button @@ -602,22 +608,21 @@ function ButtonFn( event.stopPropagation() }) - let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus: props.autoFocus ?? false }) - let { isHovered: hover, hoverProps } = useHover({ isDisabled: props.disabled ?? false }) - let { pressed: active, pressProps } = useActivePress({ disabled: props.disabled ?? false }) + let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus }) + let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled }) + let { pressed: active, pressProps } = useActivePress({ disabled }) let visible = state.popoverState === PopoverStates.Open - let slot = useMemo( - () => - ({ - open: visible, - active: active || visible, - hover, - focus, - autofocus: props.autoFocus ?? false, - }) satisfies ButtonRenderPropArg, - [visible, hover, focus, active, props.autoFocus] - ) + let slot = useMemo(() => { + return { + open: visible, + active: active || visible, + disabled, + hover, + focus, + autofocus: autoFocus, + } satisfies ButtonRenderPropArg + }, [visible, hover, focus, active, disabled, autoFocus]) let type = useResolveButtonType(props, internalButtonRef) let ourProps = isWithinPanel @@ -627,6 +632,8 @@ function ButtonFn( type, onKeyDown: handleKeyDown, onClick: handleClick, + disabled: disabled || undefined, + autoFocus, }, focusProps, hoverProps, @@ -639,6 +646,8 @@ function ButtonFn( type, 'aria-expanded': state.popoverState === PopoverStates.Open, 'aria-controls': state.panel ? state.panelId : undefined, + disabled: disabled || undefined, + autoFocus, onKeyDown: handleKeyDown, onKeyUp: handleKeyUp, onClick: handleClick, 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 2fabaa2dc4..c3c46ce3ea 100644 --- a/packages/@headlessui-react/src/components/radio-group/radio-group.tsx +++ b/packages/@headlessui-react/src/components/radio-group/radio-group.tsx @@ -371,13 +371,18 @@ function OptionFn< // But today is not that day.. TType = Parameters[0]['value'], >(props: RadioOptionProps, ref: Ref) { + let data = useData('RadioGroup.Option') + let actions = useActions('RadioGroup.Option') + let internalId = useId() let { id = `headlessui-radiogroup-option-${internalId}`, value, - disabled = false, + disabled = data.disabled || false, + autoFocus = false, ...theirProps } = props + let internalOptionRef = useRef(null) let optionRef = useSyncRefs(internalOptionRef, ref) @@ -386,9 +391,6 @@ function OptionFn< let propsRef = useLatestValue({ value, disabled }) - let data = useData('RadioGroup.Option') - let actions = useActions('RadioGroup.Option') - useIsoMorphicEffect( () => actions.registerOption({ id, element: internalOptionRef, propsRef }), [id, actions, internalOptionRef, propsRef] @@ -401,10 +403,9 @@ function OptionFn< }) let isFirstOption = data.firstOption?.id === id - let isDisabled = data.disabled || disabled - let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus: props.autoFocus ?? false }) - let { isHovered: hover, hoverProps } = useHover({ isDisabled: isDisabled ?? false }) + let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus }) + let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled }) let checked = data.compare(data.value as TType, value) let ourProps = mergeProps( @@ -415,31 +416,30 @@ function OptionFn< 'aria-checked': checked ? 'true' : 'false', 'aria-labelledby': labelledby, 'aria-describedby': describedby, - 'aria-disabled': isDisabled ? true : undefined, + 'aria-disabled': disabled ? true : undefined, tabIndex: (() => { - if (isDisabled) return -1 + if (disabled) return -1 if (checked) return 0 if (!data.containsCheckedOption && isFirstOption) return 0 return -1 })(), - onClick: isDisabled ? undefined : handleClick, + onClick: disabled ? undefined : handleClick, + autoFocus, }, focusProps, hoverProps ) - let slot = useMemo( - () => - ({ - checked, - disabled: isDisabled, - active: focus, - hover, - focus, - autofocus: props.autoFocus ?? false, - }) satisfies OptionRenderPropArg, - [checked, isDisabled, hover, focus, props.autoFocus] - ) + let slot = useMemo(() => { + return { + checked, + disabled, + active: focus, + hover, + focus, + autofocus: autoFocus, + } satisfies OptionRenderPropArg + }, [checked, disabled, hover, focus, autoFocus]) return ( @@ -500,6 +500,7 @@ function RadioFn< id = providedId || `headlessui-radio-${internalId}`, value, disabled = data.disabled || providedDisabled || false, + autoFocus = false, ...theirProps } = props let internalRadioRef = useRef(null) @@ -522,8 +523,8 @@ function RadioFn< internalRadioRef.current?.focus() }) - let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus: props.autoFocus ?? false }) - let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled ?? false }) + let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus }) + let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled }) let isFirstOption = data.firstOption?.id === id @@ -543,22 +544,15 @@ function RadioFn< if (!data.containsCheckedOption && isFirstOption) return 0 return -1 })(), + autoFocus, onClick: disabled ? undefined : handleClick, }, focusProps, hoverProps ) - let slot = useMemo( - () => - ({ - checked, - disabled, - hover, - focus, - autofocus: props.autoFocus ?? false, - }) satisfies RadioRenderPropArg, - [checked, disabled, hover, focus, props.autoFocus] - ) + let slot = useMemo(() => { + return { checked, disabled, hover, focus, autofocus: autoFocus } satisfies RadioRenderPropArg + }, [checked, disabled, hover, focus, autoFocus]) return render({ ourProps, diff --git a/packages/@headlessui-react/src/components/select/select.tsx b/packages/@headlessui-react/src/components/select/select.tsx index 3b83496c20..c3823b3f11 100644 --- a/packages/@headlessui-react/src/components/select/select.tsx +++ b/packages/@headlessui-react/src/components/select/select.tsx @@ -52,15 +52,16 @@ function SelectFn( id = providedId || `headlessui-select-${internalId}`, disabled = providedDisabled || false, invalid = false, + autoFocus = false, ...theirProps } = props let labelledBy = useLabelledBy() let describedBy = useDescribedBy() - let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus: props.autoFocus ?? false }) - let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled ?? false }) - let { pressed: active, pressProps } = useActivePress({ disabled: disabled ?? false }) + let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus }) + let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled }) + let { pressed: active, pressProps } = useActivePress({ disabled }) let ourProps = mergeProps( { @@ -70,24 +71,23 @@ function SelectFn( 'aria-describedby': describedBy, 'aria-invalid': invalid ? '' : undefined, disabled: disabled || undefined, + autoFocus, }, focusProps, hoverProps, pressProps ) - let slot = useMemo( - () => - ({ - disabled, - invalid, - hover, - focus, - active, - autofocus: props.autoFocus ?? false, - }) satisfies SelectRenderPropArg, - [disabled, invalid, hover, focus, active, props.autoFocus] - ) + let slot = useMemo(() => { + return { + disabled, + invalid, + hover, + focus, + active, + autofocus: autoFocus, + } satisfies SelectRenderPropArg + }, [disabled, invalid, hover, focus, active, autoFocus]) return render({ ourProps, diff --git a/packages/@headlessui-react/src/components/switch/switch.tsx b/packages/@headlessui-react/src/components/switch/switch.tsx index eedf7a9f7b..78bf9a31e9 100644 --- a/packages/@headlessui-react/src/components/switch/switch.tsx +++ b/packages/@headlessui-react/src/components/switch/switch.tsx @@ -151,6 +151,7 @@ function SwitchFn( name, value, form, + autoFocus = false, ...theirProps } = props let groupContext = useContext(GroupContext) @@ -192,23 +193,22 @@ function SwitchFn( let labelledBy = useLabelledBy() let describedBy = useDescribedBy() - let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus: props.autoFocus ?? false }) - let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled ?? false }) - let { pressed: active, pressProps } = useActivePress({ disabled: disabled ?? false }) - let slot = useMemo( - () => - ({ - checked, - disabled, - hover, - focus, - active, - autofocus: props.autoFocus ?? false, - changing, - }) satisfies SwitchRenderPropArg, - [checked, hover, focus, active, disabled, changing, props.autoFocus] - ) + let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus }) + let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled }) + let { pressed: active, pressProps } = useActivePress({ disabled }) + + let slot = useMemo(() => { + return { + checked, + disabled, + hover, + focus, + active, + autofocus: autoFocus, + changing, + } satisfies SwitchRenderPropArg + }, [checked, hover, focus, active, disabled, changing, autoFocus]) let ourProps = mergeProps( { @@ -220,7 +220,8 @@ function SwitchFn( 'aria-checked': checked, 'aria-labelledby': labelledBy, 'aria-describedby': describedBy, - disabled, + disabled: disabled || undefined, + autoFocus, onClick: handleClick, onKeyUp: handleKeyUp, onKeyPress: handleKeyPress, diff --git a/packages/@headlessui-react/src/components/tabs/tabs.test.tsx b/packages/@headlessui-react/src/components/tabs/tabs.test.tsx index 659140eb1c..e2da0394f5 100644 --- a/packages/@headlessui-react/src/components/tabs/tabs.test.tsx +++ b/packages/@headlessui-react/src/components/tabs/tabs.test.tsx @@ -691,25 +691,25 @@ describe('Rendering', () => { ) expect(document.querySelector('[data-panel="0"]')).toHaveTextContent( - JSON.stringify({ selected: true }) + JSON.stringify({ selected: true, focus: false }) ) expect(document.querySelector('[data-panel="1"]')).toHaveTextContent( - JSON.stringify({ selected: false }) + JSON.stringify({ selected: false, focus: false }) ) expect(document.querySelector('[data-panel="2"]')).toHaveTextContent( - JSON.stringify({ selected: false }) + JSON.stringify({ selected: false, focus: false }) ) await click(getByText('Tab 2')) expect(document.querySelector('[data-panel="0"]')).toHaveTextContent( - JSON.stringify({ selected: false }) + JSON.stringify({ selected: false, focus: false }) ) expect(document.querySelector('[data-panel="1"]')).toHaveTextContent( - JSON.stringify({ selected: true }) + JSON.stringify({ selected: true, focus: false }) ) expect(document.querySelector('[data-panel="2"]')).toHaveTextContent( - JSON.stringify({ selected: false }) + JSON.stringify({ selected: false, focus: false }) ) }) ) diff --git a/packages/@headlessui-react/src/components/tabs/tabs.tsx b/packages/@headlessui-react/src/components/tabs/tabs.tsx index f259050706..e98e813c75 100644 --- a/packages/@headlessui-react/src/components/tabs/tabs.tsx +++ b/packages/@headlessui-react/src/components/tabs/tabs.tsx @@ -421,7 +421,12 @@ function TabFn( ref: Ref ) { let internalId = useId() - let { id = `headlessui-tabs-tab-${internalId}`, ...theirProps } = props + let { + id = `headlessui-tabs-tab-${internalId}`, + disabled = false, + autoFocus = false, + ...theirProps + } = props let { orientation, activation, selectedIndex, tabs, panels } = useData('Tab') let actions = useActions('Tab') @@ -515,22 +520,20 @@ function TabFn( event.preventDefault() }) - let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus: props.autoFocus ?? false }) - let { isHovered: hover, hoverProps } = useHover({ isDisabled: props.disabled ?? false }) - let { pressed: active, pressProps } = useActivePress({ disabled: props.disabled ?? false }) + let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus }) + let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled }) + let { pressed: active, pressProps } = useActivePress({ disabled }) - let slot = useMemo( - () => - ({ - selected, - hover, - active, - focus, - autofocus: props.autoFocus ?? false, - disabled: props.disabled ?? false, - }) satisfies TabRenderPropArg, - [selected, hover, focus, active, props.autoFocus, props.disabled] - ) + let slot = useMemo(() => { + return { + selected, + hover, + active, + focus, + autofocus: autoFocus, + disabled, + } satisfies TabRenderPropArg + }, [selected, hover, focus, active, autoFocus, disabled]) let ourProps = mergeProps( { @@ -544,6 +547,8 @@ function TabFn( 'aria-controls': panels[myIndex]?.current?.id, 'aria-selected': selected, tabIndex: selected ? 0 : -1, + disabled: disabled || undefined, + autoFocus, }, focusProps, hoverProps, @@ -578,7 +583,7 @@ function PanelsFn( let { selectedIndex } = useData('Tab.Panels') let panelsRef = useSyncRefs(ref) - let slot = useMemo(() => ({ selectedIndex }), [selectedIndex]) + let slot = useMemo(() => ({ selectedIndex }) satisfies PanelsRenderPropArg, [selectedIndex]) let theirProps = props let ourProps = { ref: panelsRef } @@ -597,6 +602,7 @@ function PanelsFn( let DEFAULT_PANEL_TAG = 'div' as const type PanelRenderPropArg = { selected: boolean + focus: boolean } type PanelPropsWeControl = 'role' | 'aria-labelledby' let PanelRenderFeatures = RenderFeatures.RenderStrategy | RenderFeatures.Static @@ -629,15 +635,19 @@ function PanelFn( let selected = myIndex === selectedIndex - let slot = useMemo(() => ({ selected }), [selected]) + let { isFocusVisible: focus, focusProps } = useFocusRing() + let slot = useMemo(() => ({ selected, focus }) satisfies PanelRenderPropArg, [selected, focus]) - let ourProps = { - ref: panelRef, - id, - role: 'tabpanel', - 'aria-labelledby': tabs[myIndex]?.current?.id, - tabIndex: selected ? tabIndex : -1, - } + let ourProps = mergeProps( + { + ref: panelRef, + id, + role: 'tabpanel', + 'aria-labelledby': tabs[myIndex]?.current?.id, + tabIndex: selected ? tabIndex : -1, + }, + focusProps + ) if (!selected && (theirProps.unmount ?? true) && !(theirProps.static ?? false)) { return