@jsenv/navi 0.29.397 → 0.29.399
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/dev/jsenv_navi.js +385 -74
- package/dist/dev/jsenv_navi.js.map +15 -15
- package/dist/jsenv_navi.js +385 -74
- package/dist/jsenv_navi.js.map +15 -15
- package/docs/AI_INSTRUCTIONS.md +7 -2
- package/docs/drag_to_travel.md +51 -20
- package/docs/dynamic_import.md +3 -2
- package/docs/mobile_touch.md +224 -0
- package/docs/pan_zoom.md +1 -1
- package/docs/popup_open.md +46 -9
- package/package.json +2 -2
- package/docs/mobile_tap_suppression_after_drag.md +0 -41
package/docs/AI_INSTRUCTIONS.md
CHANGED
|
@@ -210,8 +210,13 @@ to start when unsure which export solves a problem.
|
|
|
210
210
|
moving anything with the pointer.
|
|
211
211
|
- `pan_zoom.md` — a surface under the hand: `pan`, `zoom`, and what a touch or
|
|
212
212
|
a wheel over it may do to the page around it.
|
|
213
|
-
- `
|
|
214
|
-
|
|
213
|
+
- `mobile_touch.md` — what Chrome and Safari do with a finger a gesture also
|
|
214
|
+
reads, measured: they commit it to their own pan ~8px in (read a finger's
|
|
215
|
+
intent before that), `touch-action` restarts at every scroller, when the
|
|
216
|
+
click survives, the tap Chrome drops after a touch drag, and how to reproduce
|
|
217
|
+
all of it without a device (Chrome's mobile touch emulator, Safari in the iOS
|
|
218
|
+
simulator). Read before reading a finger yourself, and before concluding a
|
|
219
|
+
gesture works because it works with a mouse.
|
|
215
220
|
|
|
216
221
|
### Layout, CSS and text
|
|
217
222
|
|
package/docs/drag_to_travel.md
CHANGED
|
@@ -35,9 +35,10 @@ and are referenced from here rather than restated.
|
|
|
35
35
|
|
|
36
36
|
## What the rules are
|
|
37
37
|
|
|
38
|
-
- A press is not a gesture until it has wandered
|
|
39
|
-
|
|
40
|
-
reading is biased,
|
|
38
|
+
- A press is not a gesture until it has wandered 10px with a mouse, 6px with a
|
|
39
|
+
finger or a pen, and the axis it leans on then is the axis it walks, for good
|
|
40
|
+
— how that axis is read, why the reading is biased, and why a finger's number
|
|
41
|
+
is a deadline rather than a feel, has [a section of its own
|
|
41
42
|
below](#the-axis-is-read-once-and-read-with-a-bias). **Except on something
|
|
42
43
|
already moving**: there the hand said what it wanted by reaching for it, so
|
|
43
44
|
the gesture answers from its first pixel and owes it every one of them —
|
|
@@ -89,7 +90,7 @@ and are referenced from here rather than restated.
|
|
|
89
90
|
## The axis is read once, and read with a bias
|
|
90
91
|
|
|
91
92
|
The axis is decided on the first movement report after the threshold and never
|
|
92
|
-
revisited.
|
|
93
|
+
revisited. For a thumb that report is a few pixels of movement, and those pixels
|
|
93
94
|
misstate the gesture: a thumb swiping sideways moves along an ARC, and the
|
|
94
95
|
start of the arc leans off-axis far more than the swipe does. Read even
|
|
95
96
|
(whichever axis covered more), the lean hands the whole press to an axis
|
|
@@ -109,19 +110,35 @@ Why the decision cannot simply wait for more evidence — this boundary is
|
|
|
109
110
|
physics, not caution:
|
|
110
111
|
|
|
111
112
|
- the browser is racing for the same press, on the axis `touch-action` leaves
|
|
112
|
-
it
|
|
113
|
-
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
113
|
+
it — and on every axis over a scroller inside the box, since a scroller
|
|
114
|
+
resets `touch-action` for what it holds;
|
|
115
|
+
- and it does not wait for us: **about 8px in, it commits the touch to its own
|
|
116
|
+
pan**, reading the direction evenly (Chrome at the first `touchmove` it
|
|
117
|
+
sends, Safari at the one near 9px). From then on refusing a `touchmove`
|
|
118
|
+
changes nothing: the pointer is cancelled, and the swipe does nothing — or
|
|
119
|
+
the travel that had started goes back;
|
|
120
|
+
- refusing a touchmove is **irreversible for that touch** too, the other way
|
|
121
|
+
round: one refused before that moment keeps the whole touch from scrolling,
|
|
122
|
+
and un-refusing later resurrects nothing;
|
|
123
|
+
- so the reading has to be done before the browser's, and every frame spent
|
|
124
|
+
gathering evidence past it is not evidence, it is a lost gesture. A decision
|
|
125
|
+
deferred until the arc has proven itself would make every genuinely vertical
|
|
126
|
+
swipe over the box a dead gesture — the exact bug, mirrored onto the page.
|
|
127
|
+
|
|
128
|
+
Hence the finger's threshold, 6px of distance (`DRAG_START_THRESHOLD_TOUCH`):
|
|
129
|
+
under the browser's 8px with a margin, because Safari does not wait for the
|
|
130
|
+
answer to a report that jumps over its decision. A distance and not a per-axis
|
|
131
|
+
reach, because the browser measures its own slop as one. Its price is the
|
|
132
|
+
click of a tap that shook more than 6px. The engines' side of all this —
|
|
133
|
+
measured, per engine, with how to reproduce it — is
|
|
134
|
+
[mobile_touch.md](./mobile_touch.md).
|
|
120
135
|
|
|
121
136
|
The bias moves the frame-one boundary to where the two hands actually
|
|
122
|
-
separate; nothing can remove the boundary.
|
|
123
|
-
|
|
124
|
-
|
|
137
|
+
separate; nothing can remove the boundary. It only works because it is read
|
|
138
|
+
first: read after the browser's own reading, it is never consulted, the
|
|
139
|
+
pointer already being cancelled. A first report steeper than the bias (a start
|
|
140
|
+
more than twice off-axis) is genuinely ambiguous with a scroll and goes to the
|
|
141
|
+
page — which at least answers it visibly.
|
|
125
142
|
|
|
126
143
|
## Two inputs, one travel
|
|
127
144
|
|
|
@@ -553,8 +570,15 @@ travel had started finishes without anyone. It is invisible with a mouse, which
|
|
|
553
570
|
is why it survives a whole session of desktop testing.
|
|
554
571
|
|
|
555
572
|
So a travel that has become ours refuses the `touchmove` (`preventDefault`), and
|
|
556
|
-
only then — a finger that means to scroll must still scroll.
|
|
557
|
-
it
|
|
573
|
+
only then — a finger that means to scroll must still scroll. "Only then" has a
|
|
574
|
+
deadline: the browser takes the touch about 8px in if nothing has refused it by
|
|
575
|
+
then, which is why a finger becomes a travel at 6px (see [the axis
|
|
576
|
+
section](#the-axis-is-read-once-and-read-with-a-bias)). `touch-action` on the
|
|
577
|
+
box does not cover for a late answer over content that scrolls: a scroller
|
|
578
|
+
inside the box resets it for what it holds, and Chrome then pans even the axis
|
|
579
|
+
the box travels on, with nothing to scroll that way (see
|
|
580
|
+
[mobile_touch.md](./mobile_touch.md#touch-action-stops-at-the-first-scroller)).
|
|
581
|
+
Three details make the refusal hold:
|
|
558
582
|
|
|
559
583
|
- the listener sits on the element the touch LANDED on as well as on the window: a
|
|
560
584
|
touch keeps being dispatched at the node it started on, and a travel may
|
|
@@ -728,9 +752,16 @@ The reading side has traps of its own:
|
|
|
728
752
|
measured velocities come out below the numbers written in the script — read
|
|
729
753
|
the measured ones, not the intended ones;
|
|
730
754
|
- what the real browser does with an unclaimed touch — take it for a pan and
|
|
731
|
-
cancel the stream,
|
|
732
|
-
|
|
733
|
-
|
|
755
|
+
cancel the stream, withhold the click — is reproducible without a device, but
|
|
756
|
+
only through two paths: Chrome's mobile touch emulator (headed, driven with
|
|
757
|
+
mouse events) and Safari in the iOS simulator driven by `safaridriver`. The
|
|
758
|
+
obvious one, Playwright's `touchscreen` / CDP `Input.dispatchTouchEvent`,
|
|
759
|
+
runs Chrome's desktop slop (15px), where a travel deciding at 10px always
|
|
760
|
+
wins and the race that loses swipes on a phone never shows. The recipe is in
|
|
761
|
+
[mobile_touch.md](./mobile_touch.md#verifying-without-a-device). What stays a
|
|
762
|
+
device question: a real finger's lift, a real device's event timing, a
|
|
763
|
+
momentum tail killed by a landing finger (and the Firefox wheel limitation
|
|
764
|
+
above is the same kind of fact).
|
|
734
765
|
|
|
735
766
|
What to read while it runs: the `navi_drag_*` events (grab, start, release)
|
|
736
767
|
say which stage a press reached and with what velocity; the box's state
|
package/docs/dynamic_import.md
CHANGED
|
@@ -146,8 +146,9 @@ by the code arriving.
|
|
|
146
146
|
`prefetch={false}` opts one out. The url is matched against the routes on
|
|
147
147
|
screen, a section and the page inside it alike, and what is prerun is **every
|
|
148
148
|
route action that asks nothing of the address** — a page's code, a read
|
|
149
|
-
without params. An action whose params come from the address is
|
|
150
|
-
prefetched: a prefetch has no address, the id may still be
|
|
149
|
+
without params. An action whose params come from the address is not
|
|
150
|
+
prefetched by a link: a link's prefetch has no address, the id may still be
|
|
151
|
+
chosen.
|
|
151
152
|
`routeAction(route, action, undefined, { prefetch: false })` keeps a
|
|
152
153
|
param-less read that is not worth a hover out of it.
|
|
153
154
|
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# What a mobile browser does with a touch
|
|
2
|
+
|
|
3
|
+
A finger on a touchscreen is read twice. The page reads it through pointer and
|
|
4
|
+
touch events. The browser reads it too, because it wants it for its own
|
|
5
|
+
answers: a pan (scrolling, the back swipe), a zoom, and a tap (the click). Any
|
|
6
|
+
gesture navi reads from a finger — a travel, a swipe, something carried, a
|
|
7
|
+
wheel spun — is a negotiation with those answers. The browser's terms are
|
|
8
|
+
fixed, they differ between engines, and **none of them shows with a mouse**. A
|
|
9
|
+
gesture can pass a whole session of desktop testing and still fail one swipe
|
|
10
|
+
in three on a phone.
|
|
11
|
+
|
|
12
|
+
What follows is measured, not remembered: Chromium 153 through its mobile touch
|
|
13
|
+
emulator, and Safari on the iOS 26.5 simulator driven with real touches (see
|
|
14
|
+
[Verifying without a device](#verifying-without-a-device)). When an engine
|
|
15
|
+
changes, measure again before trusting a number here.
|
|
16
|
+
|
|
17
|
+
- [The browser decides about 8px in, and does not wait](#the-browser-decides-about-8px-in-and-does-not-wait)
|
|
18
|
+
- [touch-action stops at the first scroller](#touch-action-stops-at-the-first-scroller)
|
|
19
|
+
- [Only the start is contested](#only-the-start-is-contested)
|
|
20
|
+
- [The click is the browser's call as much as ours](#the-click-is-the-browsers-call-as-much-as-ours)
|
|
21
|
+
- [A tap dropped after a touch drag](#a-tap-dropped-after-a-touch-drag)
|
|
22
|
+
- [A finger never makes a wheel event](#a-finger-never-makes-a-wheel-event)
|
|
23
|
+
- [Verifying without a device](#verifying-without-a-device)
|
|
24
|
+
|
|
25
|
+
## The browser decides about 8px in, and does not wait
|
|
26
|
+
|
|
27
|
+
Where the browser may pan — the direction is allowed by `touch-action` — it
|
|
28
|
+
**commits the touch to its own pan after about 8px of movement**. From then on,
|
|
29
|
+
refusing a `touchmove` changes nothing. The pointer stream gets a
|
|
30
|
+
`pointercancel`, and the gesture the page was reading is dead: the swipe does
|
|
31
|
+
nothing, or a travel that had already started goes back.
|
|
32
|
+
|
|
33
|
+
The two engines reach that moment differently:
|
|
34
|
+
|
|
35
|
+
- **Chrome** does not send a `touchmove` inside its slop: 8px with the mobile
|
|
36
|
+
configuration, 15px on a desktop touchscreen. It still sends the
|
|
37
|
+
`pointermove`s. The first `touchmove` it sends IS its decision. Refuse that
|
|
38
|
+
one, and the page keeps the whole touch: no scroll for its whole length, and
|
|
39
|
+
every later `touchmove` stays cancelable. Leave it alone, and Chrome pans if
|
|
40
|
+
`touch-action` allows the direction. Chrome reads that direction evenly: a
|
|
41
|
+
start with `|dy| ≥ |dx|` is vertical. From then on every `touchmove` arrives
|
|
42
|
+
`cancelable: false`.
|
|
43
|
+
- **Safari** sends `touchmove`s from the first pixel, all cancelable. Refusing
|
|
44
|
+
them from any point before about 9px keeps the whole touch for the page, and
|
|
45
|
+
so does refusing the first one alone: native scrolling never starts, even
|
|
46
|
+
when the later ones are left alone. If the one at about 9px goes unrefused,
|
|
47
|
+
Safari pans. It does not wait for the page's
|
|
48
|
+
answer to a report that jumps over that distance in one go: after a small
|
|
49
|
+
unrefused report, a report that jumps past 9px is already too late.
|
|
50
|
+
|
|
51
|
+
So **a gesture that shares a surface with the page's pan has to read its
|
|
52
|
+
intent before about 8px**, and measure it as a distance (`Math.hypot`), not per
|
|
53
|
+
axis: the browser measures its slop as a distance, and a per-axis threshold is
|
|
54
|
+
up to 1.4 times later on a diagonal. Waiting for more evidence is not
|
|
55
|
+
available, because whatever is read after the browser's moment is read about
|
|
56
|
+
a pointer that has already been cancelled. The travel reads a finger's intent
|
|
57
|
+
at 6px for exactly this reason (`DRAG_START_THRESHOLD_TOUCH` in
|
|
58
|
+
`@jsenv/dom`'s `drag_to_travel.js`). Its bias toward the box's own axis only
|
|
59
|
+
means something because it is read before the browser reads the same pixels
|
|
60
|
+
evenly.
|
|
61
|
+
|
|
62
|
+
Whether a `touchmove` can be refused at all is settled even earlier, when the
|
|
63
|
+
finger lands: a non-passive listener must already be on the touch's path (see
|
|
64
|
+
`keepTouchRefusable`, and
|
|
65
|
+
[drag_to_travel.md](./drag_to_travel.md#on-a-touchscreen-the-browser-takes-the-gesture-unless-it-is-refused)).
|
|
66
|
+
Being refusable is decided at the touchstart; being refused, before 8px.
|
|
67
|
+
|
|
68
|
+
## touch-action stops at the first scroller
|
|
69
|
+
|
|
70
|
+
The `touch-action` in effect under a finger is the intersection of the values
|
|
71
|
+
from the touched element up to its **nearest scroll container** (an element
|
|
72
|
+
with `overflow: auto | scroll`). At that container it starts again from `auto`.
|
|
73
|
+
That is the Pointer Events spec, and Blink and WebKit both do it. So
|
|
74
|
+
`touch-action: pan-y` on a box that travels sideways says nothing about a slide
|
|
75
|
+
inside it with `overflow="auto"`, nor about a `List` or a scrolling body: under
|
|
76
|
+
the finger there, every direction is open again.
|
|
77
|
+
|
|
78
|
+
The engines do not use that opening the same way:
|
|
79
|
+
|
|
80
|
+
- **Chrome** pans in any direction there, **even one in which nothing can
|
|
81
|
+
scroll**, and cancels the pointer. A sideways swipe over a slide that only
|
|
82
|
+
scrolls vertically is taken by Chrome for a sideways pan of nothing.
|
|
83
|
+
- **Safari** does not pan a scroller sideways when it cannot scroll sideways:
|
|
84
|
+
the same swipe stays the page's.
|
|
85
|
+
|
|
86
|
+
So `touch-action` on a box is not what protects a gesture over content that
|
|
87
|
+
scrolls; reading the intent before the browser does is. It covers this case
|
|
88
|
+
with no rule on the scrollers — which matters, because restating the box's
|
|
89
|
+
`touch-action` on them would take the finger scroll away from a row that
|
|
90
|
+
scrolls sideways inside a slide.
|
|
91
|
+
|
|
92
|
+
## Only the start is contested
|
|
93
|
+
|
|
94
|
+
Once the browser has let a touch go, a later change of direction does not bring
|
|
95
|
+
it back:
|
|
96
|
+
|
|
97
|
+
- On Chrome, a start that its even reading calls horizontal, over `pan-y`,
|
|
98
|
+
drops Chrome's scroll for the whole touch. The thumb can bend vertical halfway
|
|
99
|
+
through and nothing scrolls, whether or not the later `touchmove`s are
|
|
100
|
+
refused.
|
|
101
|
+
- On Safari, once a `touchmove` has been refused, a later vertical bend starts
|
|
102
|
+
no native scroll either.
|
|
103
|
+
|
|
104
|
+
A gesture that has won the start keeps the touch. What loses gestures is the
|
|
105
|
+
first ~8px.
|
|
106
|
+
|
|
107
|
+
## The click is the browser's call as much as ours
|
|
108
|
+
|
|
109
|
+
- **Chrome** clicks after a touch that moved up to 8px and never beyond, whether
|
|
110
|
+
or not its `touchmove`s were refused.
|
|
111
|
+
- **Safari** does not click at all once a `touchmove` has been refused, even
|
|
112
|
+
after 6px. A touch that was never refused still clicks: an unrefused sideways
|
|
113
|
+
movement over `pan-y` clicks even after 20px.
|
|
114
|
+
|
|
115
|
+
A gesture that reads its intent early therefore costs the click of a tap that
|
|
116
|
+
shook more than its threshold. Safari drops that click on its own, and on
|
|
117
|
+
Chrome the gesture's click suppression swallows it up to 8px. That is the price
|
|
118
|
+
of the deadline above, paid by the sloppiest taps only. Beyond the browser's
|
|
119
|
+
own slop there was never a click to lose.
|
|
120
|
+
|
|
121
|
+
## A tap dropped after a touch drag
|
|
122
|
+
|
|
123
|
+
On Chrome for Android, the tap that follows a finger-driven drag does not fire
|
|
124
|
+
its `click` when it comes quickly: `pointerdown` and `pointerup` arrive, the
|
|
125
|
+
synthesized `click` (and the `mousedown`/`mouseup` compat events) does not. Wait
|
|
126
|
+
about half a second and the same tap works. It is what makes a wheel feel
|
|
127
|
+
broken — spin it, tap "Définir" at once, and the command never runs.
|
|
128
|
+
|
|
129
|
+
It is the browser's, not ours: a bare `div` moved by hand under `pointermove`,
|
|
130
|
+
with no framework, reproduces it. An un-prevented touch drag feeds Chromium's
|
|
131
|
+
gesture recognizer, which then treats the next tap as not clean enough to be a
|
|
132
|
+
click. `touch-action` changes nothing (`none`, `manipulation`, `pan-y` all drop
|
|
133
|
+
it), `preventDefault()` on the pointer events changes nothing either — only
|
|
134
|
+
`preventDefault()` on the **touch** events, `touchstart` or `touchmove`, keeps
|
|
135
|
+
the following click alive. A native scroll owned by the browser leaves it alive
|
|
136
|
+
too, which is no help to a surface moved by JS.
|
|
137
|
+
|
|
138
|
+
So a surface that moves under a finger by JS refuses `touchmove` while a drag is
|
|
139
|
+
active, from a non-passive listener. `touchmove` rather than `touchstart`: the
|
|
140
|
+
move is the event whose default the drag actually owns, and preventing
|
|
141
|
+
`touchstart` also suppresses the surface's own focus and synthesized events.
|
|
142
|
+
|
|
143
|
+
```js
|
|
144
|
+
const onTouchMove = (e) => {
|
|
145
|
+
if (drag) {
|
|
146
|
+
e.preventDefault();
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
viewport.addEventListener("touchmove", onTouchMove, { passive: false });
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Two theories were tried first and disproven on the device, so they are not
|
|
153
|
+
worth trying again: pointer capture on touch, and a momentum re-render moving
|
|
154
|
+
the element under the tap.
|
|
155
|
+
|
|
156
|
+
Reference: `onTouchMove` in `src/control/wheel/wheel.jsx`; the reductions, one
|
|
157
|
+
variable per row, in `src/control/demos/lab/` (`tap_after_drag_experiment.html`,
|
|
158
|
+
`surface_css_matrix_experiment.html`, `preventdefault_matrix_experiment.html`);
|
|
159
|
+
Chromium's `gesture_provider.cc` (`ignore_single_tap_`, reset on the next down)
|
|
160
|
+
and `tap_suppression_controller.cc` (the tap that stops a fling, 180ms) for the
|
|
161
|
+
two mechanisms that do NOT explain it.
|
|
162
|
+
|
|
163
|
+
## A finger never makes a wheel event
|
|
164
|
+
|
|
165
|
+
No mobile browser turns a touch into `wheel` events: those come from a mouse
|
|
166
|
+
wheel or a trackpad, including a trackpad on an iPad. When a gesture works with
|
|
167
|
+
the wheel and fails under a finger, the wheel path is not the suspect. The two
|
|
168
|
+
paths differ in timing: a wheel gesture is claimed and refused on its very first
|
|
169
|
+
event, so the browser never gets a moment to decide, while a finger has to
|
|
170
|
+
travel a few pixels before anything can be read.
|
|
171
|
+
|
|
172
|
+
## Verifying without a device
|
|
173
|
+
|
|
174
|
+
Both engines can show the browser's own answers — the pan, the
|
|
175
|
+
`pointercancel`, the click withheld — without a phone. Only through the paths
|
|
176
|
+
below, though: the obvious ones hide the race.
|
|
177
|
+
|
|
178
|
+
**Chrome**: drive DevTools' touch emulator, which goes through Chrome's
|
|
179
|
+
gesture detector with the **mobile** configuration (8px slop):
|
|
180
|
+
|
|
181
|
+
- a headed Chromium (`chromium.launch({ headless: false })`) — headless
|
|
182
|
+
delivers nothing through this path;
|
|
183
|
+
- on a CDP session, `Emulation.setEmitTouchEventsForMouse({ enabled: true,
|
|
184
|
+
configuration: "mobile" })`, then `Input.dispatchMouseEvent` with
|
|
185
|
+
`mousePressed` / `mouseMoved` / `mouseReleased` (`buttons: 1` while down). The
|
|
186
|
+
page receives real touches and pointer events of type `touch`;
|
|
187
|
+
- those calls are **never acknowledged**: send them without awaiting, with a
|
|
188
|
+
sleep between moves (~16ms is a frame).
|
|
189
|
+
|
|
190
|
+
`Input.dispatchTouchEvent` — what Playwright's `touchscreen` uses — goes
|
|
191
|
+
through the desktop configuration instead: a 15px slop. Anything deciding at
|
|
192
|
+
10px wins there, and the race that loses swipes on a phone is invisible.
|
|
193
|
+
|
|
194
|
+
**Safari**: the iOS simulator, driven by `safaridriver`, which turns W3C touch
|
|
195
|
+
actions into real UIKit touches — Safari's own pan recognizers compete for
|
|
196
|
+
them exactly as on a phone:
|
|
197
|
+
|
|
198
|
+
- boot a simulator (`xcrun simctl boot <udid>`), start `safaridriver -p 4444`,
|
|
199
|
+
and create a session with
|
|
200
|
+
`{ platformName: "iOS", "safari:useSimulator": true, "safari:deviceUDID": "<udid>" }`;
|
|
201
|
+
- a pointer action source with `parameters: { pointerType: "touch" }`: a
|
|
202
|
+
`pointerMove` with `origin: "viewport"`, a `pointerDown`, one `pointerMove`
|
|
203
|
+
per frame (`duration: 16`), a `pointerUp` — then `DELETE /session/<id>/actions`,
|
|
204
|
+
without which the `pointerup`, `touchend` and click are never delivered;
|
|
205
|
+
- **a native scroll hangs the actions call**, and every call after it. Treat the
|
|
206
|
+
hang as the observation — "Safari took the touch" — put a timeout on every
|
|
207
|
+
call, then recover with `xcrun simctl terminate <udid> com.apple.mobilesafari`
|
|
208
|
+
and a restarted `safaridriver`. The page's `localStorage` does not survive
|
|
209
|
+
that, so whatever must be read is read before the swipe.
|
|
210
|
+
|
|
211
|
+
What to synthesize is the hand's imperfection, as
|
|
212
|
+
[drag_to_travel.md](./drag_to_travel.md#verifying-a-gesture) says: a slow start
|
|
213
|
+
landing a report between 6 and 9px, steep first pixels, a report that jumps. What
|
|
214
|
+
to read: the `touchmove`s with `cancelable` and `defaultPrevented`, the
|
|
215
|
+
`pointercancel`, `scrollY`, and what the gesture itself says (for a travel,
|
|
216
|
+
`data-drag-travel-walking` on `:root` and the slide that ends up current).
|
|
217
|
+
|
|
218
|
+
What neither path shows: the lift of a real finger (the last reports before
|
|
219
|
+
`pointerup`), a real device's event timing (the order in which the browser's
|
|
220
|
+
cancel and the next report reach the page), a momentum tail killed by a landing
|
|
221
|
+
finger. Those remain device questions.
|
|
222
|
+
|
|
223
|
+
Reference: `src/layout/demos/lab/slide_container_touch_race.html` — a row of
|
|
224
|
+
slides that do not scroll and a row that do, for the two cases above.
|
package/docs/pan_zoom.md
CHANGED
|
@@ -44,7 +44,7 @@ for); the pinch not beginning as a pan under its first finger; the wheel and the
|
|
|
44
44
|
pinch writing one `zoom`; the capture, the pointer the browser drops, the click
|
|
45
45
|
the release leaves behind, and the next tap's click, which Chrome on Android
|
|
46
46
|
drops after a touch drag whose `touchmove`s nobody refused (see
|
|
47
|
-
[
|
|
47
|
+
[mobile_touch.md](./mobile_touch.md#a-tap-dropped-after-a-touch-drag)).
|
|
48
48
|
A pointer pans only once it has travelled a few px (`data-drag-threshold`), so a
|
|
49
49
|
tap stays a tap: a `longpress` declared beside `pan` still gets its hold, and a
|
|
50
50
|
`double_click` still gets its two presses. A press that does pan is the
|
package/docs/popup_open.md
CHANGED
|
@@ -320,6 +320,12 @@ So a dialog whose content is seeded once — an uncontrolled field on a
|
|
|
320
320
|
first render. Learning it afterwards would mean mounting on the previous subject
|
|
321
321
|
and correcting it, which is a flicker at best and stale fields at worst.
|
|
322
322
|
|
|
323
|
+
One opening does not follow that order: a `mount="while-opened"` popup pressed on
|
|
324
|
+
its own `anchor` (a picker's trigger, an expandable's UI part) has its content
|
|
325
|
+
built by the press, ahead of the click (see
|
|
326
|
+
[what the popup holds while it is closed](#what-the-popup-holds-while-it-is-closed)). A popup opened ON something is
|
|
327
|
+
opened by a command pressed elsewhere, and keeps the order above.
|
|
328
|
+
|
|
323
329
|
The two other places one could listen are not that moment, and it is worth
|
|
324
330
|
knowing why:
|
|
325
331
|
|
|
@@ -1008,7 +1014,8 @@ slots".
|
|
|
1008
1014
|
Being open is where the user is, and what a popup draws belongs to the screen
|
|
1009
1015
|
exactly the way a page's own data does. So a popup that loads something binds
|
|
1010
1016
|
its open state (above) and asks for its data with a `routeAction` whose params
|
|
1011
|
-
are `false` while it is closed
|
|
1017
|
+
are `false` while it is closed — and its content reads the question itself,
|
|
1018
|
+
without the part that says whether it is open:
|
|
1012
1019
|
|
|
1013
1020
|
```js
|
|
1014
1021
|
const groupSheetOpenSignal = stateSignal(false, {
|
|
@@ -1016,12 +1023,17 @@ const groupSheetOpenSignal = stateSignal(false, {
|
|
|
1016
1023
|
type: "boolean",
|
|
1017
1024
|
});
|
|
1018
1025
|
|
|
1026
|
+
// asked while the sheet is open
|
|
1019
1027
|
export const GROUP_MEMBERS = routeAction(GAME_ROUTE, USER.GET_MANY, () => {
|
|
1020
1028
|
if (!groupSheetOpenSignal.value) {
|
|
1021
1029
|
return false;
|
|
1022
1030
|
}
|
|
1023
1031
|
return { group: groupSignal.value };
|
|
1024
1032
|
});
|
|
1033
|
+
// read by the sheet, open or closed
|
|
1034
|
+
export const GROUP_MEMBERS_SHOWN = USER.GET_MANY.bindParams({
|
|
1035
|
+
group: groupSignal,
|
|
1036
|
+
});
|
|
1025
1037
|
```
|
|
1026
1038
|
|
|
1027
1039
|
```jsx
|
|
@@ -1030,15 +1042,16 @@ export const GROUP_MEMBERS = routeAction(GAME_ROUTE, USER.GET_MANY, () => {
|
|
|
1030
1042
|
</Dialog>;
|
|
1031
1043
|
|
|
1032
1044
|
const GroupMembers = () => {
|
|
1033
|
-
const [members] = useAsyncData(
|
|
1045
|
+
const [members] = useAsyncData(GROUP_MEMBERS_SHOWN); // reads, never runs
|
|
1034
1046
|
…
|
|
1035
1047
|
};
|
|
1036
1048
|
```
|
|
1037
1049
|
|
|
1038
1050
|
The same shape when the popup is one of many — a sheet on every card, opened
|
|
1039
1051
|
from wherever the card is read. Its open state then says which one, and the
|
|
1040
|
-
route action reads the id straight from it
|
|
1041
|
-
card is not a
|
|
1052
|
+
route action reads the id straight from it, while each card's content reads the
|
|
1053
|
+
question of its own card. Declared on the root route, since a card is not a
|
|
1054
|
+
page:
|
|
1042
1055
|
|
|
1043
1056
|
```js
|
|
1044
1057
|
const seatSheetSignal = stateSignal(undefined, {
|
|
@@ -1048,18 +1061,39 @@ const seatSheetSignal = stateSignal(undefined, {
|
|
|
1048
1061
|
});
|
|
1049
1062
|
const ANY_PAGE = route("/", { searchParams: { seat: seatSheetSignal } });
|
|
1050
1063
|
|
|
1064
|
+
export const seatableUsersOf = (gameId) => ({ game: gameId });
|
|
1051
1065
|
export const SEATABLE_USERS = routeAction(ANY_PAGE, USER.GET_MANY, () => {
|
|
1052
1066
|
const gameId = seatSheetSignal.value;
|
|
1053
|
-
return gameId ?
|
|
1067
|
+
return gameId ? seatableUsersOf(gameId) : false;
|
|
1054
1068
|
});
|
|
1055
1069
|
```
|
|
1056
1070
|
|
|
1057
1071
|
```jsx
|
|
1058
1072
|
<Dialog signal={seatSheetSignal} value={game.id}>
|
|
1059
|
-
<SeatableUsers />
|
|
1060
|
-
</Dialog
|
|
1073
|
+
<SeatableUsers gameId={game.id} />
|
|
1074
|
+
</Dialog>;
|
|
1075
|
+
|
|
1076
|
+
const SeatableUsers = ({ gameId }) => {
|
|
1077
|
+
const [users] = useAsyncData(
|
|
1078
|
+
USER.GET_MANY.bindParams(seatableUsersOf(gameId)),
|
|
1079
|
+
);
|
|
1080
|
+
…
|
|
1081
|
+
};
|
|
1061
1082
|
```
|
|
1062
1083
|
|
|
1084
|
+
**The content reads its question, never the route action.** The route action's
|
|
1085
|
+
params are `false` while the popup is closed, and for the route action that is
|
|
1086
|
+
right: it says _when_ to ask. Read by the content, the same `false` says there
|
|
1087
|
+
is nothing to show — `useAsyncData` hands back `undefined` the moment the popup
|
|
1088
|
+
closes, the list empties while hidden, and the next opening builds it again from
|
|
1089
|
+
the top: the scroll position is lost, and every row is rendered once more for an
|
|
1090
|
+
answer that never left the store. When the popup is one of many, it is worse: the
|
|
1091
|
+
route action follows whichever popup is open, so a card's sheet kept mounted
|
|
1092
|
+
since an earlier opening redraws with another card's rows each time that one
|
|
1093
|
+
opens. Closing a popup does not change what it is about, so what it reads leaves
|
|
1094
|
+
the open state out. Equal params share one instance — the content reads the very
|
|
1095
|
+
one the route action runs, so nothing is asked twice and nothing is copied.
|
|
1096
|
+
|
|
1063
1097
|
**A popup that waits holds its own `<Loading>`**, like every other part of a
|
|
1064
1098
|
screen that can wait — it is not built into `Dialog`/`Popover`, because only the
|
|
1065
1099
|
code inside knows whether anything in there loads at all:
|
|
@@ -1116,7 +1150,8 @@ become a popup's private request — asked for late, and asked for alone.
|
|
|
1116
1150
|
|
|
1117
1151
|
A closed popup builds nothing: `children` are mounted on the first open, and
|
|
1118
1152
|
stay mounted afterwards — a reopened popup finds its scroll position and its
|
|
1119
|
-
half-typed form where it left them
|
|
1153
|
+
half-typed form where it left them, as long as the data it draws does not leave
|
|
1154
|
+
with the close (see [A popup that loads data](#a-popup-that-loads-data)).
|
|
1120
1155
|
|
|
1121
1156
|
"Closed" is two states, not one — never opened yet, and closed again after an
|
|
1122
1157
|
opening — so the `mount` prop answers both at once:
|
|
@@ -1134,7 +1169,9 @@ off it, fields a surrounding form submits, a size measured from outside.
|
|
|
1134
1169
|
|
|
1135
1170
|
Whatever the value but `"while-opened"`, intent on the anchor — a pointer
|
|
1136
1171
|
entering it, focus landing in it — builds the content ahead of the click that
|
|
1137
|
-
will open it.
|
|
1172
|
+
will open it. A `"while-opened"` popup is warmed by a press on its anchor only,
|
|
1173
|
+
and a press that ends without opening it — a scroll, a drag, a release
|
|
1174
|
+
elsewhere — throws the content away: there is still one content at a time.
|
|
1138
1175
|
|
|
1139
1176
|
`"while-opened"` is what an uncontrolled field seeded from a `defaultValue`
|
|
1140
1177
|
needs: without it, a popup reopened after the underlying value changed still
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jsenv/navi",
|
|
3
|
-
"version": "0.29.
|
|
3
|
+
"version": "0.29.399",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Library of components including navigation to create frontend applications",
|
|
6
6
|
"repository": {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"preact": "^11.0.0-rc.2"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@jsenv/dom": "0.17.
|
|
37
|
+
"@jsenv/dom": "0.17.75",
|
|
38
38
|
"@jsenv/humanize": "1.8.5",
|
|
39
39
|
"@jsenv/validity": "0.4.5"
|
|
40
40
|
},
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
# A tap dropped after a touch drag
|
|
2
|
-
|
|
3
|
-
On Chrome for Android, the tap that follows a finger-driven drag does not fire
|
|
4
|
-
its `click` when it comes quickly: `pointerdown` and `pointerup` arrive, the
|
|
5
|
-
synthesized `click` (and the `mousedown`/`mouseup` compat events) does not. Wait
|
|
6
|
-
about half a second and the same tap works. It is what makes a wheel feel
|
|
7
|
-
broken — spin it, tap "Définir" at once, and the command never runs.
|
|
8
|
-
|
|
9
|
-
It is the browser's, not ours: a bare `div` moved by hand under `pointermove`,
|
|
10
|
-
with no framework, reproduces it. An un-prevented touch drag feeds Chromium's
|
|
11
|
-
gesture recognizer, which then treats the next tap as not clean enough to be a
|
|
12
|
-
click. `touch-action` changes nothing (`none`, `manipulation`, `pan-y` all drop
|
|
13
|
-
it), `preventDefault()` on the pointer events changes nothing either — only
|
|
14
|
-
`preventDefault()` on the **touch** events, `touchstart` or `touchmove`, keeps
|
|
15
|
-
the following click alive. A native scroll owned by the browser leaves it alive
|
|
16
|
-
too, which is no help to a surface moved by JS.
|
|
17
|
-
|
|
18
|
-
So a surface that moves under a finger by JS refuses `touchmove` while a drag is
|
|
19
|
-
active, from a non-passive listener. `touchmove` rather than `touchstart`: the
|
|
20
|
-
move is the event whose default the drag actually owns, and preventing
|
|
21
|
-
`touchstart` also suppresses the surface's own focus and synthesized events.
|
|
22
|
-
|
|
23
|
-
```js
|
|
24
|
-
const onTouchMove = (e) => {
|
|
25
|
-
if (drag) {
|
|
26
|
-
e.preventDefault();
|
|
27
|
-
}
|
|
28
|
-
};
|
|
29
|
-
viewport.addEventListener("touchmove", onTouchMove, { passive: false });
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
Two theories were tried first and disproven on the device, so they are not
|
|
33
|
-
worth trying again: pointer capture on touch, and a momentum re-render moving
|
|
34
|
-
the element under the tap.
|
|
35
|
-
|
|
36
|
-
Reference: `onTouchMove` in `src/control/wheel/wheel.jsx`; the reductions, one
|
|
37
|
-
variable per row, in `src/control/demos/lab/` (`tap_after_drag_experiment.html`,
|
|
38
|
-
`surface_css_matrix_experiment.html`, `preventdefault_matrix_experiment.html`);
|
|
39
|
-
Chromium's `gesture_provider.cc` (`ignore_single_tap_`, reset on the next down)
|
|
40
|
-
and `tap_suppression_controller.cc` (the tap that stops a fling, 180ms) for the
|
|
41
|
-
two mechanisms that do NOT explain it.
|