@jsenv/navi 0.29.28 → 0.29.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,405 @@
1
+ # `interactions` — a component that answers more than a click
2
+
3
+ ## What we want
4
+
5
+ An element should be able to answer a gesture — a row swiped aside to archive it,
6
+ a card held down to open a menu, a shortcut that sends a form — and the person
7
+ writing that element should only have to **name** the gesture and say what it
8
+ does.
9
+
10
+ Everything hard about a gesture is not the detection. It is the four things
11
+ around it:
12
+
13
+ - **who owns the press** when boxes are nested (a row swiped sideways inside a
14
+ container that travels sideways);
15
+ - **which of several gestures a single press turns out to be** (a swipe, a hold, a
16
+ click — one press, one arbiter);
17
+ - **the click the browser fires afterwards**, which would follow the link the
18
+ gesture started from;
19
+ - **whether the element is allowed to be interacted with at all** (disabled,
20
+ read-only, waiting on something).
21
+
22
+ navi owns those four. An application that reads the pointer itself gets two of
23
+ them wrong by construction, because two of them can only be decided from inside
24
+ navi and before the first pixel moves.
25
+
26
+ ## The prop
27
+
28
+ `interactions` is a prop of `Box`, so it is available on anything built from
29
+ one — `Box`, `List.Item`, `Button`, `Link`, the field components. Its keys are
30
+ **event types**, its values say what that interaction does.
31
+
32
+ ```jsx
33
+ <Box
34
+ interactions={{
35
+ "swipe_right": "request_action",
36
+ "swipe_left": (event) => markUnread(event),
37
+ "longpress": (event) => openMenu(event),
38
+ "keyboard:ctrl+backspace": "request_action",
39
+ }}
40
+ />
41
+ ```
42
+
43
+ `action` is untouched by this and keeps its own wiring — a click on a button, a
44
+ change on a field. `interactions` is the other half: everything that is not that
45
+ natural one.
46
+
47
+ ### The three values
48
+
49
+ | Value | Meaning |
50
+ | --------------------- | ---------------------------------------------------- |
51
+ | `"request_action"` | ask the nearest control for its `action` prop |
52
+ | `"request_ui_action"` | ask it for a ui action (what says "the user acted") |
53
+ | a function | do this, with the interaction event as only argument |
54
+
55
+ A falsy value means "not this one", so an interaction can be declared under a
56
+ condition: `{ swipe_right: canArchive && archive }`.
57
+
58
+ ### The interactions navi detects
59
+
60
+ | Key | Read from |
61
+ | ------------------------------------------------------ | ---------------------------------------------- |
62
+ | `mousedown` `mouseup` `click` `dblclick` `contextmenu` | the browser's own events |
63
+ | `swipe_left` `swipe_right` `swipe_up` `swipe_down` | a press that travels |
64
+ | `longpress` | a press held still |
65
+ | `move` `reorder` `toss` | the element carried, and what letting go means |
66
+ | `"keyboard:<shortcut>"` | keys, e.g. `"keyboard:ctrl+backspace"` |
67
+
68
+ A name nothing knows how to detect produces a dev warning naming the detectors
69
+ that exist.
70
+
71
+ ## Which interaction asked
72
+
73
+ An interaction navi makes is **dispatched as an event of its own name** —
74
+ bubbling, cancelable, chained onto the event it was read from. So an action does
75
+ not need to be told which interaction asked for it: it reads the event it already
76
+ receives.
77
+
78
+ ```jsx
79
+ <Button
80
+ action={(value, { event }) => {
81
+ const swipe = findEvent(event, "swipe_right");
82
+ if (swipe) {
83
+ const { axis, sign, pulled, size, progress } = swipe.detail;
84
+ }
85
+ }}
86
+ interactions={{ swipe_right: "request_action" }}
87
+ />
88
+ ```
89
+
90
+ `findEvent` is exported from `@jsenv/navi`. Because these are real events, an
91
+ ancestor can also listen for one, and `preventDefault()` on it means "not this
92
+ time".
93
+
94
+ The lower-level event the interaction was read from is reachable too:
95
+ `interactionEvent.detail.event` is the `pointerdown` a swipe or a hold was made
96
+ of — which is how a menu is opened at the point the press happened.
97
+
98
+ ## Reaching the control
99
+
100
+ Everything goes through the interaction gate of the **nearest control** — itself,
101
+ an ancestor, or a descendant, in that order. So a disabled, read-only or busy
102
+ control answers a swipe the way it answers a click: it says why, where the
103
+ interaction happened, and nothing runs. A `Box` with no control anywhere near it
104
+ still answers a callback; only `"request_action"` has nothing to ask, and says so
105
+ in dev.
106
+
107
+ ## What a swipe draws, and what it leaves to you
108
+
109
+ navi makes the element follow the finger — there is nothing to decide about
110
+ that — and says where the gesture is up to:
111
+
112
+ | Written on the element | Meaning |
113
+ | ---------------------------------------- | ----------------------------------------- |
114
+ | `--swipe-pulled` | how far it has come, signed, in px |
115
+ | `--swipe-progress` | the same as a fraction, signed, inherited |
116
+ | `[data-swiping="left\|right\|up\|down"]` | which way, while a finger holds it |
117
+ | `[data-swipe-past-threshold]` | letting go now would go through with it |
118
+
119
+ WHAT is revealed behind is yours: navi does not know what putting a row away
120
+ looks like. A trail is usually a child of the swiped element sized off
121
+ `--swipe-pulled` — which is what makes those values reachable from CSS at all, a
122
+ sibling could not read them.
123
+
124
+ ```css
125
+ .trail {
126
+ position: absolute;
127
+ top: 0;
128
+ right: 100%; /* the strip the row just left */
129
+ bottom: 0;
130
+ width: var(--swipe-pulled);
131
+ opacity: calc(var(--swipe-progress) * 3);
132
+ }
133
+ [data-swipe-past-threshold] .trail {
134
+ background: var(--ok-color);
135
+ }
136
+ ```
137
+
138
+ While the answer takes time, the element **stays where the gesture left it**, and
139
+ comes back once it settles — a failure leaves the row in place so it can be tried
140
+ again. What a success does to the element is yours (a list that redemands its
141
+ rows, a row that leaves): navi does not make it disappear.
142
+
143
+ ## Carrying something: `move`, `reorder`, `toss`
144
+
145
+ All three are the same gesture — the element is picked up and carried — and what
146
+ differs is the release. One detector reads them all, because it is one press.
147
+
148
+ `reorder` and `toss` **combine**: dropped on another item the element changes
149
+ places, thrown far and fast it is gotten rid of. `move` does **not** combine with
150
+ `reorder` — an element either goes where it is put or takes a place in a list,
151
+ and one release cannot mean both (a dev warning says so).
152
+
153
+ `move` carries the element ITSELF and leaves it where it was put; the other two
154
+ carry a copy and put the original back. That is the same difference said in layout
155
+ terms: something moved has a new place of its own, something reordered had its
156
+ place taken by the list.
157
+
158
+ ```jsx
159
+ <Box
160
+ id={token.id}
161
+ interactions={{
162
+ move: (event) => remember(event.detail.x, event.detail.y),
163
+ }}
164
+ />
165
+ ```
166
+
167
+ `data-drag-free` on the element or a container lets it leave; by default a `move`
168
+ stays inside what one can SEE of its container — which requires that container to
169
+ be a scroll container at all (`overflow` anything but `visible`), since there is
170
+ nothing else for "inside" to mean. A `move` whose answer rejects travels back, because a
171
+ place the application would not accept must not stay on screen as if it had.
172
+
173
+ **Declaring `toss` frees the area by itself.** What is dragged is otherwise kept
174
+ inside its scroll area — right for a reorder, since a row belongs to its list, and
175
+ fatal for a throw: the copy hits the edge of the list, no distance is ever covered,
176
+ so no throw can happen and no sideways movement is even visible. So the two
177
+ together let it leave.
178
+
179
+ ```jsx
180
+ <List.Item
181
+ id={task.id}
182
+ data-view-transition-name={`task_${task.id}`}
183
+ interactions={{
184
+ reorder: (event) => {
185
+ const { fromId, toId, syncCloneWithDropTarget } = event.detail;
186
+ return document.startViewTransition(() => {
187
+ syncCloneWithDropTarget();
188
+ setOrder(moveBefore(order, fromId, toId));
189
+ }).finished;
190
+ },
191
+ toss: (event) => remove(event.detail.id),
192
+ }}
193
+ />
194
+ ```
195
+
196
+ The gesture is `startDragTo`'s, whole: `move` carries the element itself, the
197
+ other two carry a copy above the page while the original keeps its place, with a
198
+ drop hint, drop targets found by intersection, no-op drops filtered out, and the
199
+ flight of a thrown copy plus its return when the answer refuses. Only what the
200
+ declared outcomes need runs — no copy for a move, no hint for something
201
+ that can only be thrown away.
202
+
203
+ Every element declaring `reorder` marks itself, so the set of items IS the set of
204
+ elements that declared it — no selector to pass, and an item that must not move
205
+ simply does not declare it. An element declaring only `toss` marks nothing: it is
206
+ not a place anything lands. Items are named by their `id`.
207
+
208
+ `toId` is null for a drop at the end. `syncCloneWithDropTarget` must be called
209
+ synchronously inside the transition callback, next to the state change, so the copy
210
+ is captured where it lands rather than where it was let go of.
211
+
212
+ **The promise matters in both cases**: the gesture holds its copy until the answer
213
+ settles. Returning the transition is what makes a landing continuous; a `toss` that
214
+ rejects brings the copy back, because the thing still exists and the screen has to
215
+ say so.
216
+
217
+ A throw is asked about before a landing: a hand that sent something across the
218
+ screen has not asked for it to swap places with whatever it flew over.
219
+
220
+ Starting a document transition is the application's call, not navi's: a
221
+ `view-transition-name` must be unique per document, so only the application can
222
+ name what moves.
223
+
224
+ | Attribute | Meaning |
225
+ | -------------------------------------------------------- | -------------------------------------- |
226
+ | `data-drag-axis="x"\|"y"\|"xy"` | which axes the drag walks |
227
+ | `data-drag-delay` `data-drag-slop` `data-drag-threshold` | when the press becomes a grab |
228
+ | `data-toss-distance` `data-toss-speed` | how far and how fast counts as a throw |
229
+
230
+ ### Dressing the clone
231
+
232
+ What the pointer carries is a copy, and a copy of a transparent element is
233
+ invisible — an element has no background unless something gave it one, and a row
234
+ usually gets its own from the list around it, which the copy has left. So the
235
+ clone's look is the page's to declare, through the attributes the gesture puts on
236
+ it:
237
+
238
+ | Attribute | On |
239
+ | ------------------------- | ------------------------------------------------------ |
240
+ | `navi-drag-clone` | the copy being carried |
241
+ | `navi-drag-clone-wrapper` | what positions it (already shadowed, in the top layer) |
242
+ | `navi-drag-clone-source` | the original, still in place (already hidden) |
243
+
244
+ ```css
245
+ .task[navi-drag-clone] {
246
+ background: white;
247
+ border-radius: 6px;
248
+ }
249
+ ```
250
+
251
+ Reusing the item's own class is the point: the copy is that item, so it is styled
252
+ as that item plus whatever being carried changes. The copy is a real element in the
253
+ page, in the top layer, and everything about its look is reachable from CSS —
254
+ including these, read off the dragged element so a whole list or a single item can
255
+ answer:
256
+
257
+ | Variable | What it changes |
258
+ | --------------------- | ------------------------------------------------------------- |
259
+ | `--drag-clone-shadow` | what being lifted casts; `none` for something that flies flat |
260
+ | `--drag-clone-scale` | how much bigger it gets once picked up |
261
+
262
+ **What stays behind is the source, not a hole.** The original is never taken out of
263
+ the page — it keeps its place in the layout and wears `navi-drag-clone-source`,
264
+ which only makes it `visibility: hidden`. So a mark left where the thing was — an
265
+ imprint, a dashed outline, the shape a note was pinned on — is drawn ON that
266
+ element and not next to it, and its parts have to say `visibility: visible` to come
267
+ back from the hidden source:
268
+
269
+ ```css
270
+ .paper[navi-drag-clone-source]::after {
271
+ position: absolute;
272
+ inset: 0;
273
+ border: 1px dashed currentColor;
274
+ opacity: 0.35;
275
+ visibility: visible;
276
+ content: "";
277
+ }
278
+ ```
279
+
280
+ It stays until the answer settles, which is what makes it say where the thing left
281
+ from for as long as the question is open — and if the answer refuses, the copy comes
282
+ back to it.
283
+
284
+ `data-drag-axis` says which axes the drag walks, and its default is not the same
285
+ for every outcome: `reorder` alone walks the list (`y`, or `x` for a list that runs
286
+ sideways), while a `move` goes wherever it is put and a `toss` wherever it was
287
+ thrown (`xy`). `data-drag-delay`, `data-drag-slop`, `data-drag-threshold` tune when
288
+ the press becomes a grab.
289
+
290
+ ## Tuning
291
+
292
+ Read off the element or any ancestor carrying the attribute, so a whole list is
293
+ tuned in one place and a stylesheet can read the same value.
294
+
295
+ | Attribute | Default | Meaning |
296
+ | ---------------------- | ------- | ----------------------------------------- |
297
+ | `data-swipe-threshold` | `0.33` | fraction of the element to pull to commit |
298
+ | `data-longpress-delay` | `450` | ms the press must be held |
299
+ | `data-longpress-slop` | `8` | px the pointer may drift during the wait |
300
+
301
+ A threshold is a **fraction and never a distance**: the same gesture must mean
302
+ the same thing on a phone and on a wide screen. Speed answers on its own on top
303
+ of it — a brief flick counts whatever the distance covered.
304
+
305
+ ## Registering an interaction navi does not have
306
+
307
+ The registry holds no detector of its own: navi's swipes, holds and shortcuts go
308
+ through the same door an application uses.
309
+
310
+ ```js
311
+ import { defineInteractionDetector } from "@jsenv/navi";
312
+
313
+ defineInteractionDetector({
314
+ name: "triple_click",
315
+ claims: (type) => type === "triple_click",
316
+ setup: (element, trigger) => {
317
+ let count = 0;
318
+ let timeout = null;
319
+ const onClick = (clickEvent) => {
320
+ count++;
321
+ clearTimeout(timeout);
322
+ timeout = setTimeout(() => {
323
+ count = 0;
324
+ }, 1000);
325
+ if (count < 3) {
326
+ return;
327
+ }
328
+ count = 0;
329
+ trigger(clickEvent);
330
+ };
331
+ element.addEventListener("click", onClick);
332
+ return () => {
333
+ clearTimeout(timeout);
334
+ element.removeEventListener("click", onClick);
335
+ };
336
+ },
337
+ });
338
+ ```
339
+
340
+ `setup(element, trigger, { types, readConfig })` runs **once per element** and
341
+ returns how to undo whatever it did. Listeners, attributes, anything: it is a
342
+ plain setup and teardown, so a detector counts what it needs in its own closure
343
+ and nothing has to hold state on its behalf.
344
+
345
+ `claims` takes a **set** of names rather than one, because interactions sharing an
346
+ input have to be arbitrated together — a swipe, a hold and a click dispute the
347
+ same press, and read apart they walk over each other. `types` (third argument) is
348
+ which of them were actually declared here.
349
+
350
+ `trigger(type, originalEvent, detail)` says the interaction happened. Called with
351
+ a single event — `trigger(event)` — the type is the detector's own, which only
352
+ works when exactly one of its names is declared. When `originalEvent.type` is
353
+ already the interaction's name (a native one), that event IS the interaction and
354
+ no second one is dispatched.
355
+
356
+ It returns **`null` when nothing ran** (the gate refused, no control to ask, the
357
+ interaction event was prevented) and otherwise a **promise**: resolved once the
358
+ effect worked, rejected when it did not. Those two answers are not the same and a
359
+ detector usually treats them differently — a row pulled out comes back either
360
+ way, something thrown off the screen only comes back if the throw failed.
361
+
362
+ `readConfig(attribute, defaultValue)` reads a number off the element or any
363
+ ancestor carrying that attribute, so a whole list is tuned in one place.
364
+
365
+ A detector that reads the pointer must mark itself in the DOM so a travelling
366
+ container above it does not take the gesture:
367
+ `element.setAttribute("data-no-drag-travel", "")`, undone in the teardown (see
368
+ `docs/drag_to_travel.md`). navi's own swipes do the equivalent with
369
+ `data-travel-by-drag`.
370
+
371
+ ## Things worth knowing before guessing
372
+
373
+ - **A hold does not take the context menu.** Declaring `longpress` says what a
374
+ held finger does; a right click comes from the other button and keeps opening
375
+ the browser's menu. Declare `contextmenu` beside it to make the right click do
376
+ the same thing. (A held _finger_ is the system's own context-menu gesture, and
377
+ that one is refused while the wait runs.)
378
+ - **A swipe cannot also be dragged out of the page.** An element declaring a
379
+ swipe gets `draggable={false}` and its `dragstart` refused — a native drag _is_
380
+ press-and-move, and a link or an image is draggable without anyone asking. One
381
+ gesture cannot mean both.
382
+ - **`interactions` adds, it does not replace.** A control's own wiring stays:
383
+ `actionEvent` / `actionOnMouseDown` are still how you change what triggers
384
+ `action` by default.
385
+ - **A popup can open while the finger is still down.** navi's `Popover` is
386
+ `popover="manual"` and owns its dismissal, so the `pointerup` ending a hold is
387
+ not read as an interaction outside it — a menu can appear under a waiting
388
+ finger, which is the native gesture. To place it at the press point rather than
389
+ on the element:
390
+ `triggerNaviCommand(target, "--navi-open", interactionEvent, { anchor })`.
391
+ - **A swipe has no keyboard equivalent.** There is nothing to press that means
392
+ "swipe right", so a swipe is only reachable if something else on the element
393
+ offers the same thing — a `"keyboard:<shortcut>"`, a `contextmenu`, or the
394
+ control's own action.
395
+
396
+ ## Reference
397
+
398
+ - `src/control/interaction/interaction_registry.js` — the prop, the three values,
399
+ the registry.
400
+ - `src/control/interaction/interaction_press.js` — swipes and holds, and what a
401
+ swipe writes on the element.
402
+ - `src/control/interaction/interaction_keyboard.js`,
403
+ `interaction_native.js` — the other two detectors.
404
+ - `src/control/demos/38_interactions_demo.html` — every case above, plus a
405
+ mailbox and a custom `swipe_out` gesture registered from the page.
package/docs/z_index.md CHANGED
@@ -69,22 +69,46 @@ context is how a value ends up tuned to a symptom.
69
69
 
70
70
  ## 5. The values navi plays with
71
71
 
72
- | What | Value | Notes |
73
- | ----------------------------------------------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
74
- | Top layer (`Dialog`/`Popover` with `layer="top"`) | above everything | Browser top layer no `z-index` involved, nothing in the page can beat it |
75
- | `Dialog`/`Popover` with `layer="local"`, and their backdrop | `--navi-popup-z-index` (1000) `+ stack order` | The stack order increments per open, so the last opened wins |
76
- | Callout (validation messages) | `--callout-z-index` (1000) | |
77
- | `FixedBar` | 1 | `position: fixed` — it opens its own stacking context, but competes in the root one at 1, which is exactly why a stray `z-index: 2` anywhere on the page lands in front of it |
78
- | `List` sticky group labels, `List` footer | 1 | Local to the list |
79
- | `Table` (sticky cells, drag, resize) | 1–7, see `src/control/table/z_indexes.js` | Derived from each other, never literals |
80
-
81
- Two things to read from this table:
82
-
83
- - navi itself keeps its values low and relative, except for popups, which sit
84
- at 1000 precisely so nothing has to guess;
85
- - an app that writes a number above 1 is already competing with `FixedBar`.
86
- Write `isolation: isolate` on the parent instead, and the number stops
87
- meaning anything outside it.
72
+ They all live in `src/navi_z_indexes.js`, as tokens, in one ordered list — the
73
+ file is the overview, this table is its summary. Bands are a decade apart so
74
+ one can grow without reaching the next, and so a value seen in devtools says
75
+ which band it came from.
76
+
77
+ | Band | Token | Value |
78
+ | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | ---------------------------- |
79
+ | Top layer (`Dialog`/`Popover` with `layer="top"`) | | above everything |
80
+ | `Dialog`/`Popover` with `layer="local"`, their backdrop, callouts | `--navi-z-index-popup`, `--navi-z-index-callout` | 1000 `+ stack order` |
81
+ | `FixedBar` | `--navi-z-index-bar` | 100 |
82
+ | Sticky while something scrolls under: `List` header/footer/group labels, `SidePanel` head/foot | `--navi-z-index-sticky` | 10 |
83
+ | A `Group` member under the pointer, then the one holding focus | `--navi-z-index-control-hovered`, `--navi-z-index-control-focused` | 1, 2 |
84
+ | `Table` sticky cells, drag, resize | `src/control/table/z_indexes.js` | 1–7, derived from each other |
85
+
86
+ What to read from it:
87
+
88
+ - **The order matters more than the numbers.** A bar is above anything the page
89
+ scrolls, a popup above the bar, and a control raising itself above its
90
+ neighbour is at the bottom — a hovered control crossing the top bar is the
91
+ bug the gaps exist to make impossible.
92
+ - **A z-index that only orders a component's own parts stays a literal** next to
93
+ the rule that needs it. Tokens are for what is decided against another
94
+ component; putting "above my own sibling" in the global list would only
95
+ dilute it.
96
+ - **An app writing its own number is competing with this scale.** Write
97
+ `isolation: isolate` on the parent instead, and the number stops meaning
98
+ anything outside it.
99
+
100
+ ### Why a `Group` member is not isolated
101
+
102
+ `Group` overlaps its members by one border width, so the one the user is on has
103
+ to paint over its neighbour — otherwise its focus ring is sliced in half by the
104
+ member that comes after it in the DOM. DOM order cannot express "whichever one
105
+ is hovered", so this is a legitimate `z-index`.
106
+
107
+ `isolation: isolate` on the group would contain those two values, but it would
108
+ also contain the popup of a `Picker` held in the group: its 1000 would become
109
+ local, and the popup would be capped inside the group instead of covering the
110
+ page. So the group is deliberately not isolated, and what keeps its 1 and 2
111
+ harmless is the scale above them.
88
112
 
89
113
  ## A card that stacks three layers with no `z-index`
90
114
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/navi",
3
- "version": "0.29.28",
3
+ "version": "0.29.30",
4
4
  "type": "module",
5
5
  "description": "Library of components including navigation to create frontend applications",
6
6
  "repository": {
@@ -29,7 +29,7 @@
29
29
  "prepublishOnly": "npm run build"
30
30
  },
31
31
  "dependencies": {
32
- "@jsenv/dom": "0.17.9",
32
+ "@jsenv/dom": "0.17.10",
33
33
  "@jsenv/humanize": "1.7.8",
34
34
  "@jsenv/validity": "0.4.2"
35
35
  },
@@ -41,8 +41,8 @@
41
41
  "@jsenv/snapshot": "../../tooling/snapshot",
42
42
  "@jsenv/terminal-table": "../../tooling/terminal-table",
43
43
  "@jsenv/urls": "../../tooling/urls",
44
- "@preact/signals": "2.9.4",
45
- "playwright": "1.61.1",
44
+ "@preact/signals": "2.11.1",
45
+ "playwright": "1.62.1",
46
46
  "preact": "11.0.0-beta.2"
47
47
  },
48
48
  "publishConfig": {
@@ -50,6 +50,9 @@
50
50
  },
51
51
  "sideEffects": [
52
52
  "./src/navi_css_vars.js",
53
- "./dist/jsenv_navi_side_effects.js"
53
+ "./src/navi_z_indexes.js",
54
+ "./src/control/interaction/*.js",
55
+ "./dist/jsenv_navi_side_effects.js",
56
+ "./dist/jsenv_navi.js"
54
57
  ]
55
58
  }