@jsenv/navi 0.27.85 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/jsenv_navi.js +17207 -14806
- package/dist/jsenv_navi.js.map +329 -143
- package/docs/MOBILE_TAP_SUPPRESSION_AFTER_DRAG.md +163 -0
- package/package.json +4 -4
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# Mobile tap suppression after a drag
|
|
2
|
+
|
|
3
|
+
## What happens
|
|
4
|
+
|
|
5
|
+
On **Chrome for Android**, after you drag on a surface with your finger, the
|
|
6
|
+
**next quick tap on any element does not fire a `click`**. The tap still produces
|
|
7
|
+
`pointerdown` and `pointerup` — only the browser-synthesized `click` (and the
|
|
8
|
+
`mousedown`/`mouseup` compat events) is silently dropped.
|
|
9
|
+
|
|
10
|
+
Observed on a real device:
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
# device: Chrome 150.0.0 · Android 10
|
|
14
|
+
[experiment] SURFACE pointerdown (id=2)
|
|
15
|
+
[experiment] SURFACE pointerup (id=2)
|
|
16
|
+
[experiment] TARGET pointerdown
|
|
17
|
+
[experiment] TARGET pointerup
|
|
18
|
+
← no "TARGET click"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
This is what makes the picker feel broken: drag the wheel, then quickly tap
|
|
22
|
+
**Définir** — the button gets `pointerdown`/`pointerup` but never `click`, so the
|
|
23
|
+
command never runs. Wait ~½ second and the same tap works fine.
|
|
24
|
+
|
|
25
|
+
## It is a browser behavior, not ours
|
|
26
|
+
|
|
27
|
+
The reproduction was reduced all the way down to a **bare div with no framework
|
|
28
|
+
at all** — no navi, no Preact, no momentum, no pointer capture, no
|
|
29
|
+
`preventDefault`:
|
|
30
|
+
|
|
31
|
+
```html
|
|
32
|
+
<div style="width:64px; height:160px; overflow:hidden; touch-action:none;">
|
|
33
|
+
<div id="inner">▤▤▤▤▤</div>
|
|
34
|
+
</div>
|
|
35
|
+
<button id="target">Tap me</button>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
surface.addEventListener("pointerdown", (e) => (start = e.clientY));
|
|
40
|
+
surface.addEventListener("pointermove", (e) => {
|
|
41
|
+
inner.style.transform = `translateY(${e.clientY - start}px)`;
|
|
42
|
+
});
|
|
43
|
+
// drag this, then quickly tap #target → its "click" never fires
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Dragging this rectangle and then tapping the button reproduces the dropped
|
|
47
|
+
`click` exactly. So nothing in navi's wheel (its momentum, its synthetic `input`
|
|
48
|
+
events, its per-crossing re-render) is required — the browser suppresses the
|
|
49
|
+
click on its own.
|
|
50
|
+
|
|
51
|
+
Reproductions live in
|
|
52
|
+
[`src/control/demos/lab/`](../src/control/demos/lab/):
|
|
53
|
+
|
|
54
|
+
- [`tap_after_drag_experiment.html`](../src/control/demos/lab/tap_after_drag_experiment.html) —
|
|
55
|
+
the minimal one-surface + one-button case above.
|
|
56
|
+
- [`surface_css_matrix_experiment.html`](../src/control/demos/lab/surface_css_matrix_experiment.html) —
|
|
57
|
+
the same repro with one variable changed per row (`touch-action` values,
|
|
58
|
+
`overflow`, movement on/off, `preventDefault`, pointer capture, touch events,
|
|
59
|
+
and a real native-scroll control), each button flipping green (click fired) or
|
|
60
|
+
red (suppressed), to isolate exactly which trait arms the suppression.
|
|
61
|
+
|
|
62
|
+
## Why the browser does it
|
|
63
|
+
|
|
64
|
+
Chromium has two distinct mechanisms that drop a tap's `click`. The subtle part:
|
|
65
|
+
**neither one cleanly explains the bare-surface repro**, which is the whole
|
|
66
|
+
reason it is worth documenting.
|
|
67
|
+
|
|
68
|
+
### 1. `ignore_single_tap_` — within one touch sequence
|
|
69
|
+
|
|
70
|
+
In [`ui/events/gesture_detection/gesture_provider.cc`](https://chromium.googlesource.com/chromium/src/+/HEAD/ui/events/gesture_detection/gesture_provider.cc),
|
|
71
|
+
once a scroll is recognized during a touch sequence, that sequence's
|
|
72
|
+
`GestureTap` (which becomes the synthesized `click`) is suppressed. **But this
|
|
73
|
+
flag resets on the next `DOWN`**, so it cannot reach across from the surface drag
|
|
74
|
+
to a _separate_ tap on another element.
|
|
75
|
+
|
|
76
|
+
### 2. `TapSuppressionController` — the tap that stops a fling
|
|
77
|
+
|
|
78
|
+
In [`components/input/tap_suppression_controller.cc`](https://chromium.googlesource.com/chromium/src/+/HEAD/components/input/tap_suppression_controller.cc),
|
|
79
|
+
after a `GestureFlingCancel` (the user taps to stop an in-flight fling), taps are
|
|
80
|
+
suppressed for a window:
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
max_cancel_to_down_time = base::Milliseconds(180)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
This matches the "~½ second and it works again" feel, **but it requires a real
|
|
87
|
+
fling** — and in the bare-surface repro **no scroll and no fling ever fire**
|
|
88
|
+
(`touch-action: none` means the browser never scrolls, and the `scroll` /
|
|
89
|
+
`touchcancel` / `gesturestart` probes stay silent).
|
|
90
|
+
|
|
91
|
+
### 3. Android's "click is a compatibility event" rule
|
|
92
|
+
|
|
93
|
+
Per the cross-browser touch-event notes, on **Android specifically**:
|
|
94
|
+
|
|
95
|
+
> `click` is a compatibility event, fired only for a _clean_ tap without too much
|
|
96
|
+
> movement; on Android, `touchmove` is effectively mutually exclusive with
|
|
97
|
+
> `click`.
|
|
98
|
+
|
|
99
|
+
Android is far more aggressive here than iOS or desktop. The bare-surface case
|
|
100
|
+
looks like a variant of this rule leaking across the very short gap between the
|
|
101
|
+
drag's release and the next tap. `touch-action: none` — which per spec should
|
|
102
|
+
cause a normal `click` on release — does **not** prevent it.
|
|
103
|
+
|
|
104
|
+
## What the matrix found
|
|
105
|
+
|
|
106
|
+
Running [`surface_css_matrix_experiment.html`](../src/control/demos/lab/surface_css_matrix_experiment.html)
|
|
107
|
+
and [`preventdefault_matrix_experiment.html`](../src/control/demos/lab/preventdefault_matrix_experiment.html)
|
|
108
|
+
on a real device (Chrome 150, Android 10) pinned it precisely:
|
|
109
|
+
|
|
110
|
+
- **`touch-action` makes no difference.** `none`, `auto`, `manipulation`, `pan-y`
|
|
111
|
+
all suppress the click — confirming the spec expectation ("`touch-action:none`
|
|
112
|
+
should still fire a `click` on release") does **not** hold on Android Chrome.
|
|
113
|
+
- **Only `preventDefault()` fixes it — and only on the _touch_ events.**
|
|
114
|
+
`preventDefault()` on `pointerdown`/`pointermove` does nothing (pointer events
|
|
115
|
+
don't stop Chrome's touch→gesture pipeline). On `touchstart` **or** `touchmove`
|
|
116
|
+
it works: either one alone is enough to keep the following tap's `click` alive.
|
|
117
|
+
- A genuine native scroll (the browser owning the gesture) also leaves the next
|
|
118
|
+
tap's click intact — but that path doesn't apply to a JS-driven wheel.
|
|
119
|
+
|
|
120
|
+
So the mechanism is: an un-`preventDefault`ed touch drag feeds Chrome's gesture
|
|
121
|
+
recognizer, which arms the tap suppression; consuming the touch stream with
|
|
122
|
+
`preventDefault()` stops it.
|
|
123
|
+
|
|
124
|
+
## The fix
|
|
125
|
+
|
|
126
|
+
`preventDefault()` on **`touchmove`**, only while a drag is active. `touchmove`
|
|
127
|
+
rather than `touchstart` because:
|
|
128
|
+
|
|
129
|
+
- the drag _is_ the move — that is the event whose default we actually want to
|
|
130
|
+
own; and
|
|
131
|
+
- preventing `touchstart` has wider side effects (it also suppresses the
|
|
132
|
+
surface's own focus / synthesized events), so it's the more sensitive one to
|
|
133
|
+
touch.
|
|
134
|
+
|
|
135
|
+
The listener must be **non-passive** (`{ passive: false }`) for `preventDefault()`
|
|
136
|
+
to take effect. It is gated on an active drag so ordinary taps are untouched:
|
|
137
|
+
|
|
138
|
+
```js
|
|
139
|
+
const onTouchMove = (e) => {
|
|
140
|
+
if (drag) {
|
|
141
|
+
e.preventDefault();
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
vp.addEventListener("touchmove", onTouchMove, { passive: false });
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
This landed in the wheel — see `onTouchMove` in
|
|
148
|
+
[wheel.jsx](../src/control/wheel/wheel.jsx). Note this replaces the earlier,
|
|
149
|
+
disproven theory that the dropped click came from `setPointerCapture` on touch or
|
|
150
|
+
from a momentum re-render moving the element: neither was the cause, and neither
|
|
151
|
+
"fix" worked on the device.
|
|
152
|
+
|
|
153
|
+
## Summary
|
|
154
|
+
|
|
155
|
+
| Mechanism | Scope | Window | Fires in our repro? |
|
|
156
|
+
| -------------------------------- | ------------------------------ | ------ | --------------------------------------------- |
|
|
157
|
+
| `ignore_single_tap_` | within one touch sequence | n/a | No — resets on the next `DOWN` |
|
|
158
|
+
| `TapSuppressionController` | the tap that stops a fling | 180ms | No — no scroll/fling occurs |
|
|
159
|
+
| Android "click = clean-tap-only" | touch → click compat synthesis | short | **Yes** — the un-prevented touch drag arms it |
|
|
160
|
+
|
|
161
|
+
The confirmed fix: on a JS-driven touch drag over a `touch-action: none` surface,
|
|
162
|
+
`preventDefault()` the `touchmove` (non-passive, while dragging). Either
|
|
163
|
+
`touchstart` or `touchmove` works; `touchmove` is chosen as the least invasive.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jsenv/navi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Library of components including navigation to create frontend applications",
|
|
6
6
|
"repository": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"prepublishOnly": "npm run build"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@jsenv/dom": "0.
|
|
32
|
+
"@jsenv/dom": "0.17.0",
|
|
33
33
|
"@jsenv/humanize": "1.7.8",
|
|
34
34
|
"@jsenv/validity": "0.4.2"
|
|
35
35
|
},
|
|
@@ -41,9 +41,9 @@
|
|
|
41
41
|
"@jsenv/snapshot": "../../tooling/snapshot",
|
|
42
42
|
"@jsenv/terminal-table": "../../tooling/terminal-table",
|
|
43
43
|
"@jsenv/urls": "../../tooling/urls",
|
|
44
|
-
"@preact/signals": "2.9.
|
|
44
|
+
"@preact/signals": "2.9.4",
|
|
45
45
|
"playwright": "1.61.1",
|
|
46
|
-
"preact": "11.0.0-beta.
|
|
46
|
+
"preact": "11.0.0-beta.2"
|
|
47
47
|
},
|
|
48
48
|
"publishConfig": {
|
|
49
49
|
"access": "public"
|