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`
This commit is contained in:
Robin Malfait
2024-05-07 16:49:07 +02:00
committed by GitHub
parent 2d5d35a533
commit 886fdf7e6c
6 changed files with 74 additions and 13 deletions
@@ -13,6 +13,6 @@ export function useFrameDebounce() {
return useEvent((cb: () => void) => {
d.dispose()
d.nextFrame(() => cb())
d.nextFrame(cb)
})
}
+1
View File
@@ -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
@@ -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<HTMLElement | null>(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,
@@ -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
}
@@ -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)
}
}
+4
View File
@@ -0,0 +1,4 @@
export enum MouseButton {
Left = 0,
Right = 2,
}