@jsenv/navi 0.29.418 → 0.29.419

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.
Files changed (45) hide show
  1. package/README.md +1 -1
  2. package/dist/dev/jsenv_navi.js +192 -43
  3. package/dist/dev/jsenv_navi.js.map +10 -10
  4. package/dist/jsenv_navi.js +190 -41
  5. package/dist/jsenv_navi.js.map +10 -10
  6. package/docs/AI_INSTRUCTIONS.md +177 -261
  7. package/docs/actions.md +164 -251
  8. package/docs/autofocus.md +66 -83
  9. package/docs/control_object.md +117 -214
  10. package/docs/control_value.md +121 -185
  11. package/docs/create_and_edit.md +159 -254
  12. package/docs/css_architecture.md +196 -260
  13. package/docs/data_states.md +41 -44
  14. package/docs/dialog_shape.md +91 -134
  15. package/docs/drag_interactions.md +115 -174
  16. package/docs/drag_to_travel.md +10 -9
  17. package/docs/dynamic_import.md +119 -136
  18. package/docs/error_handling.md +75 -127
  19. package/docs/field_validation.md +49 -89
  20. package/docs/form_changed.md +61 -78
  21. package/docs/group.md +141 -0
  22. package/docs/i18n.md +31 -76
  23. package/docs/interactions.md +6 -4
  24. package/docs/list_action.md +36 -51
  25. package/docs/list_refresh.md +73 -115
  26. package/docs/mobile_touch.md +56 -62
  27. package/docs/navigation.md +377 -723
  28. package/docs/network_policy.md +61 -75
  29. package/docs/pan_zoom.md +62 -103
  30. package/docs/popup_backdrop.md +128 -168
  31. package/docs/popup_lift.md +179 -268
  32. package/docs/popup_open.md +380 -651
  33. package/docs/resource.md +228 -408
  34. package/docs/route_transitions.md +333 -482
  35. package/docs/safe_area.md +102 -89
  36. package/docs/scroll.md +175 -335
  37. package/docs/state_binding.md +2 -1
  38. package/docs/testid.md +18 -23
  39. package/docs/typography.md +111 -141
  40. package/docs/view_transitions.md +106 -104
  41. package/docs/z_index.md +61 -109
  42. package/package.json +2 -2
  43. package/docs/badge_list.md +0 -83
  44. package/docs/control_group.md +0 -168
  45. package/docs/mobile_layout_pitfalls.md +0 -145
@@ -1,10 +1,10 @@
1
1
  # What a form sends, and what it measures against
2
2
 
3
3
  A form answers a question. Asked again with the same answer, it has nothing to
4
- say — so by default **a `<Form>` sends nothing when nothing changed**. This is
5
- the whole subject of this file: what "changed" is measured against, what counts
6
- as an answer the form already holds, and what to do on a screen whose fields are
7
- filled a request later.
4
+ say — so by default **a `<Form>` sends nothing when nothing changed**. This file
5
+ is about what "changed" is measured against, what counts as an answer the form
6
+ already holds, and what to do on a screen whose fields are filled a request
7
+ later.
8
8
 
9
9
  - [Sending nothing is the default](#sending-nothing-is-the-default)
10
10
  - [What follows a send](#what-follows-a-send)
@@ -21,8 +21,9 @@ hold their defaults, one reopened and left alone — runs **no action**. No
21
21
  request, nothing in the network tab.
22
22
 
23
23
  Everything around the action still happens: the constraints are checked, and
24
- what follows the send still follows it (the popup closes, the slide moves on).
25
- The user is done either way; there was simply nothing to send.
24
+ what follows the send still follows it — the popup closes, the slide moves on,
25
+ the page changes. The user is done either way; there was simply nothing to
26
+ send.
26
27
 
27
28
  `canSendWhileUnchanged` turns that off, for a form where sending the same thing
28
29
  twice is the point — a single button firing a notification, an action whose
@@ -35,18 +36,24 @@ duplicates are fine.
35
36
  ## What follows a send
36
37
 
37
38
  The form has answered its question; `command` says what the screen does about
38
- it — dismiss the popup (`--navi-close`), move on the slide map
39
- (`--navi-left`…), go to a page (`--navi-nav-to:/games/42`), go back to the one
40
- the reader came from (`--navi-nav-back:/games`), empty itself and stay where it
41
- is (`--navi-reset`), stay put as it is (`--navi-void`). Left out, the surface the
42
- form sits in decides: a popup closes, a slide goes on, a form on a page does
43
- nothing.
39
+ it — `--navi-close`, `--navi-left`…, `--navi-nav-to:/games/42`,
40
+ `--navi-nav-back:/games` (back to the page the reader came from),
41
+ `--navi-reset` (empty itself and stay), `--navi-void` (stay put as it is). Left
42
+ out, the surface the form sits in decides: a popup closes, a slide goes on, a
43
+ form on a page does nothing. A submit button says its own with `formCommand`,
44
+ which wins over the form's: in a form with two ways out, "save" stays and
45
+ "delete" goes back to the list.
46
+
47
+ It is a prop, decided before the send, because it runs **whether or not there
48
+ was anything to send**. Nothing runs when the send fails, or when a constraint
49
+ refuses it: the form stays in front of the person, showing what it is waiting
50
+ for.
44
51
 
45
52
  ### A popup that stays open
46
53
 
47
- A side panel, a dialog or a popover closes once a form inside it is sent. When
48
- the popup is a place one keeps working in — a panel where a push is sent, two
49
- fields are fixed, an account is linked — the form says it stays:
54
+ A popup closes once a form inside it is sent. When it is a place one keeps
55
+ working in — a panel where a push is sent, two fields are fixed, an account is
56
+ linked — the form says it stays:
50
57
 
51
58
  ```jsx
52
59
  <SidePanel signal={openUserSignal} value={user.id}>
@@ -56,13 +63,9 @@ fields are fixed, an account is linked — the form says it stays:
56
63
  </SidePanel>
57
64
  ```
58
65
 
59
- The form says it, not the popup. A popup does not know which of its forms is
60
- its answer: the same panel can hold a form you edit in over and over and a
61
- "delete" whose send has to close it. A popup that stays open whatever is sent
62
- inside it would make every form in it that should close say so again, which
63
- is the same repetition in the other direction. Nor does a `SidePanel` get a
64
- different default from a `Dialog`: the same content in either one should
65
- behave the same, and on a small screen a `SidePanel` often _is_ a dialog.
66
+ The form says it, not the popup: the same panel can hold a form edited in over
67
+ and over and a "delete" whose send has to close it, and the same content should
68
+ behave the same in a `SidePanel` or a `Dialog`.
66
69
 
67
70
  When the popup's open state is a url param (`signal` bound to a route's search
68
71
  param), closing it takes the param out of the url. A form that forgot
@@ -71,28 +74,20 @@ is what a reload would have brought back.
71
74
 
72
75
  `--navi-reset` is the one to reach for when the form is a place one comes back
73
76
  to — an entry created, then the next one: the fields go back to their
74
- `defaultValue`, ready for the next entry. (What the form is measured against
75
- stays what was just sent — see below — so the emptied form reads as changed,
76
- and the fields' own constraints are what keep an empty one from being sent.)
77
+ `defaultValue`. What the form is measured against stays what was just sent (see
78
+ below), so the emptied form reads as changed, and the fields' own constraints
79
+ are what keep an empty one from being sent.
77
80
 
78
81
  ```jsx
79
82
  <Form action={createPlaceGroup} command="--navi-reset">
80
83
  ```
81
84
 
82
- It runs **whether or not there was anything to send** — that is the other half
83
- of the rule above: the person is done either way, and a submit that ran no
84
- action still closes the popup, still moves on, still navigates. Which is why
85
- this is a prop, decided before the send: the form has to know where it goes even
86
- when nothing happened.
87
-
88
- Nothing runs when the send fails, or when a constraint refuses it. The form then
89
- stays in front of the person, showing what it is waiting for.
90
-
91
85
  ### When only the response knows where to go
92
86
 
93
87
  A creation lands on the page the server just made, and its id comes back with
94
88
  the response — too late for a prop. Do it in the action, which is where the
95
- answer is:
89
+ answer is (a creation always has something to send, so there is no "the press
90
+ did nothing" case for `command` to cover):
96
91
 
97
92
  ```jsx
98
93
  <Form
@@ -103,13 +98,10 @@ answer is:
103
98
  >
104
99
  ```
105
100
 
106
- Nothing to declare: a creation always has something to send, so there is no
107
- "the press did nothing" case for `command` to cover.
108
-
109
- If you would rather it go through the command machinery all the same (to reuse
110
- whatever a command does on that surface), the form carries what follows the send
111
- as `data-after-send`, read once the send has succeeded — so an action can write
112
- it while it runs:
101
+ To go through the command machinery all the same (to reuse whatever a command
102
+ does on that surface), write it where the form keeps what follows the send,
103
+ `data-after-send`, read once the send has succeeded — an action can write it
104
+ while it runs:
113
105
 
114
106
  ```js
115
107
  formRef.current.setAttribute("data-after-send", `--navi-nav-to:/games/${id}`);
@@ -151,19 +143,17 @@ An age that is usually 18, a duration that is usually 1h30 — the form holds
151
143
  nothing there, and confirming the suggestion IS an answer ("yes, 18"), which
152
144
  must be sendable. A bound signal falls on whichever side its content puts it: a
153
145
  signal restored from the url or set by whoever fills the screen carries an
154
- answer, even when the signal also declares a default (its default only says
155
- where a reset goes back to).
156
-
157
- The same question is asked of a single control by `isUIStateHeld`
158
- (`src/control/held_ui_state.js`) — a form asks it once per field.
146
+ answer. A `stateSignal` that declares a default is never empty — the default is
147
+ what it holds until something else is written, and what writing `undefined`
148
+ puts back — so its field counts as held from the start.
159
149
 
160
150
  ## A screen filled after it opened: `pristineKey`
161
151
 
162
- The baseline is taken as soon as the fields have registered, which is right for
163
- a form whose values are there on the first render — and wrong for a screen that
164
- modifies something: the resource arrives a request later and fills the fields,
165
- so a form measured against what it held BEFORE that opens **already changed**.
166
- Its submit is live, and pressing it sends back the resource untouched.
152
+ The baseline taken as the fields register is right for a form whose values are
153
+ there on the first render — and wrong for a screen that modifies something: the
154
+ resource arrives a request later and fills the fields, so the form opens
155
+ **already changed**. Its submit is live, and pressing it sends back the resource
156
+ untouched.
167
157
 
168
158
  `pristineKey` takes the baseline again. Pass whatever says the filling is done:
169
159
 
@@ -171,23 +161,19 @@ Its submit is live, and pressing it sends back the resource untouched.
171
161
  <Form pristineKey={game && players && places ? "loaded" : undefined}>
172
162
  ```
173
163
 
174
- Change it **once**, when the screen is ready. Taken again after someone started
175
- typing, it would call what they wrote the reference.
176
-
177
- No need to delay it by a tick: the reference is taken when the fields have
178
- settled, and again at the end of that same tick — so a row that arrives in a
179
- render of its own (a value computed from signals, a memoized row) is part of it
180
- without the screen having to know which of its fields settle late.
164
+ Change it **once**, when the screen is ready: taken again after someone started
165
+ typing, it would call what they wrote the reference. There is no tick to wait
166
+ for — fields that settle in a render of their own are part of it.
181
167
 
182
168
  Do not use a `key` on the `<Form>` for this: it remounts every control and every
183
169
  popup inside it, and anything half-typed goes with them.
184
170
 
185
171
  ## A submit that says it is waiting
186
172
 
187
- By default a submit that sends nothing is still accepted — in a dialog or a
188
- slide it closes / moves on all the same. In a form that goes nowhere on its own,
189
- the press would visibly do nothing; `readOnlyWhileFormUnchanged` on the button
190
- holds it back and says what it is waiting for.
173
+ A submit that sends nothing is still accepted — in a dialog or a slide it closes
174
+ or moves on all the same. In a form that goes nowhere on its own, the press
175
+ would visibly do nothing; `readOnlyWhileFormUnchanged` on the button holds it
176
+ back and says what it is waiting for.
191
177
 
192
178
  ```jsx
193
179
  <Button type="submit" readOnlyWhileFormUnchanged>
@@ -208,26 +194,23 @@ something, a list acting on every touch. They say so with `standalone`:
208
194
  ```
209
195
 
210
196
  `standalone` is not a filter, it is the absence of a relationship: the control
211
- does not register with the group around it. Nothing goes up — its value is not
212
- collected, and the form stays unchanged whatever it does — and nothing comes
213
- down either: the form's reset does not reach it, nor does its validation
214
- cascade.
197
+ does not register with the group around it. Nothing goes up (its value is not
198
+ collected, the form stays unchanged whatever it does) and nothing comes down
199
+ (the form's reset, its validation cascade).
215
200
 
216
201
  What it does **not** say is "ignore everything around me". `disabled`,
217
202
  `readOnly` and `loading` travel on their own contexts and go on reaching it,
218
- and that is deliberate: a door that carries no value of its own can still
219
- write into the form through what its popup does, and a read-only form must
220
- still shut it. An affordance that genuinely writes nowhere says that
221
- separately, with `whenSelfInteractionsBlocked="ignore"` — as it says whose
222
- press it is with `selfInteractions`, and a control whose action is meant to be
223
- left running says THAT separately too, with `actionStandalone`. One question
224
- each, one prop each (see
203
+ deliberately: a door with no value of its own can still write into the form
204
+ through what its popup does, and a read-only form must still shut it. An
205
+ affordance that genuinely writes nowhere says so separately
206
+ (`whenSelfInteractionsBlocked="ignore"`), as it says whose press it is with
207
+ `selfInteractions`, and a control whose action is meant to be left running
208
+ with `actionStandalone` — one question each, one prop each (see
225
209
  [interactions.md](./interactions.md#the-third-question-whose-value-is-it)).
226
210
 
227
- Every control takes the prop, groups included — `<Form standalone>` is the
228
- same sentence, and so is a `<List standalone>` inside a picker's popup, which
229
- is what lets a search box or a second list sit beside the one that IS the
230
- answer (see
211
+ Every control takes the prop, groups included — `<Form standalone>`, or a
212
+ `<List standalone>` sitting beside the one that IS the answer in a picker's
213
+ popup (see
231
214
  [control_object.md](./control_object.md#a-picker-whose-value-is-an-object)).
232
215
 
233
216
  ## See also
package/docs/group.md ADDED
@@ -0,0 +1,141 @@
1
+ # Grouping controls (`<Group>`)
2
+
3
+ What we want: **several controls reading as one object.** A search input and
4
+ its button, a row of segmented buttons, a column of setting rows — when they
5
+ belong together, the eye should see one frame with divisions inside it, not
6
+ three boxes that happen to touch: one border along each seam instead of two side
7
+ by side, and a radius only on the four outer corners.
8
+
9
+ `<Group>` is what produces that. Reach for it whenever you place controls
10
+ against each other and any of them has a border or a radius — never hand-write
11
+ negative margins, `border-radius: 0` overrides, or a `borderRadius` prop set to
12
+ `0` on the middle members. Those spellings look right on the case you are
13
+ looking at and break on the next one: a member added at the end, a member
14
+ hidden by a condition (the "first" is then the second child), a switch from row
15
+ to column.
16
+
17
+ ```jsx
18
+ <Group>
19
+ <Input name="search" placeholder="Search…" />
20
+ <Button>Go</Button>
21
+ </Group>
22
+ ```
23
+
24
+ Stacked, one setting per row:
25
+
26
+ ```jsx
27
+ <Group row>
28
+ <Picker name="side" ui={…} expandX />
29
+ <Picker name="level" ui={…} expandX />
30
+ <Picker name="city" ui={…} expandX />
31
+ </Group>
32
+ ```
33
+
34
+ `row` (or `vertical`, the same prop — each member is a row, so they stack
35
+ vertically and meet along horizontal seams) is the only prop that changes the
36
+ arrangement; everything else is read off the members themselves. Say it rather
37
+ than `flex="y"`: the seams follow `row`, not the layout.
38
+
39
+ Live examples: `src/control/demos/15_group_demo.html`.
40
+
41
+ ## What Group does to its members
42
+
43
+ - **Seams**: a member with a member before it is pulled back by one border
44
+ width, so the two borders along a seam become one line — the member's own
45
+ `--border-width` when it declares one, else the group's `--group-border-width`
46
+ (default: `--navi-control-border-width`).
47
+ - **Corners**: a member loses the radius on each side where it faces another
48
+ member. A single member keeps its own radius — a group of one looks like the
49
+ control alone.
50
+ - **Members only**: a child out of the group's flow is none of its business. A
51
+ popup renders inside its opener's subtree, so a `Dialog` or `Popover` written
52
+ next to the button that opens it is a child of the group, backdrop included; a
53
+ callout anchored on a `<button>` is mounted in that button's parent. They mark
54
+ themselves `navi-out-of-flow`, and the group skips them: none takes a corner,
55
+ none moves anyone else's (a row of one button and the dialog it opens is still
56
+ a group of one), none is pulled back by a border width.
57
+ - **Overlap order**: the member hovered, the one showing a focus ring (on itself
58
+ or inside it), and the one holding something open (`aria-expanded="true"`)
59
+ paint above their neighbours, so their border and ring are not sliced
60
+ (`--navi-z-index-control-hovered` / `-focused` / `-expanded`, expanded
61
+ highest). The group is deliberately not isolated — see
62
+ [z_index.md](./z_index.md#why-a-group-member-is-not-isolated).
63
+
64
+ Nothing else: a group does not restyle its members, does not impose a size,
65
+ and takes any `Box` prop for its own layout.
66
+
67
+ ## Writing a control that belongs in a group
68
+
69
+ A group never writes a selector that reaches inside a member — a member's
70
+ subtree holds more than the member (a `Picker` renders its popup inside itself;
71
+ a control carries buttons of its own, like the clear cross in a slot), and a
72
+ rule matching "some descendant" finds all of them. It asks for a square corner
73
+ in two forms instead, and a control answers with whichever fits.
74
+
75
+ **The property, on the member itself.** A control declares the radius of its
76
+ frame on its own root, and whatever inner element paints that frame takes
77
+ `border-radius: inherit`:
78
+
79
+ ```css
80
+ .navi_thing {
81
+ /* Declared here even though the box below is what draws it */
82
+ border-radius: var(--thing-border-radius);
83
+
84
+ .navi_thing_box {
85
+ border: ...;
86
+ border-radius: inherit;
87
+ }
88
+ }
89
+ ```
90
+
91
+ **The custom property, which travels.** A member is not always the control that
92
+ carries the frame: a button can arrive wrapped in a tooltip or a link, at any
93
+ depth. So the group also sets `--x-corner-top-left-radius` and its three
94
+ siblings on the member, and a control that can arrive wrapped reads them as an
95
+ override of its own radius. The `--x-` prefix marks navi's internal wiring, not
96
+ a surface an app writes to (an app changes a radius with the `borderRadius`
97
+ prop, which lands in the fallback):
98
+
99
+ ```css
100
+ .navi_thing {
101
+ border-top-left-radius: var(
102
+ --x-corner-top-left-radius,
103
+ var(--thing-border-radius)
104
+ );
105
+ /* …and the three others */
106
+ }
107
+ ```
108
+
109
+ **Whoever answers the ask also stops it.** Custom properties inherit all the
110
+ way down, so the control that consumed a corner sets the four back to `initial`
111
+ on the first element inside it — otherwise a button in a slot, or the Save
112
+ button of a form in an open popup, reads a corner meant for the row that opened
113
+ it. `Popover` and `Dialog` stop it at their own root. **A member drawing the
114
+ frame itself stops it too**: a `Box` that paints a background or a border, or
115
+ insets what it holds with padding, is what the outer corner belongs to, and
116
+ marks itself `navi-box-frame` to stop the claims for everything inside. A
117
+ wrapper adding neither — a tooltip, a link, a bare `<Box>` — lets them through
118
+ to the control that really carries the frame.
119
+
120
+ A control that declares its radius on an inner element instead is invisible to
121
+ `Group`: it keeps round corners in the middle of the row, and no rule in
122
+ `group.jsx` can reach it without naming that private class. Fix the control, not
123
+ the group. The controls shipped by navi all follow this: `Button`, `Input`,
124
+ `Select`, `Picker`, `Spin`, checkbox, range.
125
+
126
+ ## When it is not a Group
127
+
128
+ - Controls separated by space, each with its own frame — that is a `Box` with
129
+ `spacing`, they were never one object.
130
+ - A label and its control — that is `Field`.
131
+ - Several controls making ONE value between them (an address out of three
132
+ fields, a day and two wheels out of one moment) — that is `ControlGroup`,
133
+ which is about the value and draws nothing. Same word, other subject: see
134
+ [control_object.md](./control_object.md). Both at once is fine — a `Group`
135
+ around the members of a `ControlGroup`.
136
+ - Radio buttons or checkboxes sharing a name and a validation — that is
137
+ `RadioGroup` / `CheckboxGroup`, which is about the value, not the frame. They
138
+ can be put inside a `Group` if you also want them to share a frame.
139
+ - Controls that are ONE value between them, with a word written inside the frame
140
+ (the hours and the minutes of "07h30") — that is `SpinGroup`, which drops the
141
+ members' frames rather than joining them.
package/docs/i18n.md CHANGED
@@ -9,7 +9,7 @@ Translation is a consequence of doing that, not the reason to do it. An app with
9
9
  one language still benefits from one place holding its wording; and once it does,
10
10
  adding a second language becomes a data change instead of a refactor.
11
11
 
12
- Three tools, from smallest commitment to largest:
12
+ Four tools, from smallest commitment to largest:
13
13
 
14
14
  | Tool | Gives you |
15
15
  | ----------------------- | ---------------------------------------- |
@@ -18,6 +18,11 @@ Three tools, from smallest commitment to largest:
18
18
  | `createI18n()` | a registry of the app's texts |
19
19
  | `naviI18n` | navi's own texts, to override |
20
20
 
21
+ Reach for `interpolateText`/`<Interpolate>` as soon as a sentence has a value in
22
+ it, and for `createI18n` as soon as more than a handful of texts exist — the
23
+ migration from the first to the second is wrapping the string in a call, which
24
+ is why starting with plain interpolation costs nothing.
25
+
21
26
  ## Writing a sentence: `interpolateText` / `<Interpolate>`
22
27
 
23
28
  Use these the moment a sentence contains a value. They need no i18n instance and
@@ -40,14 +45,9 @@ In JSX, `<Interpolate>` keeps the sentence readable when part of it is styled:
40
45
  The point is that the full sentence is visible in one place. The alternative —
41
46
  `Data limited to <Text bold>{radius} km</Text> around {zone}.` — reads as
42
47
  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.
48
+ single string to be the key. Placeholders are `[name]`; what a value can be (a
49
+ dot-path, a function) and what a missing one does are in `interpolateText`'s
50
+ JSDoc.
51
51
 
52
52
  ## Centralizing texts: `createI18n`
53
53
 
@@ -93,44 +93,13 @@ message needs a stable name that does not move when navi rewords its default
93
93
  English. Same reasoning applies to any shared component library — the trade-off
94
94
  flips with who owns the text.
95
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. The language is resolved per key: the preferred languages in order
121
- (`lang`, or the whole `languagesSignal` list by default), then the
122
- `fallbackLang` given at creation, and the first one translating the key wins. A
123
- half-translated language therefore shows its own words where it has them and the
124
- next language's everywhere else.
125
-
126
- When none of them translates the key, it comes back as is, so a missing
127
- translation degrades to readable text rather than blank space or a crash. With
128
- opaque keys that text is the key's name, which is why an instance with opaque
129
- keys wants a `fallbackLang` holding every key — `naviI18n` falls back to
130
- English.
131
-
132
- `i18n.has(key)` is the way to tell "no translation" apart from "translation that
133
- happens to equal the key".
96
+ A key no language translates comes back as is, so a missing translation
97
+ degrades to readable text rather than blank space or a crash — with opaque keys,
98
+ to the key's name. That is why an instance with opaque keys wants a
99
+ `fallbackLang` holding every key (`naviI18n` falls back to English), and
100
+ `i18n.has(key)` is how to tell "no translation" apart from "a translation that
101
+ happens to equal the key". Registering (`add`, `addAll`, `addLangKeys`) and how
102
+ the language is resolved key by key are in `createI18n`'s JSDoc.
134
103
 
135
104
  ## Which language
136
105
 
@@ -153,13 +122,13 @@ languages. With it, that browser lands on the app's best available language for
153
122
  all of them.
154
123
 
155
124
  It decides more than the wording: `<Time>` and the format helpers
156
- (`formatNumber`, `formatTime`, …) read the same signal, so **a document that never calls it follows the browser,
157
- dates and numbers included** — `2026-09-01` reads "09/01/2026" on an en-US
158
- machine and "01/09/2026" on a French one, from the same code and the same data.
159
- Nothing on screen says which reading is being applied, so this is worth one call
160
- at startup even in an app with a single language, and worth remembering for a
161
- second document in the same repo (an admin panel, a demo page), which starts
162
- from zero.
125
+ (`formatNumber`, `formatTime`, …) read the same signal, so **a document that
126
+ never calls it follows the browser, dates and numbers included** —
127
+ `2026-09-01` reads "09/01/2026" on an en-US machine and "01/09/2026" on a French
128
+ one, from the same code and the same data. Nothing on screen says which reading
129
+ is being applied, so this is worth one call at startup even in an app with a
130
+ single language, and in every document of the repo (an admin panel, a demo
131
+ page), each of which starts from zero.
163
132
 
164
133
  Because the language is read at lookup time, a component that reads `i18n(...)`
165
134
  during render re-renders with the new wording when the language changes — as
@@ -177,28 +146,14 @@ naviI18n.add("list.empty", {
177
146
  en: "Nothing here yet.",
178
147
  fr: "Rien pour l'instant.",
179
148
  });
180
- naviI18n.addLangKeys("ja", { "button.close": "閉じる" });
181
149
  ```
182
150
 
183
- Its JSDoc lists the key namespaces; the exhaustive list of keys and defaults is
184
- the registration block in `src/text/navi_i18n.js`, which is meant to be read —
185
- plus `src/i18n/humanize_i18n.js` in `@jsenv/humanize` for the `time.*` wording
186
- the formatters use. Both register into the one registry `naviI18n` is, so
187
- overriding either goes through the same call.
151
+ Its JSDoc lists the key namespaces and the unit names `<Quantity>`/`<Unit>`
152
+ look up; the exhaustive list of keys and defaults is the registration block in
153
+ `src/text/navi_i18n.js`, plus the `time.*` wording the formatters use,
154
+ registered by `@jsenv/humanize`. Both go into the one registry `naviI18n` is,
155
+ so overriding either is the same call.
188
156
 
189
- Do not put application texts in `naviI18n`. Key collisions aside, the two have
190
- different lifecycles: navi's keys come with the package version, the app's come
191
- with the app.
192
-
193
- One convention to know, since it is what `<Quantity>`/`<Unit>` look up: a unit
194
- name may have `<unit>__plural` and `<unit>__short` variants, both optional
195
- (missing ones fall back to the singular). An unregistered unit goes to
196
- `Intl.NumberFormat`, so only units Intl gets wrong or does not know need
197
- registering at all.
198
-
199
- ## Choosing, in one line
200
-
201
- Reach for `interpolateText`/`<Interpolate>` as soon as a sentence has a value in
202
- it. Reach for `createI18n` as soon as more than a handful of texts exist — the
203
- migration from the first to the second is wrapping the string in a call, which
204
- is why starting with plain interpolation costs nothing.
157
+ **Do not put application texts in `naviI18n`.** Key collisions aside, the two
158
+ have different lifecycles: navi's keys come with the package version, the app's
159
+ come with the app.
@@ -225,9 +225,9 @@ Anything that means "twice" therefore counts presses, which is what
225
225
  ```
226
226
 
227
227
  The rhythm is one window, opened by the FIRST press: the second has to land
228
- inside it and near enough to it (`data-double-click-delay` and
229
- `data-double-click-slop`, see [Tuning](#tuning)) — a fingertip rather than a
230
- pixel, because between the two the finger leaves the glass and lands again. The
228
+ inside it (`data-double-click-delay`) and near enough to it
229
+ (`data-double-click-slop`, a fingertip rather than a pixel, because between the
230
+ two the finger leaves the glass and lands again — see [Tuning](#tuning)). The
231
231
  window is shorter than the hold's wait, so a press slow enough to be a hold
232
232
  cannot start a double click, and a hold answered on the second press takes that
233
233
  press back.
@@ -418,7 +418,9 @@ the effect failed), `implies`, `refusable`, `disputesPress` — and
418
418
  `38_interactions_demo.html` registers a `triple_click` from the page. A detector
419
419
  claims a **set** of names rather than one, because interactions sharing an input
420
420
  have to be arbitrated together: a swipe, a hold and a click dispute the same
421
- press, and read apart they walk over each other.
421
+ press, and read apart they walk over each other. An event handed to `trigger`
422
+ that already bears the interaction's name (a native one) IS the interaction, and
423
+ no second one is dispatched.
422
424
 
423
425
  A detector that reads the pointer says two more things: `disputesPress`, so a
424
426
  control asking for its action on the press waits for the click (see