@jsenv/navi 0.29.482 → 0.29.484

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.
@@ -208,8 +208,9 @@ start when unsure which export solves a problem.
208
208
  line height for everything; `BadgeList`. Before an overflowing label, and
209
209
  before touching a `line-height`.
210
210
  - `i18n.md` — `interpolateText`/`<Interpolate>` for one sentence, `createI18n`
211
- for the app's texts, `naviI18n` for navi's own. Before writing a user-visible
212
- sentence.
211
+ for the app's texts, `naviI18n` for navi's own, and which helper writes a
212
+ number, a day or a time. Before writing a user-visible sentence, a number, a
213
+ date or a time.
213
214
  - `testid.md` — role and accessible name first; where `data-testid` lands; what
214
215
  not to target. Before a selector in a test.
215
216
 
package/docs/actions.md CHANGED
@@ -84,6 +84,10 @@ const userAction = getUser.bindParams({ id: userIdSignal });
84
84
  // and starts it, with useAsyncData(userAction, { run: true })
85
85
  ```
86
86
 
87
+ The params a signal or a getter hands over may be a fresh object each time:
88
+ params equal to the ones held are the same question, so the binding keeps the
89
+ object it has — nothing is asked again, and its readers do not render again.
90
+
87
91
  `{ debounce }` puts a delay between the signal and the instance, for params that
88
92
  move faster than a request should — a search box, a wheel, a slider:
89
93
 
@@ -104,6 +108,15 @@ params by hand. It follows where the signal **settles** (`A → B → A` sends
104
108
  nothing for `B`). `actionRunEffect`, which takes the action itself and a params
105
109
  getter, is for a request that must go out whether or not something draws it.
106
110
 
111
+ A request that follows typing — the suggestions under an address field — is
112
+ this binding, never the field's `action`: a field whose action runs is busy,
113
+ and a busy field is read-only, so the person could not go on typing.
114
+ `actionDebounce` delays an action that saves what was typed. Such a request,
115
+ written with a plain `createAction`, has no verb for the network policy to see
116
+ ([network_policy.md](./network_policy.md)): offline, its params say there is
117
+ nothing to ask (`undefined`, [below](#nothing-to-ask-and-coming-back)).
118
+ `src/control/demos/integration/0_address_autocomplete_demo.html` is this shape.
119
+
107
120
  ### The answer is kept on the instance that ran
108
121
 
109
122
  An action's state — `dataSignal`, `errorSignal`, `runningStateSignal` — belongs
@@ -479,6 +492,38 @@ stays on screen while it does, is in [list_refresh.md](./list_refresh.md); a
479
492
  scope with reruns of its own, and a rerun after another resource writes, are
480
493
  [`withParams()`](./resource.md#withparams-a-scope-with-reruns-of-its-own).
481
494
 
495
+ A read that is no row of a resource — a count, a summary — follows writes the
496
+ same way, with `dependencies` on the action:
497
+
498
+ ```js
499
+ const SUMMARY = createAction(fetchSummary, {
500
+ dependencies: [ORDER, { resource: CART, on: ["POST"] }],
501
+ });
502
+ ```
503
+
504
+ After a listed write, its instances that answered, or are still asking, ask
505
+ again; `on` is read as a resource's is
506
+ ([resource.md](./resource.md#dependencies-rerun-after-another-resource-writes)).
507
+
508
+ What the server moves on its own — an email that may bounce, an account being
509
+ linked in another app — no write announces: its reader asks again on a timer.
510
+
511
+ ```js
512
+ const [delivery] = useAsyncData(DELIVERY.GET.bindParams({ id }), {
513
+ run: true,
514
+ poll: {
515
+ every: 3000,
516
+ until: (delivery) => delivery.status !== "pending",
517
+ for: 60_000,
518
+ },
519
+ });
520
+ ```
521
+
522
+ It ticks only while that reader is mounted and the document visible (once at
523
+ once when it shows again), never while a network policy answers reads from the
524
+ store, and each tick revalidates: the answer stays drawn and `loading` stays
525
+ false. `until`, `for` and a failure end it.
526
+
482
527
  `rerunActions(actionSet)` reruns several at once;
483
528
  `updateActions({ prerunSet, runSet, rerunSet, resetSet })` settles the four
484
529
  kinds of request in one pass — a route change goes through it.
@@ -18,6 +18,7 @@ somewhere else in the app.
18
18
  - [Which controls take a `signal`](#which-controls-take-a-signal)
19
19
  - [The PROP is what controls, not its value](#the-prop-is-what-controls-not-its-value)
20
20
  - [A yes/no shown as two rows](#a-yesno-shown-as-two-rows)
21
+ - [A choice that can be given back](#a-choice-that-can-be-given-back)
21
22
  - [Clearing, resetting, and what is shown meanwhile](#clearing-resetting-and-what-is-shown-meanwhile)
22
23
  - [`value` and `signal` exclude each other](#value-and-signal-exclude-each-other)
23
24
  - [A `stateSignal` brings more than a value](#a-statesignal-brings-more-than-a-value)
@@ -25,12 +26,12 @@ somewhere else in the app.
25
26
 
26
27
  ## The three answers
27
28
 
28
- | what you pass | who holds the value | when the user acts |
29
- | ---------------------- | ------------------- | ------------------------------------------------------------------------------------- |
30
- | nothing | the control | it keeps it; `uiAction` tells you |
31
- | `defaultValue` | the control | same — the default is where it starts, and a new one is taken while nothing is edited |
32
- | `signal` | the signal | the control writes it back, both ways |
33
- | `value` (or `checked`) | you | nothing moves until you hand a new one down |
29
+ | what you pass | who holds the value | when the user acts |
30
+ | ---------------------- | ------------------- | ------------------------------------------------------------------------------------------ |
31
+ | nothing | the control | it keeps it; `uiAction` tells you |
32
+ | `defaultValue` | the control | same — the default is where it starts, and a new one is taken while nothing is edited |
33
+ | `signal` | the signal | the control writes it back, both ways |
34
+ | `value` (or `checked`) | you | it holds what you hand down: the press shows its change, a failed `action` puts yours back |
34
35
 
35
36
  A control given `value` and nothing to listen to it (`uiAction`, `action`, a
36
37
  `signal`, a `command`, a form or group collecting it) is read-only, and says so
@@ -57,6 +58,12 @@ const minutesSignal = useSignal(0);
57
58
  minutesSignal.value = 30;
58
59
  ```
59
60
 
61
+ A control moved by a gesture writes each value the gesture passes through: a
62
+ wheel dragged, flung or turned by the mouse wheel writes every row it crosses,
63
+ and its `action` runs once, on the row it settles on. Whatever reads the signal
64
+ re-renders at that rate, so read it in the smallest component that shows it, or
65
+ through a `computed` of what it needs.
66
+
60
67
  "Every change" includes the ones the control did not decide: a group placing
61
68
  its children, a picker filling its popup at open, and the same picker putting
62
69
  back what it held when Escape cancels. So the signal always says where the
@@ -242,10 +249,22 @@ or taken back (the action failed, the popup was cancelled).
242
249
 
243
250
  This holds for a control, for a group given a `defaultValue` (a selectable list,
244
251
  a `ControlGroup`), and for everything inside a picker's popup, which stays
245
- mounted while closed. So the record goes in as `defaultValue` and nothing else —
246
- no `key` to remount the popup when it changes: a key also destroys whatever is
247
- being typed when the answer to ANOTHER field arrives. A picker's cancel follows
248
- the same line: it undoes the person's edits
252
+ mounted while closed.
253
+
254
+ Where the record goes depends on who sends it. A control that saves itself —
255
+ its own `action`, nothing around it to press — takes it as `defaultValue`
256
+ (`defaultChecked`). A picker whose `action` sends what its popup holds takes it
257
+ as its own `value`, the controls in the popup carrying only their `name`: a
258
+ default is a suggestion the picker does not hold, and closing on one untouched
259
+ confirms it ([form_changed.md](./form_changed.md#what-counts-as-already-held)),
260
+ so a popup opened and closed with defaults in it sends the record back. A group
261
+ holding nothing of its own counts as holding an answer as soon as one of its
262
+ controls does. A form editing a record has the same trap, and its own way out
263
+ ([create_and_edit.md](./create_and_edit.md#the-edit-screen-opens-before-its-values)).
264
+
265
+ Either way, no `key` to remount the popup when the record changes: a key also
266
+ destroys whatever is being typed when the answer to ANOTHER field arrives. A
267
+ picker's cancel follows the same line: it undoes the person's edits
249
268
  ([popup_open.md](./popup_open.md#what-cancel-actually-undoes)), and what the
250
269
  outside moved during the opening stays moved.
251
270
 
@@ -296,13 +315,13 @@ no field needs a url of its own — the fields then take nothing but their `name
296
315
  What a control is worth when it holds nothing has the type of what it holds when
297
316
  it holds something. A list of days nobody picked is `[]`, not `""`:
298
317
 
299
- | control | empty is |
300
- | ------------------------------------------------- | -------- |
301
- | `Picker type="array"`, `List selectable multiple` | `[]` |
302
- | `Picker type="object"` | `{}` |
303
- | text/number/date `Input`, `Select` | `""` |
304
- | `Input type="checkbox" boolean` | `false` |
305
- | radio, any other checkbox | absent |
318
+ | control | empty is |
319
+ | ------------------------------------------------------------------ | -------- |
320
+ | `Picker type="array"`, `List selectable multiple`, `CheckboxGroup` | `[]` |
321
+ | `Picker type="object"` | `{}` |
322
+ | text/number/date `Input`, `Select` | `""` |
323
+ | `Input type="checkbox" boolean` | `false` |
324
+ | radio, `List selectable`, any other checkbox | absent |
306
325
 
307
326
  This is what a clear (`--navi-clear`, a row's cross) leaves behind and what the
308
327
  object around it carries, so `value.days || []` is not needed.
@@ -333,6 +352,24 @@ Choose by what the box asks:
333
352
  value) takes its own `value`, or none for `"on"`. Unchecked carries nothing,
334
353
  so the group gathers only the checked ones into an array.
335
354
 
355
+ A button that stays pressed — a mode ("Edit" / "Done"), show/hide, a setting —
356
+ is a checkbox too, drawn as a button: `<Input type="checkbox" variant="button"
357
+ boolean signal={editingSignal}>`, its children its words and its name. A
358
+ `<Button aria-pressed>` whose press writes the signal back is the binding done
359
+ by hand: the state it shows is the app's to keep in step, where the checkbox
360
+ announces it as checked and follows the signal whoever writes it.
361
+
362
+ ```jsx
363
+ // ✗ a button wired to remember a state
364
+ <Button aria-pressed={editing} uiAction={() => (editingSignal.value = !editing)}>
365
+ {editing ? "Done" : "Edit"}
366
+ </Button>
367
+ // ✓
368
+ <Input type="checkbox" variant="button" boolean signal={editingSignal}>
369
+ {editingSignal.value ? "Done" : "Edit"}
370
+ </Input>
371
+ ```
372
+
336
373
  ## Which controls take a `signal`
337
374
 
338
375
  All of them: `Input` (every type), `Picker`, `Select`, `Wheel`, `Spin`,
@@ -389,6 +426,25 @@ What cannot be done is give a row `undefined` to mean "no value" — `undefined`
389
426
  is what UNCHECKED means, here as in HTML, so such a row can never be ticked.
390
427
  "Nothing chosen" is the absence of a row, not a row.
391
428
 
429
+ ## A choice that can be given back
430
+
431
+ A single choice that may also be no choice at all — left, right, or neither — is
432
+ `<List selectable deselectable>`: the selected row, pressed again, lets go, and
433
+ the list holds nothing (absent). Without `deselectable` it is a radio group: a
434
+ choice moves to another row but never goes away.
435
+
436
+ ```jsx
437
+ <List selectable deselectable name="side" defaultValue={me.side}>
438
+ <List.Item value="left">Gauche</List.Item>
439
+ <List.Item value="right">Droite</List.Item>
440
+ <List.Item value="both">Les deux</List.Item>
441
+ </List>
442
+ ```
443
+
444
+ An answer that is the others together ("both") is a row of its own, not two
445
+ rows held at once; what it lights in the others hangs off `aria-selected` in
446
+ CSS.
447
+
392
448
  ## Clearing, resetting, and what is shown meanwhile
393
449
 
394
450
  Three things that look alike and are not:
@@ -232,6 +232,24 @@ for a screen that can be blanked without the person losing their place.
232
232
  Either way, cancelling is a link away: the screen is thrown away with what was
233
233
  typed in it, and coming back re-reads the resource. There is nothing to restore.
234
234
 
235
+ ### A screen that stays open after Save
236
+
237
+ Everything above assumes the screen is left on saving. One edited in place —
238
+ a settings panel, saved, then edited again — has no shape that answers all of
239
+ it yet, and each of the three leaves one thing out:
240
+
241
+ - `defaultValue={record.x}` follows the record and never undoes an edit, but is
242
+ not held: an untouched Save sends the record back.
243
+ - `onLoad` + `pristineKey` is held, and stops following: a record moved while
244
+ the screen is open stays as `onLoad` found it.
245
+ - `useSignalSync(record.x)` ([state_binding.md](./state_binding.md)) is held
246
+ and follows, but the record moving overwrites what is being typed — and it
247
+ compares by reference, so a record read again as a new array or object
248
+ counts as a move even when nothing in it changed.
249
+
250
+ What not to write: `useSignal(record.x)` with `key={record.updated_at}` on the
251
+ block. The key throws away what is being typed each time the record moves.
252
+
235
253
  ## A field that picks from a list too big to load
236
254
 
237
255
  A place, a player, a category: the field is a picker whose popup holds **one
@@ -399,7 +417,14 @@ only its mechanisms change.
399
417
  | the draft lives in the `searchParams` | it lives in the dialog, and dies with it |
400
418
  | cancelling = leaving the screen, which is thrown away | `mount="while-opened"` (+ a `key` on the form), same effect |
401
419
  | `RouteTravel` between the two screens | nothing: a dialog has no neighbour |
402
- | `onLoad` + `pristineKey`: the resource arrives after | it is already in hand — the dialog is opened FROM the list |
420
+ | `onLoad` + `pristineKey`: the resource arrives after | it is in hand — the dialog is opened FROM the list, or re-reads it as it opens (below) |
421
+
422
+ A list's copy of a record can be minutes old, so a sheet that saves the whole
423
+ record reads it again as it opens, and that answer lands after the popup has
424
+ taken what it holds at open. A `Picker` given the record as its `value` (or its
425
+ signal) counts what the outside writes before the person changes anything as
426
+ what was there at open: a look and a close send nothing, and a cancel goes back
427
+ to the record as it arrived, not to the list's copy.
403
428
 
404
429
  What does not change:
405
430
 
@@ -50,6 +50,19 @@ along with the code; at module scope, it lands on every page importing the
50
50
  module, and nothing can shake it out. Assign at module scope only for a module
51
51
  that is a side effect by design — an app's tokens, say.
52
52
 
53
+ The same holds for an app's components, and the choice moves the cascade:
54
+ between unlayered sheets the last one adopted wins a tie. Assigned at module
55
+ scope in a module the entry imports, an app's sheet is adopted before any navi
56
+ component renders, and loses every tie with navi's rules, the same way every
57
+ session. Assigned in render, it lands after the navi sheets already adopted and
58
+ before the ones still to come, so a tie goes to whichever came last — which
59
+ depends on what the user opened first. Before moving a sheet from one to the
60
+ other, find its ties (a `.navi_*` class as a rule's subject, an app class on a
61
+ navi component setting what navi's one-class rule sets) and settle each by
62
+ weight, a prop or a variable rather than by order; what other modules use from
63
+ the sheet (a class they wear) goes in a shared sheet, installed by each of them
64
+ as below.
65
+
53
66
  The setter is keyed by module: **two assignments in one module do not add up**,
54
67
  the second replaces the first. A stylesheet shared between components is
55
68
  therefore a module of its own, exposing an install function its callers run
@@ -258,6 +271,21 @@ A variant (`icon`, `discrete`, `bare`, `border`, `headless`…) describes what
258
271
  the caller did not say: it only moves defaults, so a prop given next to it
259
272
  always wins.
260
273
 
274
+ #### Where a prop lands
275
+
276
+ A `Button`, a range input and a `CardLayout` paint a frame inside themselves.
277
+ Their visual props — `background*`, `border*`, `borderRadius`, `boxShadow`,
278
+ `outline*`, `opacity`, `filter`, `cursor`, `overflow` — and their padding go to
279
+ that frame; size, position and margins stay on the control. A `style={{…}}` is
280
+ not sorted: all its keys paint the outer element, so a background or a shadow
281
+ written there lands around the frame rather than on it. Write them as props.
282
+
283
+ What navi's unlayered structure sets — a control's `position: relative` —
284
+ changes with the prop (`<Button absolute inset>`), or with the variable that
285
+ feeds it where there is one (`--nav-width`), never with a class: one class only
286
+ ties with navi's rule, and sheet order decides the tie (see
287
+ [Where CSS lives](#where-css-lives-importmetacss)).
288
+
261
289
  ### 2. CSS variables (for global or theme-level changes)
262
290
 
263
291
  When the same change applies to many components (e.g. a design token update),
@@ -279,6 +307,32 @@ with `--navi-app-max-width` — never `--dialog-max-width`, a `--component-*`
279
307
  token every dialog resets on itself: see
280
308
  [safe_area.md](./safe_area.md#an-app-that-is-narrower-than-the-window).
281
309
 
310
+ #### The tokens a rule may read
311
+
312
+ What `:root` declares, and so what an app's own css may read with `var()`:
313
+
314
+ - spacing: `--navi-xxs` … `--navi-xxl` (`xxs`, `xs`, `s`, `m`, `l`, `xl`,
315
+ `xxl`), in `em` — the scale the spacing props take, and `borderRadius` too
316
+ (`borderRadius="s"` is `var(--navi-s)`): there is no radius scale of its own;
317
+ - type: `--navi-typo-xxs` … `--navi-typo-xxl`, the scale `size` takes, and
318
+ `--navi-line-height` ([typography.md](./typography.md#emoji));
319
+ - the paper: `--navi-surface-color`, `--navi-surface-text-color`,
320
+ `--navi-chrome-color` (see [below](#two-planes-the-paper-and-the-frame));
321
+ - `--navi-accent-color`; `--navi-error-color`, `--navi-info-color`,
322
+ `--navi-success-color` and `--navi-warning-color`, each with a `-light` twin;
323
+ - the stacking bands `--navi-z-index-*` ([z_index.md](./z_index.md)) and the
324
+ screen's `--navi-safe-area-inset-*` ([safe_area.md](./safe_area.md));
325
+ - the themable defaults of controls and surfaces: `--navi-control-*`,
326
+ `--navi-button-*` (the button grey is `--navi-button-background-color`),
327
+ `--navi-picker-*`, `--navi-link-*`, `--navi-popup-*`, `--navi-callout-*`,
328
+ `--navi-focus-outline-*`, `--navi-placeholder-*`.
329
+
330
+ The color keywords are not read this way: they go through the `color` prop
331
+ ([typography.md](./typography.md)). A name found neither here nor in the doc of
332
+ what it belongs to is most likely declared nowhere, and `var()` on it draws its
333
+ fallback without a word — `--navi-radius-s`, `--navi-space-m`, `--navi-font-mono`
334
+ look plausible and are not tokens.
335
+
282
336
  #### `--navi-*` vs `--component-*`: where the override has to go
283
337
 
284
338
  The two families are **not** interchangeable, and layers have nothing to do
@@ -327,9 +381,16 @@ Scoping to an ancestor is not enough: `.my-sidebar { --link-color-pressed: blue
327
381
  fails for the same reason `:root` does. The selector has to reach the link
328
382
  element itself.
329
383
 
384
+ The rule is about declarations, not about the family. A `--component-*` navi
385
+ only reads with a fallback and declares nowhere — `--button-background`,
386
+ `--button-border`, `--button-padding*` — has nothing on the element to beat, so
387
+ an ancestor's value reaches it like any inherited custom property; one navi
388
+ declares — `--button-color`, `--nav-current-indicator-color` — stops at the
389
+ element.
390
+
330
391
  When a component default deserves to be themed globally, promote it: declare a
331
- `--navi-<component>-<thing>` in [navi_css_vars.js](../src/navi_css_vars.js) and
332
- make the component default read `var(--navi-…)`.
392
+ `--navi-<component>-<thing>` in `src/navi_css_vars.js` (in navi's repository)
393
+ and make the component default read `var(--navi-…)`.
333
394
 
334
395
  #### Two planes: the paper and the frame
335
396
 
@@ -414,6 +475,24 @@ and it stops at the surface. Declaring the formulas on `*` to make container
414
475
  ratios work is the thing not to do: it would overwrite the pinned keyword on
415
476
  each of the card's children, and a paper could no longer say anything.
416
477
 
478
+ ##### A dark surface inside a light page
479
+
480
+ `color` on a surface turns what is written against `currentColor` — the four
481
+ keyword formulas, and whatever an app writes the same way. It does not turn the
482
+ colors navi writes as `light-dark()` or as a system color: the paper
483
+ (`--navi-surface-color`) and its ink (`CanvasText`), the control border, the
484
+ button grey, the focus ring, a `Wheel`'s ink, glass and frame. A card saying
485
+ `color: white` and nothing else still holds a near-black wheel.
486
+
487
+ `color-scheme: dark` on the surface turns those: a `light-dark()` is resolved
488
+ where it is used, so every one below the surface takes its dark side — except a
489
+ token the app pinned to an absolute on `:root`. A dark card says both. A
490
+ component's own color token (`--wheel-color`) still tints it, set on the card
491
+ or any ancestor. `color-scheme` is inherited and no surface takes it back: a
492
+ popup opened from the card is drawn dark too. (A lowered `light-dark()` follows
493
+ `prefers-color-scheme` only: see
494
+ [Browser support](#browser-support-navis-css-and-your-target).)
495
+
417
496
  ### 3. Direct rule override (avoid unless necessary)
418
497
 
419
498
  Overriding the actual CSS rules (not the variables) is intentionally hard. If
@@ -496,6 +575,7 @@ elements and the boxes before and after.
496
575
  | A global design token | `--navi-*` on `:root` |
497
576
  | The share of ink in `secondary`/`emphasis`/`discrete`/`hint` | `--navi-color-*-mix` on `:root` — never on a container |
498
577
  | A container's own paper (a dark card) | `color` on it, plus a keyword pinned if its formula reads wrong; both stop at the next popup |
578
+ | A dark surface inside a light page | `color-scheme: dark` on it, beside its `color` — it turns every `light-dark()` below |
499
579
  | How wide popups may ever get | `--navi-app-max-width` on `:root` |
500
580
  | A structural layout rule | Expose a new CSS variable (contribute) |
501
581
  | A rule counting children of a container holding navi controls | `:nth-child(… of S)`, `:not([navi-out-of-flow])`, or a class — a popup or a callout is a child too |
@@ -38,6 +38,12 @@ the component reads:
38
38
  | `error: true` | suspends | `error`, plus `dismissError` |
39
39
  | both | `loading` | `error` |
40
40
 
41
+ A delegated failure is thrown whatever the action still holds: a refresh that
42
+ fails — the rerun when the app comes back to the foreground, a dependency's —
43
+ takes rows that were on screen to the boundary, whose fallback replaces them. A
44
+ reader with something to keep showing asks for `error: true`, and each reader of
45
+ a shared action decides this on its own.
46
+
41
47
  The invariant that makes this worth relying on: **`loading: true` never
42
48
  suspends.** Not while the action runs, not while a debounced binding is
43
49
  settling, not while it holds params nobody has started yet, not after a failure
@@ -113,7 +119,9 @@ told it shimmers until something runs that action.
113
119
 
114
120
  navi's own placeholders take that flag rather than inventing it: `<Text loading>`
115
121
  shimmers, `<Text skeleton>` is the same bar held still, and
116
- `<Text skeleton loading={loading}>` is one that tells the truth either way.
122
+ `<Text skeleton loading={loading}>` is one that tells the truth either way. A
123
+ spinner beside a value is `<LoadingIndicator>` — never `<Loading>`, which is the
124
+ boundary a delegated wait suspends into and draws nothing of its own.
117
125
 
118
126
  **Knowing the count beforehand beats guessing it.** A screen that already knows
119
127
  how many rows are coming — a count carried by the parent resource, a total
@@ -60,17 +60,33 @@ carry began with, for the release as well (unlike a swipe's), except for
60
60
  | `leave` | `{ id, x, y }` |
61
61
  | `grab` | `{ pointerType, gestureInfo }` |
62
62
  | `release` | `{ id, x, y, outcome }` |
63
+ | `settle` | `{ id, outcome, refused }` |
63
64
  | `refuse` | `{ pointerType }` |
64
65
 
65
66
  Items and places are named by their `id`. **What an outcome returns is waited
66
67
  on**: the gesture holds what it carries until that promise settles, and a
67
68
  rejection sends it home — a place the application would not accept must not stay
68
- on screen as if it had. `moving`, `grab`, `release` and `refuse` **report, they
69
- do not ask**: what they return is not waited on, and preventing their event
70
- calls nothing off.
69
+ on screen as if it had. `moving`, `grab`, `release`, `settle` and `refuse`
70
+ **report, they do not ask**: what they return is not waited on, and preventing
71
+ their event calls nothing off.
72
+
73
+ **A refusal says why, on the element, once it is home**: an error callout, the
74
+ way a control says its action failed — a seat taken in the meantime is something
75
+ the person has to read, not guess from a card flying back. What the outcome
76
+ rejected with is read the way an `errorMapping` answer is
77
+ ([error_handling.md](./error_handling.md#someone-just-acted--a-controls-action)):
78
+ an error, a string or an element is shown; `{ message, target }` is shown on
79
+ that target; rejecting with nothing (`return Promise.reject()`) refuses without
80
+ a word. An outcome answered by `"request_action"` is said by its control.
71
81
 
72
82
  **Where the position is kept is the answer's.** Until the release navi carries
73
- the element with a translate of its own. An element drawn from state
83
+ the element on its `translate` property, added to the translate it already had.
84
+ The element's matrix is `translate`, then `rotate`, then `scale`, then
85
+ `transform`, so the hand's translate applies outside whatever the element is
86
+ drawn with — a shape turned to a bearing, a marker scaled with the zoom, by
87
+ property or inside `transform` — and the element stays under the hand. What it
88
+ is drawn with is left alone, a `var()` included, and a release kept in place
89
+ writes only `translate` back. An element drawn from state
74
90
  (`left`/`top` computed from a position the application holds) is redrawn by the
75
91
  `move` handler, and its layout owns the position from then on: navi sees it drawn
76
92
  elsewhere and drops its translate, or it would land twice as far as the hand
@@ -90,20 +106,22 @@ whatever it is declared on, and a `data-drop-container` (a place outside is only
90
106
  reached from outside).
91
107
 
92
108
  ```jsx
109
+ const startViewTransition = ensureDocumentStartViewTransition();
110
+
93
111
  <List.Item
94
112
  id={task.id}
95
113
  data-view-transition-name={`task_${task.id}`}
96
114
  interactions={{
97
115
  reorder: (event) => {
98
116
  const { fromId, toId, syncCloneWithDropTarget } = event.detail;
99
- return document.startViewTransition(() => {
117
+ return startViewTransition(() => {
100
118
  syncCloneWithDropTarget();
101
119
  setOrder(moveBefore(order, fromId, toId));
102
120
  }).finished;
103
121
  },
104
122
  toss: (event) => remove(event.detail.id),
105
123
  }}
106
- />
124
+ />;
107
125
  ```
108
126
 
109
127
  Every element declaring `reorder` marks itself, so the set of items IS the set of
@@ -406,6 +424,12 @@ up (a counter of what is in the hand) stays set up with nothing to take it down.
406
424
  hand lets go, while still knowing whether something is on its way. Like `grab`,
407
425
  it needs a drag to happen in, and a dev warning says so.
408
426
 
427
+ `settle` is when the carry is over, after the answer: the copy gone and the
428
+ original shown again (it is hidden, `navi-drag-clone-source`, while the copy
429
+ waits and flies), or the element at rest where it stays. `refused` says the
430
+ answer rejected. A copy taken back in hand on its way home has not settled — that
431
+ carry goes on, and its own end is told.
432
+
409
433
  ### The hand pulls and nothing follows: `refuse`
410
434
 
411
435
  Something that can be carried is not always free to be: a court whose place on the
@@ -214,8 +214,10 @@ failed as failed: a second `import()` of the same address is refused without a
214
214
  request, for the life of the document. So a retry has nothing to do —
215
215
  `error.action.rerun()` fails again at once, on the same failure — and the one way
216
216
  out is a fresh document: **`window.location.reload()`**, which the screen offers
217
- in words the reader understands. navi does not reload on its own: a document
218
- reloading itself is a loop waiting to happen.
217
+ in words the reader understands. Not navi's own `reload()`: it asks the route
218
+ actions again inside this same document, and the import fails again at once.
219
+ navi does not reload on its own: a document reloading itself is a loop waiting
220
+ to happen.
219
221
 
220
222
  The page or the component reading its code says it where it stands, as above. A
221
223
  failure left to a boundary reaches it like a failed read does; a boundary that
@@ -92,6 +92,25 @@ the page. Being an element, the message:
92
92
  Reference: `src/control/demos/form_error_mapping_jsx/demo.html`, a mapping
93
93
  answering with a different component per error kind.
94
94
 
95
+ ### Someone acted, and no control asked — a gesture, an autosave
96
+
97
+ A card dropped on a place, a row swiped away, a draft saved as it is typed: the
98
+ person did something and the write runs from a callback, with no control's
99
+ action to draw its failure. The rule does not change — the answer belongs where
100
+ they acted — and what holds the callback says it there:
101
+
102
+ - **a drag outcome** (`land`, `reorder`, `toss`, `leave`, `move`) is said on the
103
+ element once it is home, in an error callout, and what the callback rejected
104
+ with is read like an `errorMapping` answer — rejecting with nothing refuses
105
+ without a word ([drag_interactions.md](./drag_interactions.md#carrying-something-move-reorder-land-toss-leave));
106
+ - **any other interaction's callback** that rejects is reported like every
107
+ error nothing displays (below): a swipe whose write failed comes back in
108
+ place, and the error is not lost with it. Reject with nothing for a refusal
109
+ that has nothing to say;
110
+ - **a write nobody's gesture is waiting on** (an autosave in an effect) has no
111
+ place to answer at: the screen reads the action that ran it
112
+ (`useAsyncData(action, { error: true })`) and says it where the draft is.
113
+
95
114
  ### A screen's data failed — a route action
96
115
 
97
116
  Nobody clicked; the page simply cannot be drawn. It is the page, or a piece of
package/docs/i18n.md CHANGED
@@ -69,6 +69,16 @@ import { i18n } from "@/app_i18n.js";
69
69
  i18n("Deleting [name]…", { name: file.name });
70
70
  ```
71
71
 
72
+ A placeholder's value is data — a name, a number, a formatted date — or wording
73
+ the code picks: "tonight", a label from a table. Wording is looked up too, and
74
+ handed over translated; so is a unit word of the app's own, which `<Quantity>`
75
+ shows as given:
76
+
77
+ ```jsx
78
+ i18n("Updated [when]", { when: i18n("tonight") });
79
+ <Quantity unit={[i18n("ticket"), i18n("tickets")]}>{count}</Quantity>;
80
+ ```
81
+
72
82
  ### In an app, the text is the key
73
83
 
74
84
  `keyLang: "en"` means "keys are already written in English". The key doubles as
@@ -101,6 +111,46 @@ to the key's name. That is why an instance with opaque keys wants a
101
111
  happens to equal the key". Registering (`add`, `addAll`, `addLangKeys`) and how
102
112
  the language is resolved key by key are in `createI18n`'s JSDoc.
103
113
 
114
+ ## A sentence that depends on a number
115
+
116
+ A sentence whose words follow a count is one translation with several forms,
117
+ never two keys picked in code with one language's rule baked in (`count > 1 ?
118
+ plural : singular` is wrong in French at 0 and in English at 0, and Polish has
119
+ three forms where English has two). The forms go in place of the string, and
120
+ `count` picks one: an exact number written as a key first, then the form the
121
+ language gives that number (`Intl.PluralRules`), then `other`:
122
+
123
+ ```js
124
+ const seats = {
125
+ 0: "Full",
126
+ one: "[count] free seat",
127
+ other: "[count] free seats",
128
+ };
129
+ i18n(seats, { count: place.freeSeats });
130
+ ```
131
+
132
+ The `other` string is the key the other languages register their forms under —
133
+ each language with the forms it has:
134
+
135
+ ```js
136
+ i18n.add("[count] free seats", {
137
+ fr: {
138
+ 0: "Complet",
139
+ one: "[count] place libre",
140
+ other: "[count] places libres",
141
+ },
142
+ });
143
+ ```
144
+
145
+ With opaque keys, every language registers its forms under the key, and the
146
+ call passes `count`: `naviI18n` does this for its own messages. `<Quantity>`
147
+ picks its unit's form by the same rule, and `pluralCategory(count, { lang })`
148
+ answers it for a choice that is not a sentence.
149
+
150
+ A list of items read as one phrase is joined by `formatList` — the language's
151
+ "and" or "or" and its commas ("8h–10h, 12h et 18h–20h"), never a hand-written
152
+ `" et "`.
153
+
104
154
  ## Which language
105
155
 
106
156
  Nothing needs configuring for navi and app texts to agree on a language: both
@@ -134,6 +184,33 @@ Because the language is read at lookup time, a component that reads `i18n(...)`
134
184
  during render re-renders with the new wording when the language changes — as
135
185
  long as the call happens in render, not in a value captured once outside it.
136
186
 
187
+ ## Writing a value
188
+
189
+ A number, a day or a time on screen is wording as much as a sentence is, and
190
+ navi writes it: never `Intl` or `toLocale*` with a locale of the app's own,
191
+ which pins one region beside navi's dates and ignores `setSupportedLanguages`.
192
+ Each helper takes `lang` and otherwise reads the signal above; the component is
193
+ for JSX, the function for a string (a `title`, a notification).
194
+
195
+ | Value | Component | String |
196
+ | ----------------------- | -------------------------------------------------- | ------------------------------------------------ |
197
+ | a number | `<Quantity>` | `formatNumber` |
198
+ | a day | `<Time type="date">` | `formatDay` |
199
+ | a month | `<Time type="month">` | `formatMonth` |
200
+ | a day and its time | `<Time type="datetime">` | `formatDatetime` |
201
+ | a time of day | `<Time type="time">` | `formatTimeOfDay`, or `formatTime` from a `Date` |
202
+ | a clock from a count | `<Time type="hour"\|"minute" format="timestring">` | |
203
+ | two times of day | `<TimeRange>` | `formatTimeRange` |
204
+ | a day relative to today | `dayLabel` on `<Time type="date">`, beside the day | `formatDayRelative` |
205
+ | an instant, from now | `<Time type="relative">` | `formatTimeRelative` |
206
+ | a duration | `<Time type="duration">` | `formatDuration` and its per-unit siblings |
207
+ | items read as one list | | `formatList` |
208
+
209
+ A spelled day writes its year only when it is not in `now`'s year (`year:
210
+ "auto"`, see `formatDay`): appending the year by hand writes it twice for a
211
+ date in another year. A capital letter at the start of a value is
212
+ `<Text capitalize>`, not a sliced string.
213
+
137
214
  ## Changing what navi says
138
215
 
139
216
  `naviI18n` holds navi's own texts. Register on it to override a default or to
@@ -156,4 +233,6 @@ so overriding either is the same call.
156
233
 
157
234
  **Do not put application texts in `naviI18n`.** Key collisions aside, the two
158
235
  have different lifecycles: navi's keys come with the package version, the app's
159
- come with the app.
236
+ come with the app. The unit names it looks up are for rewording a unit navi or
237
+ `Intl` already knows ("hour", "kilometer"); a unit word of the app's own comes
238
+ from the app's registry, translated (see [above](#centralizing-texts-createi18n)).