@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.
@@ -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.27.85",
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.16.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.3",
44
+ "@preact/signals": "2.9.4",
45
45
  "playwright": "1.61.1",
46
- "preact": "11.0.0-beta.1"
46
+ "preact": "11.0.0-beta.2"
47
47
  },
48
48
  "publishConfig": {
49
49
  "access": "public"