Skip to content

Commit f96538e

Browse files
authored
Don't open Combobox when touching the ComboboxButton while dragging on mobile (#3795)
This PR fixes an issue where the `ComboboxButton` would open the `Combobox` when you accidentally touched it while scrolling on a touch device. To solve this we only handle the `pointerdown` event when using a `mouse` pointer type. But when it's a different pointer type (like `touch` or `pen`) then we will use the classic `click` event to open the `Combobox` instead. We already applied this logic to the `Listbox` and `Menu` components. This now solves it for the `ComboboxButton` as well. To prevent diverging behavior in the future, this now uses a shared hook that handles this logic. ## Test plan Here is a little before and after video on mobile. In both scenario's I'm dragging the page to scroll up and down on the button area. **Before:** It essentially immediately opens the moment you touch it. https://github.com/user-attachments/assets/220222f1-c678-4ff6-a398-b51145dc3a78 **After:** It only opens when you actually tap it, not when you accidentally touch it while dragging. https://github.com/user-attachments/assets/445be498-afd0-429e-99ca-c76aee544d32 The only time it actually opens is when I intentionally tap on the button. Fixes: #3793
1 parent fe9ec17 commit f96538e

File tree

5 files changed

+67
-62
lines changed

5 files changed

+67
-62
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Fixed
1111

1212
- Improve focus management in shadow DOM roots ([#3794](https://github.com/tailwindlabs/headlessui/pull/3794))
13+
- Don't accidentally open the `Combobox` when touching the `ComboboxButton` while dragging on mobile ([#3795](https://github.com/tailwindlabs/headlessui/pull/3795))
1314

1415
## [2.2.8] - 2025-09-12
1516

packages/@headlessui-react/src/components/combobox/combobox.tsx

Lines changed: 7 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import React, {
1717
type FocusEvent as ReactFocusEvent,
1818
type KeyboardEvent as ReactKeyboardEvent,
1919
type MouseEvent as ReactMouseEvent,
20-
type PointerEvent as ReactPointerEvent,
2120
type Ref,
2221
} from 'react'
2322
import { flushSync } from 'react-dom'
@@ -28,6 +27,7 @@ import { useDefaultValue } from '../../hooks/use-default-value'
2827
import { useDisposables } from '../../hooks/use-disposables'
2928
import { useElementSize } from '../../hooks/use-element-size'
3029
import { useEvent } from '../../hooks/use-event'
30+
import { useHandleToggle } from '../../hooks/use-handle-toggle'
3131
import { useId } from '../../hooks/use-id'
3232
import { useInertOthers } from '../../hooks/use-inert-others'
3333
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
@@ -62,7 +62,6 @@ import { stackMachines } from '../../machines/stack-machine'
6262
import { useSlice } from '../../react-glue'
6363
import type { EnsureArray, Props } from '../../types'
6464
import { history } from '../../utils/active-element-history'
65-
import { isDisabledReactIssue7711 } from '../../utils/bugs'
6665
import { Focus } from '../../utils/calculate-active-index'
6766
import { disposables } from '../../utils/disposables'
6867
import * as DOM from '../../utils/dom'
@@ -1088,24 +1087,11 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
10881087
}
10891088
})
10901089

1091-
let handlePointerDown = useEvent((event: ReactPointerEvent<HTMLButtonElement>) => {
1092-
// We use the `pointerdown` event here since it fires before the focus
1093-
// event, allowing us to cancel the event before focus is moved from the
1094-
// `ComboboxInput` to the `ComboboxButton`. This keeps the input focused,
1095-
// preserving the cursor position and any text selection.
1096-
event.preventDefault()
1097-
1098-
if (isDisabledReactIssue7711(event.currentTarget)) return
1099-
1100-
// Since we're using the `mousedown` event instead of a `click` event here
1101-
// to preserve the focus of the `ComboboxInput`, we need to also check
1102-
// that the `left` mouse button was clicked.
1103-
if (event.button === MouseButton.Left) {
1104-
if (machine.state.comboboxState === ComboboxState.Open) {
1105-
machine.actions.closeCombobox()
1106-
} else {
1107-
machine.actions.openCombobox()
1108-
}
1090+
let toggleProps = useHandleToggle(() => {
1091+
if (machine.state.comboboxState === ComboboxState.Open) {
1092+
machine.actions.closeCombobox()
1093+
} else {
1094+
machine.actions.openCombobox()
11091095
}
11101096

11111097
// Ensure we focus the input
@@ -1139,9 +1125,9 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
11391125
'aria-labelledby': labelledBy,
11401126
disabled: disabled || undefined,
11411127
autoFocus,
1142-
onPointerDown: handlePointerDown,
11431128
onKeyDown: handleKeyDown,
11441129
},
1130+
toggleProps,
11451131
focusProps,
11461132
hoverProps,
11471133
pressProps

packages/@headlessui-react/src/components/listbox/listbox.tsx

Lines changed: 3 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,6 @@ import React, {
1515
type ElementType,
1616
type MutableRefObject,
1717
type KeyboardEvent as ReactKeyboardEvent,
18-
type MouseEvent as ReactMouseEvent,
19-
type PointerEvent as ReactPointerEvent,
2018
type Ref,
2119
} from 'react'
2220
import { flushSync } from 'react-dom'
@@ -27,6 +25,7 @@ import { useDefaultValue } from '../../hooks/use-default-value'
2725
import { useDisposables } from '../../hooks/use-disposables'
2826
import { useElementSize } from '../../hooks/use-element-size'
2927
import { useEvent } from '../../hooks/use-event'
28+
import { useHandleToggle } from '../../hooks/use-handle-toggle'
3029
import { useId } from '../../hooks/use-id'
3130
import { useInertOthers } from '../../hooks/use-inert-others'
3231
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
@@ -59,7 +58,6 @@ import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-cl
5958
import { stackMachines } from '../../machines/stack-machine'
6059
import { useSlice } from '../../react-glue'
6160
import type { EnsureArray, Props } from '../../types'
62-
import { isDisabledReactIssue7711 } from '../../utils/bugs'
6361
import { Focus } from '../../utils/calculate-active-index'
6462
import { disposables } from '../../utils/disposables'
6563
import * as DOM from '../../utils/dom'
@@ -84,7 +82,6 @@ import {
8482
import { useDescribedBy } from '../description/description'
8583
import { Keys } from '../keyboard'
8684
import { Label, useLabelledBy, useLabels, type _internal_ComponentLabel } from '../label/label'
87-
import { MouseButton } from '../mouse'
8885
import { Portal } from '../portal/portal'
8986
import { ActionTypes, ActivationTrigger, ListboxStates, ValueMode } from './listbox-machine'
9087
import { ListboxContext, useListboxMachine, useListboxMachineContext } from './listbox-machine-glue'
@@ -420,9 +417,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
420417
}
421418
})
422419

423-
let toggle = useEvent((event: ReactPointerEvent | ReactMouseEvent) => {
424-
if (event.button !== MouseButton.Left) return // Only handle left clicks
425-
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
420+
let toggleProps = useHandleToggle((event) => {
426421
if (machine.state.listboxState === ListboxStates.Open) {
427422
flushSync(() => machine.actions.closeListbox())
428423
machine.state.buttonElement?.focus({ preventScroll: true })
@@ -432,18 +427,6 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
432427
}
433428
})
434429

435-
let pointerTypeRef = useRef<'touch' | 'mouse' | 'pen' | null>(null)
436-
let handlePointerDown = useEvent((event: ReactPointerEvent) => {
437-
pointerTypeRef.current = event.pointerType
438-
if (event.pointerType !== 'mouse') return
439-
toggle(event)
440-
})
441-
442-
let handleClick = useEvent((event: ReactMouseEvent) => {
443-
if (pointerTypeRef.current === 'mouse') return
444-
toggle(event)
445-
})
446-
447430
// This is needed so that we can "cancel" the click event when we use the `Enter` key on a button.
448431
let handleKeyPress = useEvent((event: ReactKeyboardEvent<HTMLElement>) => event.preventDefault())
449432

@@ -482,9 +465,8 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
482465
onKeyDown: handleKeyDown,
483466
onKeyUp: handleKeyUp,
484467
onKeyPress: handleKeyPress,
485-
onPointerDown: handlePointerDown,
486-
onClick: handleClick,
487468
},
469+
toggleProps,
488470
focusProps,
489471
hoverProps,
490472
pressProps

packages/@headlessui-react/src/components/menu/menu.tsx

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,14 @@ import React, {
1212
type CSSProperties,
1313
type ElementType,
1414
type KeyboardEvent as ReactKeyboardEvent,
15-
type PointerEvent as ReactPointerEvent,
1615
type Ref,
1716
} from 'react'
1817
import { flushSync } from 'react-dom'
1918
import { useActivePress } from '../../hooks/use-active-press'
2019
import { useDisposables } from '../../hooks/use-disposables'
2120
import { useElementSize } from '../../hooks/use-element-size'
2221
import { useEvent } from '../../hooks/use-event'
22+
import { useHandleToggle } from '../../hooks/use-handle-toggle'
2323
import { useId } from '../../hooks/use-id'
2424
import { useInertOthers } from '../../hooks/use-inert-others'
2525
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
@@ -48,7 +48,6 @@ import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-cl
4848
import { stackMachines } from '../../machines/stack-machine'
4949
import { useSlice } from '../../react-glue'
5050
import type { Props } from '../../types'
51-
import { isDisabledReactIssue7711 } from '../../utils/bugs'
5251
import { Focus } from '../../utils/calculate-active-index'
5352
import { disposables } from '../../utils/disposables'
5453
import * as DOM from '../../utils/dom'
@@ -72,7 +71,6 @@ import {
7271
import { useDescriptions } from '../description/description'
7372
import { Keys } from '../keyboard'
7473
import { useLabelContext, useLabels } from '../label/label'
75-
import { MouseButton } from '../mouse'
7674
import { Portal } from '../portal/portal'
7775
import { ActionTypes, ActivationTrigger, MenuState, type MenuItemDataRef } from './menu-machine'
7876
import { MenuContext, useMenuMachine, useMenuMachineContext } from './menu-machine-glue'
@@ -263,9 +261,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
263261
select: useCallback((target) => target.click(), []),
264262
})
265263

266-
let toggle = useEvent((event: ReactPointerEvent) => {
267-
if (event.button !== MouseButton.Left) return // Only handle left clicks
268-
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
264+
let toggleProps = useHandleToggle((event) => {
269265
if (disabled) return
270266
if (menuState === MenuState.Open) {
271267
flushSync(() => machine.send({ type: ActionTypes.CloseMenu }))
@@ -280,18 +276,6 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
280276
}
281277
})
282278

283-
let pointerTypeRef = useRef<'touch' | 'mouse' | 'pen' | null>(null)
284-
let handlePointerDown = useEvent((event: ReactPointerEvent) => {
285-
pointerTypeRef.current = event.pointerType
286-
if (event.pointerType !== 'mouse') return
287-
toggle(event)
288-
})
289-
290-
let handleClick = useEvent((event: ReactPointerEvent) => {
291-
if (pointerTypeRef.current === 'mouse') return
292-
toggle(event)
293-
})
294-
295279
let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus })
296280
let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled })
297281
let { pressed: active, pressProps } = useActivePress({ disabled })
@@ -318,9 +302,8 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
318302
autoFocus,
319303
onKeyDown: handleKeyDown,
320304
onKeyUp: handleKeyUp,
321-
onPointerDown: handlePointerDown,
322-
onClick: handleClick,
323305
},
306+
toggleProps,
324307
focusProps,
325308
hoverProps,
326309
pressProps
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { useRef, type PointerEvent as ReactPointerEvent } from 'react'
2+
import { MouseButton } from '../components/mouse'
3+
import { isDisabledReactIssue7711 } from '../utils/bugs'
4+
import { useEvent } from './use-event'
5+
6+
export function useHandleToggle(cb: (event: ReactPointerEvent) => void) {
7+
let pointerTypeRef = useRef<'touch' | 'mouse' | 'pen' | null>(null)
8+
9+
let handlePointerDown = useEvent((event: ReactPointerEvent<HTMLButtonElement>) => {
10+
pointerTypeRef.current = event.pointerType
11+
12+
// Skip disabled elements
13+
if (isDisabledReactIssue7711(event.currentTarget)) return
14+
15+
// We only want to handle mouse events here. Touch and pen events should be
16+
// ignored to prevent accidentally blocking scrolling. They will be
17+
// handled by the click listener instead.
18+
if (event.pointerType !== 'mouse') return
19+
20+
// We are only interested in left clicks, but because this is a pointerdown
21+
// event we have to check this property manually because this is also
22+
// fired for right clicks.
23+
if (event.button !== MouseButton.Left) return
24+
25+
// We use the `pointerdown` event here since it fires before the focus
26+
// event, allowing us to cancel the event before focus is moved.
27+
//
28+
// If this is used in a button where the currently focused element is an
29+
// `input` then we keep the focus in the `input` instead of moving it to
30+
// the button. This preserves the cursor position and any text selection
31+
// in the input.
32+
event.preventDefault()
33+
34+
// Finally we are ready to toggle
35+
cb(event)
36+
})
37+
38+
let handleClick = useEvent((event: ReactPointerEvent<HTMLButtonElement>) => {
39+
// Skip mouse events, they are already handled in the pointerdown handler above.
40+
if (pointerTypeRef.current === 'mouse') return
41+
42+
// Skip disabled elements
43+
if (isDisabledReactIssue7711(event.currentTarget)) return
44+
45+
// Finally we are ready to toggle
46+
cb(event)
47+
})
48+
49+
return {
50+
onPointerDown: handlePointerDown,
51+
onClick: handleClick,
52+
}
53+
}

0 commit comments

Comments
 (0)