Skip to content

Add a quick trigger action to the Menu, Listbox and Combobox components #3700

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

Merged
merged 12 commits into from
Apr 24, 2025
Merged
15 changes: 15 additions & 0 deletions jest/polyfills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,18 @@ Object.defineProperty(HTMLElement.prototype, 'innerText', {
this.textContent = value
},
})

// Source: https://github.com/testing-library/react-testing-library/issues/838#issuecomment-735259406
//
// Polyfill the PointerEvent class for JSDOM
class PointerEvent extends Event {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Had to add this to make the tests pass

constructor(type, props) {
super(type, props)
if (props.button != null) {
// @ts-expect-error JSDOM doesn't support `button` yet...
this.button = props.button
}
}
}
// @ts-expect-error JSDOM doesn't support `PointerEvent` yet...
window.PointerEvent = PointerEvent
4 changes: 3 additions & 1 deletion packages/@headlessui-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

- Nothing yet!
### Added

- Add a quick trigger action to the `Menu`, `Listbox` and `Combobox` components ([#3700](https://github.com/tailwindlabs/headlessui/pull/3700))

## [2.2.2] - 2025-04-17

Expand Down
51 changes: 41 additions & 10 deletions packages/@headlessui-react/src/components/combobox/combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import React, {
type FocusEvent as ReactFocusEvent,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
type Ref,
} from 'react'
import { flushSync } from 'react-dom'
Expand All @@ -34,6 +35,7 @@ import { useLatestValue } from '../../hooks/use-latest-value'
import { useOnDisappear } from '../../hooks/use-on-disappear'
import { useOutsideClick } from '../../hooks/use-outside-click'
import { useOwnerDocument } from '../../hooks/use-owner'
import { Action as QuickReleaseAction, useQuickRelease } from '../../hooks/use-quick-release'
import { useRefocusableInput } from '../../hooks/use-refocusable-input'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { useScrollLock } from '../../hooks/use-scroll-lock'
Expand Down Expand Up @@ -989,9 +991,43 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
...theirProps
} = props

let inputElement = useSlice(machine, (state) => state.inputElement)
let [comboboxState, inputElement, optionsElement] = useSlice(machine, (state) => [
state.comboboxState,
state.inputElement,
state.optionsElement,
])
let refocusInput = useRefocusableInput(inputElement)

let enableQuickRelease = comboboxState === ComboboxState.Open
useQuickRelease(enableQuickRelease, {
trigger: localButtonElement,
action: useCallback(
(e) => {
if (localButtonElement?.contains(e.target)) {
return QuickReleaseAction.Ignore
}

if (inputElement?.contains(e.target)) {
return QuickReleaseAction.Ignore
}

let option = e.target.closest('[role="option"]:not([data-disabled])')
if (option !== null) {
return QuickReleaseAction.Select(option as HTMLElement)
}

if (optionsElement?.contains(e.target)) {
return QuickReleaseAction.Ignore
}

return QuickReleaseAction.Close
},
[localButtonElement, inputElement, optionsElement]
),
close: machine.actions.closeCombobox,
select: machine.actions.selectActiveOption,
})
Comment on lines +1001 to +1029
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Open to suggestions for the API here. But essentially what we want to know is:

  1. What is the trigger element where it all starts. E.g.: MenuButton
  2. Depending on the current target when we release the pointerup, what action should we take? Options are:
  3. Ignore — essentially do nothing. In this case it means keep the Menu open and that's it.
  4. Select — select the current DOM node. In the Listbox and Combobox we can rely on the selectActiveOption. In the Menu we don't have that, so instead we just look for the [role="menuitem"]. If it exists and it's not disabled then we can select this option.
  5. Close — this should close the dropdown again. This will happen if you drag off the MenuButton for example.
  6. We need a way to close the current dropdown
  7. We need a way to select the current element. This will receive the element you provided via Select(…).

The reason I didn't just call close() and select() as part of the action is because we also have to make sure that we take the ~100ms delay into account. This threshold is only relevant for if a dropdown opens on top of the current cursor position (e.g.: https://catalyst.tailwindui.com/docs/listbox) because then we don't want to select the item.

If we check for the time differences for any action, then it could result in the menu not closing if you were quick enough.


let handleKeyDown = useEvent((event: ReactKeyboardEvent<HTMLElement>) => {
switch (event.key) {
// Ref: https://www.w3.org/WAI/ARIA/apg/patterns/menu/#keyboard-interaction-12
Expand Down Expand Up @@ -1044,9 +1080,9 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
}
})

let handleMouseDown = useEvent((event: ReactMouseEvent<HTMLButtonElement>) => {
// 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
let handlePointerDown = useEvent((event: ReactPointerEvent<HTMLButtonElement>) => {
// We use the `poitnerdown` event here since it fires before the focus
// event, allowing us to cancel the event before focus is moved from the
// `ComboboxInput` to the `ComboboxButton`. This keeps the input focused,
// preserving the cursor position and any text selection.
event.preventDefault()
Expand Down Expand Up @@ -1074,11 +1110,6 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled })
let { pressed: active, pressProps } = useActivePress({ disabled })

let [comboboxState, optionsElement] = useSlice(machine, (state) => [
state.comboboxState,
state.optionsElement,
])

let slot = useMemo(() => {
return {
open: comboboxState === ComboboxState.Open,
Expand All @@ -1102,7 +1133,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
'aria-labelledby': labelledBy,
disabled: disabled || undefined,
autoFocus,
onMouseDown: handleMouseDown,
onPointerDown: handlePointerDown,
onKeyDown: handleKeyDown,
},
focusProps,
Expand Down
45 changes: 36 additions & 9 deletions packages/@headlessui-react/src/components/listbox/listbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import React, {
type ElementType,
type MutableRefObject,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
type Ref,
} from 'react'
import { flushSync } from 'react-dom'
Expand All @@ -34,6 +34,7 @@ import { useLatestValue } from '../../hooks/use-latest-value'
import { useOnDisappear } from '../../hooks/use-on-disappear'
import { useOutsideClick } from '../../hooks/use-outside-click'
import { useOwnerDocument } from '../../hooks/use-owner'
import { Action as QuickReleaseAction, useQuickRelease } from '../../hooks/use-quick-release'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { useScrollLock } from '../../hooks/use-scroll-lock'
import { useSyncRefs } from '../../hooks/use-sync-refs'
Expand Down Expand Up @@ -359,6 +360,38 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
let buttonRef = useSyncRefs(ref, useFloatingReference(), machine.actions.setButtonElement)
let getFloatingReferenceProps = useFloatingReferenceProps()

let [listboxState, buttonElement, optionsElement] = useSlice(machine, (state) => [
state.listboxState,
state.buttonElement,
state.optionsElement,
])

let enableQuickRelease = listboxState === ListboxStates.Open
useQuickRelease(enableQuickRelease, {
trigger: buttonElement,
action: useCallback(
(e) => {
if (buttonElement?.contains(e.target)) {
return QuickReleaseAction.Ignore
}

let option = e.target.closest('[role="option"]:not([data-disabled])')
if (option !== null) {
return QuickReleaseAction.Select(option as HTMLElement)
}

if (optionsElement?.contains(e.target)) {
return QuickReleaseAction.Ignore
}

return QuickReleaseAction.Close
},
[buttonElement, optionsElement]
),
close: machine.actions.closeListbox,
select: machine.actions.selectActiveOption,
})

let handleKeyDown = useEvent((event: ReactKeyboardEvent<HTMLButtonElement>) => {
switch (event.key) {
// Ref: https://www.w3.org/WAI/ARIA/apg/patterns/menubutton/#keyboard-interaction-13
Expand Down Expand Up @@ -393,7 +426,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
}
})

let handleMouseDown = useEvent((event: ReactMouseEvent) => {
let handlePointerDown = useEvent((event: ReactPointerEvent) => {
if (event.button !== 0) return // Only handle left clicks
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
if (machine.state.listboxState === ListboxStates.Open) {
Expand All @@ -415,8 +448,6 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled })
let { pressed: active, pressProps } = useActivePress({ disabled })

let listboxState = useSlice(machine, (state) => state.listboxState)

let slot = useMemo(() => {
return {
open: listboxState === ListboxStates.Open,
Expand All @@ -431,10 +462,6 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
}, [listboxState, data.value, disabled, hover, focus, active, data.invalid, autoFocus])

let open = useSlice(machine, (state) => state.listboxState === ListboxStates.Open)
let [buttonElement, optionsElement] = useSlice(machine, (state) => [
state.buttonElement,
state.optionsElement,
])
let ourProps = mergeProps(
getFloatingReferenceProps(),
{
Expand All @@ -451,7 +478,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
onKeyDown: handleKeyDown,
onKeyUp: handleKeyUp,
onKeyPress: handleKeyPress,
onMouseDown: handleMouseDown,
onPointerDown: handlePointerDown,
},
focusProps,
hoverProps,
Expand Down
48 changes: 38 additions & 10 deletions packages/@headlessui-react/src/components/menu/menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import React, {
type CSSProperties,
type ElementType,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
type Ref,
} from 'react'
import { flushSync } from 'react-dom'
Expand All @@ -28,6 +28,7 @@ import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
import { useOnDisappear } from '../../hooks/use-on-disappear'
import { useOutsideClick } from '../../hooks/use-outside-click'
import { useOwnerDocument } from '../../hooks/use-owner'
import { Action as QuickReleaseAction, useQuickRelease } from '../../hooks/use-quick-release'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { useScrollLock } from '../../hooks/use-scroll-lock'
import { useSyncRefs } from '../../hooks/use-sync-refs'
Expand Down Expand Up @@ -224,12 +225,39 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
}
})

let [menuState, itemsElement] = useSlice(machine, (state) => [
let [menuState, buttonElement, itemsElement] = useSlice(machine, (state) => [
state.menuState,
state.buttonElement,
state.itemsElement,
])

let handleMouseDown = useEvent((event: ReactMouseEvent) => {
let enableQuickRelease = menuState === MenuState.Open
useQuickRelease(enableQuickRelease, {
trigger: buttonElement,
action: useCallback(
(e) => {
if (buttonElement?.contains(e.target)) {
return QuickReleaseAction.Ignore
}

let item = e.target.closest('[role="menuitem"]:not([data-disabled])')
if (item !== null) {
return QuickReleaseAction.Select(item as HTMLElement)
}

if (itemsElement?.contains(e.target)) {
return QuickReleaseAction.Ignore
}

return QuickReleaseAction.Close
},
[buttonElement, itemsElement]
),
close: useCallback(() => machine.send({ type: ActionTypes.CloseMenu }), []),
select: useCallback((target) => target.click(), []),
})

let handlePointerDown = useEvent((event: ReactPointerEvent) => {
if (event.button !== 0) return // Only handle left clicks
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
if (disabled) return
Expand Down Expand Up @@ -274,7 +302,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
autoFocus,
onKeyDown: handleKeyDown,
onKeyUp: handleKeyUp,
onMouseDown: handleMouseDown,
onPointerDown: handlePointerDown,
},
focusProps,
hoverProps,
Expand Down Expand Up @@ -640,8 +668,8 @@ function ItemFn<TTag extends ElementType = typeof DEFAULT_ITEM_TAG>(

let pointer = useTrackedPointer()

let handleEnter = useEvent((evt) => {
pointer.update(evt)
let handleEnter = useEvent((event) => {
pointer.update(event)
if (disabled) return
if (active) return
machine.send({
Expand All @@ -652,8 +680,8 @@ function ItemFn<TTag extends ElementType = typeof DEFAULT_ITEM_TAG>(
})
})

let handleMove = useEvent((evt) => {
if (!pointer.wasMoved(evt)) return
let handleMove = useEvent((event) => {
if (!pointer.wasMoved(event)) return
if (disabled) return
if (active) return
machine.send({
Expand All @@ -664,8 +692,8 @@ function ItemFn<TTag extends ElementType = typeof DEFAULT_ITEM_TAG>(
})
})

let handleLeave = useEvent((evt) => {
if (!pointer.wasMoved(evt)) return
let handleLeave = useEvent((event) => {
if (!pointer.wasMoved(event)) return
if (disabled) return
if (!active) return
machine.send({ type: ActionTypes.GoToItem, focus: Focus.Nothing })
Expand Down
Loading