Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ensure clicking a ComboboxOption after filtering the options, correctly triggers a change #3180

Merged
merged 8 commits into from
May 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/@headlessui-react/src/hooks/use-frame-debounce.ts
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export enum MouseButton {
Left = 0,
Right = 2,
}
Loading