Skip to content

Commit 03c22b4

Browse files
authored
Cancel outside click behavior on touch devices when scrolling (#3266)
* make `handleOutsideClick` stable * cancel "outside click" when "scrolling" on touch device When on a touch device, then the `touchend` event will fire, even if you scrolled a bit and scrolling was your intention. This now tracks that touches were at least 30px apart in either the X or Y direction. If that's the case, then we do not consider it an outside click. * add `enabled` parameter to `useDocumentEvent` and `useWindowEvent` * update `useDocumentEvent` and `useWindowEvent` usages This now takes the new `enabled` value into account. * update changelog * bump vue and vite in playground
1 parent 2d3ec80 commit 03c22b4

File tree

12 files changed

+1406
-368
lines changed

12 files changed

+1406
-368
lines changed

package-lock.json

Lines changed: 1239 additions & 271 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1919
- Fix visual jitter in `Combobox` component when using native scrollbar ([#3190](https://github.com/tailwindlabs/headlessui/pull/3190))
2020
- Use `useId` instead of React internals (for React 19 compatibility) ([#3254](https://github.com/tailwindlabs/headlessui/pull/3254))
2121
- Ensure `ComboboxInput` does not sync with current value while typing ([#3259](https://github.com/tailwindlabs/headlessui/pull/3259))
22+
- Cancel outside click behavior on touch devices when scrolling ([#3266](https://github.com/tailwindlabs/headlessui/pull/3266))
2223

2324
## [2.0.4] - 2024-05-25
2425

packages/@headlessui-react/src/hooks/use-document-event.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,21 @@ import { useEffect } from 'react'
22
import { useLatestValue } from './use-latest-value'
33

44
export function useDocumentEvent<TType extends keyof DocumentEventMap>(
5+
enabled: boolean,
56
type: TType,
67
listener: (ev: DocumentEventMap[TType]) => any,
78
options?: boolean | AddEventListenerOptions
89
) {
910
let listenerRef = useLatestValue(listener)
1011

1112
useEffect(() => {
13+
if (!enabled) return
14+
1215
function handler(event: DocumentEventMap[TType]) {
1316
listenerRef.current(event)
1417
}
1518

1619
document.addEventListener(type, handler, options)
1720
return () => document.removeEventListener(type, handler, options)
18-
}, [type, options])
21+
}, [enabled, type, options])
1922
}

packages/@headlessui-react/src/hooks/use-outside-click.ts

Lines changed: 102 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,132 +1,131 @@
1-
import { useEffect, useRef, type MutableRefObject } from 'react'
1+
import { useCallback, useRef, type MutableRefObject } from 'react'
22
import { FocusableMode, isFocusableElement } from '../utils/focus-management'
33
import { isMobile } from '../utils/platform'
44
import { useDocumentEvent } from './use-document-event'
55
import { useIsTopLayer } from './use-is-top-layer'
6+
import { useLatestValue } from './use-latest-value'
67
import { useWindowEvent } from './use-window-event'
78

89
type Container = MutableRefObject<HTMLElement | null> | HTMLElement | null
910
type ContainerCollection = Container[] | Set<Container>
1011
type ContainerInput = Container | ContainerCollection
1112

13+
// If the user moves their finger by ${MOVE_THRESHOLD_PX} pixels or more, we'll
14+
// assume that they are scrolling and not clicking. This will prevent the click
15+
// from being triggered when the user is scrolling.
16+
//
17+
// This also allows you to "cancel" the click by moving your finger more than
18+
// the threshold in pixels in any direction.
19+
const MOVE_THRESHOLD_PX = 30
20+
1221
export function useOutsideClick(
1322
enabled: boolean,
1423
containers: ContainerInput | (() => ContainerInput),
1524
cb: (event: MouseEvent | PointerEvent | FocusEvent | TouchEvent, target: HTMLElement) => void
1625
) {
1726
let isTopLayer = useIsTopLayer(enabled, 'outside-click')
27+
let cbRef = useLatestValue(cb)
1828

19-
// TODO: remove this once the React bug has been fixed: https://github.com/facebook/react/issues/24657
20-
let enabledRef = useRef(false)
21-
useEffect(
22-
process.env.NODE_ENV === 'test'
23-
? () => {
24-
enabledRef.current = isTopLayer
25-
}
26-
: () => {
27-
requestAnimationFrame(() => {
28-
enabledRef.current = isTopLayer
29-
})
30-
},
31-
[isTopLayer]
32-
)
33-
34-
function handleOutsideClick<E extends MouseEvent | PointerEvent | FocusEvent | TouchEvent>(
35-
event: E,
36-
resolveTarget: (event: E) => HTMLElement | null
37-
) {
38-
if (!enabledRef.current) return
29+
let handleOutsideClick = useCallback(
30+
function handleOutsideClick<E extends MouseEvent | PointerEvent | FocusEvent | TouchEvent>(
31+
event: E,
32+
resolveTarget: (event: E) => HTMLElement | null
33+
) {
34+
// Check whether the event got prevented already. This can happen if you
35+
// use the useOutsideClick hook in both a Dialog and a Menu and the inner
36+
// Menu "cancels" the default behavior so that only the Menu closes and
37+
// not the Dialog (yet)
38+
if (event.defaultPrevented) return
3939

40-
// Check whether the event got prevented already. This can happen if you use the
41-
// useOutsideClick hook in both a Dialog and a Menu and the inner Menu "cancels" the default
42-
// behavior so that only the Menu closes and not the Dialog (yet)
43-
if (event.defaultPrevented) return
40+
let target = resolveTarget(event)
4441

45-
let target = resolveTarget(event)
42+
if (target === null) {
43+
return
44+
}
4645

47-
if (target === null) {
48-
return
49-
}
46+
// Ignore if the target doesn't exist in the DOM anymore
47+
if (!target.getRootNode().contains(target)) return
5048

51-
// Ignore if the target doesn't exist in the DOM anymore
52-
if (!target.getRootNode().contains(target)) return
49+
// Ignore if the target was removed from the DOM by the time the handler
50+
// was called
51+
if (!target.isConnected) return
5352

54-
// Ignore if the target was removed from the DOM by the time the handler was called
55-
if (!target.isConnected) return
53+
let _containers = (function resolve(containers): ContainerCollection {
54+
if (typeof containers === 'function') {
55+
return resolve(containers())
56+
}
5657

57-
let _containers = (function resolve(containers): ContainerCollection {
58-
if (typeof containers === 'function') {
59-
return resolve(containers())
60-
}
58+
if (Array.isArray(containers)) {
59+
return containers
60+
}
6161

62-
if (Array.isArray(containers)) {
63-
return containers
64-
}
62+
if (containers instanceof Set) {
63+
return containers
64+
}
6565

66-
if (containers instanceof Set) {
67-
return containers
68-
}
66+
return [containers]
67+
})(containers)
6968

70-
return [containers]
71-
})(containers)
69+
// Ignore if the target exists in one of the containers
70+
for (let container of _containers) {
71+
if (container === null) continue
72+
let domNode = container instanceof HTMLElement ? container : container.current
73+
if (domNode?.contains(target)) {
74+
return
75+
}
7276

73-
// Ignore if the target exists in one of the containers
74-
for (let container of _containers) {
75-
if (container === null) continue
76-
let domNode = container instanceof HTMLElement ? container : container.current
77-
if (domNode?.contains(target)) {
78-
return
77+
// If the click crossed a shadow boundary, we need to check if the
78+
// container is inside the tree by using `composedPath` to "pierce" the
79+
// shadow boundary
80+
if (event.composed && event.composedPath().includes(domNode as EventTarget)) {
81+
return
82+
}
7983
}
8084

81-
// If the click crossed a shadow boundary, we need to check if the container
82-
// is inside the tree by using `composedPath` to "pierce" the shadow boundary
83-
if (event.composed && event.composedPath().includes(domNode as EventTarget)) {
84-
return
85+
// This allows us to check whether the event was defaultPrevented when you
86+
// are nesting this inside a `<Dialog />` for example.
87+
if (
88+
// This check allows us to know whether or not we clicked on a
89+
// "focusable" element like a button or an input. This is a backwards
90+
// compatibility check so that you can open a <Menu /> and click on
91+
// another <Menu /> which should close Menu A and open Menu B. We might
92+
// revisit that so that you will require 2 clicks instead.
93+
!isFocusableElement(target, FocusableMode.Loose) &&
94+
// This could be improved, but the `Combobox.Button` adds tabIndex={-1}
95+
// to make it unfocusable via the keyboard so that tabbing to the next
96+
// item from the input doesn't first go to the button.
97+
target.tabIndex !== -1
98+
) {
99+
event.preventDefault()
85100
}
86-
}
87-
88-
// This allows us to check whether the event was defaultPrevented when you are nesting this
89-
// inside a `<Dialog />` for example.
90-
if (
91-
// This check allows us to know whether or not we clicked on a "focusable" element like a
92-
// button or an input. This is a backwards compatibility check so that you can open a <Menu
93-
// /> and click on another <Menu /> which should close Menu A and open Menu B. We might
94-
// revisit that so that you will require 2 clicks instead.
95-
!isFocusableElement(target, FocusableMode.Loose) &&
96-
// This could be improved, but the `Combobox.Button` adds tabIndex={-1} to make it
97-
// unfocusable via the keyboard so that tabbing to the next item from the input doesn't
98-
// first go to the button.
99-
target.tabIndex !== -1
100-
) {
101-
event.preventDefault()
102-
}
103101

104-
return cb(event, target)
105-
}
102+
return cbRef.current(event, target)
103+
},
104+
[cbRef]
105+
)
106106

107107
let initialClickTarget = useRef<EventTarget | null>(null)
108108

109109
useDocumentEvent(
110+
isTopLayer,
110111
'pointerdown',
111112
(event) => {
112-
if (enabledRef.current) {
113-
initialClickTarget.current = event.composedPath?.()?.[0] || event.target
114-
}
113+
initialClickTarget.current = event.composedPath?.()?.[0] || event.target
115114
},
116115
true
117116
)
118117

119118
useDocumentEvent(
119+
isTopLayer,
120120
'mousedown',
121121
(event) => {
122-
if (enabledRef.current) {
123-
initialClickTarget.current = event.composedPath?.()?.[0] || event.target
124-
}
122+
initialClickTarget.current = event.composedPath?.()?.[0] || event.target
125123
},
126124
true
127125
)
128126

129127
useDocumentEvent(
128+
isTopLayer,
130129
'click',
131130
(event) => {
132131
if (isMobile()) {
@@ -151,9 +150,31 @@ export function useOutsideClick(
151150
true
152151
)
153152

153+
let startPosition = useRef({ x: 0, y: 0 })
154154
useDocumentEvent(
155+
isTopLayer,
156+
'touchstart',
157+
(event) => {
158+
startPosition.current.x = event.touches[0].clientX
159+
startPosition.current.y = event.touches[0].clientY
160+
},
161+
true
162+
)
163+
164+
useDocumentEvent(
165+
isTopLayer,
155166
'touchend',
156167
(event) => {
168+
// If the user moves their finger by ${MOVE_THRESHOLD_PX} pixels or more,
169+
// we'll assume that they are scrolling and not clicking.
170+
let endPosition = { x: event.changedTouches[0].clientX, y: event.changedTouches[0].clientY }
171+
if (
172+
Math.abs(endPosition.x - startPosition.current.x) >= MOVE_THRESHOLD_PX ||
173+
Math.abs(endPosition.y - startPosition.current.y) >= MOVE_THRESHOLD_PX
174+
) {
175+
return
176+
}
177+
157178
return handleOutsideClick(event, () => {
158179
if (event.target instanceof HTMLElement) {
159180
return event.target
@@ -177,6 +198,7 @@ export function useOutsideClick(
177198
// If so this was because of a click, focus, or other interaction with the child iframe
178199
// and we can consider it an "outside click"
179200
useWindowEvent(
201+
isTopLayer,
180202
'blur',
181203
(event) => {
182204
return handleOutsideClick(event, () => {

packages/@headlessui-react/src/hooks/use-tab-direction.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ export enum Direction {
88

99
export function useTabDirection() {
1010
let direction = useRef(Direction.Forwards)
11+
let enabled = true
1112

1213
useWindowEvent(
14+
enabled,
1315
'keydown',
1416
(event) => {
1517
if (event.key === 'Tab') {

packages/@headlessui-react/src/hooks/use-window-event.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,21 @@ import { useEffect } from 'react'
22
import { useLatestValue } from './use-latest-value'
33

44
export function useWindowEvent<TType extends keyof WindowEventMap>(
5+
enabled: boolean,
56
type: TType,
67
listener: (ev: WindowEventMap[TType]) => any,
78
options?: boolean | AddEventListenerOptions
89
) {
910
let listenerRef = useLatestValue(listener)
1011

1112
useEffect(() => {
13+
if (!enabled) return
14+
1215
function handler(event: WindowEventMap[TType]) {
1316
listenerRef.current(event)
1417
}
1518

1619
window.addEventListener(type, handler, options)
1720
return () => window.removeEventListener(type, handler, options)
18-
}, [type, options])
21+
}, [enabled, type, options])
1922
}

packages/@headlessui-vue/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- Add `immediate` prop to `<Combobox />` for immediately opening the Combobox when the `input` receives focus ([#2686](https://github.com/tailwindlabs/headlessui/pull/2686))
1313
- Add `virtual` prop to `Combobox` component ([#2779](https://github.com/tailwindlabs/headlessui/pull/2779))
1414

15+
### Fixed
16+
17+
- Cancel outside click behavior on touch devices when scrolling ([#3266](https://github.com/tailwindlabs/headlessui/pull/3266))
18+
1519
## [1.7.22] - 2024-05-08
1620

1721
### Fixed

packages/@headlessui-vue/src/hooks/use-document-event.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
1-
import { watchEffect } from 'vue'
1+
import { watchEffect, type Ref } from 'vue'
22
import { env } from '../utils/env'
33

44
export function useDocumentEvent<TType extends keyof DocumentEventMap>(
5+
enabled: Ref<boolean>,
56
type: TType,
67
listener: (this: Document, ev: DocumentEventMap[TType]) => any,
78
options?: boolean | AddEventListenerOptions
89
) {
910
if (env.isServer) return
1011

1112
watchEffect((onInvalidate) => {
13+
if (!enabled.value) return
14+
1215
document.addEventListener(type, listener, options)
1316
onInvalidate(() => document.removeEventListener(type, listener, options))
1417
})

0 commit comments

Comments
 (0)