@jsenv/navi 0.29.45 → 0.29.47

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.
@@ -0,0 +1,468 @@
1
+ # Creating a resource, then editing it
2
+
3
+ The loop almost every application has: a screen that creates something, the page
4
+ of the thing just created, and a screen that edits it. It is worth a page of its
5
+ own because what makes it hard is not the form — it is that **the two screens
6
+ look like the same form and are not the same thing at all**: one holds a draft
7
+ the person is writing, the other holds a resource the server owns.
8
+
9
+ Working example:
10
+ [../src/control/demos/integration/create_then_edit/create_then_edit.html](../src/control/demos/integration/create_then_edit/create_then_edit.html)
11
+ — the whole loop, with a backend on the page answering by hand so the loading
12
+ and the failures can be looked at.
13
+
14
+ - [What the loop owes the person](#what-the-loop-owes-the-person)
15
+ - [The routes](#the-routes)
16
+ - [The resource](#the-resource)
17
+ - [Two screens, two states](#two-screens-two-states)
18
+ - [The edit screen opens before its values](#the-edit-screen-opens-before-its-values)
19
+ - [A field that picks from a list too big to load](#a-field-that-picks-from-a-list-too-big-to-load)
20
+ - [Where each screen goes next](#where-each-screen-goes-next)
21
+ - [After a write: what goes back to the network](#after-a-write-what-goes-back-to-the-network)
22
+ - [Movement between them](#movement-between-them)
23
+
24
+ ## What the loop owes the person
25
+
26
+ The rules below are what the rest of this page is for. Each one is a decision
27
+ about what the person is owed, not about navi.
28
+
29
+ - **Creating lands on what was created.** Going back to the list after a
30
+ creation asks the person to find their own thing to be sure it exists; the
31
+ thing itself is the proof, and it is also where they were heading.
32
+ - **A draft is theirs until it is sent, and not a minute longer.** Half a form
33
+ filled must survive a reload (it is in the url), and must be gone the next
34
+ time "create" is opened — a create screen showing the last thing created is a
35
+ screen nobody trusts.
36
+ - **Saving goes back to the thing, and so does a press that had nothing to
37
+ send.** The person is done either way; refusing to move because "nothing
38
+ changed" makes them press again to find out why.
39
+ - **Cancelling puts back what the server says**, not what was typed and
40
+ abandoned. What leaving with unsaved changes does is a decision each screen
41
+ takes: dropping them is right when they are a few fields the person chose to
42
+ leave (that is what the shape below does, for free — the screen is thrown
43
+ away), and worth a confirmation when they are half an hour of work.
44
+ - **What was written shows up everywhere at once.** A name changed on one screen
45
+ and stale on the list two seconds later reads as data loss. Some of that is
46
+ free (the store), some of it is a request the backend has to answer (a list
47
+ after a creation) — see [after a write](#after-a-write-what-goes-back-to-the-network).
48
+ - **A failure is shown where the thing was asked for**, with what was typed
49
+ still there: an error on a form that emptied itself is worse than the failure.
50
+ - **Every screen is a url.** Reload, back, a link sent to someone — the screens
51
+ of this loop are places, and the movement between them says how they are
52
+ related.
53
+
54
+ Each of them has a mechanism below, and each mechanism is a line of code, not a
55
+ framework.
56
+
57
+ ## The routes
58
+
59
+ Four places, and each of them is a url someone can open, reload or share:
60
+
61
+ ```js
62
+ const HOME_ROUTE = route("/");
63
+ const NEW_GAME_ROUTE = route("/games/new", {
64
+ searchParams: {
65
+ name: nameSignal,
66
+ level: levelSignal,
67
+ players: playersSignal,
68
+ },
69
+ });
70
+ const GAME_ROUTE = route("/games/:gameId");
71
+ const EDIT_GAME_ROUTE = route("/games/:gameId/edit");
72
+ setupRoutes([HOME_ROUTE, NEW_GAME_ROUTE, GAME_ROUTE, EDIT_GAME_ROUTE]);
73
+ ```
74
+
75
+ The create screen declares its fields as **search params**: a draft that
76
+ survives a reload and travels in a link. The edit screen declares none — its
77
+ values belong to the resource, not to the position.
78
+
79
+ Two facts about matching decide the rest, and both surprise:
80
+
81
+ - **Several routes match at once.** A route matches by prefix, so `/` is still
82
+ matching on `/games/2/edit`, and `/games/new` is also a `/games/:gameId`.
83
+ That is what keeps a section active while one is inside it.
84
+ - **The first that matches wins**, in the order it is written — the `<Route>`
85
+ children, and the pages of a travel row alike. So they go from the most
86
+ precise to the widest:
87
+
88
+ ```jsx
89
+ <Route>
90
+ <Route route={EDIT_GAME_ROUTE} element={EditGamePage} />
91
+ <Route route={NEW_GAME_ROUTE} element={NewGamePage} />
92
+ <Route route={GAME_ROUTE} element={GamePage} />
93
+ <Route route={HOME_ROUTE} element={HomePage} />
94
+ </Route>
95
+ ```
96
+
97
+ Same thing for what a route LOADS: the loader of `/games/:gameId` must step
98
+ aside on `/games/new`, which is a page and not a game.
99
+
100
+ ```js
101
+ const GAME_OF_ROUTE = routeAction(
102
+ [GAME_ROUTE, EDIT_GAME_ROUTE],
103
+ GAME.GET,
104
+ () => {
105
+ if (NEW_GAME_ROUTE.matchingSignal.value) {
106
+ return null; // "new" is not an id
107
+ }
108
+ const gameId =
109
+ GAME_ROUTE.paramsSignal.value.gameId ||
110
+ EDIT_GAME_ROUTE.paramsSignal.value.gameId;
111
+ return gameId ? { id: gameId } : null;
112
+ },
113
+ );
114
+ ```
115
+
116
+ ## The resource
117
+
118
+ Declare the REST callbacks once ([resource.md](./resource.md)) and the store
119
+ does the rest — this is what makes the detail page show the new name **the
120
+ moment the PUT answers**, with nobody reloading anything:
121
+
122
+ ```js
123
+ const GAME = resource("game", {
124
+ GET: ({ id }) => api.readGame(id),
125
+ GET_MANY: () => api.readGames(),
126
+ POST: (values) => api.createGame(values),
127
+ PUT: ({ id, ...values }) => api.updateGame(id, values),
128
+ });
129
+ ```
130
+
131
+ Two things to know or the screen stays empty:
132
+
133
+ - **Reading an action does not start it.** `useAsyncData` waits for data
134
+ someone else asked for; what asks is the route (`routeAction`). A component
135
+ reading an action nobody runs suspends forever, and the whole `<Loading>`
136
+ subtree stays blank.
137
+ - **Handle the error where it happens**, or hand it to an `<ErrorBoundary>`:
138
+ `useAsyncData(action, { loading: true, error: true })` returns
139
+ `[data, loading, error]`, which is what lets a page draw its own "the server
140
+ refused" with a "try again" that calls `action.rerun()`.
141
+
142
+ ## Two screens, two states
143
+
144
+ The same three fields, and two different things behind them:
145
+
146
+ - the **create** screen holds a **draft** — nobody else's, not saved anywhere,
147
+ worth keeping while it is being written. It belongs in the url
148
+ (`searchParams`), which is what makes it survive a reload and travel in a
149
+ link;
150
+ - the **edit** screen holds **what the server has**, loaded, and proposes
151
+ changes to it. It is the screen's own state, alive as long as the screen is.
152
+
153
+ So the fields are written once, and know nothing about which screen they are in:
154
+
155
+ ```jsx
156
+ const GameFormFields = ({
157
+ nameSignal,
158
+ levelSignal,
159
+ playersSignal,
160
+ loading,
161
+ }) => (
162
+ <>
163
+ <Input name="name" signal={nameSignal} loading={loading} required />
164
+ <Select name="level" signal={levelSignal} loading={loading}>
165
+
166
+ </Select>
167
+ </>
168
+ );
169
+ ```
170
+
171
+ Each screen hands it its own:
172
+
173
+ ```jsx
174
+ // créer: le brouillon, qui vit dans l'url
175
+ <GameFormFields
176
+ nameSignal={draftNameSignal}
177
+ levelSignal={draftLevelSignal}
178
+ playersSignal={draftPlayersSignal}
179
+ />;
180
+
181
+ // modifier: ceux de cet écran-ci, remplis quand la partie arrive
182
+ const nameSignal = useSignal(undefined);
183
+ ```
184
+
185
+ **Do not let the two share one set of signals.** It is the mistake this shape
186
+ exists to prevent, and it does not look like one: bind both screens to the same
187
+ `nameSignal`, edit a game, then press "create" — the game you just edited is
188
+ sitting in the create form, and in the url. A draft and a resource are not the
189
+ same state; one is on the screen, the other is at the backend.
190
+
191
+ The draft's other half is the end of its life:
192
+
193
+ ```jsx
194
+ action={async (values) => {
195
+ const game = await GAME.POST.bindParams(values).rerun();
196
+ GAME_ROUTE.navTo({ gameId: game.id });
197
+ draftNameSignal.value = undefined; // il a servi
198
+ draftLevelSignal.value = undefined;
199
+ }}
200
+ ```
201
+
202
+ `undefined`, not `""`: a state signal put back to undefined returns to its
203
+ default and leaves the url — see [control_value.md](./control_value.md).
204
+
205
+ **Clear after navigating, not before.** Those signals are read by things on the
206
+ screen being left — the list of places is asked for with the place the draft
207
+ holds — so emptying them while that screen is still up asks for the list again,
208
+ for a screen nobody is looking at any more. Navigate first and the clearing is
209
+ what it should be: tidying up behind oneself.
210
+
211
+ > One signal for the whole form works too: `<Form signal={gameSignal}>` fills
212
+ > its named children from the object, follows it when something else writes it,
213
+ > and writes back the whole object as the fields change. Reach for it when the
214
+ > values arrive as one object and no field needs a url of its own — the fields
215
+ > then take nothing but their `name`.
216
+
217
+ ## The edit screen opens before its values
218
+
219
+ The resource arrives a request after the screen. Two shapes, and both are
220
+ right — the question is what the person should be looking at meanwhile:
221
+
222
+ **The screen waits, showing the form.** The fields are there, empty and busy
223
+ (`loading` on a control marks it `aria-busy` and shows it), and they fill in
224
+ when the resource lands. Nothing blinks in and out, and a long screen does not
225
+ collapse to a spinner. What it costs is one thing to say — **when the filling is
226
+ done**:
227
+
228
+ ```jsx
229
+ const [game, loading, error] = useAsyncData(GAME_OF_ROUTE, {
230
+ loading: true,
231
+ error: true,
232
+ onLoad: (game) => {
233
+ nameSignal.value = game.name;
234
+ levelSignal.value = game.level;
235
+ },
236
+ });
237
+
238
+ <Form pristineKey={game?.id}>;
239
+ ```
240
+
241
+ `onLoad` is what the screen does with the data **once, when it becomes known**,
242
+ and the two hard parts are already answered by it:
243
+
244
+ - **How often.** Not every time the data arrives — a successful PUT, a list
245
+ reloading, a poll all hand the same game back, and copying it again would
246
+ overwrite what the person is in the middle of writing. It fires once per set
247
+ of params, which is the action's own answer to "is this another thing, or the
248
+ same one again". Written by hand this is a `useEffect` keyed on `game?.id`,
249
+ and `[game]` is the natural, wrong, thing to write.
250
+ - **When.** From a layout effect, so what it writes belongs to the same tick as
251
+ the render that got the data. That is what lets `pristineKey` be the id
252
+ itself: the form takes its reference again at the end of that tick, and by
253
+ then the fields are filled. A copy written in a passive effect (after the
254
+ paint) would be too late — the screen would open **already changed**, and Save
255
+ would send the resource back to the server untouched.
256
+
257
+ The rest of `pristineKey` is in [form_changed.md](./form_changed.md).
258
+
259
+ **Or the screen waits, showing nothing of the form**: render a skeleton until
260
+ the resource is there, then the form with its values already in the fields.
261
+ Nothing to announce then — the form holds them from its first render — but the
262
+ screen has to be one that can be blanked without the person losing their place.
263
+
264
+ Either way, cancelling is a link away: the screen is thrown away with what was
265
+ typed in it, and coming back re-reads the resource. There is nothing to restore.
266
+
267
+ ## A field that picks from a list too big to load
268
+
269
+ A place, a player, a category: the field is a picker, and its popup holds a list
270
+ the backend answers with. The list is **one page** — the nearest, the most
271
+ recent, whatever a `LIMIT` returned. And the screen is pre-filled from the url:
272
+
273
+ ```
274
+ /games/new?place=halle-des-sports
275
+ ```
276
+
277
+ That is what pre-filling is: a link from a place's page, a back button, a
278
+ reload, a shared invitation. The signal is the selection, and the url is its
279
+ memory. But the url carries an **identifier and nothing else** — no name — so
280
+ the screen looks for it in the list it has:
281
+
282
+ ```js
283
+ const selected = places.find((place) => place.id === placeId);
284
+ ```
285
+
286
+ Nothing guarantees that `find`. The place may be far down the ranking, created
287
+ by someone else a minute ago, or named in a link built elsewhere. Nothing is
288
+ broken — the screen falls back on the identifier — but it then shows
289
+ `halle-des-sports` where it promised "Halle des sports", which is the opposite
290
+ of what pre-filling was for. Rare, never seen in development, and always at
291
+ someone else's.
292
+
293
+ Two ways out, and only one of them keeps the feature:
294
+
295
+ - **Clear the signals when the screen opens.** No selection, no selection to
296
+ display. This throws away pre-filling, which is the reason those params exist:
297
+ removing a feature is not fixing its edge case.
298
+ - **Ask for the list SAYING what you already hold.** What the screen holds
299
+ travels with the request, and the backend guarantees that item is in the
300
+ answer — on top of its page, whatever its rank.
301
+
302
+ ```js
303
+ const PLACES_OF_SCREEN = routeAction(
304
+ [NEW_GAME_ROUTE, EDIT_GAME_ROUTE],
305
+ PLACE.GET_MANY,
306
+ // pas `() => true`: ce que l'écran tient déjà doit voyager avec la demande
307
+ () => {
308
+ // en création: l'url, connue tout de suite
309
+ if (NEW_GAME_ROUTE.matchingSignal.value) {
310
+ return { include: draftPlaceSignal.value };
311
+ }
312
+ // en modification: elle arrive avec la ressource
313
+ const game = GAME_OF_ROUTE.dataSignal.value;
314
+ return game ? { include: game.placeId } : null;
315
+ },
316
+ );
317
+ ```
318
+
319
+ Reading the selection in the params is also what makes the list **reload when
320
+ the selection changes** — the action reruns on its own. Which is why the edit
321
+ screen returns `null` until the resource is there: asking for the list before
322
+ knowing what it must contain is asking for it twice, and the first answer is the
323
+ one that cannot show the name.
324
+
325
+ What `include` is, on the backend side, is the whole subject:
326
+
327
+ - **an addition, not a filter.** The page stays the page; the asked-for item is
328
+ in it as well if it was not already, and once if it was;
329
+ - **it takes what a url can hold** — a slug as much as an id;
330
+ - **an `include` that designates nothing is not an error.** The answer is simply
331
+ the page, and the screen falls back on its "not found" case — which then says
332
+ something true (that place is gone) instead of being an artefact of
333
+ pagination;
334
+ - it takes **several** values when several fields are pre-filled:
335
+ `GET /users?include=42,57`.
336
+
337
+ > If a url signal designates an item of a paginated list, the identifier it
338
+ > carries must travel with the list request, and the backend must guarantee its
339
+ > presence in the answer. A paginated list is not "the first N", it is "the first
340
+ > N **plus what the caller already holds**".
341
+
342
+ The other half of the same problem is answered by the resource rather than by
343
+ the list: **what comes back with a resource carries its own label**. The game's
344
+ own page shows "Lieu: Halle des sports" with no list at all, because the GET
345
+ answers with the name next to the id. Only the url is reduced to an identifier,
346
+ and that is exactly where `include` earns its place.
347
+
348
+ ## Where each screen goes next
349
+
350
+ The two screens navigate for opposite reasons, and that is why they say it in
351
+ two different places:
352
+
353
+ - **Create**: the page to land on is the one the server just made, and its id
354
+ comes back with the response. Nothing can be declared before the send, so the
355
+ action navigates:
356
+
357
+ ```jsx
358
+ <Form
359
+ action={async (values) => {
360
+ const game = await GAME.POST.bindParams(values).rerun();
361
+ GAME_ROUTE.navTo({ gameId: game.id });
362
+ }}
363
+ >
364
+ ```
365
+
366
+ - **Edit**: the destination is known before the send — and it has to be, because
367
+ a press with **nothing to send** must leave too. That is `command`, which runs
368
+ whether or not there was an action to run:
369
+
370
+ ```jsx
371
+ <Form
372
+ command={`--navi-nav-to:${GAME_ROUTE.buildUrl({ gameId: game.id })}`}
373
+ action={(values) => GAME.PUT.bindParams({ id: game.id, ...values }).rerun()}
374
+ >
375
+ ```
376
+
377
+ Do not hold that submit back with `readOnlyWhileFormUnchanged`: the press
378
+ still does something — it leaves. Holding it back is for a form that goes
379
+ nowhere, where the press would visibly do nothing at all.
380
+
381
+ ## After a write: what goes back to the network
382
+
383
+ Creating one game, measured on the demo:
384
+
385
+ ```
386
+ POST /games la création
387
+ GET /games la liste se relit
388
+ GET /games/2 la page de ce qui vient d'être créé
389
+ ```
390
+
391
+ Each of the two GETs is a decision, and neither is an accident:
392
+
393
+ - **The list re-reads itself** because whether a new item belongs to a list
394
+ depends on filters, pagination and sort — the backend knows, the client does
395
+ not (`rerunOn.GET_MANY: ["POST"]`, and the whole table of defaults is in
396
+ [list_refresh.md](./list_refresh.md)). A `PUT` does **not** re-read it: the
397
+ store carries the new values into every list already holding that item, which
398
+ is why the name changed on the game's page shows up on the list without a
399
+ request.
400
+ - **The detail GET is not saved by the store.** The action for that id had never
401
+ run, and the store holding the item is not the same thing as an action having
402
+ its data. It is also often not redundant: a detail representation is richer
403
+ than what a write answers — here the GET adds the place's name, the POST does
404
+ not, and a screen trusting the POST would show "Lieu: —". Skipping it could
405
+ only ever be a per-resource decision ("my POST answers the same shape as my
406
+ GET").
407
+
408
+ Coming back to that page later in the same session costs **nothing**: an action
409
+ that has completed is not run again for the same params. That, and not any
410
+ cache, is what makes a screen already visited open instantly — and what a
411
+ `rerun()` is for when something must genuinely be read again.
412
+
413
+ ## Movement between them
414
+
415
+ The screens are places, so the movement between them is `RouteTravel` — not
416
+ `SlideContainer`, which is for positions with no url (see
417
+ [navigation.md](./navigation.md#tabs-with-no-url)).
418
+
419
+ A row says two things at once, and they are said apart. Its `<Route>` children
420
+ are ordered by matching precision (above); `routes` is the order of the
421
+ **journey** — what "one step that way" means:
422
+
423
+ ```jsx
424
+ <RouteTravel routes={[NEW_GAME_ROUTE, GAME_ROUTE]}>
425
+ ```
426
+
427
+ Creating sits to the left of the game, so arriving on what was just created goes
428
+ right ("here is what I just made"). **A page left out of `routes` does not
429
+ travel at all**: the list is not a step along this row — one opens the create
430
+ screen from it, one does not slide there — so it is absent, and that move has no
431
+ animation. Leaving a page out is how a movement is refused; there is no "no
432
+ transition" to ask for.
433
+
434
+ A pair with a movement of its own gets a row of its own, on its own axis. The
435
+ game and its edit screen are the same thing seen two ways, so they travel
436
+ vertically inside the position the outer row holds for them:
437
+
438
+ ```jsx
439
+ const GameArea = () => (
440
+ <RouteTravel axis="y" routes={[EDIT_GAME_ROUTE, GAME_ROUTE]}>
441
+ <Route>
442
+ <Route route={EDIT_GAME_ROUTE} element={EditGamePage} />
443
+ <Route route={GAME_ROUTE} element={GamePage} />
444
+ </Route>
445
+ </RouteTravel>
446
+ );
447
+ ```
448
+
449
+ Editing sits above the game on that column, so it comes down over it and saving
450
+ sends it back up. The outer row does not move for it: both urls are the same position
451
+ there, which is what makes the two rows independent —
452
+
453
+ ```jsx
454
+ <Route route={EDIT_GAME_ROUTE} element={GameArea} />
455
+ <Route route={GAME_ROUTE} element={GameArea} />
456
+ ```
457
+
458
+ — the same element on both branches, so the inner row stays mounted across the
459
+ two and has something to travel between.
460
+
461
+ The gesture, the back button and a link pressed all move the same way.
462
+
463
+ ## See also
464
+
465
+ - [form_changed.md](./form_changed.md) — what a form sends, what follows a send
466
+ - [navigation.md](./navigation.md) — routes, links, travelling
467
+ - [resource.md](./resource.md) — the store behind GET/POST/PUT
468
+ - [control_value.md](./control_value.md) — binding fields to signals
@@ -65,6 +65,57 @@ Props are the primary way to customize appearance. They translate to inline `sty
65
65
  <Button style={{ "--button-height": "48px" }} />
66
66
  ```
67
67
 
68
+ #### Variants set defaults, never resolved values
69
+
70
+ A control resolves each styled property in two steps: the public variable
71
+ holds what was asked for (`--picker-background-color`), and an internal
72
+ `--x-` variable holds what is finally painted, per state:
73
+
74
+ ```css
75
+ .navi_picker {
76
+ --x-picker-background-color: var(--picker-background-color);
77
+
78
+ &[data-hover] {
79
+ --x-picker-background-color: var(--picker-background-color-hover);
80
+ }
81
+ }
82
+ ```
83
+
84
+ A variant (`icon`, `discrete`, `bare`, `border`, `headless`…) describes what
85
+ the caller did **not** say, so it writes the public variable — the default —
86
+ and never the `--x-` one:
87
+
88
+ ```css
89
+ &[data-variant="icon"] {
90
+ /* ✅ a default: a backgroundColor prop, being inline on this same element, wins */
91
+ --picker-background-color: transparent;
92
+ /* ❌ a verdict: the prop is read, translated, and then thrown away */
93
+ --x-picker-background-color: transparent;
94
+ }
95
+ ```
96
+
97
+ Writing `--x-` from a variant is the one failure mode that costs real time to
98
+ diagnose: the prop is accepted, it reaches its variable with the right value,
99
+ and nothing happens. A prop silently without effect is worse than a prop
100
+ refused.
101
+
102
+ Two things come with moving the default:
103
+
104
+ - the **per-state** variables are derived from the base one by formula
105
+ (`hover` = 5% black over the background, `disabled` = 5% grey), so a variant
106
+ that clears the background must re-point them at the base
107
+ (`--picker-background-color-hover: var(--picker-background-color)`), or a box
108
+ reappears on hover under a control that is supposed to have none. When the
109
+ variant does have a resting movement, express it as a mix **into** the
110
+ background (`color-mix(in srgb, currentColor 8%, var(--picker-background-color))`)
111
+ rather than a replacement, so it still composes with a color the caller gave.
112
+ - a variable fed by another prop keeps that chain in its fallback:
113
+ `--button-background-color: var(--button-background, transparent)` leaves both
114
+ `background` and `backgroundColor` working.
115
+
116
+ The same holds for sizing: a variant lowers `--picker-padding-x-default`, not
117
+ `--x-picker-padding-left`.
118
+
68
119
  ### 2. CSS variables (for global or theme-level changes)
69
120
 
70
121
  When the same change applies to many components (e.g. a design token update), set the variable at a higher scope:
@@ -200,3 +251,4 @@ Overriding the actual CSS rules (not the variables) is intentionally hard — th
200
251
  | A global design token | `--navi-*` on `:root` |
201
252
  | How wide popups may ever get | `--navi-app-max-width` on `:root` |
202
253
  | A structural layout rule | Expose a new CSS variable (contribute) |
254
+ | What a variant decided | A prop — a variant only ever moves defaults, so props keep winning |
@@ -176,4 +176,6 @@ It is then neither collected nor complained about.
176
176
  nothing, a bound `signal`, or you
177
177
  - [control_object.md](./control_object.md) — one value made of several
178
178
  controls: `ControlGroup`, `Form`, and a picker whose value is an object
179
+ - [create_and_edit.md](./create_and_edit.md) — the create/edit loop this is
180
+ half of: routes, the resource, and where each screen goes next
179
181
  - [actions.md](./actions.md) — what an action does around the send itself
@@ -248,6 +248,13 @@ read from the children in the order they are written. Pass `routes` only to say
248
248
  another order, or when the pages are not children of the box. An entry is a route,
249
249
  or `{ route, params }` when the tabs are params of one route.
250
250
 
251
+ The row is on the **first** of its pages that matches, the way a `<Route>` shows
252
+ its first matching branch — several routes match at once when one is a case of
253
+ another. A page left out of the row does not travel: reaching it is a change of
254
+ place, not a step along the row, and it plays no movement. `axis="y"` lays the
255
+ pages out as a column instead: forward is then the page rising and the next one
256
+ coming up from below.
257
+
251
258
  A swipe **replaces** the current history entry (a gesture browses; a tab pressed
252
259
  aims at a place and pushes, which its `<Link>` already does). `onTravel` decides
253
260
  otherwise.
@@ -260,6 +267,14 @@ Demo: [../src/nav/demos/route_travel/route_travel.html](../src/nav/demos/route_t
260
267
  and [../src/nav/demos/tabs/tabs.html](../src/nav/demos/tabs/tabs.html). The full
261
268
  spec of the gesture is [drag_to_travel.md](./drag_to_travel.md).
262
269
 
270
+ ## Creating something, then editing it
271
+
272
+ The create screen, the page of what was created, the edit screen — three routes,
273
+ one form, and a movement between them. It is assembled in
274
+ [create_and_edit.md](./create_and_edit.md), which is also where the two matching
275
+ rules that decide the shape of the `<Route>` tree are spelled out (several routes
276
+ match at once; the first matching branch wins).
277
+
263
278
  ## Tabs with no URL
264
279
 
265
280
  `SlideContainer` holds slides that replace one another in one box, with the same
package/docs/scroll.md CHANGED
@@ -46,9 +46,17 @@ Two consequences worth knowing before fighting them:
46
46
  - the body is `flex: 0 1 auto` — **it shrinks, it never grows**. A short body
47
47
  leaves the footer right under it rather than pushed to the bottom of a box it
48
48
  does not fill. Adding `expandY` to "fix" that is undoing a deliberate default.
49
- - the separating line is a `box-shadow`, not a `border`: it draws without taking
50
- part in layout, so nothing shifts by a pixel when it appears. Don't add a
51
- border of your own you get two lines.
49
+ - the separating line is a `border-bottom` on the header (`border-top` on the
50
+ footer). Don't add a border of your own you get two lines. It used to be a
51
+ `box-shadow`, which is drawn outside the box and so lost to whatever was
52
+ painted after it: the body covered the very line meant to separate them.
53
+ - header and footer sit in the sticky band
54
+ (`var(--navi-z-index-sticky)`), so everything the box contains passes under
55
+ them — positioned or not. Write `style={{ "--box-header-z-index": "auto" }}`
56
+ (`--box-footer-z-index` likewise) at the call site that needs the opposite: a
57
+ badge or a stamp overflowing a row is otherwise sliced by a header it never
58
+ scrolls under. `isolation: isolate` on the box keeps either value local to it.
59
+ See `docs/z_index.md` and `src/box/demos/9_scrollable_z_index_demo.html`.
52
60
 
53
61
  Padding belongs on the parts, not on the scrolling box: padding on a scroller
54
62
  sits inside the scrollbars, and a control flush against the edge of a scrolling
@@ -56,7 +64,8 @@ area raises a scrollbar of its own (a focus outline is drawn outside the control
56
64
  it belongs to).
57
65
 
58
66
  Reference: `src/box/box.jsx` (the `[data-scrollable]` CSS),
59
- `src/box/demos/8_scrollable_demo.html`.
67
+ `src/box/demos/8_scrollable_demo.html`,
68
+ `src/box/demos/9_scrollable_z_index_demo.html` (sticky parts and stacking).
60
69
 
61
70
  ## 1. The document scrolls
62
71
 
@@ -183,7 +192,7 @@ everyone else does, by asking for the overflow — and it already asks, on itsel
183
192
  So the parts are direct children of the `Dialog`:
184
193
 
185
194
  ```jsx
186
- <Dialog id="…" dockedOnTouch scrollCapture>
195
+ <Dialog id="…" dockedOnSmallTouchScreen scrollCapture>
187
196
  <Box header>title + close</Box>
188
197
  <Box body>
189
198
  <List scroller="parent" /> {/* NOT "self" */}
package/docs/z_index.md CHANGED
@@ -74,14 +74,14 @@ file is the overview, this table is its summary. Bands are a decade apart so
74
74
  one can grow without reaching the next, and so a value seen in devtools says
75
75
  which band it came from.
76
76
 
77
- | Band | Token | Value |
78
- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | ---------------------------- |
79
- | Top layer (`Dialog`/`Popover` with `layer="top"`) | — | above everything |
80
- | `Dialog`/`Popover` with `layer="local"`, their backdrop, callouts | `--navi-z-index-popup`, `--navi-z-index-callout` | 1000 `+ stack order` |
81
- | `FixedBar` | `--navi-z-index-bar` | 100 |
82
- | Sticky while something scrolls under: `List` header/footer/group labels, `SidePanel` head/foot | `--navi-z-index-sticky` | 10 |
83
- | A `Group` member under the pointer, then the one holding focus | `--navi-z-index-control-hovered`, `--navi-z-index-control-focused` | 1, 2 |
84
- | `Table` sticky cells, drag, resize | `src/control/table/z_indexes.js` | 1–7, derived from each other |
77
+ | Band | Token | Value |
78
+ | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | ---------------------------- |
79
+ | Top layer (`Dialog`/`Popover` with `layer="top"`) | — | above everything |
80
+ | `Dialog`/`Popover` with `layer="local"`, their backdrop, callouts | `--navi-z-index-popup`, `--navi-z-index-callout` | 1000 `+ stack order` |
81
+ | `FixedBar` | `--navi-z-index-bar` | 100 |
82
+ | Sticky while something scrolls under: `List` header/footer/group labels, `SidePanel` head/foot, `Box` header/footer | `--navi-z-index-sticky` | 10 |
83
+ | A `Group` member under the pointer, then the one holding focus | `--navi-z-index-control-hovered`, `--navi-z-index-control-focused` | 1, 2 |
84
+ | `Table` sticky cells, drag, resize | `src/control/table/z_indexes.js` | 1–7, derived from each other |
85
85
 
86
86
  What to read from it:
87
87
 
@@ -138,6 +138,19 @@ the label behind the rows, it puts it behind that background and out of sight.
138
138
  See the "Sticky parts" chapter of
139
139
  [12_list_demo.html](../src/control/demos/12_list_demo.html).
140
140
 
141
+ `Box`'s own `header`/`footer` take the opposite default, and for a reason worth
142
+ knowing: they are in the band **always**, not only while stuck. `List` can tell
143
+ — it measures its parts against its own scroller. A `Box` cannot: it is the
144
+ generic scrolling area, its content is whatever the app puts in it, and a
145
+ sticky part that drops to `auto` loses to anything that content positioned, a
146
+ `transform` or an `opacity` below 1 included. So the band is the default,
147
+ `isolation: isolate` on the scrolling box keeps it local, and
148
+ `--box-header-z-index` / `--box-footer-z-index` write it back to `auto` at the
149
+ one call site that knows nothing inside is positioned.
150
+ [9_scrollable_z_index_demo.html](../src/box/demos/9_scrollable_z_index_demo.html)
151
+ shows the band, what `auto` would look like, and what the band costs, side by
152
+ side.
153
+
141
154
  ### Why a `Group` member is not isolated
142
155
 
143
156
  `Group` overlaps its members by one border width, so the one the user is on has
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/navi",
3
- "version": "0.29.45",
3
+ "version": "0.29.47",
4
4
  "type": "module",
5
5
  "description": "Library of components including navigation to create frontend applications",
6
6
  "repository": {