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/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 diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.ts b/packages/@headlessui-vue/src/components/combobox/combobox.ts index 2ee21089b..f2d3ef627 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 @@ -1429,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 }) @@ -1468,28 +1478,45 @@ 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. + // 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 })) } if (api.mode.value === ValueMode.Single) { - requestAnimationFrame(() => api.closeCombobox()) + api.closeCombobox() } } @@ -1537,7 +1564,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, 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 +} 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..9ad08fb71 --- /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) + } +} 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, +}