From d25e76e7609b30eb88e112b291fad97836dec965 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 7 May 2024 16:35:53 +0200 Subject: [PATCH 1/8] add mouse buttons --- packages/@headlessui-vue/src/mouse.ts | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 packages/@headlessui-vue/src/mouse.ts diff --git a/packages/@headlessui-vue/src/mouse.ts b/packages/@headlessui-vue/src/mouse.ts new file mode 100644 index 000000000..8f56ab7ba --- /dev/null +++ b/packages/@headlessui-vue/src/mouse.ts @@ -0,0 +1,4 @@ +export enum MouseButton { + Left = 0, + Right = 2, +} From 553fb707f14e43f3bdb53115dcd429585e68116c Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 7 May 2024 16:36:06 +0200 Subject: [PATCH 2/8] add `useDisposables` hook --- .../@headlessui-vue/src/hooks/use-disposables.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 packages/@headlessui-vue/src/hooks/use-disposables.ts diff --git a/packages/@headlessui-vue/src/hooks/use-disposables.ts b/packages/@headlessui-vue/src/hooks/use-disposables.ts new file mode 100644 index 000000000..2417432d9 --- /dev/null +++ b/packages/@headlessui-vue/src/hooks/use-disposables.ts @@ -0,0 +1,12 @@ +import { onUnmounted } from 'vue' +import { disposables } from '../utils/disposables' + +/** + * The `useDisposables` hook returns a `disposables` object that is disposed + * when the component is unmounted. + */ +export function useDisposables() { + let d = disposables() + onUnmounted(() => d.dispose()) + return d +} From e8ea1e08a8b49e96e74b907711314d981c07bdf1 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 7 May 2024 16:36:20 +0200 Subject: [PATCH 3/8] add `useFrameDebounce` hook Schedule a task in the next frame --- .../src/hooks/use-frame-debounce.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 packages/@headlessui-vue/src/hooks/use-frame-debounce.ts diff --git a/packages/@headlessui-vue/src/hooks/use-frame-debounce.ts b/packages/@headlessui-vue/src/hooks/use-frame-debounce.ts new file mode 100644 index 000000000..ced5c5e6b --- /dev/null +++ b/packages/@headlessui-vue/src/hooks/use-frame-debounce.ts @@ -0,0 +1,17 @@ +import { useDisposables } from './use-disposables' + +/** + * 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 (cb: () => void) => { + d.dispose() + d.nextFrame(() => cb()) + } +} From 6f5d18d0c0d93defa14b4eeb80c628ef197e498a Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 7 May 2024 16:37:05 +0200 Subject: [PATCH 4/8] ensure we reset the `isTyping` flag correctly --- .../@headlessui-vue/src/components/combobox/combobox.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.ts b/packages/@headlessui-vue/src/components/combobox/combobox.ts index 2ee21089b..3d11bfd47 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.ts @@ -23,6 +23,7 @@ import { type UnwrapNestedRefs, } from 'vue' import { useControllable } from '../../hooks/use-controllable' +import { useFrameDebounce } from '../../hooks/use-frame-debounce' import { useId } from '../../hooks/use-id' import { useOutsideClick } from '../../hooks/use-outside-click' import { useResolveButtonType } from '../../hooks/use-resolve-button-type' @@ -31,6 +32,7 @@ import { useTreeWalker } from '../../hooks/use-tree-walker' import { Hidden, Features as HiddenFeatures } from '../../internal/hidden' import { State, useOpenClosed, useOpenClosedProvider } from '../../internal/open-closed' import { Keys } from '../../keyboard' +import { MouseButton } from '../../mouse' import { history } from '../../utils/active-element-history' import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index' import { disposables } from '../../utils/disposables' @@ -1062,8 +1064,13 @@ export let ComboboxInput = defineComponent({ }) } + let debounce = useFrameDebounce() function handleKeyDown(event: KeyboardEvent) { isTyping.value = true + debounce(() => { + isTyping.value = false + }) + switch (event.key) { // Ref: https://www.w3.org/WAI/ARIA/apg/patterns/menu/#keyboard-interaction-12 From 9a9c17bdc678701c13e157c930dac1b59de0a5a0 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 7 May 2024 16:39:03 +0200 Subject: [PATCH 5/8] use same `mousedown` API as we did in React This allows us to never leave the `input`, even when clicking on an option. --- .../src/components/combobox/combobox.ts | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.ts b/packages/@headlessui-vue/src/components/combobox/combobox.ts index 3d11bfd47..d9ee64e83 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.ts @@ -1436,6 +1436,9 @@ export let ComboboxOption = defineComponent({ let api = useComboboxContext('ComboboxOption') let id = `headlessui-combobox-option-${useId()}` let internalOptionRef = ref(null) + let disabled = computed(() => { + return props.disabled || api.virtual.value?.disabled(props.value) + }) expose({ el: internalOptionRef, $el: internalOptionRef }) @@ -1475,14 +1478,27 @@ export let ComboboxOption = defineComponent({ nextTick(() => dom(internalOptionRef)?.scrollIntoView?.({ block: 'nearest' })) }) - function handleClick(event: MouseEvent) { - if (props.disabled || api.virtual.value?.disabled(props.value)) return event.preventDefault() + function handleMouseDown(event: MouseEvent) { + // 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.value) return api.selectOption(id) // We want to make sure that we don't accidentally trigger the virtual keyboard. // - // This would happen if the input is focused, the options are open, you select an option - // (which would blur the input, and focus the option (button), then we re-focus the input). + // This would happen if the input is focused, the options are open, you select an option (which + // would blur the input, and focus the option (button), then we re-focus the input). // // This would be annoying on mobile (or on devices with a virtual keyboard). Right now we are // assuming that the virtual keyboard would open on mobile devices (iOS / Android). This @@ -1496,7 +1512,7 @@ export let ComboboxOption = defineComponent({ } if (api.mode.value === ValueMode.Single) { - requestAnimationFrame(() => api.closeCombobox()) + api.closeCombobox() } } @@ -1544,7 +1560,7 @@ export let ComboboxOption = defineComponent({ // both single and multi-select. 'aria-selected': selected.value, disabled: undefined, // Never forward the `disabled` prop - onClick: handleClick, + onMousedown: handleMouseDown, onFocus: handleFocus, onPointerenter: handleEnter, onMouseenter: handleEnter, From 17503d5e09a1ca30a8f492058eefbef24fc4a86f Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 7 May 2024 16:41:30 +0200 Subject: [PATCH 6/8] update changelog --- packages/@headlessui-vue/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/@headlessui-vue/CHANGELOG.md b/packages/@headlessui-vue/CHANGELOG.md index 6c95a9e28..4c089a76b 100644 --- a/packages/@headlessui-vue/CHANGELOG.md +++ b/packages/@headlessui-vue/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Prevent closing the `Combobox` component when clicking inside the scrollbar area ([#3104](https://github.com/tailwindlabs/headlessui/pull/3104)) +- Ensure clicking a `ComboboxOption` after filtering the options, correctly triggers a change ([#3180](https://github.com/tailwindlabs/headlessui/pull/3180)) ## [1.7.20] - 2024-04-15 From b59d085ae66ba070a39b1f1fea086146e1fd1677 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 7 May 2024 16:44:40 +0200 Subject: [PATCH 7/8] format comments --- .../src/components/combobox/combobox.ts | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.ts b/packages/@headlessui-vue/src/components/combobox/combobox.ts index d9ee64e83..f2d3ef627 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.ts @@ -1479,8 +1479,8 @@ export let ComboboxOption = defineComponent({ }) function handleMouseDown(event: MouseEvent) { - // 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 + // 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() @@ -1495,17 +1495,21 @@ export let ComboboxOption = defineComponent({ if (disabled.value) return api.selectOption(id) - // We want to make sure that we don't accidentally trigger the virtual keyboard. + // We want to make sure that we don't accidentally trigger the virtual + // keyboard. // - // This would happen if the input is focused, the options are open, you select an option (which - // would blur the input, and focus the option (button), then we re-focus the input). + // This would happen if the input is focused, the options are open, you + // select an option (which would blur the input, and focus the option + // (button), then we re-focus the input). // - // This would be annoying on mobile (or on devices with a virtual keyboard). Right now we are - // assuming that the virtual keyboard would open on mobile devices (iOS / Android). This - // assumption is not perfect, but will work in the majority of the cases. + // This would be annoying on mobile (or on devices with a virtual + // keyboard). Right now we are assuming that the virtual keyboard would open + // on mobile devices (iOS / Android). This assumption is not perfect, but + // will work in the majority of the cases. // - // Ideally we can have a better check where we can explicitly check for the virtual keyboard. - // But right now this is still an experimental feature: + // Ideally we can have a better check where we can explicitly check for + // the virtual keyboard. But right now this is still an experimental + // feature: // https://developer.mozilla.org/en-US/docs/Web/API/Navigator/virtualKeyboard if (!isMobile()) { requestAnimationFrame(() => dom(api.inputRef)?.focus({ preventScroll: true })) From 17ba5c4a0937caca4109d6097c9fe02eef55af77 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 7 May 2024 16:45:49 +0200 Subject: [PATCH 8/8] inline `cb` --- packages/@headlessui-react/src/hooks/use-frame-debounce.ts | 2 +- packages/@headlessui-vue/src/hooks/use-frame-debounce.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@headlessui-react/src/hooks/use-frame-debounce.ts b/packages/@headlessui-react/src/hooks/use-frame-debounce.ts index fa79640fe..94c085340 100644 --- a/packages/@headlessui-react/src/hooks/use-frame-debounce.ts +++ b/packages/@headlessui-react/src/hooks/use-frame-debounce.ts @@ -13,6 +13,6 @@ export function useFrameDebounce() { return useEvent((cb: () => void) => { d.dispose() - d.nextFrame(() => cb()) + d.nextFrame(cb) }) } diff --git a/packages/@headlessui-vue/src/hooks/use-frame-debounce.ts b/packages/@headlessui-vue/src/hooks/use-frame-debounce.ts index ced5c5e6b..9ad08fb71 100644 --- a/packages/@headlessui-vue/src/hooks/use-frame-debounce.ts +++ b/packages/@headlessui-vue/src/hooks/use-frame-debounce.ts @@ -12,6 +12,6 @@ export function useFrameDebounce() { return (cb: () => void) => { d.dispose() - d.nextFrame(() => cb()) + d.nextFrame(cb) } }