Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Keep focus inside of the <ComboboxInput /> component #3073

Merged
merged 16 commits into from
Apr 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/@headlessui-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<ComboboxInput />` component ([#3073](https://github.com/tailwindlabs/headlessui/pull/3073))

### Changed

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
]}
/>
)
Expand Down
71 changes: 59 additions & 12 deletions packages/@headlessui-react/src/components/combobox/combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1077,8 +1079,13 @@ function InputFn<
})
})

let debounce = useFrameDebounce()
let handleKeyDown = useEvent((event: ReactKeyboardEvent<HTMLInputElement>) => {
isTyping.current = true
debounce(() => {
isTyping.current = false
})

switch (event.key) {
// Ref: https://www.w3.org/WAI/ARIA/apg/patterns/menu/#keyboard-interaction-12

Expand Down Expand Up @@ -1388,11 +1395,26 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
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())
Expand Down Expand Up @@ -1424,16 +1446,28 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
}
})

let handleClick = useEvent((event: ReactMouseEvent<HTMLButtonElement>) => {
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
if (data.comboboxState === ComboboxState.Open) {
actions.closeCombobox()
} else {
event.preventDefault()
actions.openCombobox()
let handleMouseDown = useEvent((event: ReactMouseEvent<HTMLButtonElement>) => {
// 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()
reinink marked this conversation as resolved.
Show resolved Hide resolved
})

let labelledBy = useLabelledBy([id])
Expand Down Expand Up @@ -1464,7 +1498,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
'aria-labelledby': labelledBy,
disabled: disabled || undefined,
autoFocus,
onClick: handleClick,
onMouseDown: handleMouseDown,
onKeyDown: handleKeyDown,
},
focusProps,
Expand Down Expand Up @@ -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<HTMLButtonElement>) => {
// 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.
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions packages/@headlessui-react/src/components/mouse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export enum MouseButton {
Left = 0,
Right = 2,
}
18 changes: 18 additions & 0 deletions packages/@headlessui-react/src/hooks/use-frame-debounce.ts
Original file line number Diff line number Diff line change
@@ -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())
})
}
4 changes: 4 additions & 0 deletions packages/@headlessui-react/src/hooks/use-refocusable-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ export function useRefocusableInput(ref: MutableRefObject<HTMLInputElement | nul

return useEvent(() => {
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

Expand Down
4 changes: 4 additions & 0 deletions packages/@headlessui-react/src/utils/disposables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ export function disposables() {
},

add(cb: () => void) {
if (_disposables.includes(cb)) {
return
}

_disposables.push(cb)
return () => {
let idx = _disposables.indexOf(cb)
Expand Down
Loading