@jsenv/navi 0.29.416 → 0.29.418
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 +1595 -1103
- package/dist/dev/jsenv_navi.js.map +19 -17
- package/dist/jsenv_navi.js +1566 -1088
- package/dist/jsenv_navi.js.map +19 -17
- package/docs/AI_INSTRUCTIONS.md +13 -6
- package/docs/drag_interactions.md +297 -401
- package/docs/drag_to_travel.md +157 -754
- package/docs/interactions.md +170 -328
- package/docs/mobile_touch.md +35 -0
- package/docs/resource.md +13 -0
- package/docs/route_transitions.md +35 -7
- package/docs/scroll.md +23 -5
- package/docs/state_binding.md +39 -84
- package/package.json +2 -2
package/docs/drag_to_travel.md
CHANGED
|
@@ -1,247 +1,101 @@
|
|
|
1
1
|
# Travelling by drag, and who owns the gesture
|
|
2
2
|
|
|
3
|
-
A
|
|
4
|
-
one: slides inside a box (`SlideContainer`), pages that are URLs
|
|
5
|
-
(`RouteTravel`)
|
|
6
|
-
|
|
7
|
-
a
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
and are referenced from here rather than restated.
|
|
3
|
+
A travel is a pointer or a wheel pushing a whole screen aside to bring in the
|
|
4
|
+
next one: slides inside a box (`SlideContainer`), pages that are URLs
|
|
5
|
+
(`RouteTravel`), a docked `Dialog` or a `SidePanel` pushed back the way it came
|
|
6
|
+
to close it, a row swiped aside with `interactions`. One module answers what
|
|
7
|
+
such a gesture IS — `drag_to_travel.js` in @jsenv/dom — and all of them read
|
|
8
|
+
it, so a hand never has to learn two sets of numbers.
|
|
9
|
+
|
|
10
|
+
What an application has to know is who gets a press that lands inside such a
|
|
11
|
+
box. It is decided at the press, from the DOM, before anything moves — so read
|
|
12
|
+
[Who owns a gesture](#who-owns-a-gesture) before putting anything that reads
|
|
13
|
+
the pointer inside a box that travels.
|
|
15
14
|
|
|
16
15
|
- [What the rules are](#what-the-rules-are)
|
|
17
|
-
- [The axis is read once, and read with a bias](#the-axis-is-read-once-and-read-with-a-bias)
|
|
18
|
-
- [Two inputs, one travel](#two-inputs-one-travel)
|
|
19
16
|
- [Who owns a gesture](#who-owns-a-gesture)
|
|
20
17
|
- [Boxes inside boxes](#boxes-inside-boxes)
|
|
21
18
|
- [Something being carried inside a box](#something-being-carried-inside-a-box)
|
|
22
|
-
- [A popup pushed back the way it came](#a-popup-pushed-back-the-way-it-came)
|
|
23
19
|
- [A surface in the top layer](#a-surface-in-the-top-layer)
|
|
24
|
-
- [The browser also wants to answer the gesture](#the-browser-also-wants-to-answer-the-gesture)
|
|
25
|
-
- [What is contained, and what still leaks](#what-is-contained-and-what-still-leaks)
|
|
26
|
-
- [A hand reaching for something still moving is reaching for THAT thing](#a-hand-reaching-for-something-still-moving-is-reaching-for-that-thing)
|
|
27
|
-
- [A hand that does not stop at the end of a page is asking for the next one](#a-hand-that-does-not-stop-at-the-end-of-a-page-is-asking-for-the-next-one)
|
|
28
|
-
- [Two ways of holding a render still, and why only one is global](#two-ways-of-holding-a-render-still-and-why-only-one-is-global)
|
|
29
|
-
- [On a touchscreen, the browser takes the gesture unless it is refused](#on-a-touchscreen-the-browser-takes-the-gesture-unless-it-is-refused)
|
|
30
20
|
- [A navi component that reads the pointer marks ITSELF](#a-navi-component-that-reads-the-pointer-marks-itself)
|
|
21
|
+
- [What is contained, and what still leaks](#what-is-contained-and-what-still-leaks)
|
|
31
22
|
- [The two consumers that travel between screens](#the-two-consumers-that-travel-between-screens)
|
|
32
|
-
- [Asking for a page while one is on its way](#asking-for-a-page-while-one-is-on-its-way)
|
|
33
23
|
- [One gesture that bar cannot follow](#one-gesture-that-bar-cannot-follow)
|
|
34
|
-
- [Verifying a gesture](#verifying-a-gesture)
|
|
35
24
|
|
|
36
25
|
## What the rules are
|
|
37
26
|
|
|
38
27
|
- 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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
delivered in one report sets off with nothing left of itself to have pulled —
|
|
51
|
-
it moves nothing on screen and is refused at the release for it, and what the
|
|
52
|
-
hand feels is a swipe that did strictly nothing.
|
|
28
|
+
finger or a pen, and the axis it leans on then is the axis it walks, for good:
|
|
29
|
+
a diagonal would ask for two travels at once when only one screen can arrive.
|
|
30
|
+
The reading leans towards the box's own axis — a thumb swiping sideways moves
|
|
31
|
+
along an arc, and its first pixels lean off-axis far more than the swipe does
|
|
32
|
+
— so the cross axis takes the press only when it clearly dominates, and a
|
|
33
|
+
scroll, near-pure on its axis from the first pixel, still leaves whole. A
|
|
34
|
+
finger's 6px is a deadline rather than a feel: the browser commits a touch to
|
|
35
|
+
its own pan about 8px in, and whatever is read after that is read about a
|
|
36
|
+
pointer it has already cancelled (see
|
|
37
|
+
[mobile_touch.md](./mobile_touch.md#the-browser-decides-about-8px-in-and-does-not-wait)).
|
|
38
|
+
Its price is the click of a tap that shook more than 6px.
|
|
53
39
|
- **A hand still moving when it lets go says where this goes, and it says it
|
|
54
|
-
both ways.** Towards what it was bringing in, the travel carries on
|
|
55
|
-
slowly
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
is judged by the picture — about a third of a box — because there the
|
|
60
|
-
position is the only witness left.
|
|
61
|
-
- **A travel caught in flight and thrown on the way it was going asks for the
|
|
62
|
-
one after.** Two swipes the same way are two screens, however close
|
|
63
|
-
together: the second one is not a vote for the screen already arriving. See
|
|
64
|
-
[below](#a-hand-reaching-for-something-still-moving-is-reaching-for-that-thing).
|
|
65
|
-
- **The bars those verdicts compare against sit well below the speed the
|
|
66
|
-
fingertip actually has, and they must.** Velocity is averaged over a trailing
|
|
67
|
-
window (pointer events arrive irregularly, and the last one before a release
|
|
68
|
-
often repeats its coordinates — measured on that pair alone, every throw ends
|
|
69
|
-
at zero), and the release itself adds one more sample at the same place a
|
|
70
|
-
moment later. Both pull the measure down from the hand's peak: a threshold
|
|
71
|
-
sized against the hand refuses the hand.
|
|
40
|
+
both ways.** Towards what it was bringing in, the travel carries on, however
|
|
41
|
+
slowly, once it has pulled a few pixels. Away from it, fast, everything goes
|
|
42
|
+
back whatever the distance already covered — otherwise a screen caught in
|
|
43
|
+
flight and thrown back still arrives. Only a release at rest is judged by the
|
|
44
|
+
picture: about a third of the box.
|
|
72
45
|
- Pulling towards nothing follows the finger at a fraction of its distance and
|
|
73
46
|
comes back: a wall one can lean on, never walk through.
|
|
74
47
|
- **A direction that will be refused is one of those walls, and it must be a
|
|
75
|
-
wall from the first pixel.** Whatever holds the user where they are — a
|
|
76
|
-
waiting for its answer, a
|
|
77
|
-
the gesture
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
- A
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
revisited. For a thumb that report is a few pixels of movement, and those pixels
|
|
97
|
-
misstate the gesture: a thumb swiping sideways moves along an ARC, and the
|
|
98
|
-
start of the arc leans off-axis far more than the swipe does. Read even
|
|
99
|
-
(whichever axis covered more), the lean hands the whole press to an axis
|
|
100
|
-
nobody meant — and a press given up is given up WHOLE: the hand's remaining
|
|
101
|
-
hundred pixels are read by no one, and what it feels is a swipe that did
|
|
102
|
-
nothing at all.
|
|
103
|
-
|
|
104
|
-
So on a box that travels one axis, the reading is biased towards that axis:
|
|
105
|
-
the cross axis takes the press only when it clearly dominates the first
|
|
106
|
-
report (2×, `AXIS_CROSS_DOMINANCE`). A gesture that really belongs to the
|
|
107
|
-
other axis — a scroll — is near-pure on its axis from the first pixel (4-6×),
|
|
108
|
-
so it still leaves whole, at once, in the same tick. A box that travels both
|
|
109
|
-
axes keeps the even reading: every answer is a travel there, and there is
|
|
110
|
-
nothing to protect.
|
|
111
|
-
|
|
112
|
-
Why the decision cannot simply wait for more evidence — this boundary is
|
|
113
|
-
physics, not caution:
|
|
114
|
-
|
|
115
|
-
- the browser is racing for the same press, on the axis `touch-action` leaves
|
|
116
|
-
it — and on every axis over a scroller inside the box, since a scroller
|
|
117
|
-
resets `touch-action` for what it holds;
|
|
118
|
-
- and it does not wait for us: **about 8px in, it commits the touch to its own
|
|
119
|
-
pan**, reading the direction evenly (Chrome at the first `touchmove` it
|
|
120
|
-
sends, Safari at the one near 9px). From then on refusing a `touchmove`
|
|
121
|
-
changes nothing: the pointer is cancelled, and the swipe does nothing — or
|
|
122
|
-
the travel that had started goes back;
|
|
123
|
-
- refusing a touchmove is **irreversible for that touch** too, the other way
|
|
124
|
-
round: one refused before that moment keeps the whole touch from scrolling,
|
|
125
|
-
and un-refusing later resurrects nothing;
|
|
126
|
-
- so the reading has to be done before the browser's, and every frame spent
|
|
127
|
-
gathering evidence past it is not evidence, it is a lost gesture. A decision
|
|
128
|
-
deferred until the arc has proven itself would make every genuinely vertical
|
|
129
|
-
swipe over the box a dead gesture — the exact bug, mirrored onto the page.
|
|
130
|
-
|
|
131
|
-
Hence the finger's threshold, 6px of distance (`DRAG_START_THRESHOLD_TOUCH`):
|
|
132
|
-
under the browser's 8px with a margin, because Safari does not wait for the
|
|
133
|
-
answer to a report that jumps over its decision. A distance and not a per-axis
|
|
134
|
-
reach, because the browser measures its own slop as one. Its price is the
|
|
135
|
-
click of a tap that shook more than 6px. The engines' side of all this —
|
|
136
|
-
measured, per engine, with how to reproduce it — is
|
|
137
|
-
[mobile_touch.md](./mobile_touch.md).
|
|
138
|
-
|
|
139
|
-
The bias moves the frame-one boundary to where the two hands actually
|
|
140
|
-
separate; nothing can remove the boundary. It only works because it is read
|
|
141
|
-
first: read after the browser's own reading, it is never consulted, the
|
|
142
|
-
pointer already being cancelled. A first report steeper than the bias (a start
|
|
143
|
-
more than twice off-axis) is genuinely ambiguous with a scroll and goes to the
|
|
144
|
-
page — which at least answers it visibly.
|
|
145
|
-
|
|
146
|
-
## Two inputs, one travel
|
|
147
|
-
|
|
148
|
-
A thumb dragging the page and a wheel pushing it sideways both ask to travel,
|
|
149
|
-
and both consumers take both. But they do not ask for the same thing, and this
|
|
150
|
-
is the one place where the two really part:
|
|
151
|
-
|
|
152
|
-
- **a hand HOLDS a screen** and says where to put it. It is owed every pixel, it
|
|
153
|
-
may change its mind halfway, and letting go is a question with an answer
|
|
154
|
-
(`onStart`/`onPull`/`onEnd`);
|
|
155
|
-
- **a wheel POINTS at the next screen** and says "that one". What travels is a
|
|
156
|
-
row of slides, not a long strip one stops in the middle of, so one push moves
|
|
157
|
-
one slide — `onStep`, and the travel that follows plays at its own pace,
|
|
158
|
-
exactly as it would from a tab pressed or an arrow key.
|
|
159
|
-
|
|
160
|
-
A wheel gesture also has **no press and no release**: it is a stream that begins
|
|
161
|
-
with its first event and ends in silence — a gap long enough to mean the hand is
|
|
162
|
-
gone, and long enough to survive the busiest frames of a travel (a navigation, a
|
|
163
|
-
render, a picture being taken).
|
|
164
|
-
|
|
165
|
-
Cutting that stream into pushes is the whole difficulty, because momentum keeps
|
|
166
|
-
arriving with the fingers gone, and counted it turns one flick into five slides.
|
|
167
|
-
Two things say "the hand asked again", and a stream only ever has one of them:
|
|
168
|
-
|
|
169
|
-
- **a gap.** A mouse spends tens of milliseconds between two notches; a
|
|
170
|
-
trackpad, which sends whether or not the fingers are still there, never does.
|
|
171
|
-
So a notch is a step, however fast the wheel is spun;
|
|
172
|
-
- **a number that grows after having shrunk.** Momentum only ever weakens, so a
|
|
173
|
-
trackpad picking up again is a hand pushing again. One flick — rise, peak,
|
|
174
|
-
decay — is therefore one step, and a second flick over the tail of the first
|
|
175
|
-
is heard as its own.
|
|
176
|
-
|
|
177
|
-
Both are read against a stream that never announces itself: the browser sees
|
|
178
|
-
one unbroken burst (the tail of the first push never went silent), so "the
|
|
179
|
-
hand asked again" is reconstructed, never received. Two consequences are easy
|
|
180
|
-
to get wrong from there:
|
|
181
|
-
|
|
182
|
-
- **a second push is answered like a first event — a screen, now** — never with
|
|
183
|
-
credit towards one. The price a screen after the first costs (an accumulated
|
|
184
|
-
delta, deliberately steep) exists to keep momentum from walking slides on its
|
|
185
|
-
own; charged to a hand that pushed again over the tail, it reads as "my
|
|
186
|
-
swipes are ignored until I wait for silence". The stream itself says which is
|
|
187
|
-
which: once it has faded into a recognized tail, growing **twice in a row**
|
|
188
|
-
is a hand (`WHEEL_REGROW_RUN`) — decay jitter bumps up in isolated events and
|
|
189
|
-
never twice consecutively.
|
|
190
|
-
- **cross-axis events are swallowed absolutely, and they renew nothing.** A
|
|
191
|
-
hand is never perfectly straight, so events leaning the other way are the
|
|
192
|
-
gesture's own wobble and are eaten with the rest — and there the swallowing
|
|
193
|
-
has to be total, because no per-event reading tells a scroll's onset from
|
|
194
|
-
that wobble. It was tried, and every filter leaks: end-of-fade crumbs are a
|
|
195
|
-
pixel or two landing on either axis (a `(0,1)` event is "clearly the other
|
|
196
|
-
axis" by any dominance test), a slightly diagonal swipe's tail carries
|
|
197
|
-
hand-sized deltas on both axes, and one crumb let through scrolls the
|
|
198
|
-
slide's own content under the travel — a header creeping off the edge of the
|
|
199
|
-
box, one pixel per leak. Do not re-attempt a "hand it back when it is really
|
|
200
|
-
a scroll" heuristic; the honest boundary is elsewhere: a swallowed
|
|
201
|
-
cross-axis event does NOT renew the claim. Silence is the only end a wheel
|
|
202
|
-
gesture has, and it is silence ON ITS AXIS — renewed by what it eats, the
|
|
203
|
-
gesture would outlive its own stream, and a hand starting a scroll over the
|
|
204
|
-
tail would extend, event by event, the very deadness it was waiting out.
|
|
205
|
-
Left to lapse, the claim dies shortly after the travel axis goes quiet (on a
|
|
206
|
-
system where touching the surface kills the old momentum, that is moments
|
|
207
|
-
after the new scroll begins), and the browser answers the rest of the scroll
|
|
208
|
-
itself.
|
|
209
|
-
|
|
210
|
-
Taking it is also the only way to stop the browser from answering it: on a
|
|
211
|
-
laptop a horizontal two-finger swipe IS the back-navigation gesture, and a
|
|
212
|
-
region that neither takes it nor lets it go is the worst of the three — the page
|
|
213
|
-
rocks and nothing happens.
|
|
214
|
-
|
|
215
|
-
Not the same thing as **carrying an object** across the page, although both read
|
|
216
|
-
the pointer through the same loop
|
|
217
|
-
([drag_gesture.js](../../dom/src/interaction/drag/drag_gesture.js)): a carry has
|
|
218
|
-
it lay a backdrop over the document, make everything else `inert`, take the
|
|
219
|
-
focus and block the scroll keys. Here nothing is picked up and the page must keep
|
|
220
|
-
its focus and its scrolling while a screen slides, so the travel asks it for none
|
|
221
|
-
of that (`documentInteractions: "manual"`). Same loop, other gesture.
|
|
48
|
+
wall from the first pixel.** Whatever holds the user where they are — a
|
|
49
|
+
`<Slide required>` waiting for its answer, a `preventNavNext` — is read where
|
|
50
|
+
the gesture is ARMED, not only when it is let go of. Read at the release
|
|
51
|
+
alone, the screen one may not reach is walked to, read on the way, and then
|
|
52
|
+
taken back: a wall that shows what is behind it and pushes you back reads as a
|
|
53
|
+
bug even when it is a rule. What a lock answers is therefore not "is there a
|
|
54
|
+
screen that way" but "may I go there".
|
|
55
|
+
- **A hand that walks a whole box and keeps going is asking for the next one**,
|
|
56
|
+
and the gesture walks on into it without being let go of.
|
|
57
|
+
- **A wheel push moves one screen**, the way an arrow key does — never a
|
|
58
|
+
distance. What keeps arriving after the fingers are gone is momentum and is
|
|
59
|
+
not counted: a second screen takes a second push, or one that insists.
|
|
60
|
+
- **A press on a travel still moving is the next swipe first.** A step the way
|
|
61
|
+
it is going asks for the screen after, and the travel goes on there without
|
|
62
|
+
ever having stopped; only a step against it, or a finger saying nothing for
|
|
63
|
+
about 100ms, catches it where it stands. Let go before either — a tap — and
|
|
64
|
+
the travel was never touched.
|
|
65
|
+
|
|
66
|
+
How to drive a gesture without a device, and why the obvious way hides the race
|
|
67
|
+
that loses swipes on a phone, is
|
|
68
|
+
[mobile_touch.md](./mobile_touch.md#verifying-without-a-device).
|
|
222
69
|
|
|
223
70
|
## Who owns a gesture
|
|
224
71
|
|
|
225
72
|
Five things can claim a pointer that landed on a travelling box, and all five
|
|
226
|
-
are read before the box moves:
|
|
227
|
-
|
|
228
|
-
1. **What says so itself.**
|
|
229
|
-
|
|
73
|
+
are read at the press, before the box moves:
|
|
74
|
+
|
|
75
|
+
1. **What says so itself.** What the browser already answers the pointer on — a
|
|
76
|
+
field, a `contenteditable`, a `select`, a range; a dedicated drag handle
|
|
77
|
+
(`data-drag-handle`); anything carrying `data-no-drag-travel`; an element
|
|
78
|
+
whose `selfInteractions` names `drag` or `"*"` (see
|
|
79
|
+
[interactions.md](./interactions.md#an-affordance-inside-somebody-elses-box-selfinteractions));
|
|
80
|
+
and a popover or a `<dialog>` inside the box, which is a layer over it. A
|
|
81
|
+
button, a link or a checkbox is not among them on purpose: a drag from one
|
|
82
|
+
travels, and the click it would have made is swallowed. A field that only
|
|
83
|
+
reads the press — a picker's façade is an `<input>` that opens a popup — says
|
|
84
|
+
so with `data-press-only`, and a travel starts there like anywhere else.
|
|
230
85
|
2. **A scroller in between with room left that way.** It keeps the gesture until
|
|
231
86
|
it has no room left, and only then hands the travel over — so a row that
|
|
232
87
|
scrolls sideways inside a page still scrolls sideways.
|
|
233
|
-
3. **Another travelling box in between
|
|
234
|
-
|
|
235
|
-
4. **Something in between that is picked up and carried
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
came](#a-popup-pushed-back-the-way-it-came).
|
|
88
|
+
3. **Another travelling box in between** — see
|
|
89
|
+
[Boxes inside boxes](#boxes-inside-boxes).
|
|
90
|
+
4. **Something in between that is picked up and carried** — see
|
|
91
|
+
[Something being carried inside a box](#something-being-carried-inside-a-box).
|
|
92
|
+
5. **A surface in the top layer in between** — see
|
|
93
|
+
[A surface in the top layer](#a-surface-in-the-top-layer).
|
|
94
|
+
|
|
95
|
+
And one thing narrows it from the other end: a docked `Dialog` reads the press
|
|
96
|
+
only on its grip — its header, plus anything carrying `data-swipe-grip` — so a
|
|
97
|
+
board, a map or a list it holds keeps its own gestures, and a sheet with neither
|
|
98
|
+
is not pushed back at all. A `SidePanel` is pushed from its whole surface.
|
|
245
99
|
|
|
246
100
|
### Boxes inside boxes
|
|
247
101
|
|
|
@@ -256,54 +110,32 @@ to be declared for this: each box says which axes it travels in the DOM
|
|
|
256
110
|
read.
|
|
257
111
|
|
|
258
112
|
Decided at the press, once and for all: from the first pixel the gesture belongs
|
|
259
|
-
to whoever asked the browser for the pointer last, which is the outermost box
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
it does when it is alone. Travelling the box around it means starting the gesture
|
|
264
|
-
outside it.
|
|
113
|
+
to whoever asked the browser for the pointer last, which is the outermost box.
|
|
114
|
+
So an inner box sitting on its last slide does not hand the gesture over
|
|
115
|
+
mid-drag: it leans on its wall, the way it does when it is alone. Travelling the
|
|
116
|
+
box around it means starting the gesture outside it.
|
|
265
117
|
|
|
266
118
|
### Something being carried inside a box
|
|
267
119
|
|
|
268
|
-
A drag
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
about the other.
|
|
120
|
+
A drag (`interactions={{ move, reorder, land, toss, leave }}`, see
|
|
121
|
+
[drag_interactions.md](./drag_interactions.md)) reads the same press a travel
|
|
122
|
+
does and holds the pointer from it, so the two share a finger exactly as two
|
|
123
|
+
travelling boxes do: what is picked up says which axes it walks, and the box
|
|
124
|
+
above keeps what is left. A list reordered along its own line inside a row of
|
|
125
|
+
slides swiped sideways: both gestures live, and neither had to be told about the
|
|
126
|
+
other.
|
|
276
127
|
|
|
277
128
|
When the two want the same axes — a piece carried both ways inside a sheet
|
|
278
129
|
pushed down to close it — nothing is left and the press is the piece's, whole.
|
|
279
130
|
That is the right way round: the box above is a surface, and the thing in it is
|
|
280
131
|
what the hand came for.
|
|
281
132
|
|
|
282
|
-
Something that is NOT free to be carried takes nothing: `move: "refuse"`
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
and [pan_zoom.md](./pan_zoom.md)).
|
|
289
|
-
|
|
290
|
-
The exception is a **dedicated handle** (`data-drag-handle`), which has no axis:
|
|
291
|
-
it is a place whose only purpose is to be taken hold of, from the first pixel,
|
|
292
|
-
so it takes the press outright.
|
|
293
|
-
|
|
294
|
-
### A popup pushed back the way it came
|
|
295
|
-
|
|
296
|
-
A `Dialog` docked to an edge (`dockedOnSmallTouchScreen`) and a `SidePanel`
|
|
297
|
-
close by being pushed back the way they came in — a third consumer
|
|
298
|
-
of this same travel, `swipe_to_close.js`. A popup that names a **grip** reads the
|
|
299
|
-
press only there: for a `Dialog` that is its header, plus anything carrying
|
|
300
|
-
`data-swipe-grip`. Everything else it holds is content the finger came to operate
|
|
301
|
-
— a board a piece is dragged across, a map, a list — and a press there never
|
|
302
|
-
reaches the travel at all, whatever it is made of.
|
|
303
|
-
|
|
304
|
-
So a sheet with no header and nothing marked is not pushed back; it closes by
|
|
305
|
-
its own controls, the backdrop and Escape. A `SidePanel` names no grip and is
|
|
306
|
-
pushed from its whole surface, which suits a panel made of nothing else.
|
|
133
|
+
Something that is NOT free to be carried takes nothing: `move: "refuse"` walks
|
|
134
|
+
no axis, so a swipe that starts on it is the swipe of the box it stands in (see
|
|
135
|
+
[drag_interactions.md](./drag_interactions.md#the-hand-pulls-and-nothing-follows-refuse)).
|
|
136
|
+
A dedicated handle (`data-drag-handle`) is the opposite case: it has no axis, it
|
|
137
|
+
is a place whose only purpose is to be taken hold of, so it takes the press
|
|
138
|
+
outright.
|
|
307
139
|
|
|
308
140
|
### A surface in the top layer
|
|
309
141
|
|
|
@@ -320,521 +152,92 @@ surface, and behind is not under the finger. `showModal()` does not do this on
|
|
|
320
152
|
its own: the rest of the document is made inert, but the press still bubbles out
|
|
321
153
|
of the (not inert) dialog to a listener that sits above it.
|
|
322
154
|
|
|
323
|
-
### The browser also wants to answer the gesture
|
|
324
|
-
|
|
325
|
-
A gesture that is already answered — something is being dragged — must not be
|
|
326
|
-
answered a second time by the browser. Two of its answers show up as "the whole
|
|
327
|
-
page moved a little, and it looked wrong":
|
|
328
|
-
|
|
329
|
-
- **the leftovers of a scroll**, handed up the chain until something moves: a
|
|
330
|
-
list inside the box reaches its end and the page scrolls behind the travel.
|
|
331
|
-
`overscroll-behavior-<axis>: contain !important`, **written once and for all,
|
|
332
|
-
never while the finger is down**: a browser decides what a gesture may do when
|
|
333
|
-
the gesture BEGINS (at the touchstart, at the first wheel event), so a
|
|
334
|
-
property written after that decision arrives too late for the gesture it was
|
|
335
|
-
meant for. That is what "usually it does not move, sometimes it does" is made
|
|
336
|
-
of — and it is why none of this can be done in JS at the moment it is needed.
|
|
337
|
-
On the travelling axis only — the other one is the content's own scrolling,
|
|
338
|
-
and containing does not stop scrolling anyway, it stops spilling. WHERE that
|
|
339
|
-
property is written is a question of its own, and the engines do not answer it
|
|
340
|
-
the same way: see [What is contained, and what still
|
|
341
|
-
leaks](#what-is-contained-and-what-still-leaks);
|
|
342
|
-
- **the elastic bounce** at the end of a page, and the swipe that goes back in
|
|
343
|
-
history with it: `overscroll-behavior: none` on the document while a finger is
|
|
344
|
-
down. Same lateness applies, so this is a last resort behind the rule above
|
|
345
|
-
rather than the thing that does the work;
|
|
346
|
-
- **the selection** a drag paints across the text it crosses: `user-select:
|
|
347
|
-
none`, but only once the press has become a travel — a press on text IS how
|
|
348
|
-
one selects it, and nothing has said otherwise yet. What its first pixels
|
|
349
|
-
selected is collapsed at that same moment: it was the beginning of the
|
|
350
|
-
travel, not a selection.
|
|
351
|
-
|
|
352
|
-
Both are written by the gesture itself (`data-drag-travel-gesture` and
|
|
353
|
-
`data-drag-travel-walking` on `:root`), so a page that bounces the rest of the
|
|
354
|
-
time goes on bouncing. `preventDefault()` on each move says the same thing to
|
|
355
|
-
the browser for what those two properties do not cover.
|
|
356
|
-
|
|
357
|
-
### What is contained, and what still leaks
|
|
358
|
-
|
|
359
|
-
Containing is only ever read on a **scroll container** — an element whose
|
|
360
|
-
`overflow` is `hidden`, `auto` or `scroll`, whether or not it has anything to
|
|
361
|
-
scroll (`clip` cuts without making one). Three places
|
|
362
|
-
could carry it: the travelling box, everything inside it, or the scrollers
|
|
363
|
-
themselves. Which of the three works is an engine question, and the answer
|
|
364
|
-
splits in two:
|
|
365
|
-
|
|
366
|
-
- **Blink** asks every scroll container between the pointer and the page whether
|
|
367
|
-
the gesture may go past it, _even one with nothing to scroll_. Containing the
|
|
368
|
-
travelling box is therefore the whole answer — and saying it to everything
|
|
369
|
-
inside is actively harmful: anything that happens to clip (a line with an
|
|
370
|
-
ellipsis, a rounded card, the invisible checkbox that covers a selectable row)
|
|
371
|
-
becomes a **dead zone under the wheel**, a container that stops the gesture
|
|
372
|
-
and has nothing to move with it. This is the bug that made a list refuse the
|
|
373
|
-
wheel inside a popup and only answer its scrollbar.
|
|
374
|
-
- **Gecko and WebKit** ask only the containers that actually scroll. The
|
|
375
|
-
travelling box is walked past — it travels, it does not scroll — so the
|
|
376
|
-
scroller itself has to be told, and saying it to everything is harmless there.
|
|
377
|
-
|
|
378
|
-
So `drag_to_travel.js` writes it on the box for everyone, and on everything
|
|
379
|
-
inside only outside Blink (`@supports not (-webkit-app-region: none)`, one of
|
|
380
|
-
the few properties that names an engine rather than a user agent string).
|
|
381
|
-
|
|
382
|
-
It also names the scrollers a browser makes on its own — `textarea`,
|
|
383
|
-
`select[multiple]`, `select[size]` — wherever they are inside the box. Nobody
|
|
384
|
-
declared those, so nothing can find them by looking; being native is exactly
|
|
385
|
-
what makes them nameable. `input` is deliberately not among them: it has
|
|
386
|
-
nothing to scroll on the axis anything travels on, and containing it is how a
|
|
387
|
-
row-wide invisible checkbox becomes a hole under the wheel. The cost of naming
|
|
388
|
-
them is that an empty `textarea` is contained too — a browser cannot be asked
|
|
389
|
-
"only if it scrolls" — so on Blink a wheel over one moves nothing rather than
|
|
390
|
-
the list around it.
|
|
391
|
-
|
|
392
|
-
On Blink this leaves the boxes that are **not scroll containers**, since those
|
|
393
|
-
are never asked. Of navi's own: a `SidePanel` is a `Dialog`, which scrolls
|
|
394
|
-
(`overflow: auto`), and is asked. A `SlideContainer` cuts with `overflow: clip`,
|
|
395
|
-
which is not a scroll container (`hidden` would be one, and would let the slides
|
|
396
|
-
off stage be scrolled to), so it is not asked. A `RouteTravel` box does not clip
|
|
397
|
-
at all and must not be made a scroll container: one there would become the
|
|
398
|
-
nearest one for every `position: sticky` inside the pages it holds. A row marked
|
|
399
|
-
swipeable by `interactions` is not one either.
|
|
400
|
-
|
|
401
|
-
Which is why navi contains what it KNOWS scrolls, in `box.jsx`:
|
|
402
|
-
|
|
403
|
-
```css
|
|
404
|
-
[data-drag-travel*="y"] [data-scrollable] {
|
|
405
|
-
overscroll-behavior-y: contain !important;
|
|
406
|
-
}
|
|
407
|
-
```
|
|
408
|
-
|
|
409
|
-
`[data-scrollable]` is worn by a `Box` that ASKED for `overflow: auto|scroll`,
|
|
410
|
-
never by one that merely clips — so this cannot make a dead zone the way `*`
|
|
411
|
-
does, and it puts the containment exactly where every engine reads it. A list, a
|
|
412
|
-
dialog body, a scrolling panel inside a travelling page are covered on Blink
|
|
413
|
-
again.
|
|
414
|
-
|
|
415
|
-
What still leaks, and only on Blink, and only under a box that is not a scroll
|
|
416
|
-
container: a
|
|
417
|
-
scroller **nobody declared and no tag names** — a bare
|
|
418
|
-
`<div style="overflow: auto">`, a widget from elsewhere. Its leftovers reach the
|
|
419
|
-
page, which is the old symptom in a much smaller corner. Two ways out, per case:
|
|
420
|
-
give the scroller a `Box` with an `overflow` (it is then declared), or contain it
|
|
421
|
-
by hand. The general fix belongs to Blink.
|
|
422
|
-
|
|
423
|
-
A note for whoever tests this: **Firefox cannot be measured with a synthetic
|
|
424
|
-
wheel**. Playwright dispatches one outside APZ, where Gecko enforces
|
|
425
|
-
`overscroll-behavior`, so containment never shows up there — inline, from a
|
|
426
|
-
stylesheet, headless or headed alike. The non-Blink branch is therefore left
|
|
427
|
-
exactly as it always was rather than tuned against a measurement that does not
|
|
428
|
-
exist.
|
|
429
|
-
|
|
430
|
-
### A hand reaching for something still moving is reaching for THAT thing
|
|
431
|
-
|
|
432
|
-
A gesture arriving while a travel is playing takes **that travel** over — it does
|
|
433
|
-
not ask for a new one, and it is not refused. Refusing it is what makes a page
|
|
434
|
-
rock: a gesture given back to the browser is answered by the browser, over a
|
|
435
|
-
travel that is already moving.
|
|
436
|
-
|
|
437
|
-
But a hand landing on a travel is two different hands, and the likelier one is
|
|
438
|
-
not reaching for it at all: **it is the next swipe**, thrown while the last one
|
|
439
|
-
is still playing. Stopping the travel at the press answers the rarer hand —
|
|
440
|
-
the one catching — and makes the likelier one feel a stall: the screen stops
|
|
441
|
-
dead under a swipe that starts from rest, then crawls at the finger's speed
|
|
442
|
-
before it goes anywhere. So what the hand is doing is listened to first
|
|
443
|
-
(`inFlight`), with the touch refused from the first pixel meanwhile (the
|
|
444
|
-
browser's deadline does not wait for us — see
|
|
445
|
-
[mobile_touch.md](./mobile_touch.md)):
|
|
446
|
-
|
|
447
|
-
- **a step the travel's way** (the start threshold) is the next swipe: one
|
|
448
|
-
screen further (`onPushOn`), and the travel goes on there from where it is —
|
|
449
|
-
as a second arrow press sends it — never having stopped. The hand holds
|
|
450
|
-
nothing; it pushed;
|
|
451
|
-
- **a step against it, or a hand saying nothing** for `DRAG_CATCH_HOLD_MS`
|
|
452
|
-
(100ms) is a catch: the travel stops where it stands at that moment, and the
|
|
453
|
-
hand holds it from there;
|
|
454
|
-
- **let go before either** — a tap — and the travel was never touched.
|
|
455
|
-
|
|
456
|
-
The price is paid by the rarer hand: a catch lands up to 100ms after the finger,
|
|
457
|
-
so the screen goes on that much under it. A slow hand stepping the travel's way
|
|
458
|
-
before stopping reads as the next swipe too. Both are the right side to be
|
|
459
|
-
wrong on.
|
|
460
|
-
|
|
461
|
-
Both consumers answer the next swipe without stopping anything, each in its own
|
|
462
|
-
way:
|
|
463
|
-
|
|
464
|
-
- a `SlideContainer` sets off a new travel from where the track is, to the slide
|
|
465
|
-
after — the one a second arrow press asks for — over the distance left, so it
|
|
466
|
-
speeds up;
|
|
467
|
-
- a `RouteTravel` keeps the transition it is playing and points the router one
|
|
468
|
-
page further — what a tab pressed while it plays does (see [Asking for a page
|
|
469
|
-
while one is on its
|
|
470
|
-
way](#asking-for-a-page-while-one-is-on-its-way)) — so the pace does not
|
|
471
|
-
change and the page arriving is simply the next one. What the transition
|
|
472
|
-
carries besides the pages cannot follow that, see [One gesture that bar
|
|
473
|
-
cannot follow](#one-gesture-that-bar-cannot-follow).
|
|
474
|
-
|
|
475
|
-
Position alone does not say either is working. What gives it away is SPEED:
|
|
476
|
-
measure the position frame by frame across the press (for a `RouteTravel`, the
|
|
477
|
-
`currentTime` and `playState` of the transition's animations). A next swipe
|
|
478
|
-
must not change the pace at all until the travel takes the new target, and
|
|
479
|
-
then only keep it or speed up; a catch must stop on the frame the hold is
|
|
480
|
-
decided, not later.
|
|
481
|
-
|
|
482
|
-
Taking over means the pictures stop where they are and answer the finger again,
|
|
483
|
-
from where they stand (`slack`) rather than from zero. Only one box is in hand,
|
|
484
|
-
and walking out of either of its ends is a travel of its own — see below.
|
|
485
|
-
|
|
486
|
-
What letting go of a travel caught this way means is read from the hand, three
|
|
487
|
-
ways:
|
|
488
|
-
|
|
489
|
-
- **merely touched** — a press that moved less than a gesture's worth: nobody
|
|
490
|
-
asked the travel to stop, it was asked to wait, and it carries on to where it
|
|
491
|
-
was going;
|
|
492
|
-
- **thrown back** — against the way it was going, fast: everything goes back;
|
|
493
|
-
- **thrown on** — the way it was already going, fast, having moved by more than
|
|
494
|
-
a tremor: that is a second push, and it asks for the screen AFTER the one
|
|
495
|
-
arriving (`thrownOn`) — the same answer as a step the travel's way, for a
|
|
496
|
-
hand that caught it first. Read as a verdict on the box in hand, it only
|
|
497
|
-
confirms what was already arriving, and the hand feels its second swipe
|
|
498
|
-
swallowed. So the gesture
|
|
499
|
-
asks for the next box at the release (`onEdge` with `thrown`), with the
|
|
500
|
-
picture still short of the end of the one in hand, and nothing on screen
|
|
501
|
-
moves for it: the travel goes on from where the hand left it, to one screen
|
|
502
|
-
further. It is the wheel's rule (a second push over the tail is a screen, not
|
|
503
|
-
credit towards one) said for a hand.
|
|
504
|
-
|
|
505
|
-
A slow hand going the same way and stopping before it lets go is not thrown:
|
|
506
|
-
there the picture decides, and the screen arriving arrives.
|
|
507
|
-
|
|
508
|
-
### A hand that does not stop at the end of a page is asking for the next one
|
|
509
|
-
|
|
510
|
-
One travel brings in one neighbour, but a gesture is not over because a travel
|
|
511
|
-
is: reaching an end and carrying on says "and the one after that", and being
|
|
512
|
-
made to let go and press again to say it is a wall in the middle of a movement.
|
|
513
|
-
So the gesture asks for another box at the end it reached (`onEdge`), and the
|
|
514
|
-
pixels past that end are the new box's first ones — nothing is spent twice.
|
|
515
|
-
|
|
516
|
-
The two ends cost differently, and it is worth knowing which one is being felt:
|
|
517
|
-
|
|
518
|
-
- **out the far end** — the page arrived, and the page it was leaving is gone:
|
|
519
|
-
there is no pair left to travel with, so the next travel wants pictures of its
|
|
520
|
-
own. A navigation, a render, a snapshot — and over those frames nothing
|
|
521
|
-
follows the finger before catching up with it. At the start of a gesture that
|
|
522
|
-
gap is invisible, the hand has barely moved; here the hand is at full speed;
|
|
523
|
-
- **back out of the start** — the pair in hand is already the right one: the
|
|
524
|
-
still it starts from is the same page, and what is being brought in is LIVE,
|
|
525
|
-
so pointing the router at the other neighbour is enough for it to show that
|
|
526
|
-
one instead. The travel turns around where it stands, on the same transition,
|
|
527
|
-
and there is no gap at all.
|
|
528
|
-
|
|
529
|
-
That cost is a `RouteTravel` one: a `SlideContainer` has every slide mounted,
|
|
530
|
-
so handing over is only re-staging — the slide reached becomes the one dragged,
|
|
531
|
-
the one left stays beside it, the slide past it is put on the other side, and
|
|
532
|
-
nothing on screen moves. A throw (`thrown`, above) is answered differently for
|
|
533
|
-
the same reason: a `SlideContainer` hands over by re-staging, while a
|
|
534
|
-
`RouteTravel` would have to jump its pictures the rest of the way before
|
|
535
|
-
handing over, a page seen leaping — so it aims the travel in hand one page
|
|
536
|
-
further instead, hands nothing over, and lets it go: it arrives there.
|
|
537
|
-
|
|
538
|
-
What the browser will not turn around with it is everything ELSE the
|
|
539
|
-
transition carries — see [One gesture that bar cannot
|
|
540
|
-
follow](#one-gesture-that-bar-cannot-follow).
|
|
541
|
-
|
|
542
|
-
What a travel in hand must never lose:
|
|
543
|
-
|
|
544
|
-
- **a travel being undone is not up for grabs.** Its end is already decided;
|
|
545
|
-
held again mid-revert, its animations never finish, the wait for them never
|
|
546
|
-
resolves — and the pictures stand where they are, over a page that cannot be
|
|
547
|
-
touched anymore;
|
|
548
|
-
- **a held travel is let go of before anything else animates.** A hold is
|
|
549
|
-
written in CSS against whatever transition is running (see the animations
|
|
550
|
-
skill), so a transition starting while a finger holds ours would be born
|
|
551
|
-
paused with nobody holding it — it never finishes, and the page freezes under
|
|
552
|
-
its pictures. Every transition navi starts passes through one funnel, which
|
|
553
|
-
releases the hold first;
|
|
554
|
-
- **the hold belongs to a travel, not to the page.** Only the travel that took
|
|
555
|
-
it may give it back — and it must give it back even when it ends after
|
|
556
|
-
something else has replaced it, or the hold survives its owner;
|
|
557
|
-
- **a capture that goes while the pointer is still down was taken, not given.**
|
|
558
|
-
The ends a gesture has are the pointer going up and the pointer being
|
|
559
|
-
cancelled; a `lostpointercapture` before either means someone else asked for
|
|
560
|
-
the pointer (or the element it was held on left the document). What was being
|
|
561
|
-
carried goes back rather than landing wherever the hand happened to be, and a
|
|
562
|
-
travel comes home rather than committing. Home is where the travel was going
|
|
563
|
-
BEFORE the press: one this gesture began goes back, one it caught in flight
|
|
564
|
-
carries on to the end that was decided before the finger landed — the
|
|
565
|
-
gesture said nothing, and nothing is what it changes. On a touchscreen this
|
|
566
|
-
is the common case, not a corner: a finger reaching for a travel lands on the
|
|
567
|
-
document root (the box is captured, unpointable), where neither the box's
|
|
568
|
-
`touch-action` nor its touchmove listener is on the touch's path — so while
|
|
569
|
-
a travel plays, `RouteTravel` keeps the touch refusable from the root, and
|
|
570
|
-
the gesture that catches it (started from the grab) refuses every touchmove
|
|
571
|
-
of that press. A scroll begun over a page still sliding is that page's, not
|
|
572
|
-
the document's;
|
|
573
|
-
- **a gesture must hear its own end wherever it is delivered.** A pointer can be
|
|
574
|
-
cancelled somewhere the box is not on the path (the document root, during a
|
|
575
|
-
transition): missed, the gesture never ends, and whatever it was holding stays
|
|
576
|
-
held. The end is listened for on the window too, filtered by pointer id;
|
|
577
|
-
- **a box's own navigations are not somebody changing the route.** Routing is
|
|
578
|
-
asynchronous: a travel's navigation lands well after the travel decided
|
|
579
|
-
anything about it — sometimes after it was undone. Read back as "the route
|
|
580
|
-
changed", it starts a second travel nobody asked for, over pictures already
|
|
581
|
-
showing something else. So the box remembers what it asked for and recognises
|
|
582
|
-
its own answer when it arrives;
|
|
583
|
-
- **a travel ENDS, whatever happened on the way.** Whoever set the hold lifts it,
|
|
584
|
-
and the "a travel is playing" state is cleared in a `finally`. A travel left in
|
|
585
|
-
flight is not a small leak: it freezes the page under its own pictures, and
|
|
586
|
-
every gesture after it finds the box busy.
|
|
587
|
-
|
|
588
|
-
One browser fact makes this harder than it reads: **an element captured in a
|
|
589
|
-
view transition cannot be pointed at.** It is not painted where it stands
|
|
590
|
-
anymore, so nothing hit-tests to it — the press falls through to the nearest
|
|
591
|
-
ancestor still being painted, whatever the pseudo-elements are told about
|
|
592
|
-
`pointer-events`. Both readings answer it the same way: the event is caught at
|
|
593
|
-
the document and handed to the box when it fell inside its rectangle, which is
|
|
594
|
-
where the hand thinks it is.
|
|
595
|
-
|
|
596
|
-
### Two ways of holding a render still, and why only one is global
|
|
597
|
-
|
|
598
|
-
Nothing may reach the DOM between the moment a transition is asked for and the
|
|
599
|
-
moment the browser photographs the page — the photograph is taken a frame later,
|
|
600
|
-
and Preact renders sooner than that. So a navigation holds rendering from its
|
|
601
|
-
very first write until the transition's own callback: **all of it**, because a
|
|
602
|
-
view transition photographs the whole document. Hold only the routes and the tab
|
|
603
|
-
row updates first — the bar is then photographed already under the tab one is
|
|
604
|
-
going to, and it has nowhere to slide from. It lasts one frame.
|
|
605
|
-
|
|
606
|
-
The other hold is the revert above, and it can last a whole travel. There
|
|
607
|
-
nothing is being photographed: the pictures exist, and the only thing that must
|
|
608
|
-
not change is what the LIVE one shows. So only the pages are held
|
|
609
|
-
(`freezeRouteRender`), by the containers themselves — a route container keeps
|
|
610
|
-
returning the branch it returned last time — and the rest of the document goes
|
|
611
|
-
on rendering.
|
|
612
|
-
|
|
613
|
-
The same browser fact decides something bigger: **`RouteTravel` opts the page
|
|
614
|
-
OUT of the transition** (`view-transition-name: none` on the root, against the browser's
|
|
615
|
-
default). Captured, the whole page would be unpointable for the length of every
|
|
616
|
-
travel — a tab row beside the box stops highlighting, the cursor goes back to an
|
|
617
|
-
arrow, and a press on the tab one has just changed one's mind about goes
|
|
618
|
-
nowhere. Left live it answers as it always did, and nothing shows through where
|
|
619
|
-
the pages are: the box itself IS captured, so it paints nothing, and the two
|
|
620
|
-
pictures cover its rectangle between them at every moment of the travel.
|
|
621
|
-
|
|
622
|
-
It costs more for a wheel than for a press, because a press is one event and a
|
|
623
|
-
wheel gesture is a stream: heard on the box alone, a wheel that sets a travel
|
|
624
|
-
off loses every event after the first. What one sees then is a page nudged a
|
|
625
|
-
few pixels, going quiet, being put back — and scrolling behind the travel with
|
|
626
|
-
everything that was not taken.
|
|
627
|
-
|
|
628
|
-
### On a touchscreen, the browser takes the gesture unless it is refused
|
|
629
|
-
|
|
630
|
-
A `pointermove` is a report; a **`touchmove` is the decision**. Left alone, the
|
|
631
|
-
browser consumes the touch to scroll with — it latches on the first move, and a
|
|
632
|
-
touch it has taken is a pointer stream it CANCELS. The gesture then dies at its
|
|
633
|
-
second pixel: the finger is still down, nothing reads it anymore, and whatever
|
|
634
|
-
travel had started finishes without anyone. It is invisible with a mouse, which
|
|
635
|
-
is why it survives a whole session of desktop testing.
|
|
636
|
-
|
|
637
|
-
So a travel that has become ours refuses the `touchmove` (`preventDefault`), and
|
|
638
|
-
only then — a finger that means to scroll must still scroll. "Only then" has a
|
|
639
|
-
deadline: the browser takes the touch about 8px in if nothing has refused it by
|
|
640
|
-
then, which is why a finger becomes a travel at 6px (see [the axis
|
|
641
|
-
section](#the-axis-is-read-once-and-read-with-a-bias)). `touch-action` on the
|
|
642
|
-
box does not cover for a late answer over content that scrolls: a scroller
|
|
643
|
-
inside the box resets it for what it holds, and Chrome then pans even the axis
|
|
644
|
-
the box travels on, with nothing to scroll that way (see
|
|
645
|
-
[mobile_touch.md](./mobile_touch.md#touch-action-stops-at-the-first-scroller)).
|
|
646
|
-
Three details make the refusal hold:
|
|
647
|
-
|
|
648
|
-
- the listener sits on the element the touch LANDED on as well as on the window: a
|
|
649
|
-
touch keeps being dispatched at the node it started on, and a travel may
|
|
650
|
-
replace the DOM under the finger (a page that travels navigates), after which
|
|
651
|
-
that node no longer passes through the window on its way anywhere;
|
|
652
|
-
- the pointer is captured on the BOX rather than on what the finger landed on,
|
|
653
|
-
for the same reason: what the caller does may take that target away, and a
|
|
654
|
-
capture whose element leaves the document is a capture the browser drops;
|
|
655
|
-
- and it is captured only once the travel has been ACCEPTED — not when the press
|
|
656
|
-
crossed its threshold. There is one capture per pointer for the whole document,
|
|
657
|
-
so taking it is taking it from whoever had it, who is then told the very thing
|
|
658
|
-
it is told when its own gesture ends. A travel that gives itself up one event
|
|
659
|
-
later (an axis this box does not walk, an `onStart` that refuses) would have
|
|
660
|
-
killed a gesture already carrying something. Until the capture is claimed the
|
|
661
|
-
moves are read from the window, filtered by pointer id, so nothing is missed
|
|
662
|
-
for not owning the pointer.
|
|
663
|
-
|
|
664
|
-
And the refusal has to be _listenable_ **before the finger lands**, even though
|
|
665
|
-
it only refuses later: whether a touchmove can be refused at all is decided
|
|
666
|
-
when the touch begins, from the non-passive listeners present at that moment.
|
|
667
|
-
Registered at the pointerdown it is already too late — the events arrive
|
|
668
|
-
`cancelable: false`, the listener runs, calls `preventDefault`, and nothing
|
|
669
|
-
happens. What that costs is not the start of the gesture but its middle: the
|
|
670
|
-
travel takes the press fine (the axis the browser was left never moved), and
|
|
671
|
-
then the thumb's arc bends towards the axis `touch-action` leaves to the page —
|
|
672
|
-
the browser starts the page's own scroll and CANCELS the pointer stream, and
|
|
673
|
-
the screen the hand was carrying snaps back with the finger still down.
|
|
674
|
-
|
|
675
|
-
So every box that travels says it from its own render, next to the attribute
|
|
676
|
-
naming its axes: `onTouchMove={keepTouchRefusable}` — a no-op listener whose
|
|
677
|
-
being registered is the whole of it (the same function everything picked up
|
|
678
|
-
and carried registers, see `keepTouchRefusable` in drag_after_intent.js). A
|
|
679
|
-
JSX prop is enough because the passive-by-default intervention only covers
|
|
680
|
-
`window`, `document` and `document.body`: on any other element a plain
|
|
681
|
-
listener is non-passive already — which matters, because a framework's event
|
|
682
|
-
prop cannot say `passive: false`. The one place the explicit option is
|
|
683
|
-
load-bearing is a listener that goes down on the document root (see
|
|
684
|
-
drag_to.js). And a surface whose swipe starts only from a grip carrying
|
|
685
|
-
`touch-action: none` needs none of this: the browser was never offered
|
|
686
|
-
anything there to take back.
|
|
687
|
-
|
|
688
155
|
### A navi component that reads the pointer marks ITSELF
|
|
689
156
|
|
|
690
157
|
`data-no-drag-travel` is written by the component that takes the pointer, never
|
|
691
|
-
by whoever puts it in a page
|
|
158
|
+
by whoever puts it in a page. The caller cannot know — a `Table` whose columns
|
|
159
|
+
can be dragged, a `Wheel` spun with a thumb, a canvas one draws on all look like
|
|
160
|
+
ordinary content from outside — and will not find out until they watch a page
|
|
161
|
+
leave under their finger. The component knows, so the component says it.
|
|
692
162
|
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
163
|
+
The rule is the application's too: a component of yours that reads the pointer
|
|
164
|
+
by hand, rather than through `interactions`, marks its own element
|
|
165
|
+
`data-no-drag-travel`.
|
|
696
166
|
|
|
697
|
-
|
|
698
|
-
with a thumb, a canvas one draws on all look like ordinary content from
|
|
699
|
-
outside — and will not find out until they watch a page leave under their
|
|
700
|
-
finger. The component knows, so the component says it.
|
|
167
|
+
### What is contained, and what still leaks
|
|
701
168
|
|
|
702
|
-
|
|
703
|
-
|
|
169
|
+
A travel must not take the page with it: a list inside the box that reaches its
|
|
170
|
+
end must not scroll the page behind the travel, and the page must not bounce, or
|
|
171
|
+
go back in history, under a screen being dragged. navi says so on everything it
|
|
172
|
+
knows scrolls inside a travelling box — the box itself, a `Box` that asked for
|
|
173
|
+
an `overflow`, and the scrollers a browser makes on its own (a `textarea`, a
|
|
174
|
+
`select` showing a list; an empty one too, so on Blink a wheel over it moves
|
|
175
|
+
nothing rather than the list around it). It is written once and for all rather
|
|
176
|
+
than while a finger is down, because a browser decides what a gesture may do
|
|
177
|
+
when the gesture begins; the bounce is refused only while a gesture runs, so a
|
|
178
|
+
page that bounces the rest of the time goes on bouncing.
|
|
179
|
+
|
|
180
|
+
What still leaks, only on Blink (Chrome, Edge), and only under a box that is not
|
|
181
|
+
a scroll container — a `SlideContainer` (it cuts with `overflow: clip`), a
|
|
182
|
+
`RouteTravel`, a swiped row: a scroller **nobody declared and no tag names** — a
|
|
183
|
+
bare `<div style="overflow: auto">`, a widget from elsewhere. Its leftovers
|
|
184
|
+
reach the page. Two ways out, per case: give the scroller a `Box` with an
|
|
185
|
+
`overflow` (it is then declared), or contain it by hand
|
|
186
|
+
(`overscroll-behavior-x: contain` under a box travelling sideways). The general
|
|
187
|
+
fix belongs to Blink.
|
|
704
188
|
|
|
705
189
|
## The two consumers that travel between screens
|
|
706
190
|
|
|
707
|
-
A popup
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
screens, and everything the rest of this file weighs is about them.
|
|
191
|
+
A popup pushed back towards its edge and a swiped row are travels too, the
|
|
192
|
+
simple kind: one box, one direction, no neighbour to bring in. The two below
|
|
193
|
+
carry screens.
|
|
711
194
|
|
|
712
|
-
| | `SlideContainer`
|
|
713
|
-
| ----------------------- |
|
|
714
|
-
| what the screens are | `<Slide>`s in one box, all mounted
|
|
715
|
-
| what says which is here | `current` / a command
|
|
716
|
-
| what the finger moves | a translated track
|
|
717
|
-
| letting go too early | the track comes back
|
|
718
|
-
| what says the order | the layout map
|
|
719
|
-
| what one wheel push is | `move(±1)`, as an arrow key
|
|
195
|
+
| | `SlideContainer` | `RouteTravel` |
|
|
196
|
+
| ----------------------- | ----------------------------------- | ---------------------------------- |
|
|
197
|
+
| what the screens are | `<Slide>`s in one box, all mounted | routes — one mounted, ever |
|
|
198
|
+
| what says which is here | a `signal`, `current` / a command | the URL |
|
|
199
|
+
| what the finger moves | a translated track | the pictures of a view transition |
|
|
200
|
+
| letting go too early | the track comes back | the transition is played backwards |
|
|
201
|
+
| what says the order | the layout map | the `<Route>` children, in order |
|
|
202
|
+
| what one wheel push is | `move(±1)`, as an arrow key | one travel, as a tab pressed |
|
|
720
203
|
|
|
721
204
|
Both expose how far the travel has come, and the way to read it differs because
|
|
722
|
-
what draws an indicator differs
|
|
723
|
-
`--slide-travel-progress`
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
- the picture being brought in is LIVE, and the page it shows is now the
|
|
747
|
-
page one is going back TO: both sides show the same thing and the way back
|
|
748
|
-
is invisible — one presses, and one is simply there. So the PAGES are held
|
|
749
|
-
where they are (`freezeRouteRender`) until the pictures are back at their
|
|
750
|
-
start; only the pages, because nothing is being photographed here and
|
|
751
|
-
everything else may keep rendering. The page being left stays on screen
|
|
752
|
-
while it is brought back — the rule this file states about reverts,
|
|
753
|
-
applied one step earlier;
|
|
754
|
-
- the way back is paid for in DISTANCE, not in time: the way in is eased, so
|
|
755
|
-
at half of its time a travel has covered ~80% of its distance, and rewound
|
|
756
|
-
at `-1` the visible way home collapses into the steep end of the curve — a
|
|
757
|
-
snap, not a return. The pictures walk home over how far they visibly are
|
|
758
|
-
from home, at the travel's own pace (`walkPicturesHome` in
|
|
759
|
-
`src/transition/view_transition_revert.js`);
|
|
760
|
-
- both of the above run straight into the compositor traps: the distance is
|
|
761
|
-
computed from the clock through the easing curve (the pseudo-elements'
|
|
762
|
-
animated position cannot be read), and the rate is handed over with
|
|
763
|
-
`updatePlaybackRate`, never the `playbackRate` setter. The traps
|
|
764
|
-
themselves — including why no JS reading and no ordinary screenshot will
|
|
765
|
-
ever show this class of bug — are in the animations skill, "The main
|
|
766
|
-
thread lies about a running transition";
|
|
767
|
-
- **further the same way** — the picture being brought in is LIVE, so pointing
|
|
768
|
-
the router at another page is all it takes for it to show that one instead.
|
|
769
|
-
Nothing moves, and it costs nothing. Two tabs along or five makes no
|
|
770
|
-
difference: a travel goes from where it left to where it is going, never
|
|
771
|
-
through what lies between — which is just as well here, since the pages in
|
|
772
|
-
between are not mounted and there is nothing to show of them;
|
|
773
|
-
- **the other way** — the pictures have to change places, so the pair starts
|
|
774
|
-
again from the beginning. A travel barely begun turns around unnoticed; one
|
|
775
|
-
nearly arrived snaps back first. That is the price of changing one's mind
|
|
776
|
-
late, and there is no picture that could have covered it.
|
|
205
|
+
what draws an indicator differs. `SlideContainer` writes
|
|
206
|
+
`--slide-travel-progress` — where the picture stands against the current slide,
|
|
207
|
+
in boxes: 0 on it, +1 a whole box before it, -1 one after — on its box, for
|
|
208
|
+
anything drawn inside it, and on every element marked
|
|
209
|
+
`data-slide-container-follows={id}` outside it (a tab bar above the box). It is
|
|
210
|
+
declared with `@property`, so it interpolates. `RouteTravel` leaves it to the
|
|
211
|
+
browser: give the indicator a `view-transition-name` of its own and it is
|
|
212
|
+
animated from where it was to where it is, even from outside the box (see
|
|
213
|
+
[view_transitions.md](./view_transitions.md#a-name-is-unique-per-document)).
|
|
214
|
+
|
|
215
|
+
A travel is not a queue: a tab pressed while a page is arriving does not wait
|
|
216
|
+
its turn, and does not start a second travel on top of the first either. The
|
|
217
|
+
travel in flight is aimed there instead. Back where it set off from, it is
|
|
218
|
+
undone — the same pictures, run backwards; further the same way, it costs
|
|
219
|
+
nothing; the other way, the pictures start again from the beginning, and a page
|
|
220
|
+
nearly arrived snaps back first.
|
|
221
|
+
|
|
222
|
+
A hand that walks a `RouteTravel` page whole and keeps going gets the next page,
|
|
223
|
+
but out of the far end there is a short gap: the page it was leaving is gone,
|
|
224
|
+
the next travel needs pictures of its own (a navigation, a render, a snapshot),
|
|
225
|
+
and over those frames nothing follows the finger before catching up with it.
|
|
226
|
+
Walked back out of the start there is none — the pair in hand is already the
|
|
227
|
+
right one. A `SlideContainer` has every slide mounted and hands over without a
|
|
228
|
+
gap either way.
|
|
777
229
|
|
|
778
230
|
### One gesture that bar cannot follow
|
|
779
231
|
|
|
780
|
-
A travel that turns around mid-gesture
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
everything ELSE the transition carries was
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
picture and the thing are in two places at once, and one sees two bars.
|
|
787
|
-
|
|
788
|
-
The same holds whenever a travel is aimed further the same way while it plays
|
|
789
|
-
— a tab pressed two pages along, the next swipe landing on a page still
|
|
790
|
-
arriving: the bar was photographed going to a tab nobody is going to anymore.
|
|
232
|
+
A `RouteTravel` travel that turns around mid-gesture, or is aimed further the
|
|
233
|
+
same way while it plays — a tab pressed two pages along, the next swipe landing
|
|
234
|
+
on a page still arriving — keeps its pages by pointing the router elsewhere
|
|
235
|
+
under the same transition. But everything ELSE the transition carries was
|
|
236
|
+
photographed when it began, on its way to a tab nobody is going to anymore, and
|
|
237
|
+
the thing itself has already moved on in the live page: one would see two bars.
|
|
791
238
|
|
|
792
239
|
So on those gestures the pictures of everything that is not the pages are
|
|
793
240
|
dropped, and those things are left where they are, live: the bar jumps to the
|
|
794
241
|
tab one is heading for instead of sliding to a tab one is not. A slide would be
|
|
795
242
|
nicer, and it is not available — the browser measured both of its ends before
|
|
796
243
|
the hand changed its mind, and neither can be asked for again.
|
|
797
|
-
|
|
798
|
-
## Verifying a gesture
|
|
799
|
-
|
|
800
|
-
A gesture is verified the way movement is (see the animations skill,
|
|
801
|
-
"Verifying") — driven synthetically, read as numbers — but what has to be
|
|
802
|
-
synthesized is the HAND'S imperfection, because that is where gesture bugs
|
|
803
|
-
live. A gesture simulated as clean, evenly-spaced points along one axis passes
|
|
804
|
-
forever and proves nothing:
|
|
805
|
-
|
|
806
|
-
- **coalesce**: deliver a whole flick in one or two moves — that is what a
|
|
807
|
-
fast thumb looks like to the main thread;
|
|
808
|
-
- **arc**: lean the first points off-axis the way a thumb does, and only then
|
|
809
|
-
commit to the axis;
|
|
810
|
-
- **interleave**: start the second gesture while the first one's travel — or
|
|
811
|
-
its momentum tail — is still playing, and sweep the delay between the two;
|
|
812
|
-
- **for a wheel**: a swipe is a ramp, a peak and a decaying tail at frame
|
|
813
|
-
rate, and the interesting cases live in the tail — a second swipe over it, a
|
|
814
|
-
cross-axis scroll into it, decay jitter that must not step.
|
|
815
|
-
|
|
816
|
-
The reading side has traps of its own:
|
|
817
|
-
|
|
818
|
-
- coordinates measured off an element that is mid-travel are stale by dispatch
|
|
819
|
-
time — aim at where the CONTAINER is, never at where the content was;
|
|
820
|
-
- a scripted gesture's wall-clock is diluted by driver round-trips, so
|
|
821
|
-
measured velocities come out below the numbers written in the script — read
|
|
822
|
-
the measured ones, not the intended ones;
|
|
823
|
-
- what the real browser does with an unclaimed touch — take it for a pan and
|
|
824
|
-
cancel the stream, withhold the click — is reproducible without a device, but
|
|
825
|
-
only through two paths: Chrome's mobile touch emulator (headed, driven with
|
|
826
|
-
mouse events) and Safari in the iOS simulator driven by `safaridriver`. The
|
|
827
|
-
obvious one, Playwright's `touchscreen` / CDP `Input.dispatchTouchEvent`,
|
|
828
|
-
runs Chrome's desktop slop (15px), where a travel deciding at 10px always
|
|
829
|
-
wins and the race that loses swipes on a phone never shows. The recipe is in
|
|
830
|
-
[mobile_touch.md](./mobile_touch.md#verifying-without-a-device). What stays a
|
|
831
|
-
device question: a real finger's lift, a real device's event timing, a
|
|
832
|
-
momentum tail killed by a landing finger (and the Firefox wheel limitation
|
|
833
|
-
above is the same kind of fact).
|
|
834
|
-
|
|
835
|
-
What to read while it runs: the `navi_drag_*` events (grab, start, release)
|
|
836
|
-
say which stage a press reached and with what velocity; the box's state
|
|
837
|
-
attribute (`data-slide-current`, the URL) says what came of it; the track's
|
|
838
|
-
measured position, sampled across frames, says what the screen did in
|
|
839
|
-
between — including the mini-movements and stalls no end-state check ever
|
|
840
|
-
sees.
|