Repository: tailwindlabs/headlessui
Pull request #3073 (2024-04-02 09:42:12)

Keep focus inside of the `<ComboboxInput />` component
This PR improves the UX of the `Combobox` component. 

When you open the combobox via the button or when you select an option from the list, then the focus is briefly moved to the button or the option respectively and then we restore the focus to the input itself.

We improved this behaviour in #3065 where we ensure that the selection and cursor position inside of the input will be preserved when the focus is moved around as-if nothing happened.

However, this can result in a tiny selection flicker (because when the focus is moved to the button or the option, the selection is removed).

This PR improves on that behaviour by using the `mousedown` event instead of the `click` event. If we use `event.preventDefault()` in the `mousedown` event, then the focus will not be moved to the button or the option and the focus remains in the input. This also means that the selection remains in the input.

We now use the `mousedown` event, which means that we have to do a tiny bit more work compared to the `click` event. For example:
- We have to check that the left mouse button was used. The `mousedown` event will also be triggered when using the right mouse button (the click event doesn't do this)
- We have to manually check for `space` or `enter` keydown events on the button — this was previously handled by the `click` event as well.


https://github.com/tailwindlabs/headlessui/assets/1834413/6b30420f-f658-4308-a1aa-7fd7644a3a13



vercel[bot]: [vc]: #lJijuWpf+gTvBVJoYTgxdATwUmgthy9afyvlTycFaDA=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJoZWFkbGVzc3VpLXZ1ZSIsInJvb3REaXJlY3RvcnkiOiJwbGF5Z3JvdW5kcy92dWUiLCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vdGFpbHdpbmRsYWJzL2hlYWRsZXNzdWktdnVlL0VGazRZU0J2Z2VkckxqQ2ZmZng1QmRjRWY3dkIiLCJwcmV2aWV3VXJsIjoiaGVhZGxlc3N1aS12dWUtZ2l0LWZpeC1rZWVwLWZvY3VzLWluLWlucHV0LXRhaWx3aW5kbGFicy52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IlBFTkRJTkciLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoiaGVhZGxlc3N1aS12dWUtZ2l0LWZpeC1rZWVwLWZvY3VzLWluLWlucHV0LXRhaWx3aW5kbGFicy52ZXJjZWwuYXBwIn19XX0=
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **headlessui-vue** | 🔄 Building ([Inspect](https://vercel.com/tailwindlabs/headlessui-vue/EFk4YSBvgedrLjCfffx5BdcEf7vB)) | [Visit Preview](https://vercel.live/open-feedback/headlessui-vue-git-fix-keep-focus-in-input-tailwindlabs.vercel.app?via=pr-comment-visit-preview-link&passThrough=1) | 💬 [**Add feedback**](https://vercel.live/open-feedback/headlessui-vue-git-fix-keep-focus-in-input-tailwindlabs.vercel.app?via=pr-comment-feedback-link) | Apr 2, 2024 9:42am |



Patch:
diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md
index bd9dbba..b592bc3 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 `<ComboboxInput />` component ([#3073](https://github.com/tailwindlabs/headlessui/pull/3073))
 
 ### Changed
 
diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx
index 061cdbe..d31d66e 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<HTMLInputElement>) => {
     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<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())
@@ -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()
   })
 
   let labelledBy = useLabelledBy([id])
@@ -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,
@@ -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.
@@ -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 0000000..8f56ab7
--- /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 0000000..fa79640
--- /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 a13b243..2ed48df 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<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
 
diff --git a/packages/@headlessui-react/src/utils/disposables.ts b/packages/@headlessui-react/src/utils/disposables.ts
index 5e14bb9..55ca27b 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)


Test patch:
diff --git a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx
index 0e824d8..15a6447 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 },
             ]}
           />
         )