@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.
@@ -172,11 +172,27 @@ receives.
172
172
  ancestor can also listen for one, and `preventDefault()` on it means "not this
173
173
  time".
174
174
 
175
- The lower-level event is reachable too: `interactionEvent.detail.event` is the
176
- `pointerdown` a hold was made of, and the `pointerup` that ended a swipe or a
177
- tap — which is how a menu is opened at the point the press happened. A drag's
178
- is its `pointerdown`, release included, with one exception (see
179
- [drag_interactions.md](./drag_interactions.md)).
175
+ What the callback receives depends on who made the interaction.
176
+ `click`, `mousedown`, `mouseup` and `contextmenu` **are** the browser's event:
177
+ its `target` is what was pressed, its `detail` the click count, and there is no
178
+ `detail.event`. Every other interaction is an event navi dispatches **on the
179
+ element that declares it** — its `target` is that element, whatever was pressed
180
+ inside — and the event it was read from is `detail.event`:
181
+
182
+ | Interaction | `detail.event` |
183
+ | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- |
184
+ | `longpress` | the `pointerdown` held |
185
+ | a swipe | the release that ended it |
186
+ | `double_click`, `single_click` | the `pointerup` of the tap that settled it; a `single_click` no press made (the keyboard, `element.click()`): the `click` |
187
+ | the carrying family (`move`, `reorder`, …) | the `pointerdown`, release included, with one exception (see [drag_interactions.md](./drag_interactions.md)) |
188
+ | `"keyboard:<shortcut>"` | the `keydown` |
189
+ | `pan` | the `pointermove` |
190
+ | `zoom` | the `pointermove` of a pinch, or the `wheel` |
191
+
192
+ So `event.target` tells inner controls apart for the four native ones only.
193
+ Whatever the interaction, the press itself — where a menu opens at the point the
194
+ finger was — is `findEvent(event, ["pointerdown", "pointerup", "click",
195
+ "contextmenu"]) || event`.
180
196
 
181
197
  ## Reaching the control
182
198
 
@@ -190,7 +206,32 @@ still answers a callback; only the two requests (`"request_action"`,
190
206
  A `Box` that lays out **several** controls — a row of badges, a toolbar — belongs
191
207
  to none of them: its interactions are its own, answered with no gate. A click on
192
208
  one of the controls reaches the box too, the way any click bubbles; the callback
193
- reads `event.target` when it has to tell them apart.
209
+ reads `event.target` when it has to tell them apart — on a `click`; any other
210
+ interaction's target is the box itself, and what was pressed is in its
211
+ `detail.event` (see [Which interaction asked](#which-interaction-asked)).
212
+
213
+ A box answering a press on its **blank space** only — a row that folds, a card
214
+ that opens on any press but its buttons', a viewer that closes on its
215
+ background — asks `isPressOnControl(event, box)`: true when the press was aimed
216
+ at a control strictly inside the box (a field, a button, a link, a navi
217
+ control, an element claiming its own interactions). Bounded by the box, so the
218
+ list or the dialog around it is never taken for what was pressed:
219
+
220
+ ```jsx
221
+ <Box
222
+ interactions={{
223
+ click: (event) => {
224
+ if (isPressOnControl(event, event.currentTarget)) {
225
+ return;
226
+ }
227
+ toggleFolded();
228
+ },
229
+ }}
230
+ />
231
+ ```
232
+
233
+ For an interaction navi dispatches, the press is its `detail.event` and the box
234
+ is the event's `target`: `isPressOnControl(event.detail.event, event.target)`.
194
235
 
195
236
  **The gate takes every declared interaction for a write.** Letting a read through
196
237
  a read-only or busy control is navi's to grant inside its own controls, and
@@ -441,7 +482,9 @@ The registry holds no detector of its own: navi's swipes, holds and shortcuts go
441
482
  through the same door an application uses, `defineInteractionDetector`. Its
442
483
  JSDoc is the contract — `setup` once per element and its teardown, what
443
484
  `trigger` returns (`null` when nothing ran, else a promise that rejects when
444
- the effect failed), `implies`, `refusable`, `disputesPress` — and
485
+ the effect failed — what a callback rejects with is reported unless the
486
+ detector says it itself, `claimsRefusals`), `implies`, `refusable`,
487
+ `disputesPress` — and
445
488
  `38_interactions_demo.html` registers a `triple_click` from the page. A detector
446
489
  claims a **set** of names rather than one, because interactions sharing an input
447
490
  have to be arbitrated together: a swipe, a hold and a click dispute the same
@@ -345,6 +345,10 @@ The routes, the document url and the renders still follow every write; only
345
345
  `window.location` waits for the state to stay still that long, and a back pressed
346
346
  before the write keeps the last address written. A push is never debounced.
347
347
 
348
+ A `stateSignal` bound to a `Wheel` is such a value: the wheel writes every row a
349
+ drag, a fling or a mouse wheel passes through, not only the one it settles on, so
350
+ the state declares `debounceUrl`.
351
+
348
352
  ## Rendering routes
349
353
 
350
354
  `<Route>` is the only primitive. With `children` it is a container that renders
@@ -370,6 +374,12 @@ the pages of a `RouteTravel` row alike:
370
374
  </Route>
371
375
  ```
372
376
 
377
+ `fallback` also takes a `routeFallback()`, which names the page it shows
378
+ (`<Route fallback={NOT_FOUND_PAGE} element={NotFoundPage} />`): a movement can then
379
+ be written about it (see
380
+ [route_transitions.md](./route_transitions.md#a-page-opened-from-anywhere)), and
381
+ its `matchingSignal` read outside the tree.
382
+
373
383
  A section with a shared prefix owns its own sub-router: one leaf on
374
384
  `/dashboard/` whose element renders its own `<Route>` tree and chrome, handing its
375
385
  local state down through `elementProps`. Pages sharing a layout but no prefix
@@ -388,7 +398,12 @@ section of pages shares one:
388
398
  <Route>
389
399
  <ErrorBoundary
390
400
  fallback={({ error, resetError }) => (
391
- <ErrorScreen error={error} onRetry={resetError} />
401
+ <ErrorScreen
402
+ error={error}
403
+ // the rerun lifts the boundary; resetError only dismisses, and a
404
+ // page that never loaded has nothing to show once dismissed
405
+ onRetry={error.action ? () => error.action.rerun() : resetError}
406
+ />
392
407
  )}
393
408
  >
394
409
  <Loading fallback={<GameSkeleton />}>
@@ -452,6 +467,9 @@ with `currentAlso={[PLACE_ROUTE]}`, and `currentExcept` names a place under the
452
467
  section it does NOT stand for. Both amend what a link claims about itself and
453
468
  nothing else — neither is an answer to a route that lies about where the reader
454
469
  is (see [what its address may say](#a-layer-over-the-screen-what-its-address-may-say)).
470
+ A bar entry drawn as a button for its press feedback says the same with
471
+ `<Button route currentAlso currentExcept>`: it is drawn as a link and wears
472
+ `aria-current="page"` from the same reading.
455
473
 
456
474
  The routing takes a plain click that moves this tab, and nothing else. A link
457
475
  with `target="_blank"` (or any `target` other than `_self`) opens the app in that
@@ -678,6 +696,14 @@ should go somewhere, a fragment, down to a single row (`#tournament_42`) — nev
678
696
  a search param plus a `scrollIntoView` in an effect, which fires before what sits
679
697
  above the element has laid out, with nothing to tell it how long to wait.
680
698
 
699
+ A mark that only designates — the row a notification points at, outlined until
700
+ the reader moves on — is `:target` too, even though it restyles the element: it
701
+ lasts until the page next writes its URL, which is the length of "this one". A
702
+ search param would outlive it, as state the page does not act on. The alignment
703
+ `setUrlTargetOptions({ block })` sets holds for the whole app; a target that wants
704
+ to land with more room before it says so on itself, with a scroll-margin
705
+ (`scrollMarginBlock` on a `Box`), which the landing honours.
706
+
681
707
  ### A row of tabs, where a replace IS an arrival
682
708
 
683
709
  The tabs of a `<RouteTravel>` navigate by replacing, and yet each one is another
@@ -111,8 +111,11 @@ all, so it is left alone, and so is a function handed to a control's `action`:
111
111
  it is wrapped into an action with no verb, even when it calls one, so the
112
112
  control is not held and the write fails after the press with a
113
113
  `NetworkPolicyError` ([resource.md](./resource.md#a-function-calling-the-verb-or-the-instance)).
114
- `createAction(callback, { meta: { verb } })` opts an action into what the action
115
- layer holds — a control bound to a write verb is read-only, a completed `"GET"`
114
+ Such a control says what its function does to the server with `actionVerb` —
115
+ `<Input type="checkbox" boolean action={(on) => saveSetting("weather", on)}
116
+ actionVerb="PATCH" />` — and is then held up front, with the policy's message.
117
+ `createAction(callback, { meta: { verb } })` says the same of an action declared
118
+ once, and opts it into what the action layer holds — a control bound to a write verb is read-only, a completed `"GET"`
116
119
  is not rerun — but its callback is still called when it runs: answering from
117
120
  the store and settling with a `NetworkPolicyError` are done by the callbacks a
118
121
  `resource()` wraps.
package/docs/pan_zoom.md CHANGED
@@ -35,6 +35,9 @@ the fingers, or under the wheel. Two fingers are one gesture, the point between
35
35
  them panning and the distance between them zooming, and a finger lifting
36
36
  re-anchors on what is left. What a pixel of pan means in your coordinates, and
37
37
  whether the zoom is continuous or stepped, is yours: the numbers are the numbers.
38
+ Where the hand is, rather than how far it moved, is on the event the report was
39
+ read from, `detail.event`: the `pointermove` (its `clientX`/`clientY`), or the
40
+ `wheel` for a zoom.
38
41
 
39
42
  Everything else is settled before the press, and there is nothing to wire: the
40
43
  pan steps back for what is carried across the surface (a `move`, a handle, a
@@ -93,6 +93,14 @@ one press:
93
93
  </Link>
94
94
  ```
95
95
 
96
+ `command` and `commandFor` are props of navi's controls (`Button`, `Link`…),
97
+ read when the control is pressed. Written on a plain element they are HTML's own
98
+ invoker commands, and no navi popup listens to the `command` event the browser
99
+ fires for them. `popovertarget`, `showPopover()` and `showModal()` do not go
100
+ through the popup's open state either: they toggle the element behind it (a
101
+ top-layer `Popover` is `popover="manual"` from its first render). From anything
102
+ that is not a navi control, the way in is `triggerNaviCommand`.
103
+
96
104
  ## Something else opens it: `triggerNaviCommand`
97
105
 
98
106
  The attributes cover more than "this button opens that dialog": `commandFor`
@@ -455,7 +463,10 @@ None of them is "one popup per row of a list", which is what a picker is for.
455
463
 
456
464
  `onOpen` is called on every open (for when, see
457
465
  [above](#onopen-runs-before-the-popup-has-built-anything)), and `onClose` on
458
- every real close, with `detail.isCancel` when the close meant "revert". Neither
466
+ every real close, with `detail.isCancel` when the close meant "revert" and
467
+ `detail.isConfirm` when it was a `--navi-confirm` saying yes — so a shared Dialog
468
+ asking a yes/no question after a gesture already over answers it there
469
+ (`onClose={(e) => e.detail.isConfirm && release()}`). Neither
459
470
  can veto: `onClose` is the close happening, not a request to close. The refusals
460
471
  are navi's: a close over a control mid-action, and a `Picker`'s close over a
461
472
  value that does not validate.
@@ -592,13 +603,16 @@ a thumb can hit:
592
603
  ```jsx
593
604
  <Dialog id="duration">
594
605
  <Box header flex="x" alignY="center" padding="s">
595
- <Box expandX>Durée de la partie</Box>
606
+ <Dialog.Title expandX>Durée de la partie</Dialog.Title>
596
607
  <Dialog.Close />
597
608
  </Box>
598
609
  …
599
610
  </Dialog>
600
611
  ```
601
612
 
613
+ The text of the row is the popup's title, and so its name (see
614
+ [below](#its-name)).
615
+
602
616
  ### It is the way out, so the state around it does not reach it
603
617
 
604
618
  **A close button written by hand refuses the press inside a read-only control.**
@@ -658,6 +672,12 @@ nothing else. The first of these names it:
658
672
  </Picker>
659
673
  ```
660
674
 
675
+ In a header row, beside the cross, the title is both the row's text and the
676
+ popup's name — written as plain text there instead, it names nothing. It is a
677
+ heading drawn as that text: bold, at the size of the row, with no heading
678
+ margins, unless `size` or `margin` say otherwise (see
679
+ [the close cross](#the-close-cross)).
680
+
661
681
  A `Popover` has no role of its own, and a name on an element without one is
662
682
  read by nobody: a named popover is a `dialog`, unless given another `role`. A
663
683
  picker's popover always is, which is what its trigger announces
@@ -716,6 +736,21 @@ route's search-param `stateSignal` as the signal, that is how
716
736
  [a row of cards](./popup_lift.md#a-row-of-cards-one-popup-that-walks) survives
717
737
  leaving the page: the address names the card, and the sheet reopens on it.
718
738
 
739
+ A `Picker` says the same with `openSignal` and `openValue`, its own `signal`
740
+ and `value` being its answer:
741
+
742
+ ```jsx
743
+ <Picker openSignal={editedIdSignal} openValue={item.id} action={save}>
744
+ …
745
+ </Picker>
746
+ ```
747
+
748
+ An item edited in place under `?edit=<id>` then reopens from a reload or a
749
+ pasted link, Back closes it when the signal's history pushes, and the route
750
+ actions reading `editedIdSignal` load the fresh record and what the sheet lists
751
+ while it is open ([below](#a-popup-that-loads-data)). The picker's `onOpen` and
752
+ `onClose` are left to what they are for: nothing in them writes the address.
753
+
719
754
  ### `navState` — the history entry holds it
720
755
 
721
756
  ```jsx
@@ -728,7 +763,7 @@ opening an entry of its own: the back button closes the popup rather than
728
763
  leaving the screen, and a cancel goes back with the entry
729
764
  ([above](#escapeeffectclose-and-why-it-is-a-last-resort)). A `Picker` needs none
730
765
  of this, only an `id`: its open state is nav state under that id, pushed in
731
- dialog mode. Without an `id` the key names one mount — the page left with the
766
+ dialog mode (unless an `openSignal` holds it, see above). Without an `id` the key names one mount — the page left with the
732
767
  picker open and come back to is a new mount, and the state stays in the entry
733
768
  with nothing to read it (navi warns). A picker whose popup leads somewhere, a
734
769
  link inside it, has an `id`.
@@ -923,6 +958,11 @@ option is the component drawing its own wait
923
958
  (`useAsyncData(action, { loading: true })`); what is not an option is neither.
924
959
  Both are on `src/layout/demos/13_popup_loading_demo.html`.
925
960
 
961
+ The failure has no boundary of its own either: delegated, it lands on the
962
+ nearest `<ErrorBoundary>` above the popup — the one holding the page, whose
963
+ fallback replaces the page under it. A read inside a popup takes `error: true`
964
+ and says the failure where it stands.
965
+
926
966
  What the popup gains is everything a page has: the request leaves **with the
927
967
  screen**, in parallel with the rest of what the address needs, instead of behind
928
968
  the gesture that opens the popup; `useAsyncData` reads it (see
package/docs/resource.md CHANGED
@@ -38,6 +38,7 @@ Actions are read in components through the action system (`useAsyncData`,
38
38
  - [`GET_RANGE`: feeding a list that loads as it scrolls](#get_range-feeding-a-list-that-loads-as-it-scrolls)
39
39
  - [Searching the same collection](#searching-the-same-collection)
40
40
  - [Relations: pick one of the four methods](#relations-pick-one-of-the-four-methods)
41
+ - [Several writes on one item: `writes`](#several-writes-on-one-item-writes)
41
42
  - [Callback return contracts](#callback-return-contracts)
42
43
  - [When the backend answers a sub-route with the whole parent](#when-the-backend-answers-a-sub-route-with-the-whole-parent)
43
44
  - [Relations and autorerun](#relations-and-autorerun)
@@ -80,7 +81,8 @@ that keeps the copy, a `stateSignal` that persists:
80
81
 
81
82
  ```js
82
83
  const mePersistedSignal = stateSignal(undefined, {
83
- id: `me@${APP_VERSION}`,
84
+ id: "me",
85
+ version: APP_VERSION,
84
86
  persists: true,
85
87
  type: "object",
86
88
  });
@@ -106,8 +108,10 @@ test wants.
106
108
  What stays the app's, because only the app knows:
107
109
 
108
110
  - **the key.** A deploy that changes the shape of the answer must not draw the
109
- old one: put in the signal's `id` whatever makes a copy unusable — the
110
- deployed version — and a new id starts clean.
111
+ old one: the signal's `version` says what makes a copy unusable — the
112
+ deployed version. It is stored under `me@<version>`, and creating the signal
113
+ removes the copies every other version left (and one stored under the bare
114
+ id), so a device does not keep one per deploy it ever opened.
111
115
  - **`when`: is the copy about this session?** A tab viewing as someone else, an
112
116
  account being switched — the row is somebody's, and must not be kept for the
113
117
  next person. `when` is read whenever the copy is about to be read or written:
@@ -149,7 +153,8 @@ slot, replaced when the user moves:
149
153
 
150
154
  ```js
151
155
  const pageKeptSignal = stateSignal(undefined, {
152
- id: `page@${APP_VERSION}`,
156
+ id: "page",
157
+ version: APP_VERSION,
153
158
  persists: true,
154
159
  type: "object",
155
160
  });
@@ -429,6 +434,7 @@ people — is two relations spelled as one, and the one the gesture is about
429
434
  | The relation itself carries fields (`candidate_since`, `seen_at`, `slot`) | `.scopedMany()` — those fields belong to the pair; putting them in a shared child store corrupts that entity for every other reader |
430
435
  | The backend answers every sub-route with the whole refreshed parent | still model the relation; absorb the response with a plural callback (see below) |
431
436
  | A genuine partial update of the parent itself (cancel a game) | plain `PATCH` on the parent |
437
+ | Several writes on the item itself, none of them a sub-object (cancel, archive, mark as seen) | one named in [`writes`](#several-writes-on-one-item-writes) each |
432
438
 
433
439
  Singular vs plural is about the relation, not the verb: `.one`/`.scopedOne` for a
434
440
  single sub-object, `.many`/`.scopedMany` for a collection.
@@ -442,6 +448,39 @@ give that relation its own relationship method and a callback returning what was
442
448
  written, even when the backend must grow a field for it — never a flag next to
443
449
  the button.
444
450
 
451
+ ### Several writes on one item: `writes`
452
+
453
+ An item takes writes that are no sub-object of it: cancel, archive, mark as
454
+ seen, star. The first can be its `PATCH`; the others are named, each one a
455
+ write of its own:
456
+
457
+ ```js
458
+ const ORDER = resource("order", {
459
+ GET,
460
+ PATCH,
461
+ writes: {
462
+ cancel: {
463
+ verb: "PATCH",
464
+ callback: ({ id }) => post(`/orders/${id}/cancel`),
465
+ },
466
+ archive: {
467
+ verb: "PATCH",
468
+ callback: archiveOrder,
469
+ rerunOn: { GET_RANGE: true },
470
+ },
471
+ },
472
+ });
473
+ <Button action={ORDER.cancel.bindParams({ id })}>Cancel</Button>;
474
+ ```
475
+
476
+ `ORDER.cancel` is an action beside the verbs: its own loading and error on the
477
+ button that runs it, held by the network policy as its `verb`, and answered as
478
+ that verb is (a `PATCH` returns the item, which is upserted). Its `rerunOn` says
479
+ which reads of this resource it reruns; a read it leaves out follows `rerunOn`
480
+ for its verb. A dependent names it like a callback —
481
+ `{ resource: ORDER, on: ["cancel"] }` reruns after a cancel, not after an
482
+ archive — and a resource listed bare counts it with every other write.
483
+
445
484
  ## Callback return contracts
446
485
 
447
486
  These are not guessable — each relationship method has its own shape, written
@@ -629,7 +668,8 @@ const SUMMARY = resource("summary", {
629
668
  ```
630
669
 
631
670
  `on` names callbacks, not verbs: `PATCH` does not cover `PATCH_MANY` the way it
632
- does in `rerunOn`, so each write keeps its own answer. A name that is not a
671
+ does in `rerunOn`, nor a write [named in `writes`](#several-writes-on-one-item-writes)
672
+ whose verb is `PATCH`, so each write keeps its own answer. A name that is not a
633
673
  write, or that the resource does not define, throws at declaration — a
634
674
  dependency that never fires leaves a count on its old value with nothing to say
635
675
  so.
@@ -65,6 +65,19 @@ is not `defineRouteDefaultTransition`, which is about every navigation nothing
65
65
  was said about. Write it only for a door that really is furniture: a page reached from a
66
66
  screen has that screen to be paired with, and the pair says more.
67
67
 
68
+ A page with no door at all is from anywhere too: reached by its address from
69
+ whatever screen one was on, as the not-found page is.
70
+
71
+ ```js
72
+ const NOT_FOUND_PAGE = routeFallback();
73
+ // <Route fallback={NOT_FOUND_PAGE} element={NotFoundPage} />
74
+ defineRouteTransition(null, NOT_FOUND_PAGE, "slide-x");
75
+ ```
76
+
77
+ So is an item's own page reached from every card it appears on: pair it only
78
+ where one screen means something different, and let the traversal retrace the way
79
+ back (see [below](#a-traversal-retraces-its-crossing)).
80
+
68
81
  **Such a page is still a PAGE**: it takes the screen's place, and the reader comes
69
82
  back to whatever the router puts there. A destination that must be drawn OVER the
70
83
  screen and give it back exactly on closing is a layer, not a page — its address
@@ -360,6 +373,13 @@ a custom movement's pictures read goes on `::view-transition`, never on the root
360
373
  }
361
374
  ```
362
375
 
376
+ The values navi hands a movement's pictures live there too, which is the only
377
+ place they can be read from: `--navi-transition-window-width` and `-height`, the
378
+ window the pages move in, and `--navi-route-transition-travel-x` and `-y`, the
379
+ distance a page travels — the window less what the bars cut off it. A depth or a
380
+ distance a movement derives from them is derived on `::view-transition`, as above;
381
+ written on `:root`, the `var()` finds nothing and takes its fallback.
382
+
363
383
  ## Two routes matching one url
364
384
 
365
385
  A relation is resolved through **which page is current** — not through the url,
package/docs/safe_area.md CHANGED
@@ -121,6 +121,51 @@ end (see below).
121
121
  Beware of making that container a scroller by accident — see
122
122
  [A document wider than the screen](#a-document-wider-than-the-screen).
123
123
 
124
+ ### A page that fits the free band
125
+
126
+ An app whose document scrolls still has screens that must fit the screen: a
127
+ head that stays put over a part that scrolls, a wizard whose step fills what is
128
+ left. The height of the free band is the visual viewport less both insets:
129
+
130
+ ```css
131
+ .wizard {
132
+ height: calc(
133
+ var(--navi-vvh) - var(--navi-safe-area-inset-top) -
134
+ var(--navi-safe-area-inset-bottom)
135
+ );
136
+ }
137
+ ```
138
+
139
+ `--navi-vvh` and not `100dvh`: it follows the visual viewport, so the page
140
+ shrinks with a keyboard that shrinks it, and the insets add the bars, the notch,
141
+ the app's own bands and an overlaying keyboard (see
142
+ [the trap](#the-trap-which-viewport)). Never `--navi-fixed-bar-space-*`: that
143
+ slot is how a `FixedBar` writes what it takes, and reading it for layout misses
144
+ the notch on an edge with no bar, the band of a capped app, and the keyboard.
145
+
146
+ ### A strip under a bar
147
+
148
+ A strip that stays under the top bar — a status banner, an "offline" line — is
149
+ not a second `FixedBar` (one bar per edge: a second one sits on the first). It
150
+ belongs to the flow, sticking where the band starts:
151
+
152
+ ```jsx
153
+ const bannerRef = useRef();
154
+ useTransitionCover(bannerRef);
155
+
156
+ <Box
157
+ ref={bannerRef}
158
+ style={{ position: "sticky", top: "var(--navi-safe-area-inset-top)" }}
159
+ >
160
+ Hors ligne
161
+ </Box>;
162
+ ```
163
+
164
+ `useTransitionCover` publishes the strip's height, so the pictures of a route
165
+ movement are cut under it rather than sliding over it. An anchor's
166
+ `scroll-margin` adds only what `:root`'s `scroll-padding` does not hold already
167
+ — the strip's own height, a few pixels of air — never the bar or the band.
168
+
124
169
  ### Reading it yourself
125
170
 
126
171
  `var(--navi-safe-area-inset-bottom)` in any rule. It is always declared, whether
@@ -241,10 +286,16 @@ data — is still a layout bug. In dev, ask who overflows:
241
286
  import { detectHorizontalOverflow } from "@jsenv/navi";
242
287
 
243
288
  if (import.meta.dev) {
244
- detectHorizontalOverflow({ root: document.querySelector("#main") });
289
+ detectHorizontalOverflow();
245
290
  }
246
291
  ```
247
292
 
293
+ Measured from `<body>`, the wrapper would hold its own overflow and hide every
294
+ culprit inside it, so the wrapper is what the content is measured against: found
295
+ down the boxes that are the only thing their parent draws (`<body>`, the mount
296
+ point, the shell). A wrapper beside other boxes is not found; pass it as
297
+ `root`.
298
+
248
299
  It outlines the culprits in red and names them in the console, at load and
249
300
  whenever the layout changes. It reports the **outermost** box that sticks out
250
301
  (its children stick out because it does), and stays quiet about what cannot
package/docs/scroll.md CHANGED
@@ -51,7 +51,9 @@ 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
54
- does not fill. Adding `expandY` to "fix" that is undoing a deliberate default.
54
+ does not fill. Adding `expandY` to "fix" that is undoing a deliberate default
55
+ — the one body that grows holds a list scrolling in its own frame (see
56
+ [A field above the list](#a-field-above-the-list)).
55
57
  - the separating line is a `border-bottom` on the header (`border-top` on the
56
58
  footer), not a `box-shadow`: a shadow is drawn outside the box and lost to
57
59
  whatever is painted after it, so the body would cover the very line meant to
@@ -133,6 +135,28 @@ opens on an item places the document itself: the offset the page kept for its ur
133
135
  is not put back over it — the items held off screen are fillers of an estimated
134
136
  height, and the same pixels now fall on other items.
135
137
 
138
+ ### Moving the list on a press
139
+
140
+ A table of contents beside a long list, a "back to today" button: the press
141
+ sends the list to a place with `--navi-scroll`, and the list goes there the way
142
+ `scrolled` takes it — asked for by name when it is not fetched yet, measured,
143
+ and held until the user reaches for the list:
144
+
145
+ ```jsx
146
+ <Button command="--navi-scroll:smooth" commandFor="feed" value={{ id: dayId }}>
147
+ Today
148
+ </Button>
149
+ <List id="feed">…</List>
150
+ ```
151
+
152
+ The value is anything `scrolled` takes (`"start"`, `"end"`, an index,
153
+ `{ id, offset, align }`) or an item's id, and the same place can be asked for
154
+ again once the list was moved away from it — no `scrolled` released and set
155
+ again a frame later. `:smooth` glides, and is instant under
156
+ `prefers-reduced-motion`. Without it a placement follows the scroller's own
157
+ `scroll-behavior`: a `scroll-behavior: smooth` on the box glides every
158
+ placement of the list, the one it opens on included.
159
+
136
160
  ### A list that comes back: one `id`, one list
137
161
 
138
162
  The position a list comes back to is kept under its `id` and the page's url, so
@@ -216,7 +240,10 @@ So the parts are direct children of the `Dialog`:
216
240
 
217
241
  ```jsx
218
242
  <Dialog id="…" dockedOnSmallTouchScreen>
219
- <Box header>title + close</Box>
243
+ <Box header flex="x" alignY="center">
244
+ <Dialog.Title expandX>…</Dialog.Title>
245
+ <Dialog.Close />
246
+ </Box>
220
247
  <Box body>
221
248
  <List scroller="parent" /> {/* NOT "self" */}
222
249
  </Box>
@@ -224,10 +251,36 @@ So the parts are direct children of the `Dialog`:
224
251
  </Dialog>
225
252
  ```
226
253
 
254
+ The title is the popup's name ([popup_open.md](./popup_open.md#its-name)),
255
+ drawn as the row's text.
256
+
227
257
  A dialog is already bounded by the room its container leaves it, so a
228
258
  `maxHeight` is only for making it smaller than that (see
229
259
  [dialog_shape.md](./dialog_shape.md#the-ceiling-nobody-sets)).
230
260
 
261
+ ### A field above the list
262
+
263
+ A sheet of fixed height that keeps a field in view above a list scrolling in
264
+ its own frame — a search box over the entries to pick from — gives the body
265
+ the height and the list the scrolling:
266
+
267
+ ```jsx
268
+ <Dialog id="…" expandY>
269
+ <Box header>…</Box>
270
+ <Box body flex="y" expandY>
271
+ <Input type="search" />
272
+ <List expandY />
273
+ </Box>
274
+ </Dialog>
275
+ ```
276
+
277
+ The body grows here, which it never does on its own: not to push a footer
278
+ down, but to bound a list that is itself the scrolling area. It still scrolls
279
+ when what stays put outgrows it (the keyboard up). `scroller="parent"` is the
280
+ shape when there is no frame to keep and the field may scroll away with the
281
+ rows. The field goes in the body, never in the header of a docked sheet: that
282
+ header is its swipe grip.
283
+
231
284
  ### `scrollCapture`
232
285
 
233
286
  ```jsx
@@ -458,9 +511,10 @@ paint on. `initial: "100%"` is the screen and nothing more, whatever the items
458
511
  weigh: a few are drawn to be measured, and the window is sized on them before
459
512
  the browser paints — a count (`initial: 6`) is a guess that holds for one kind
460
513
  of item only. A position handed back with its `visibleCount` sizes that first
461
- window itself, whatever `initial` says. The runs ask their source for a page of
462
- items (`<List.Items pageSize>`, 100 by default) whatever either says, so the
463
- smaller first window costs no second request.
514
+ window itself, whatever `initial` says. A first window smaller than a page
515
+ still asks the source for a whole page (`<List.Items pageSize>`, 100 by
516
+ default), so the smaller window costs no second request; a window wider than a
517
+ page — a count budget without `initial` — is asked for whole.
464
518
 
465
519
  The switch waits for the paint itself. Do not rebuild it with a `useEffect`
466
520
  that widens a slice: preact runs a component's pending effects early when that
@@ -51,6 +51,15 @@ was missed.
51
51
  />
52
52
  // ✓
53
53
  <Dialog signal={openSignal} />
54
+
55
+ // ✗ a button told it was pressed, and the app writes the state back down
56
+ <Button aria-pressed={editing} uiAction={() => (editingSignal.value = !editing)}>
57
+ Edit
58
+ </Button>
59
+ // ✓ a button that stays pressed is a checkbox
60
+ <Input type="checkbox" variant="button" boolean signal={editingSignal}>
61
+ Edit
62
+ </Input>
54
63
  ```
55
64
 
56
65
  In each pair the second line is not a tidier spelling of the first: it is the
package/docs/testid.md CHANGED
@@ -44,10 +44,23 @@ that contract is absent or not usable:
44
44
 
45
45
  - there is no accessible name, or it is an icon, or it is dynamic;
46
46
  - the app is translated, and the test must pass in every language;
47
+ - the name is copy still being reworded;
47
48
  - several elements legitimately share role and name (rows of a list, cells of a
48
49
  table) and only their position tells them apart — a per-item
49
50
  `data-testid={`row-${id}`}` names them by identity instead of by index.
50
51
 
52
+ A name is stable when it is not copy: an icon's `aria-label`, a landmark, the
53
+ names navi gives its own parts. When the testid is what addresses the element,
54
+ the contract stays checked as an assertion of its own — a rewording then breaks
55
+ nothing, and a missing name still fails:
56
+
57
+ ```js
58
+ const save = page.getByTestId("save-game");
59
+ await expect(save).toHaveRole("button");
60
+ await expect(save).toHaveAccessibleName(/\S/);
61
+ await save.click();
62
+ ```
63
+
51
64
  ## Where it lands in navi
52
65
 
53
66
  Navi renders more than one element per component: a control is a little tree —
@@ -149,7 +162,9 @@ or waits for it to show, targets what the row draws: a testid on its content.
149
162
  What navi builds itself is named through props. A `data-testid` on a `<Picker>`
150
163
  names the trigger; `popupTestId` names the popup it builds (the callout, under
151
164
  `mode="callout"`), and `confirmTestId` / `cancelTestId` the yes and no buttons
152
- of a `type="confirm"` picker's default body:
165
+ of a `type="confirm"` picker's default body. Likewise `menuTestId` names a
166
+ `SplitButton`'s chevron half, and `previousTestId` / `nextTestId` a `Spin`'s
167
+ two ways out (a `DaySpin`'s, a `NumberSpin`'s):
153
168
 
154
169
  ```jsx
155
170
  <Picker data-testid="tie-break" popupTestId="tie-break-sheet">
@@ -197,7 +212,12 @@ Navi's own attributes are implementation, not a contract: `data-header`,
197
212
  `navi-control*`, `.navi_*` class names, the ids navi generates when none is
198
213
  given (`useId`), and the ids it derives from yours (a picker's popup id is built
199
214
  from the picker's, so the suffix is navi's even when the base is not). They
200
- change without notice and without a migration note.
215
+ change without notice and without a migration note. The exception is the state
216
+ a guideline doc tells an app to read — `navi-refreshing`
217
+ ([list_refresh.md](./list_refresh.md)), `navi-stuck`
218
+ ([z_index.md](./z_index.md)), `data-navi-route-transition*`
219
+ ([route_transitions.md](./route_transitions.md)): a test may assert it like any
220
+ other contract.
201
221
  Likewise a `view-transition-name` or a CSS variable: those exist to draw, not
202
222
  to be found.
203
223