Skip to content

Commit 886fdf7

Browse files
authored
Ensure clicking a ComboboxOption after filtering the options, correctly triggers a change (#3180)
* add mouse buttons * add `useDisposables` hook * add `useFrameDebounce` hook Schedule a task in the next frame * ensure we reset the `isTyping` flag correctly * use same `mousedown` API as we did in React This allows us to never leave the `input`, even when clicking on an option. * update changelog * format comments * inline `cb`
1 parent 2d5d35a commit 886fdf7

File tree

6 files changed

+74
-13
lines changed

6 files changed

+74
-13
lines changed

packages/@headlessui-react/src/hooks/use-frame-debounce.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,6 @@ export function useFrameDebounce() {
1313

1414
return useEvent((cb: () => void) => {
1515
d.dispose()
16-
d.nextFrame(() => cb())
16+
d.nextFrame(cb)
1717
})
1818
}

packages/@headlessui-vue/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
### Fixed
1616

1717
- Prevent closing the `Combobox` component when clicking inside the scrollbar area ([#3104](https://github.com/tailwindlabs/headlessui/pull/3104))
18+
- Ensure clicking a `ComboboxOption` after filtering the options, correctly triggers a change ([#3180](https://github.com/tailwindlabs/headlessui/pull/3180))
1819

1920
## [1.7.20] - 2024-04-15
2021

packages/@headlessui-vue/src/components/combobox/combobox.ts

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
type UnwrapNestedRefs,
2424
} from 'vue'
2525
import { useControllable } from '../../hooks/use-controllable'
26+
import { useFrameDebounce } from '../../hooks/use-frame-debounce'
2627
import { useId } from '../../hooks/use-id'
2728
import { useOutsideClick } from '../../hooks/use-outside-click'
2829
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
@@ -31,6 +32,7 @@ import { useTreeWalker } from '../../hooks/use-tree-walker'
3132
import { Hidden, Features as HiddenFeatures } from '../../internal/hidden'
3233
import { State, useOpenClosed, useOpenClosedProvider } from '../../internal/open-closed'
3334
import { Keys } from '../../keyboard'
35+
import { MouseButton } from '../../mouse'
3436
import { history } from '../../utils/active-element-history'
3537
import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index'
3638
import { disposables } from '../../utils/disposables'
@@ -1062,8 +1064,13 @@ export let ComboboxInput = defineComponent({
10621064
})
10631065
}
10641066

1067+
let debounce = useFrameDebounce()
10651068
function handleKeyDown(event: KeyboardEvent) {
10661069
isTyping.value = true
1070+
debounce(() => {
1071+
isTyping.value = false
1072+
})
1073+
10671074
switch (event.key) {
10681075
// Ref: https://www.w3.org/WAI/ARIA/apg/patterns/menu/#keyboard-interaction-12
10691076

@@ -1429,6 +1436,9 @@ export let ComboboxOption = defineComponent({
14291436
let api = useComboboxContext('ComboboxOption')
14301437
let id = `headlessui-combobox-option-${useId()}`
14311438
let internalOptionRef = ref<HTMLElement | null>(null)
1439+
let disabled = computed(() => {
1440+
return props.disabled || api.virtual.value?.disabled(props.value)
1441+
})
14321442

14331443
expose({ el: internalOptionRef, $el: internalOptionRef })
14341444

@@ -1468,28 +1478,45 @@ export let ComboboxOption = defineComponent({
14681478
nextTick(() => dom(internalOptionRef)?.scrollIntoView?.({ block: 'nearest' }))
14691479
})
14701480

1471-
function handleClick(event: MouseEvent) {
1472-
if (props.disabled || api.virtual.value?.disabled(props.value)) return event.preventDefault()
1481+
function handleMouseDown(event: MouseEvent) {
1482+
// We use the `mousedown` event here since it fires before the focus
1483+
// event, allowing us to cancel the event before focus is moved from the
1484+
// `ComboboxInput` to the `ComboboxOption`. This keeps the input focused,
1485+
// preserving the cursor position and any text selection.
1486+
event.preventDefault()
1487+
1488+
// Since we're using the `mousedown` event instead of a `click` event here
1489+
// to preserve the focus of the `ComboboxInput`, we need to also check
1490+
// that the `left` mouse button was clicked.
1491+
if (event.button !== MouseButton.Left) {
1492+
return
1493+
}
1494+
1495+
if (disabled.value) return
14731496
api.selectOption(id)
14741497

1475-
// We want to make sure that we don't accidentally trigger the virtual keyboard.
1498+
// We want to make sure that we don't accidentally trigger the virtual
1499+
// keyboard.
14761500
//
1477-
// This would happen if the input is focused, the options are open, you select an option
1478-
// (which would blur the input, and focus the option (button), then we re-focus the input).
1501+
// This would happen if the input is focused, the options are open, you
1502+
// select an option (which would blur the input, and focus the option
1503+
// (button), then we re-focus the input).
14791504
//
1480-
// This would be annoying on mobile (or on devices with a virtual keyboard). Right now we are
1481-
// assuming that the virtual keyboard would open on mobile devices (iOS / Android). This
1482-
// assumption is not perfect, but will work in the majority of the cases.
1505+
// This would be annoying on mobile (or on devices with a virtual
1506+
// keyboard). Right now we are assuming that the virtual keyboard would open
1507+
// on mobile devices (iOS / Android). This assumption is not perfect, but
1508+
// will work in the majority of the cases.
14831509
//
1484-
// Ideally we can have a better check where we can explicitly check for the virtual keyboard.
1485-
// But right now this is still an experimental feature:
1510+
// Ideally we can have a better check where we can explicitly check for
1511+
// the virtual keyboard. But right now this is still an experimental
1512+
// feature:
14861513
// https://developer.mozilla.org/en-US/docs/Web/API/Navigator/virtualKeyboard
14871514
if (!isMobile()) {
14881515
requestAnimationFrame(() => dom(api.inputRef)?.focus({ preventScroll: true }))
14891516
}
14901517

14911518
if (api.mode.value === ValueMode.Single) {
1492-
requestAnimationFrame(() => api.closeCombobox())
1519+
api.closeCombobox()
14931520
}
14941521
}
14951522

@@ -1537,7 +1564,7 @@ export let ComboboxOption = defineComponent({
15371564
// both single and multi-select.
15381565
'aria-selected': selected.value,
15391566
disabled: undefined, // Never forward the `disabled` prop
1540-
onClick: handleClick,
1567+
onMousedown: handleMouseDown,
15411568
onFocus: handleFocus,
15421569
onPointerenter: handleEnter,
15431570
onMouseenter: handleEnter,
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { onUnmounted } from 'vue'
2+
import { disposables } from '../utils/disposables'
3+
4+
/**
5+
* The `useDisposables` hook returns a `disposables` object that is disposed
6+
* when the component is unmounted.
7+
*/
8+
export function useDisposables() {
9+
let d = disposables()
10+
onUnmounted(() => d.dispose())
11+
return d
12+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { useDisposables } from './use-disposables'
2+
3+
/**
4+
* Schedule some task in the next frame.
5+
*
6+
* - If you call the returned function multiple times, only the last task will
7+
* be executed.
8+
* - If the component is unmounted, the task will be cancelled.
9+
*/
10+
export function useFrameDebounce() {
11+
let d = useDisposables()
12+
13+
return (cb: () => void) => {
14+
d.dispose()
15+
d.nextFrame(cb)
16+
}
17+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export enum MouseButton {
2+
Left = 0,
3+
Right = 2,
4+
}

0 commit comments

Comments
 (0)