Skip to content

Commit 156880c

Browse files
committed
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 47d4b4f commit 156880c

File tree

5 files changed

+73
-12
lines changed

5 files changed

+73
-12
lines changed

packages/@headlessui-vue/CHANGELOG.md

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

1212
- Don’t cancel `touchmove` on `input` elements inside a dialog ([#3166](https://github.com/tailwindlabs/headlessui/pull/3166))
13+
- Ensure clicking a `ComboboxOption` after filtering the options, correctly triggers a change ([#3180](https://github.com/tailwindlabs/headlessui/pull/3180))
1314

1415
## [1.7.21] - 2024-04-26
1516

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 { Features as HiddenFeatures, Hidden } 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 { calculateActiveIndex, Focus } from '../../utils/calculate-active-index'
3638
import { disposables } from '../../utils/disposables'
@@ -1072,8 +1074,13 @@ export let ComboboxInput = defineComponent({
10721074
})
10731075
}
10741076

1077+
let debounce = useFrameDebounce()
10751078
function handleKeyDown(event: KeyboardEvent) {
10761079
isTyping.value = true
1080+
debounce(() => {
1081+
isTyping.value = false
1082+
})
1083+
10771084
switch (event.key) {
10781085
// Ref: https://www.w3.org/WAI/ARIA/apg/patterns/menu/#keyboard-interaction-12
10791086

@@ -1439,6 +1446,9 @@ export let ComboboxOption = defineComponent({
14391446
let api = useComboboxContext('ComboboxOption')
14401447
let id = `headlessui-combobox-option-${useId()}`
14411448
let internalOptionRef = ref<HTMLElement | null>(null)
1449+
let disabled = computed(() => {
1450+
return props.disabled
1451+
})
14421452

14431453
expose({ el: internalOptionRef, $el: internalOptionRef })
14441454

@@ -1478,28 +1488,45 @@ export let ComboboxOption = defineComponent({
14781488
nextTick(() => dom(internalOptionRef)?.scrollIntoView?.({ block: 'nearest' }))
14791489
})
14801490

1481-
function handleClick(event: MouseEvent) {
1482-
if (props.disabled || api.virtual.value?.disabled(props.value)) return event.preventDefault()
1491+
function handleMouseDown(event: MouseEvent) {
1492+
// We use the `mousedown` event here since it fires before the focus
1493+
// event, allowing us to cancel the event before focus is moved from the
1494+
// `ComboboxInput` to the `ComboboxOption`. This keeps the input focused,
1495+
// preserving the cursor position and any text selection.
1496+
event.preventDefault()
1497+
1498+
// Since we're using the `mousedown` event instead of a `click` event here
1499+
// to preserve the focus of the `ComboboxInput`, we need to also check
1500+
// that the `left` mouse button was clicked.
1501+
if (event.button !== MouseButton.Left) {
1502+
return
1503+
}
1504+
1505+
if (disabled.value) return
14831506
api.selectOption(id)
14841507

1485-
// We want to make sure that we don't accidentally trigger the virtual keyboard.
1508+
// We want to make sure that we don't accidentally trigger the virtual
1509+
// keyboard.
14861510
//
1487-
// This would happen if the input is focused, the options are open, you select an option
1488-
// (which would blur the input, and focus the option (button), then we re-focus the input).
1511+
// This would happen if the input is focused, the options are open, you
1512+
// select an option (which would blur the input, and focus the option
1513+
// (button), then we re-focus the input).
14891514
//
1490-
// This would be annoying on mobile (or on devices with a virtual keyboard). Right now we are
1491-
// assuming that the virtual keyboard would open on mobile devices (iOS / Android). This
1492-
// assumption is not perfect, but will work in the majority of the cases.
1515+
// This would be annoying on mobile (or on devices with a virtual
1516+
// keyboard). Right now we are assuming that the virtual keyboard would open
1517+
// on mobile devices (iOS / Android). This assumption is not perfect, but
1518+
// will work in the majority of the cases.
14931519
//
1494-
// Ideally we can have a better check where we can explicitly check for the virtual keyboard.
1495-
// But right now this is still an experimental feature:
1520+
// Ideally we can have a better check where we can explicitly check for
1521+
// the virtual keyboard. But right now this is still an experimental
1522+
// feature:
14961523
// https://developer.mozilla.org/en-US/docs/Web/API/Navigator/virtualKeyboard
14971524
if (!isMobile()) {
14981525
requestAnimationFrame(() => dom(api.inputRef)?.focus({ preventScroll: true }))
14991526
}
15001527

15011528
if (api.mode.value === ValueMode.Single) {
1502-
requestAnimationFrame(() => api.closeCombobox())
1529+
api.closeCombobox()
15031530
}
15041531
}
15051532

@@ -1547,7 +1574,7 @@ export let ComboboxOption = defineComponent({
15471574
// both single and multi-select.
15481575
'aria-selected': selected.value,
15491576
disabled: undefined, // Never forward the `disabled` prop
1550-
onClick: handleClick,
1577+
onMousedown: handleMouseDown,
15511578
onFocus: handleFocus,
15521579
onPointerenter: handleEnter,
15531580
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)