@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.
@@ -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
- - `mobile_tap_suppression_after_drag.md` — Chrome Android drops the click that
214
- follows a JS-driven touch drag; refuse `touchmove` while dragging.
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
 
@@ -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 ~10px, and the axis it leans on
39
- then is the axis it walks, for good — how that axis is read, and why the
40
- reading is biased, has [a section of its own
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. That report is ~10-25px of movement, and for a thumb those pixels
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, and it holds off only while the touchmoves are being refused;
113
- - refusing a touchmove is **irreversible for that touch**: whether the stream
114
- can scroll is decided at its first refusable move, and un-refusing later
115
- resurrects nothing;
116
- - so every frame spent gathering evidence is a frame of the page's scroll
117
- spent, for good. A decision deferred until the arc has proven itself would
118
- make every genuinely vertical swipe over the box a dead gesture — the exact
119
- bug, mirrored onto the page.
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. A first report steeper than the
123
- bias (a start more than twice off-axis) is genuinely ambiguous with a scroll
124
- and goes to the page — which at least answers it visibly.
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. Three details make
557
- it hold:
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, kill a momentum tail the moment a finger lands — does not
732
- exist in emulation at all. Claims about it are only ever settled on a
733
- device (and the Firefox wheel limitation above is the same kind of fact).
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
@@ -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 never
150
- prefetched: a prefetch has no address, the id may still be chosen.
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
- [mobile_tap_suppression_after_drag.md](./mobile_tap_suppression_after_drag.md)).
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
@@ -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(GROUP_MEMBERS); // reads, never runs
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. Declared on the root route, since a
1041
- card is not a page:
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 ? { game: gameId } : false;
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.397",
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.74",
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.