diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index bd9dbba6e..b592bc329 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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)) - Fix cursor position when re-focusing the `ComboboxInput` component ([#3065](https://github.com/tailwindlabs/headlessui/pull/3065)) +- Keep focus inside of the `` component ([#3073](https://github.com/tailwindlabs/headlessui/pull/3073)) ### Changed diff --git a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx index 0e824d856..15a64472a 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx @@ -5194,7 +5194,7 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', options={[ { value: 'alice', children: 'Alice', disabled: false }, { value: 'bob', children: 'Bob', disabled: true }, - { value: 'charile', children: 'Charlie', disabled: false }, + { value: 'charlie', children: 'Charlie', disabled: false }, ]} /> ) diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index 061cdbe58..d31d66ed1 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -27,6 +27,7 @@ import { useControllable } from '../../hooks/use-controllable' import { useDisposables } from '../../hooks/use-disposables' import { useElementSize } from '../../hooks/use-element-size' import { useEvent } from '../../hooks/use-event' +import { useFrameDebounce } from '../../hooks/use-frame-debounce' import { useId } from '../../hooks/use-id' import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect' import { useLatestValue } from '../../hooks/use-latest-value' @@ -69,6 +70,7 @@ import { import { useDescribedBy } from '../description/description' import { Keys } from '../keyboard' import { Label, useLabelledBy, useLabels, type _internal_ComponentLabel } from '../label/label' +import { MouseButton } from '../mouse' enum ComboboxState { Open, @@ -1077,8 +1079,13 @@ function InputFn< }) }) + let debounce = useFrameDebounce() let handleKeyDown = useEvent((event: ReactKeyboardEvent) => { isTyping.current = true + debounce(() => { + isTyping.current = false + }) + switch (event.key) { // Ref: https://www.w3.org/WAI/ARIA/apg/patterns/menu/#keyboard-interaction-12 @@ -1388,11 +1395,26 @@ function ButtonFn( switch (event.key) { // Ref: https://www.w3.org/WAI/ARIA/apg/patterns/menu/#keyboard-interaction-12 + case Keys.Space: + case Keys.Enter: + event.preventDefault() + event.stopPropagation() + if (data.comboboxState === ComboboxState.Closed) { + actions.openCombobox() + } + + return d.nextFrame(() => refocusInput()) + case Keys.ArrowDown: event.preventDefault() event.stopPropagation() if (data.comboboxState === ComboboxState.Closed) { actions.openCombobox() + d.nextFrame(() => { + if (!data.value) { + actions.goToOption(Focus.First) + } + }) } return d.nextFrame(() => refocusInput()) @@ -1424,16 +1446,28 @@ function ButtonFn( } }) - let handleClick = useEvent((event: ReactMouseEvent) => { - if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault() - if (data.comboboxState === ComboboxState.Open) { - actions.closeCombobox() - } else { - event.preventDefault() - actions.openCombobox() + let handleMouseDown = useEvent((event: ReactMouseEvent) => { + // We use the `mousedown` event here since it fires before the focus event, + // allowing us to cancel the event before focus is moved from the + // `ComboboxInput` to the `ComboboxButton`. This keeps the input focused, + // preserving the cursor position and any text selection. + event.preventDefault() + + if (isDisabledReactIssue7711(event.currentTarget)) return + + // Since we're using the `mousedown` event instead of a `click` event here + // to preserve the focus of the `ComboboxInput`, we need to also check + // that the `left` mouse button was clicked. + if (event.button === MouseButton.Left) { + if (data.comboboxState === ComboboxState.Open) { + actions.closeCombobox() + } else { + actions.openCombobox() + } } - d.nextFrame(() => refocusInput()) + // Ensure we focus the input + refocusInput() }) let labelledBy = useLabelledBy([id]) @@ -1464,7 +1498,7 @@ function ButtonFn( 'aria-labelledby': labelledBy, disabled: disabled || undefined, autoFocus, - onClick: handleClick, + onMouseDown: handleMouseDown, onKeyDown: handleKeyDown, }, focusProps, @@ -1689,8 +1723,21 @@ function OptionFn< /* We also want to trigger this when the position of the active item changes so that we can re-trigger the scrollIntoView */ data.activeOptionIndex, ]) - let handleClick = useEvent((event: { preventDefault: Function }) => { - if (disabled || data.virtual?.disabled(value)) return event.preventDefault() + let handleMouseDown = useEvent((event: ReactMouseEvent) => { + // We use the `mousedown` event here since it fires before the focus event, + // allowing us to cancel the event before focus is moved from the + // `ComboboxInput` to the `ComboboxOption`. This keeps the input focused, + // preserving the cursor position and any text selection. + event.preventDefault() + + // Since we're using the `mousedown` event instead of a `click` event here + // to preserve the focus of the `ComboboxInput`, we need to also check + // that the `left` mouse button was clicked. + if (event.button !== MouseButton.Left) { + return + } + + if (disabled || data.virtual?.disabled(value)) return select() // We want to make sure that we don't accidentally trigger the virtual keyboard. @@ -1758,7 +1805,7 @@ function OptionFn< // both single and multi-select. 'aria-selected': selected, disabled: undefined, // Never forward the `disabled` prop - onClick: handleClick, + onMouseDown: handleMouseDown, onFocus: handleFocus, onPointerEnter: handleEnter, onMouseEnter: handleEnter, diff --git a/packages/@headlessui-react/src/components/mouse.ts b/packages/@headlessui-react/src/components/mouse.ts new file mode 100644 index 000000000..8f56ab7ba --- /dev/null +++ b/packages/@headlessui-react/src/components/mouse.ts @@ -0,0 +1,4 @@ +export enum MouseButton { + Left = 0, + Right = 2, +} diff --git a/packages/@headlessui-react/src/hooks/use-frame-debounce.ts b/packages/@headlessui-react/src/hooks/use-frame-debounce.ts new file mode 100644 index 000000000..fa79640fe --- /dev/null +++ b/packages/@headlessui-react/src/hooks/use-frame-debounce.ts @@ -0,0 +1,18 @@ +import { useDisposables } from './use-disposables' +import { useEvent } from './use-event' + +/** + * Schedule some task in the next frame. + * + * - If you call the returned function multiple times, only the last task will + * be executed. + * - If the component is unmounted, the task will be cancelled. + */ +export function useFrameDebounce() { + let d = useDisposables() + + return useEvent((cb: () => void) => { + d.dispose() + d.nextFrame(() => cb()) + }) +} diff --git a/packages/@headlessui-react/src/hooks/use-refocusable-input.ts b/packages/@headlessui-react/src/hooks/use-refocusable-input.ts index a13b2438c..2ed48dfbb 100644 --- a/packages/@headlessui-react/src/hooks/use-refocusable-input.ts +++ b/packages/@headlessui-react/src/hooks/use-refocusable-input.ts @@ -29,6 +29,10 @@ export function useRefocusableInput(ref: MutableRefObject { let input = ref.current + + // If the input is already focused, we don't need to do anything + if (document.activeElement === input) return + if (!(input instanceof HTMLInputElement)) return if (!input.isConnected) return diff --git a/packages/@headlessui-react/src/utils/disposables.ts b/packages/@headlessui-react/src/utils/disposables.ts index 5e14bb9a2..55ca27b6b 100644 --- a/packages/@headlessui-react/src/utils/disposables.ts +++ b/packages/@headlessui-react/src/utils/disposables.ts @@ -59,6 +59,10 @@ export function disposables() { }, add(cb: () => void) { + if (_disposables.includes(cb)) { + return + } + _disposables.push(cb) return () => { let idx = _disposables.indexOf(cb)