@jsenv/navi 0.29.387 → 0.29.389

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 (43) hide show
  1. package/README.md +4 -4
  2. package/dist/dev/jsenv_navi.js +556 -392
  3. package/dist/dev/jsenv_navi.js.map +81 -81
  4. package/dist/dev/jsenv_navi_side_effects.js +87 -78
  5. package/dist/dev/jsenv_navi_side_effects.js.map +4 -4
  6. package/dist/jsenv_navi.js +551 -387
  7. package/dist/jsenv_navi.js.map +81 -81
  8. package/dist/jsenv_navi_side_effects.js +87 -78
  9. package/dist/jsenv_navi_side_effects.js.map +4 -4
  10. package/docs/AI_INSTRUCTIONS.md +39 -23
  11. package/docs/actions.md +6 -4
  12. package/docs/autofocus.md +2 -1
  13. package/docs/badge_list.md +5 -3
  14. package/docs/control_group.md +4 -3
  15. package/docs/control_object.md +1 -1
  16. package/docs/control_value.md +8 -9
  17. package/docs/css_architecture.md +16 -14
  18. package/docs/dialog_shape.md +15 -10
  19. package/docs/drag_interactions.md +49 -41
  20. package/docs/drag_to_travel.md +32 -24
  21. package/docs/dynamic_import.md +7 -5
  22. package/docs/error_handling.md +31 -30
  23. package/docs/field_validation.md +35 -6
  24. package/docs/i18n.md +16 -7
  25. package/docs/interactions.md +28 -23
  26. package/docs/list_action.md +8 -5
  27. package/docs/list_refresh.md +8 -1
  28. package/docs/mobile_layout_pitfalls.md +6 -5
  29. package/docs/navigation.md +7 -5
  30. package/docs/network_policy.md +22 -7
  31. package/docs/pan_zoom.md +16 -9
  32. package/docs/popup_backdrop.md +11 -5
  33. package/docs/popup_lift.md +5 -3
  34. package/docs/popup_open.md +39 -28
  35. package/docs/resource.md +31 -20
  36. package/docs/route_transitions.md +13 -10
  37. package/docs/safe_area.md +59 -39
  38. package/docs/scroll.md +11 -9
  39. package/docs/testid.md +10 -1
  40. package/docs/typography.md +2 -3
  41. package/docs/view_transitions.md +9 -9
  42. package/docs/z_index.md +6 -5
  43. package/package.json +2 -2
@@ -91,7 +91,7 @@ where that state lives:
91
91
 
92
92
  | what opens it | how |
93
93
  | ---------------------------------- | ------------------------------------------------------ |
94
- | a button | `command` / `commandfor` attributes, no `open` |
94
+ | a button | `command` / `commandFor` props, no `open` |
95
95
  | a gesture, an event, a JS decision | `triggerNaviCommand(...)`, still no `open` |
96
96
  | a state the app holds | `signal` — [below](#when-the-app-holds-the-open-state) |
97
97
  | where the user is | `navState`, or a route `stateSignal` given to `signal` |
@@ -99,7 +99,7 @@ where that state lives:
99
99
  ## A button opens it: the attributes
100
100
 
101
101
  ```jsx
102
- <Button command="--navi-open" commandfor="note-dialog">
102
+ <Button command="--navi-open" commandFor="note-dialog">
103
103
  Read the note
104
104
  </Button>
105
105
  <Dialog id="note-dialog">…</Dialog>
@@ -123,7 +123,7 @@ press:
123
123
  ## Something else opens it: `triggerNaviCommand`
124
124
 
125
125
  The attributes fire on every click of the element that carries them, and they
126
- cover more than "this button opens that dialog": `commandfor` says to whom,
126
+ cover more than "this button opens that dialog": `commandFor` says to whom,
127
127
  `value` says what it is about, `--navi-x:argument` says how. Before writing any
128
128
  JS here, check none of those is the answer — `triggerNaviCommand` is the last
129
129
  resort, not the general way to run a command.
@@ -209,7 +209,7 @@ closes the popup as usual.
209
209
 
210
210
  ```jsx
211
211
  <Row
212
- commandfor="row_menu"
212
+ commandFor="row_menu"
213
213
  interactions={{
214
214
  longpress: (event) =>
215
215
  triggerNaviCommand(event.target, "--navi-open", event),
@@ -225,7 +225,8 @@ own opening press from somebody else's comes from that gesture.
225
225
  The first argument is the command's **source** — the element it is triggered
226
226
  _from_. The target is resolved from it, in this order:
227
227
 
228
- 1. `commandfor="someId"` on the source,
228
+ 1. `commandFor="someId"` on the source (written to the DOM as the HTML
229
+ `commandfor` attribute, which is what is read),
229
230
  2. `navi-command-target="parent-control" | "child-control"`,
230
231
  3. the command's own fallback — for the popup commands, `closest("[aria-expanded]")`.
231
232
 
@@ -234,7 +235,7 @@ element itself as the source resolves to that popup: `closest()` starts at the
234
235
  element itself. That is the short form used above, and it is enough whenever the
235
236
  JS that decides already holds the popup's ref.
236
237
 
237
- To trigger from another element instead, give that element a `commandfor`
238
+ To trigger from another element instead, give that element a `commandFor`
238
239
  pointing at the popup's `id` — attribute-driven target resolution, JS-driven
239
240
  timing.
240
241
 
@@ -250,10 +251,11 @@ names none, and on whoever asked when nothing else says. In that order:
250
251
  3. **`detail.source`** — who asked. A button therefore opens the popup on
251
252
  itself, and a popup passed as its own source is its own anchor.
252
253
 
253
- For `Dialog` the anchor does nothing at all unless `sizeFromAnchor` is passed
254
- (then, and only then, it feeds the `--anchor-width`/`--anchor-height` CSS vars);
255
- for `Popover`, which really is positioned relative to its anchor, say what the
256
- anchor is:
254
+ For `Dialog` the anchor does nothing at all unless `sizeFromAnchor` asks for
255
+ it (it then feeds the `--anchor-width`/`--anchor-height` CSS vars) or
256
+ `animation="lifting"` does (the box the dialog comes out of, see
257
+ [popup_lift.md](./popup_lift.md)); for `Popover`, which really is positioned
258
+ relative to its anchor, say what the anchor is:
257
259
 
258
260
  ```jsx
259
261
  <Popover ref={popoverRef} anchor={rowRef}>
@@ -261,8 +263,9 @@ anchor is:
261
263
 
262
264
  `anchorCustomEventDetail="ignore"` drops the open's side of that order
263
265
  entirely, leaving the `anchor` prop alone — for a popover that must never be
264
- anchored to whatever opened it (`SidePanel` does this), and for a
265
- `sizeFromAnchor` dialog that must never be sized from it.
266
+ anchored to whatever opened it (`SidePanel` does this), and for a dialog that
267
+ must never be sized from it (`sizeFromAnchor`) or lifted out of it
268
+ (`animation="lifting"`).
266
269
 
267
270
  ## Opening it ON something
268
271
 
@@ -272,8 +275,8 @@ row is one dialog with two modes, and the press is the only thing that knows
272
275
  which one — so the press says it, with its own value:
273
276
 
274
277
  ```jsx
275
- <Button command="--navi-open" commandfor="radar-dialog">Nouveau radar</Button>
276
- <Button value={radar.id} command="--navi-open" commandfor="radar-dialog" />
278
+ <Button command="--navi-open" commandFor="radar-dialog">Nouveau radar</Button>
279
+ <Button value={radar.id} command="--navi-open" commandFor="radar-dialog" />
277
280
 
278
281
  <Dialog
279
282
  id="radar-dialog"
@@ -629,7 +632,7 @@ command once it succeeded.
629
632
  ```jsx
630
633
  // Stays open while save() runs, closes when it resolves, stays open if it
631
634
  // throws — with what was typed still there and the error on the button.
632
- <Button command="--navi-close" commandfor="note-dialog" action={save}>
635
+ <Button command="--navi-close" commandFor="note-dialog" action={save}>
633
636
  Save
634
637
  </Button>
635
638
  ```
@@ -689,12 +692,12 @@ the popup without closing it, and the error callout stays where the press was.
689
692
  The gestures that close a popup do not all mean the same thing, and that is on
690
693
  purpose:
691
694
 
692
- | gesture | what it means | who decides |
693
- | ------------------------------ | ------------- | --------------------------------- |
694
- | Escape | cancel | `escapeEffect="cancel"` (default) |
695
- | a press outside | close, keep | `pressOutside="close"` (default) |
696
- | a close cross (`--navi-close`) | close, keep | [the cross](#the-close-cross) |
697
- | `--navi-cancel` on a button | cancel | the button |
695
+ | gesture | what it means | who decides |
696
+ | ------------------------------ | ------------- | ------------------------------------------------------------------------------------------------------ |
697
+ | Escape | cancel | always, for a `Dialog`/`Popover`; `escapeEffect="cancel"` (the default) for a `Picker` |
698
+ | a press outside | close, keep | `pressOutside="close"` — the default of a `Dialog` and of a `Picker`; a bare `Popover` says `"ignore"` |
699
+ | a close cross (`--navi-close`) | close, keep | [the cross](#the-close-cross) |
700
+ | `--navi-cancel` on a button | cancel | the button |
698
701
 
699
702
  Escape says "forget it". It is the one gesture that has meant that everywhere,
700
703
  for as long as there have been dialogs, and navi keeps it that way. **A popup
@@ -757,7 +760,8 @@ empty.
757
760
 
758
761
  ### `escapeEffect="close"`, and why it is a last resort
759
762
 
760
- `escapeEffect="close"` makes Escape say what a click outside says. It exists,
763
+ `escapeEffect="close"` — a `Picker` prop; a `Dialog`/`Popover` has no such
764
+ switch — makes Escape say what a click outside says. It exists,
761
765
  and it is almost never what you want: it takes away the only key that undoes,
762
766
  and a popup with no way back is one people stop opening. Reach for a close
763
767
  cross first.
@@ -996,7 +1000,8 @@ one fill above.
996
1000
 
997
1001
  A press on another card is not an outside press once the card names the panel
998
1002
  with `data-navi-popup-inside` (see [popup_backdrop.md](./popup_backdrop.md)).
999
- The full example is `src/layout/demos/7_slot_demo.html`, "two side panels".
1003
+ The full example is `src/layout/demos/7_slot_demo.html`, "Two side-panel
1004
+ slots".
1000
1005
 
1001
1006
  ## A popup that loads data
1002
1007
 
@@ -1116,14 +1121,20 @@ half-typed form where it left them.
1116
1121
  "Closed" is two states, not one — never opened yet, and closed again after an
1117
1122
  opening — so the `mount` prop answers both at once:
1118
1123
 
1119
- | `mount` | before the first open | after a close |
1120
- | ----------------------------- | --------------------- | ------------- |
1121
- | `"always"` | mounted | mounted |
1122
- | `"from-first-open"` (default) | not mounted | mounted |
1123
- | `"while-opened"` | not mounted | not mounted |
1124
+ | `mount` | before the first open | after a close |
1125
+ | ----------------------------- | --------------------------- | ------------- |
1126
+ | `"always"` | mounted | mounted |
1127
+ | `"idle"` | mounted once the page idles | mounted |
1128
+ | `"from-first-open"` (default) | not mounted | mounted |
1129
+ | `"while-opened"` | not mounted | not mounted |
1124
1130
 
1125
1131
  `"always"` is for content something depends on before any opening: a value read
1126
1132
  off it, fields a surrounding form submits, a size measured from outside.
1133
+ `"idle"` is `"always"` minus the cost on the render that draws the page.
1134
+
1135
+ Whatever the value but `"while-opened"`, intent on the anchor — a pointer
1136
+ entering it, focus landing in it — builds the content ahead of the click that
1137
+ will open it.
1127
1138
 
1128
1139
  `"while-opened"` is what an uncontrolled field seeded from a `defaultValue`
1129
1140
  needs: without it, a popup reopened after the underlying value changed still
package/docs/resource.md CHANGED
@@ -93,14 +93,15 @@ const GAME = resource("game", {
93
93
  ```
94
94
 
95
95
  The callback receives the bound params merged with the range the list asks for
96
- (`start`, `end`, `limit`, `before`, `after`, `around`, `count`), and a `signal`
97
- as second argument — aborted when the list stops wanting those rows. It returns
98
- a range the way a `Content-Range` does: **`{ items, start, count }`** — these
99
- rows, at this place, out of that many. `start` may be omitted when the list
100
- asked for a positive one; `count` defaults to `start + items.length` (a source
101
- that does not know its total). The items are upserted on their way in, so the
102
- list draws store items — never copies of the JSON — and a request sent from a
103
- row is read back on that row.
96
+ (`start`, `end`, `limit`, `before`, `after`, `around`, `count`), and `{ signal }`
97
+ as second argument — a signal aborted when the list stops wanting those rows.
98
+ It returns a range the way a `Content-Range` does: **`{ items, start, count }`**
99
+ — these rows, at this place, out of that many. `start` may be omitted when the
100
+ list asked for one at or after `0` (a negative `start` counts back from the end,
101
+ and the source must then say where the slice landed); `count` defaults to
102
+ `start + items.length` (a source that does not know its total). The items are
103
+ upserted on their way in, so the list draws store items — never copies of the
104
+ JSON — and a request sent from a row is read back on that row.
104
105
 
105
106
  A row that must follow its **own fields** through a write reads them from the
106
107
  store rather than from the object it was handed:
@@ -131,9 +132,12 @@ this collection again after its screen went away finds it drawn and revalidates
131
132
  it rather than starting over
132
133
  ([list_refresh.md](./list_refresh.md#leaving-the-screen-and-coming-back)).
133
134
 
134
- What it does not give is membership: an item that leaves the collection stays on
135
- screen until the rows are asked for again. Give the screen its own way to ask
136
- (a refresh gesture, a `key` on the run).
135
+ Membership is what a re-read answers: a verb listed in `rerunOn.GET_RANGE`
136
+ (`["POST", "DELETE"]` by default) tells the run the collection moved, and it
137
+ asks again for the window it draws, rows staying on screen meanwhile. A write
138
+ that moves an item in or out through another verb — a `PATCH` that archives —
139
+ is added there, or `reader.invalidate()` is called; a `key` on the run is not the
140
+ way ([list_refresh.md](./list_refresh.md#a-paginated-list-stays-on-screen-too)).
137
141
 
138
142
  It reads a collection, so it lives on the resource itself (or on a
139
143
  `withParams()` of it), not on a relation.
@@ -252,7 +256,8 @@ These are not guessable — each relationship method has its own shape.
252
256
 
253
257
  ### `.one(propertyName, childResource, { GET, PUT, DELETE })`
254
258
 
255
- The callback returns the **parent** object with the child nested inside:
259
+ `GET` and `PUT` return the **parent** object with the child nested inside;
260
+ `DELETE` returns the parent id, or `{ id }`:
256
261
 
257
262
  ```js
258
263
  const USER_SESSION = USER.one("session", SESSION, {
@@ -411,14 +416,18 @@ rules, verified by `src/state/rest/tests/resource_graph_parent_rerun.test.js`:
411
416
  reruns the parent. Declare it explicitly with `dependencies` if you need it
412
417
  (see [`dependencies`](#dependencies-rerun-after-another-resource-writes)).
413
418
  - Within a relationship resource, the usual defaults still apply: its own
414
- `GET_MANY` reruns after its own `POST`; its `GET` is reset (not rerun) by its
415
- `DELETE`. Override per relation with `rerunOn`/`dependencies`, which every
416
- relationship method accepts.
419
+ `GET_MANY` reruns after its own `POST`. A `.one`/`.many` `GET` is reset (not
420
+ rerun) by its own `DELETE`, as a root `GET` is; a scoped `GET` is not reset —
421
+ its data simply resolves to nothing once the child is dropped. Override per
422
+ relation with `rerunOn`/`dependencies`, which every relationship method
423
+ accepts; what a relation leaves out is its parent resource's (`rerunOn` key
424
+ by key).
417
425
 
418
426
  Splitting a sub-resource out of a parent `PATCH` therefore changes the refresh
419
- graph: what used to be refreshed by the parent's own response is now refreshed
420
- only by these rules. When a parent field genuinely depends on a child mutation,
421
- say so with `dependencies` rather than relying on a rerun that will not happen.
427
+ graph: a parent field the parent's own response kept fresh is, once the relation
428
+ is a resource of its own, refreshed only by these rules. When a parent field
429
+ genuinely depends on a child mutation, say so with `dependencies` rather than
430
+ relying on a rerun that will not happen.
422
431
 
423
432
  ## `withParams()`: a scope with reruns of its own
424
433
 
@@ -453,8 +462,10 @@ membership of each list is a question its own scope answers.
453
462
  `withParams()` chains, merging the params —
454
463
  `USER.withParams({ role: "admin" }).withParams({ gender: "male" })` is
455
464
  `USER.withParams({ role: "admin", gender: "male" })`. Its second argument takes
456
- `rerunOn` and `dependencies` for that scope; left out, the scope inherits the
457
- resource's. Empty params throw: a scope has to be about something.
465
+ `rerunOn` and `dependencies` for that scope; what it leaves out is the
466
+ resource's — a `rerunOn` key by key, so `{ rerunOn: { GET: ["PATCH"] } }` keeps
467
+ the resource's `GET_MANY` and `GET_RANGE`. Empty params throw: a scope has to be
468
+ about something.
458
469
 
459
470
  It is not the tool for a param the user types. A search word bound with
460
471
  `withParams()` would keep its results from refreshing after a write on the
@@ -132,8 +132,9 @@ relation was written for. Two situations, two answers:
132
132
  deserves a direction. This is the case the export exists for.
133
133
 
134
134
  A default has no direction (nothing says which of two arbitrary pages is
135
- "before" the other), so only directionless movements make sense there. Written
136
- relations, and `"none"`, always win over it.
135
+ "before" the other), so only directionless movements make sense there: navi's
136
+ `slide-*` and `cover-*` are written on the direction, and as the default they
137
+ play nothing. Written relations, and `"none"`, always win over it.
137
138
 
138
139
  ## When one navigation knows better
139
140
 
@@ -179,7 +180,8 @@ the same forms `defineRouteTransition` takes, plus `direction`. It overrides
179
180
  answers for. So:
180
181
 
181
182
  - `{ direction: "back" }` keeps the pair's movement and only turns it round;
182
- - `"zoom"` swaps the movement, keeping nothing else;
183
+ - `"zoom"` swaps the movement; the way round and the pace are still the
184
+ pair's;
183
185
  - `"none"` cuts, where the pair — or the default — would have played;
184
186
  - `{ duration: 500 }` re-times what was already going to play.
185
187
 
@@ -525,12 +527,13 @@ await page.goBack();
525
527
  The window is **one frame**: the hold is given back inside the view
526
528
  transition's callback, which the browser runs at its next rendering
527
529
  opportunity. `tests/route_transition_list_revisit/` walks it the wrong way on
528
- purpose in its last case — under a movement the list that comes back is the same
529
- DOM element ten times out of ten and asks for nothing, while the same walk
530
- without a movement is a real one — and the window measured there is a back at 0
531
- or 8ms losing every round trip, at 16ms losing only the first, and past 64ms
532
- losing none. No thumb moves in one frame; an automated click continues in the
533
- same millisecond. This is a testing trap, not a user-facing behaviour.
530
+ purpose in its last case — a back pressed the instant the URL changes — and
531
+ which side of that frame a cycle lands on belongs to the machine's load: under
532
+ a movement the list that comes back is either the element that never left,
533
+ asking for nothing, or one truly remounted, asking once, while the same walk
534
+ without a movement is a real one every time. No thumb moves in one frame; an
535
+ automated click continues in the same millisecond. This is a testing trap, not
536
+ a user-facing behaviour.
534
537
 
535
538
  ## The rest, briefly
536
539
 
@@ -538,6 +541,6 @@ same millisecond. This is a testing trap, not a user-facing behaviour.
538
541
  a per-relation `{ type, duration }` for one relation.
539
542
  - The URL leads: transitions play on navigations somebody else started (a
540
543
  `<Link>`, the back button, `history.back()`). Nothing here navigates.
541
- - A browser without view transitions (Firefox) navigates with a cut. The app
544
+ - A browser without view transitions navigates with a cut. The app
542
545
  must remain fully usable that way — which it is, if the transitions state
543
546
  relations rather than carry information.
package/docs/safe_area.md CHANGED
@@ -75,13 +75,8 @@ So the app states its own screen once, and never names a component:
75
75
 
76
76
  The bands fall out of it (centered), `--navi-app-width` follows, and `FixedBar`
77
77
  pins itself to the column's edges rather than the glass. An app wanting them
78
- uneven writes `--navi-app-inset-left` / `-right` directly instead.
79
-
80
- In pixels: popup placement reads this value back from CSS to compute its own
81
- margins, and a custom property computes to a token stream rather than to a
82
- length, so `40rem` would arrive there as the string `"40rem"`. A non-px value
83
- still caps the popup's size (that part is pure CSS) but leaves the margins
84
- viewport-sized, and says so in the console.
78
+ uneven writes `--navi-app-inset-left` / `-right` directly instead; everything
79
+ below follows those the same way, popup placement included.
85
80
 
86
81
  Every popup follows: `Dialog`, `Popover`, and everything built on them
87
82
  (`Picker`, `Select`…). It is a ceiling and nothing more — on a screen narrower
@@ -98,11 +93,13 @@ Two ways NOT to get this:
98
93
  popovers would keep sizing themselves against 1500px;
99
94
  - setting `--dialog-max-width` on `.navi_dialog` from the app. It is a
100
95
  `--component-*` token, declared on the element (see
101
- [css_architecture.md](./css_architecture.md#--navi--vs---component--where-the-override-has-to-go)),
102
- so components that write it themselves outrank an app rule of lower
103
- specificity — `.navi_picker[aria-haspopup="dialog"] .navi_dialog` does exactly
104
- that, and the app's cap silently disappears for every picker. It is also the
105
- knob a single popup uses to ask for a specific size, not a ceiling:
96
+ [css_architecture.md](./css_architecture.md#--navi--vs---component--where-the-override-has-to-go)):
97
+ every dialog resets it on itself in an unlayered rule, so that a nested
98
+ dialog does not inherit its parent's size, and a `maxWidth` prop writes it
99
+ inline (a `Picker` under `dialogSizeFromAnchor` does) — an app rule of the
100
+ same specificity does not reliably win, and the cap silently disappears. It
101
+ is also the knob a single popup uses to ask for a specific size, not a
102
+ ceiling:
106
103
  `--navi-app-max-width` feeds `--dialog-maxmax-width`, the hard ceiling _under_
107
104
  that knob, so a popup that genuinely needs its own `maxWidth` can still say
108
105
  so without escaping the app's screen.
@@ -114,15 +111,19 @@ price.
114
111
 
115
112
  #### Placement follows the same rectangle
116
113
 
117
- `--navi-app-max-width` moves where a popup is placed, not only how big it may
114
+ The app's rectangle moves where a popup is placed, not only how big it may
118
115
  get. Placement is computed against the visual viewport narrowed to the level-1
119
- bands: `getAppInsets` (`src/layout/responsive.js`) is the JS reading of them,
120
- navi hands them to `@jsenv/dom` once (`setPlacementViewportInsets`, wired in
116
+ bands: `getAppInsets` (`src/layout/responsive.js`) reads `--navi-app-inset-*`
117
+ back off the computed style — the bands `--navi-app-max-width` centers and the
118
+ ones an app writes by hand alike, in any length unit — navi hands them to
119
+ `@jsenv/dom` once (`setPlacementViewportInsets`, wired in
121
120
  `navi_css_vars.js`), and `pickPositionRelativeTo` reads them on every
122
- placement. Invisible for anything centered on its cross axis — `center`,
123
- `bottom`, `top`, which is what a dialog does nearly always — but it is what puts
124
- anything anchored to an edge (a `positionArea` like `bottom-start`, a
125
- `SidePanel`) flush against the app column's edge rather than the window's.
121
+ placement. With centered bands it is invisible for anything centered on its
122
+ cross axis — `center`, `bottom`, `top`, which is what a dialog does nearly
123
+ always — and with uneven ones it is what centers that dialog on the app column
124
+ rather than on the window. Either way it is what puts anything anchored to an
125
+ edge (a `positionArea` like `bottom-start`, a `SidePanel`) flush against the
126
+ app column's edge rather than the window's.
126
127
  `FixedBar` reads the same description through CSS instead: it is pinned to
127
128
  `--navi-app-inset-*`, which says where the app's rectangle is in the window
128
129
  rather than how wide it may be.
@@ -154,18 +155,25 @@ Beware of making that container scrollable by accident — see
154
155
  `var(--navi-safe-area-inset-bottom)` in any rule. It is always declared, whether
155
156
  or not the app ever mounts a bar.
156
157
 
157
- Reading it from **JS** takes a probe: an unregistered custom property keeps its
158
- `calc()` unresolved through `getComputedStyle`. Give a hidden box
159
- `height: var(--navi-safe-area-inset-bottom)` and measure it — see
160
- `src/layout/demos/fixed_bar/keyboard.html`.
158
+ From **JS**, both levels are registered as lengths (`@property`, in
159
+ `safe_area.js`), so
160
+ `getComputedStyle(document.documentElement).getPropertyValue("--navi-safe-area-inset-bottom")`
161
+ gives pixels, and so does `--navi-app-inset-*`.
161
162
 
162
163
  ### Putting something new into it
163
164
 
164
- Publish what you take on one edge, into that edge's slot. That is the whole
165
- contract — a native banner, an OS strip, anything an app invents joins the sum
166
- without a single component learning it exists. `FixedBar` is the worked example:
167
- it measures its own border box (notch included) and writes
168
- `--navi-fixed-bar-space-*` (`src/layout/fixed_bar/fixed_bar_space.js`).
165
+ Level 2 has one slot per edge, `--navi-fixed-bar-space-*`, and it belongs to
166
+ `FixedBar`: each bar measures its own border box (notch included), and navi
167
+ writes the largest one on that edge inline on `<html>`
168
+ (`src/layout/fixed_bar/fixed_bar_space.js`). The slot is combined with
169
+ `env(safe-area-inset-*)` through `max()`, not added to it, since a bar measured
170
+ with its notch already covers the notch.
171
+
172
+ Something else taking an edge — a native banner, an OS strip — is published by
173
+ being drawn as a `FixedBar`; every component reading the safe area then clears
174
+ it without learning it exists. Writing the slot by hand holds only while no bar
175
+ takes that edge: the first bar mounting there replaces the value rather than
176
+ adding to it.
169
177
 
170
178
  ## What already reads it
171
179
 
@@ -182,24 +190,36 @@ truth:
182
190
 
183
191
  ## The trap: which viewport
184
192
 
185
- Three heights are in play and they are not the same one.
193
+ Three heights are in play and they are not the same one — and which of them
194
+ the keyboard moves depends on the browser.
195
+
196
+ | what | shrinks when the keyboard opens |
197
+ | -------------------------------- | ----------------------------------------------- |
198
+ | `window.innerHeight` / `100dvh` | no |
199
+ | `visualViewport.height` | yes, except where the keyboard overlays (below) |
200
+ | `--navi-vvh` (tracks the visual) | same as `visualViewport.height` |
186
201
 
187
- | what | shrinks when the keyboard opens |
188
- | -------------------------------- | ------------------------------- |
189
- | `window.innerHeight` / `100dvh` | no |
190
- | `visualViewport.height` | yes |
191
- | `--navi-vvh` (tracks the visual) | yes |
202
+ Wherever the browser has the VirtualKeyboard API (Chromium), navi makes the
203
+ keyboard overlay the page rather than shrink it (`src/layout/virtual_keyboard.js`):
204
+ no viewport shrinks, and the keyboard arrives as `--navi-keyboard-inset-bottom`
205
+ (`env(keyboard-inset-height)`), which `--navi-app-inset-bottom` adds. Firefox,
206
+ Safari, and an app that called `disableVirtualKeyboardOverlay()` shrink the
207
+ visual viewport instead, and `--navi-keyboard-inset-bottom` stays 0. Either way
208
+ `--navi-app-height` and the popup ceilings answer the part of the screen left
209
+ visible.
192
210
 
193
211
  `position: fixed` — so every `FixedBar` — is laid out against the **layout**
194
- viewport. A bottom bar therefore stays at the bottom of a window the keyboard is
195
- covering: it ends up _behind_ the keyboard, and no inset says so, because
196
- nothing reduced the layout viewport.
212
+ viewport. Where the visual viewport is what shrinks, a bottom bar therefore
213
+ stays at the bottom of a window the keyboard is covering: it ends up _behind_
214
+ the keyboard, and no inset says so, because nothing reduced the layout viewport.
215
+ Where the keyboard overlays, the bar is pinned to `--navi-app-inset-bottom`,
216
+ which counts it, and sits above it.
197
217
 
198
218
  The consequence for anything measuring against the insets: mix the two families
199
219
  and you get a drift that only appears with a keyboard open. `getBoundingClientRect`
200
220
  is in layout-viewport coordinates, so what is compared to it must be too
201
- (`100dvh`), while `--navi-app-*` derives from `--navi-vvh` because what navi
202
- _sizes_ must fit what is actually visible.
221
+ (`100dvh`), while `--navi-app-*` derives from `--navi-vvh` (plus the keyboard
222
+ inset) because what navi _sizes_ must fit what is actually visible.
203
223
 
204
224
  `src/layout/demos/fixed_bar/keyboard.html` puts all of these on screen at once
205
225
  and turns the bottom bar's number red when it goes under the keyboard. On a
package/docs/scroll.md CHANGED
@@ -47,7 +47,7 @@ Two shapes, and they do not behave the same:
47
47
  | `header` / `footer` alone | the container itself scrolls, and they are `position: sticky` at its edges — the content scrolls under them |
48
48
  | a `body` as well | the container becomes a flex column, its own overflow turns to `hidden`, and the **body is the only thing that scrolls**; header and footer sit outside it (`position: static`, `flex-shrink: 0`) |
49
49
 
50
- Two consequences worth knowing before fighting them:
50
+ Three consequences worth knowing before fighting them:
51
51
 
52
52
  - the body is `flex: 0 1 auto` — **it shrinks, it never grows**. A short body
53
53
  leaves the footer right under it rather than pushed to the bottom of a box it
@@ -61,7 +61,8 @@ Two consequences worth knowing before fighting them:
61
61
  them — positioned or not. Write `style={{ "--box-header-z-index": "auto" }}`
62
62
  (`--box-footer-z-index` likewise) at the call site that needs the opposite: a
63
63
  badge or a stamp overflowing a row is otherwise sliced by a header it never
64
- scrolls under. `isolation: isolate` on the box keeps either value local to it.
64
+ scrolls under. The box is `isolation: isolate` already, so either value stays
65
+ local to it.
65
66
  See `docs/z_index.md` and `src/box/demos/9_scrollable_z_index_demo.html`.
66
67
 
67
68
  Padding belongs on the parts, not on the scrolling box: padding on a scroller
@@ -237,10 +238,11 @@ A dialog is already bounded by the room its container leaves it
237
238
  (`--dialog-maxmax-height`), so a `maxHeight` is only for making it smaller than
238
239
  that.
239
240
 
240
- `dialog.jsx` deliberately declares no `overflow` of its own: a modal dialog
241
- would inherit `auto` from the UA stylesheet and a `layer="local"` one gets
242
- nothing, so without a scrolling rule its `max-height` would only decide how big
243
- the box looks while the content kept painting straight through it.
241
+ `dialog.jsx` deliberately declares no `overflow` of its own: the
242
+ `[data-scrollable]` the dialog carries is its scrolling rule. A modal dialog
243
+ would get `auto` from the UA stylesheet anyway, but a `layer="local"` one gets
244
+ nothing, and without that rule its `max-height` would only decide how big the
245
+ box looks while the content kept painting straight through it.
244
246
 
245
247
  ### `scrollCapture`
246
248
 
@@ -309,7 +311,7 @@ So: nothing scrollable between the cap and the slides. A `<Box body>` around
309
311
  them is a scroller (see the table at the top of this file) — and so is a bare
310
312
  `overflow="auto"` on a wrapper. The dialog keeps a shared `header` if the tabs
311
313
  are shared, with an explicit `flexShrink="0"` since the rule that gives it for
312
- free lives inside `[data-scrollable]`.
314
+ free applies only next to a `body`.
313
315
 
314
316
  **Padding goes on the slide** — or on its parts, since the slide is now the
315
317
  scroller (see the top of this file) — but never on the container nor on
@@ -393,8 +395,8 @@ browser paints first — what a phone screen shows, plus a few — and `after` f
393
395
  the paint on. The switch waits for the paint itself, not for an effect: preact
394
396
  runs a component's pending effects early when that component renders again,
395
397
  and something always re-renders before a popup has painted. Do not rebuild
396
- this by hand with a `useEffect` that widens a slice — that is the thing it
397
- replaces, and it fails for that reason. The runs ask their source for `after`
398
+ this by hand with a `useEffect` that widens a slice: that effect is one preact
399
+ runs early, and it fails for that reason. The runs ask their source for `after`
398
400
  rows from the start, so the smaller first window costs no second request.
399
401
 
400
402
  ### Doing it well
package/docs/testid.md CHANGED
@@ -85,7 +85,9 @@ found by nothing, while the host is what the user focuses and what
85
85
  `getByRole(role, { name })` and `getByLabel()` read.
86
86
 
87
87
  ```jsx
88
- <Picker variant="icon" aria-label="Aide" ui={<Icon>…</Icon>} />
88
+ <Picker variant="icon" aria-label="Aide" ui={<Icon>…</Icon>}>
89
+ …
90
+ </Picker>
89
91
  ```
90
92
 
91
93
  ```html
@@ -122,6 +124,13 @@ error message, a section, a row — put the testid on the surrounding component
122
124
  (`<Field data-testid="email-field">`, `<Box data-testid="cart-row">`); anything
123
125
  built on `Box` forwards it to its own element.
124
126
 
127
+ A selectable `List.Item` is a control too, and the rule holds there with a
128
+ consequence worth knowing: its host is the real checkbox or radio the row
129
+ selects with, an input navi draws invisible (`navi-visually-hidden`) and lays
130
+ under the row's content. A `data-testid` on the item names that input — what
131
+ `toBeChecked()` reads, not what the eye sees. A test that presses the row, or
132
+ waits for it to show, targets what the row draws: a testid on its content.
133
+
125
134
  A picker's popup is the one element the application does not render itself:
126
135
  `<Picker>` builds it, so a `data-testid` on the picker names the trigger and
127
136
  nothing names the popup. `popupTestId` does:
@@ -84,7 +84,7 @@ The prop is the same everywhere: a number of lines, cut with an ellipsis.
84
84
  - **`Picker`** — its value, `maxLines={1}` by default; `variant="text"` and
85
85
  `variant="bare"` draw the caller's own thing and are not clamped, a `Text` in
86
86
  their `ui` cuts itself with its own `maxLines`.
87
- - **`PickerSpin`** (`DaySpin`, `TimeSpin`…) — its value; left out, a long value
87
+ - **`Spin`** (`DaySpin`, `NumberSpin`…) — its value; left out, a long value
88
88
  wraps and the box grows.
89
89
  - **`Binder`** — its tab labels, `maxLines={1}` by default, `false` lets them
90
90
  wrap; overridable per item.
@@ -104,9 +104,8 @@ width must say it too**, or the whole chain grows instead of truncating:
104
104
 
105
105
  ```jsx
106
106
  <Box flex width="300" spacing="s">
107
+ {/* without minWidth the row just grows */}
107
108
  <Box flex expandX minWidth="0">
108
- {" "}
109
- {/* without minWidth the row just grows */}
110
109
  <Text maxLines={1}>…</Text>
111
110
  </Box>
112
111
  </Box>
@@ -75,15 +75,6 @@ does not handle a rejection, an unhandled one is what it leaves behind, and
75
75
  `.then(done, done)` is the shape that ends it whichever way it went — while a
76
76
  `ready` waiter must expect the skip: `ready.then(onPictures, () => {})`.
77
77
 
78
- ## See also
79
-
80
- - [route_transitions.md](./route_transitions.md) — pages moving against each
81
- other on navigation
82
- - [drag_to_travel.md](./drag_to_travel.md) — a transition held under a finger,
83
- and what the main thread cannot read of it
84
- - [drag_interactions.md](./drag_interactions.md#naming-what-travels) — naming
85
- what moves when something is dropped on a place
86
-
87
78
  ## The top layer is painted through the root's picture
88
79
 
89
80
  A modal `<dialog>`, its `::backdrop`, a popover: the browser paints the top
@@ -137,3 +128,12 @@ Two consequences for the switch itself:
137
128
  `display` on: hidden before the call, a stand-in measures as a zero rect and
138
129
  lands in the wrong place; and on the capture frame it is displayed while the
139
130
  real thing is still there.
131
+
132
+ ## See also
133
+
134
+ - [route_transitions.md](./route_transitions.md) — pages moving against each
135
+ other on navigation
136
+ - [drag_to_travel.md](./drag_to_travel.md) — a transition held under a finger,
137
+ and what the main thread cannot read of it
138
+ - [drag_interactions.md](./drag_interactions.md#naming-what-travels) — naming
139
+ what moves when something is dropped on a place