Skip to content

Commit

Permalink
Ensure clicking a ComboboxOption after filtering the options, corre…
Browse files Browse the repository at this point in the history
…ctly 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`
  • Loading branch information
RobinMalfait committed May 7, 2024
1 parent 2d5d35a commit 886fdf7
Show file tree
Hide file tree
Showing 6 changed files with 74 additions and 13 deletions.
2 changes: 1 addition & 1 deletion packages/@headlessui-react/src/hooks/use-frame-debounce.ts
Expand Up @@ -13,6 +13,6 @@ export function useFrameDebounce() {

return useEvent((cb: () => void) => {
d.dispose()
d.nextFrame(() => cb())
d.nextFrame(cb)
})
}
1 change: 1 addition & 0 deletions packages/@headlessui-vue/CHANGELOG.md
Expand Up @@ -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

Expand Down
51 changes: 39 additions & 12 deletions packages/@headlessui-vue/src/components/combobox/combobox.ts
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 })

Expand Down Expand Up @@ -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()
}
}

Expand Down Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions 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
}
17 changes: 17 additions & 0 deletions 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)
}
}
4 changes: 4 additions & 0 deletions packages/@headlessui-vue/src/mouse.ts
@@ -0,0 +1,4 @@
export enum MouseButton {
Left = 0,
Right = 2,
}

0 comments on commit 886fdf7

Please sign in to comment.