@jsenv/navi 0.29.27 → 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.
@@ -47,6 +47,15 @@ consistency across the app, not from any single call site.
47
47
  - `docs/z_index.md` — stacking: why DOM order is the first tool, what a
48
48
  `z-index` without `isolation: isolate` actually competes against, and the
49
49
  values navi's own popups/bars/tables use. Read it before writing a `z-index`.
50
+ - `docs/i18n.md` — where the texts an app displays live: `interpolateText` /
51
+ `<Interpolate>` for one sentence, `createI18n` for the app's registry,
52
+ `naviI18n` for navi's own texts. Read it before writing a user-visible
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.
50
59
  - `docs/MOBILE_LAYOUT_PITFALLS.md` — mobile-specific layout gotchas (viewport
51
60
  units, virtual keyboard, safe areas).
52
61
  - `docs/navigation.md` — how to build navigation: declaring routes
@@ -80,6 +89,15 @@ consistency across the app, not from any single call site.
80
89
  - **Field components** (`Input`, `Select`, `Checkbox`, etc.) take an `action`
81
90
  prop to respond to interaction — this is the standard wiring, not
82
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`.
95
+ - **Texts**: a user-visible sentence containing a value is written as one
96
+ template with `[placeholder]`s (`interpolateText` / `<Interpolate>`), not cut
97
+ into JSX fragments or concatenations. Beyond a handful of texts, an app
98
+ declares them in its own `createI18n()` instance — using the English text
99
+ itself as the key, whereas navi's `naviI18n` uses opaque keys. Application
100
+ texts never go into `naviI18n`. See `docs/i18n.md`.
83
101
  - **View transitions**: navi components animate their own changes
84
102
  (`itemTransition` on `List`, `RouteTravel` for routes) and never decide for
85
103
  the whole document. Two things are the application's call, not navi's:
package/docs/i18n.md ADDED
@@ -0,0 +1,183 @@
1
+ # Texts and i18n
2
+
3
+ Every text an app displays is a value it owns, like any other. The goal here is
4
+ that those values live somewhere findable and are written as whole sentences —
5
+ not cut into JSX fragments, not concatenated at the call site, not scattered so
6
+ widely that changing the tone of the app means grepping for punctuation.
7
+
8
+ Translation is a consequence of doing that, not the reason to do it. An app with
9
+ one language still benefits from one place holding its wording; and once it does,
10
+ adding a second language becomes a data change instead of a refactor.
11
+
12
+ Three tools, from smallest commitment to largest:
13
+
14
+ | Tool | Gives you |
15
+ | ----------------------- | ---------------------------------------- |
16
+ | `interpolateText(t, v)` | one readable sentence with values in it |
17
+ | `<Interpolate>` | the same, with JSX allowed in the values |
18
+ | `createI18n()` | a registry of the app's texts |
19
+ | `naviI18n` | navi's own texts, to override |
20
+
21
+ ## Writing a sentence: `interpolateText` / `<Interpolate>`
22
+
23
+ Use these the moment a sentence contains a value. They need no i18n instance and
24
+ no setup, so there is no reason to avoid them in a single-language app.
25
+
26
+ ```js
27
+ import { interpolateText } from "@jsenv/navi";
28
+
29
+ interpolateText("Deleting [name]…", { name: file.name });
30
+ ```
31
+
32
+ In JSX, `<Interpolate>` keeps the sentence readable when part of it is styled:
33
+
34
+ ```jsx
35
+ <Interpolate radiusKm={<Text bold>{radius} km</Text>} zoneName={zone}>
36
+ Data limited to [radiusKm] around [zoneName].
37
+ </Interpolate>
38
+ ```
39
+
40
+ The point is that the full sentence is visible in one place. The alternative —
41
+ `Data limited to <Text bold>{radius} km</Text> around {zone}.` — reads as
42
+ fragments, and cannot later become a translation key, because there is no
43
+ single string to be the key.
44
+
45
+ Placeholders are `[name]`. The delimiter is `[]` rather than `{}`/`{{}}` because
46
+ it collides with neither JSX nor template literals nor ordinary punctuation.
47
+ Details worth knowing (all in `interpolateText`'s JSDoc): a value can be a
48
+ dot-path (`[item.label]`), a function (called only if the placeholder is
49
+ present), and a placeholder with no value is left visible as `[name]` rather
50
+ than disappearing.
51
+
52
+ ## Centralizing texts: `createI18n`
53
+
54
+ Once several components display text, give the app a registry. One instance for
55
+ the whole app, exported from one module:
56
+
57
+ ```js
58
+ // src/app_i18n.js
59
+ import { createI18n } from "@jsenv/navi";
60
+
61
+ export const i18n = createI18n({ keyLang: "en" });
62
+ ```
63
+
64
+ Then call it wherever a text is needed. It is callable directly:
65
+
66
+ ```js
67
+ import { i18n } from "@/app_i18n.js";
68
+
69
+ i18n("Deleting [name]…", { name: file.name });
70
+ ```
71
+
72
+ ### In an app, the text is the key
73
+
74
+ `keyLang: "en"` means "keys are already written in English". The key doubles as
75
+ its own English template, so a single-language app registers nothing at all and
76
+ still gets readable output, and adding French later touches only the registry:
77
+
78
+ ```js
79
+ i18n.add("Deleting [name]…", { fr: "Suppression de [name]…" });
80
+ ```
81
+
82
+ This is the recommended style for applications, because it keeps the call site
83
+ readable. `i18n("Deleting [name]…")` says what appears on screen;
84
+ `i18n("file.delete.progress")` requires a lookup to know. The cost — changing
85
+ the English wording changes the key, so its translations must be updated
86
+ alongside — is the smaller problem in practice, and it is a mechanical one.
87
+
88
+ ### In navi, the key is opaque
89
+
90
+ `naviI18n` uses opaque keys (`"list.empty"`, `"constraint.required.email"`)
91
+ because a library's texts are addressed from the outside: an app overriding a
92
+ message needs a stable name that does not move when navi rewords its default
93
+ English. Same reasoning applies to any shared component library — the trade-off
94
+ flips with who owns the text.
95
+
96
+ Without `keyLang`, every language is explicit, English included:
97
+
98
+ ```js
99
+ const i18n = createI18n();
100
+ i18n.add("greeting", { en: "Hello [name]!", fr: "Bonjour [name] !" });
101
+ ```
102
+
103
+ ### Registering
104
+
105
+ Three ways in, all accumulative — re-registering a key replaces that one key and
106
+ leaves the rest alone:
107
+
108
+ ```js
109
+ i18n.add("Save", { fr: "Enregistrer" }); // one key, many languages
110
+ i18n.addAll({ Save: { fr: "Enregistrer" }, Cancel: { fr: "Annuler" } });
111
+ i18n.addLangKeys("fr", frenchPack); // a whole language, e.g. a fetched JSON file
112
+ ```
113
+
114
+ A regional variant inherits from its parent for every key it does not override,
115
+ resolved at registration time — so register `"fr"` before `"fr-CA"`.
116
+
117
+ ### What comes out
118
+
119
+ `i18n(key, values, { lang })` returns the translation with placeholders
120
+ replaced. When nothing matches it returns the key itself, so a missing
121
+ translation degrades to readable text rather than blank space or a crash. Pass
122
+ `fallbackLang` at creation to fall through to another language first, per key —
123
+ a half-translated language stays usable.
124
+
125
+ `i18n.has(key)` is the way to tell "no translation" apart from "translation that
126
+ happens to equal the key".
127
+
128
+ ## Which language
129
+
130
+ Nothing needs configuring for navi and app texts to agree on a language: both
131
+ default to `languagesSignal`, which is read live on every lookup — the browser's
132
+ `navigator.languages`, plus two app-level overrides.
133
+
134
+ ```js
135
+ import { setSupportedLanguages, setPreferredLanguage } from "@jsenv/navi";
136
+
137
+ setSupportedLanguages(["en", "fr"]); // what the app offers, at startup
138
+ setPreferredLanguage("fr"); // what this user picked, from a language picker
139
+ ```
140
+
141
+ `setSupportedLanguages` matters more than it looks: without it, a browser
142
+ preferring German resolves to German, and every key missing in German falls back
143
+ to its key. With it, that browser lands on the app's best available language
144
+ instead.
145
+
146
+ Because the language is read at lookup time, a component that reads `i18n(...)`
147
+ during render re-renders with the new wording when the language changes — as
148
+ long as the call happens in render, not in a value captured once outside it.
149
+
150
+ ## Changing what navi says
151
+
152
+ `naviI18n` holds navi's own texts. Register on it to override a default or to
153
+ add a language navi does not ship:
154
+
155
+ ```js
156
+ import { naviI18n } from "@jsenv/navi";
157
+
158
+ naviI18n.add("list.empty", {
159
+ en: "Nothing here yet.",
160
+ fr: "Rien pour l'instant.",
161
+ });
162
+ naviI18n.addLangKeys("ja", { "button.close": "閉じる" });
163
+ ```
164
+
165
+ Its JSDoc lists the key namespaces; the exhaustive list of keys and defaults is
166
+ the registration block in `src/text/navi_i18n.js`, which is meant to be read.
167
+
168
+ Do not put application texts in `naviI18n`. Key collisions aside, the two have
169
+ different lifecycles: navi's keys come with the package version, the app's come
170
+ with the app.
171
+
172
+ One convention to know, since it is what `<Quantity>`/`<Unit>` look up: a unit
173
+ name may have `<unit>__plural` and `<unit>__short` variants, both optional
174
+ (missing ones fall back to the singular). An unregistered unit goes to
175
+ `Intl.NumberFormat`, so only units Intl gets wrong or does not know need
176
+ registering at all.
177
+
178
+ ## Choosing, in one line
179
+
180
+ Reach for `interpolateText`/`<Interpolate>` as soon as a sentence has a value in
181
+ it. Reach for `createI18n` as soon as more than a handful of texts exist — the
182
+ migration from the first to the second is wrapping the string in a call, which
183
+ is why starting with plain interpolation costs nothing.
@@ -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/docs/resource.md CHANGED
@@ -25,7 +25,7 @@ Each callback returns the data to upsert into the store:
25
25
  | GET / POST / PUT / PATCH | the full item object, `{ id, … }` |
26
26
  | DELETE | the id, or `{ id }` |
27
27
  | GET_MANY / POST_MANY / … | an array of item objects |
28
- | GET_PAGE | `{ items, start, count }` (below) |
28
+ | GET_RANGE | `{ items, start, count }` (below) |
29
29
 
30
30
  Actions are read in components through the action system (`useAsyncData`,
31
31
  `<Button action>`, …) — see [actions.md](./actions.md).
@@ -48,18 +48,18 @@ it yourself is the point:
48
48
  else to land (see [When the backend answers a sub-route with the whole
49
49
  parent](#when-the-backend-answers-a-sub-route-with-the-whole-parent)).
50
50
 
51
- A list that loads its rows page by page is **not** one of those cases — that is
52
- `GET_PAGE`, right below.
51
+ A list that loads its rows a slice at a time is **not** one of those cases — that is
52
+ `GET_RANGE`, right below.
53
53
 
54
- ## `GET_PAGE`: feeding a list that loads as it scrolls
54
+ ## `GET_RANGE`: feeding a list that loads as it scrolls
55
55
 
56
56
  A `<List.Items>` asks for the rows it is about to draw and keeps what it gets.
57
- `GET_PAGE` is the resource's answer to that question — one slice at a time:
57
+ `GET_RANGE` is the resource's answer to that question — one slice at a time:
58
58
 
59
59
  ```js
60
60
  const GAME = resource("game", {
61
61
  GET: ({ id }) => fetchJson(`/games/${id}`),
62
- GET_PAGE: ({ radar, start, limit }) =>
62
+ GET_RANGE: ({ radar, start, limit }) =>
63
63
  fetchJson(`/radars/${radar.id}/games?start=${start}&limit=${limit}`),
64
64
  // { items: [{ id, … }, …], start: 20, count: 137 }
65
65
  });
@@ -68,7 +68,7 @@ const GAME = resource("game", {
68
68
  ```jsx
69
69
  <List.Items
70
70
  count={radar.match_count}
71
- itemsAction={GAME.GET_PAGE.bindParams({ radar })}
71
+ itemsAction={GAME.GET_RANGE.bindParams({ radar })}
72
72
  renderItem={(game) => <GameCard game={game} />}
73
73
  />
74
74
  ```
@@ -76,7 +76,7 @@ const GAME = resource("game", {
76
76
  The callback receives the bound params merged with the range the list asks for
77
77
  (`start`, `end`, `limit`, `before`, `after`, `around`, `count`), and a `signal`
78
78
  as second argument — aborted when the list stops wanting those rows. It returns
79
- a page the way a `Content-Range` does: **`{ items, start, count }`** — these
79
+ a range the way a `Content-Range` does: **`{ items, start, count }`** — these
80
80
  rows, at this place, out of that many. `start` may be omitted when the list
81
81
  asked for a positive one; `count` defaults to `start + items.length` (a source
82
82
  that does not know its total). The items are upserted on their way in, so the
@@ -98,12 +98,12 @@ makes a change detectable), so the one the list is holding is the one it was
98
98
  given. Relations are not concerned: they are keyed by owner, and a row reading
99
99
  `game.candidates` reads the shared collection whatever object carries it.
100
100
 
101
- `GET_PAGE` is a **reader, not an action**. It keeps no value and takes no place
101
+ `GET_RANGE` is a **reader, not an action**. It keeps no value and takes no place
102
102
  in the rerun graph, which is what makes it usable per slice:
103
103
 
104
- - the list already holds the pages it received and glues them back together —
104
+ - the list already holds the slices it received and glues them back together —
105
105
  a second memory holding one of them would fight it;
106
- - a `POST` invalidating "the collection" would otherwise send every page ever
106
+ - a `POST` invalidating "the collection" would otherwise send every slice ever
107
107
  loaded back to the network at once.
108
108
 
109
109
  What it does not give is membership: an item that leaves the collection stays on
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/navi",
3
- "version": "0.29.27",
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": {