@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.
- package/README.md +4 -4
- package/dist/dev/jsenv_navi.js +4022 -2067
- package/dist/dev/jsenv_navi.js.map +78 -70
- package/dist/jsenv_navi.js +3672 -2005
- package/dist/jsenv_navi.js.map +76 -70
- package/docs/AI_INSTRUCTIONS.md +3 -2
- package/docs/actions.md +45 -0
- package/docs/control_value.md +73 -17
- package/docs/create_and_edit.md +26 -1
- package/docs/css_architecture.md +82 -2
- package/docs/data_states.md +9 -1
- package/docs/drag_interactions.md +30 -6
- package/docs/dynamic_import.md +4 -2
- package/docs/error_handling.md +19 -0
- package/docs/i18n.md +80 -1
- package/docs/interactions.md +50 -7
- package/docs/navigation.md +27 -1
- package/docs/network_policy.md +5 -2
- package/docs/pan_zoom.md +3 -0
- package/docs/popup_open.md +43 -3
- package/docs/resource.md +45 -5
- package/docs/route_transitions.md +20 -0
- package/docs/safe_area.md +52 -1
- package/docs/scroll.md +59 -5
- package/docs/state_binding.md +9 -0
- package/docs/testid.md +22 -2
- package/docs/typography.md +17 -0
- package/docs/view_transitions.md +13 -0
- package/package.json +3 -3
package/docs/interactions.md
CHANGED
|
@@ -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
|
-
|
|
176
|
-
`
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
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
|
package/docs/navigation.md
CHANGED
|
@@ -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
|
|
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
|
package/docs/network_policy.md
CHANGED
|
@@ -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
|
-
|
|
115
|
-
|
|
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
|
package/docs/popup_open.md
CHANGED
|
@@ -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"
|
|
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
|
-
<
|
|
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:
|
|
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:
|
|
110
|
-
deployed version
|
|
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:
|
|
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`,
|
|
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(
|
|
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
|
|
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.
|
|
462
|
-
|
|
463
|
-
smaller
|
|
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
|
package/docs/state_binding.md
CHANGED
|
@@ -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
|
|