@jsenv/navi 0.29.28 → 0.29.29

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.
@@ -51,6 +51,11 @@ consistency across the app, not from any single call site.
51
51
  `<Interpolate>` for one sentence, `createI18n` for the app's registry,
52
52
  `naviI18n` for navi's own texts. Read it before writing a user-visible
53
53
  sentence, and before overriding a navi message.
54
+ - `docs/interactions.md` — the `interactions` prop: making a component answer a
55
+ swipe, a held press, a shortcut, and registering a gesture navi does not have.
56
+ Read it before reading the pointer by hand — who owns a press between nested
57
+ boxes, and what a touch may do, are decided before the first pixel moves and
58
+ cannot be got right from outside navi.
54
59
  - `docs/MOBILE_LAYOUT_PITFALLS.md` — mobile-specific layout gotchas (viewport
55
60
  units, virtual keyboard, safe areas).
56
61
  - `docs/navigation.md` — how to build navigation: declaring routes
@@ -84,6 +89,9 @@ consistency across the app, not from any single call site.
84
89
  - **Field components** (`Input`, `Select`, `Checkbox`, etc.) take an `action`
85
90
  prop to respond to interaction — this is the standard wiring, not
86
91
  `onChange` + manual state.
92
+ - **A gesture is named, not read by hand**: `interactions={{ swipe_right: … }}`
93
+ on any `Box` (so on any component). Never a `pointerdown` listener of your own
94
+ — see `docs/interactions.md`.
87
95
  - **Texts**: a user-visible sentence containing a value is written as one
88
96
  template with `[placeholder]`s (`interpolateText` / `<Interpolate>`), not cut
89
97
  into JSX fragments or concatenations. Beyond a handful of texts, an app
@@ -0,0 +1,375 @@
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.
253
+
254
+ `data-drag-axis` says which axes the drag walks, and its default is not the same
255
+ for every outcome: `reorder` alone walks the list (`y`, or `x` for a list that runs
256
+ sideways), while a `move` goes wherever it is put and a `toss` wherever it was
257
+ thrown (`xy`). `data-drag-delay`, `data-drag-slop`, `data-drag-threshold` tune when
258
+ the press becomes a grab.
259
+
260
+ ## Tuning
261
+
262
+ Read off the element or any ancestor carrying the attribute, so a whole list is
263
+ tuned in one place and a stylesheet can read the same value.
264
+
265
+ | Attribute | Default | Meaning |
266
+ | ---------------------- | ------- | ----------------------------------------- |
267
+ | `data-swipe-threshold` | `0.33` | fraction of the element to pull to commit |
268
+ | `data-longpress-delay` | `450` | ms the press must be held |
269
+ | `data-longpress-slop` | `8` | px the pointer may drift during the wait |
270
+
271
+ A threshold is a **fraction and never a distance**: the same gesture must mean
272
+ the same thing on a phone and on a wide screen. Speed answers on its own on top
273
+ of it — a brief flick counts whatever the distance covered.
274
+
275
+ ## Registering an interaction navi does not have
276
+
277
+ The registry holds no detector of its own: navi's swipes, holds and shortcuts go
278
+ through the same door an application uses.
279
+
280
+ ```js
281
+ import { defineInteractionDetector } from "@jsenv/navi";
282
+
283
+ defineInteractionDetector({
284
+ name: "triple_click",
285
+ claims: (type) => type === "triple_click",
286
+ setup: (element, trigger) => {
287
+ let count = 0;
288
+ let timeout = null;
289
+ const onClick = (clickEvent) => {
290
+ count++;
291
+ clearTimeout(timeout);
292
+ timeout = setTimeout(() => {
293
+ count = 0;
294
+ }, 1000);
295
+ if (count < 3) {
296
+ return;
297
+ }
298
+ count = 0;
299
+ trigger(clickEvent);
300
+ };
301
+ element.addEventListener("click", onClick);
302
+ return () => {
303
+ clearTimeout(timeout);
304
+ element.removeEventListener("click", onClick);
305
+ };
306
+ },
307
+ });
308
+ ```
309
+
310
+ `setup(element, trigger, { types, readConfig })` runs **once per element** and
311
+ returns how to undo whatever it did. Listeners, attributes, anything: it is a
312
+ plain setup and teardown, so a detector counts what it needs in its own closure
313
+ and nothing has to hold state on its behalf.
314
+
315
+ `claims` takes a **set** of names rather than one, because interactions sharing an
316
+ input have to be arbitrated together — a swipe, a hold and a click dispute the
317
+ same press, and read apart they walk over each other. `types` (third argument) is
318
+ which of them were actually declared here.
319
+
320
+ `trigger(type, originalEvent, detail)` says the interaction happened. Called with
321
+ a single event — `trigger(event)` — the type is the detector's own, which only
322
+ works when exactly one of its names is declared. When `originalEvent.type` is
323
+ already the interaction's name (a native one), that event IS the interaction and
324
+ no second one is dispatched.
325
+
326
+ It returns **`null` when nothing ran** (the gate refused, no control to ask, the
327
+ interaction event was prevented) and otherwise a **promise**: resolved once the
328
+ effect worked, rejected when it did not. Those two answers are not the same and a
329
+ detector usually treats them differently — a row pulled out comes back either
330
+ way, something thrown off the screen only comes back if the throw failed.
331
+
332
+ `readConfig(attribute, defaultValue)` reads a number off the element or any
333
+ ancestor carrying that attribute, so a whole list is tuned in one place.
334
+
335
+ A detector that reads the pointer must mark itself in the DOM so a travelling
336
+ container above it does not take the gesture:
337
+ `element.setAttribute("data-no-drag-travel", "")`, undone in the teardown (see
338
+ `docs/drag_to_travel.md`). navi's own swipes do the equivalent with
339
+ `data-travel-by-drag`.
340
+
341
+ ## Things worth knowing before guessing
342
+
343
+ - **A hold does not take the context menu.** Declaring `longpress` says what a
344
+ held finger does; a right click comes from the other button and keeps opening
345
+ the browser's menu. Declare `contextmenu` beside it to make the right click do
346
+ the same thing. (A held _finger_ is the system's own context-menu gesture, and
347
+ that one is refused while the wait runs.)
348
+ - **A swipe cannot also be dragged out of the page.** An element declaring a
349
+ swipe gets `draggable={false}` and its `dragstart` refused — a native drag _is_
350
+ press-and-move, and a link or an image is draggable without anyone asking. One
351
+ gesture cannot mean both.
352
+ - **`interactions` adds, it does not replace.** A control's own wiring stays:
353
+ `actionEvent` / `actionOnMouseDown` are still how you change what triggers
354
+ `action` by default.
355
+ - **A popup can open while the finger is still down.** navi's `Popover` is
356
+ `popover="manual"` and owns its dismissal, so the `pointerup` ending a hold is
357
+ not read as an interaction outside it — a menu can appear under a waiting
358
+ finger, which is the native gesture. To place it at the press point rather than
359
+ on the element:
360
+ `triggerNaviCommand(target, "--navi-open", interactionEvent, { anchor })`.
361
+ - **A swipe has no keyboard equivalent.** There is nothing to press that means
362
+ "swipe right", so a swipe is only reachable if something else on the element
363
+ offers the same thing — a `"keyboard:<shortcut>"`, a `contextmenu`, or the
364
+ control's own action.
365
+
366
+ ## Reference
367
+
368
+ - `src/control/interaction/interaction_registry.js` — the prop, the three values,
369
+ the registry.
370
+ - `src/control/interaction/interaction_press.js` — swipes and holds, and what a
371
+ swipe writes on the element.
372
+ - `src/control/interaction/interaction_keyboard.js`,
373
+ `interaction_native.js` — the other two detectors.
374
+ - `src/control/demos/38_interactions_demo.html` — every case above, plus a
375
+ mailbox and a custom `swipe_out` gesture registered from the page.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/navi",
3
- "version": "0.29.28",
3
+ "version": "0.29.29",
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": {