@jsenv/navi 0.29.44 → 0.29.46

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.
@@ -102,6 +102,12 @@ attribute:
102
102
  A group (a selectable list, a checkbox group) writes its whole selection into
103
103
  the signal, not one item's value — its children put it together between them.
104
104
 
105
+ A `<Form>` (or a `<ControlGroup>`) takes one the same way, holding the whole
106
+ object: its named children are filled from it, they move when something else
107
+ writes it, and what they change is written back into it. One signal for a screen
108
+ whose values arrive together — see
109
+ [create_and_edit.md](./create_and_edit.md#two-screens-two-states).
110
+
105
111
  ## Which controls take a `signal`
106
112
 
107
113
  All of them: `Input` (every type), `Picker`, `Select`, `Wheel`, `Spin`,
@@ -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
@@ -120,6 +120,71 @@ Note that scoping to an ancestor is not enough: `.my-sidebar { --link-color-pres
120
120
 
121
121
  When a component default deserves to be themed globally, promote it: declare a `--navi-<component>-<thing>` in [navi_css_vars.js](../src/navi_css_vars.js) and make the component default read `var(--navi-…)`.
122
122
 
123
+ #### An app narrower than the screen
124
+
125
+ An app that never spans the whole window — a phone-shaped column centered in a
126
+ wide one, bands on the sides — has one problem with popups: a dialog lives in
127
+ the browser's top layer, so it is calibrated on the _viewport_, and would paint
128
+ 1500px of modal over a 600px app. The top bar and the bottom nav have the same
129
+ problem and solve it by repeating the app width by hand; popups must not need
130
+ that, because the app would then have to know which components exist.
131
+
132
+ So the app states its own screen once, and never names a component:
133
+
134
+ ```css
135
+ :root {
136
+ --navi-app-max-width: 600px;
137
+ /* --navi-app-max-height too, for an app that also caps its height */
138
+ }
139
+ ```
140
+
141
+ In pixels: popup placement reads this value back from CSS to compute its own
142
+ margins, and a custom property computes to a token stream rather than to a
143
+ length, so `40rem` would arrive there as the string `"40rem"`. A non-px value
144
+ still caps the popup's size (that part is pure CSS) but leaves the margins
145
+ viewport-sized, and says so in the console.
146
+
147
+ Every popup follows: `Dialog`, `Popover`, and everything built on them
148
+ (`Picker`, `Select`…). It is a ceiling and nothing more — on a screen narrower
149
+ than the app it never binds, and each popup still subtracts its own
150
+ `marginWithContainer` from it, so the gap with the edges is kept either way.
151
+ That gap is itself a share of the app's screen, not of the window (`"3appw"`,
152
+ navi's own unit alongside `vvw`/`vvh`) — otherwise a 3% margin measured on a
153
+ 1500px window would eat 90px out of a 600px app.
154
+
155
+ Do **not** try to get this by setting `--dialog-max-width` on `.navi_dialog`
156
+ from the app. Two reasons:
157
+
158
+ - it is a `--component-*` token, declared on the element (see the table above),
159
+ so components that write it themselves outrank an app rule of lower
160
+ specificity — `.navi_picker[aria-haspopup="dialog"] .navi_dialog` does exactly
161
+ that, and the app's cap silently disappears for every picker;
162
+ - it is the knob a single popup uses to ask for a specific size, not a ceiling.
163
+ `--navi-app-max-width` feeds `--dialog-maxmax-width`, the hard ceiling _under_
164
+ that knob, so a popup that genuinely needs its own `maxWidth` can still say so
165
+ without any of them escaping the app's screen.
166
+
167
+ ##### Current limitations
168
+
169
+ `--navi-app-max-width` caps how big a popup may get; it does not move where one
170
+ is placed. Placement is still computed against the real viewport
171
+ (`pickPositionRelativeTo`, in `@jsenv/dom`). That is invisible for anything
172
+ centered on its cross axis — `center`, `bottom`, `top`, which is what a dialog
173
+ does nearly always — but shows for anything anchored to an edge: a
174
+ `positionArea` like `bottom-start`, a `SidePanel`, a fixed bar. Those sit
175
+ against the window's edge rather than the app column's, so they stay on the real
176
+ viewport for now (`side_panel.jsx` restates `--dialog-maxmax-width` as the full
177
+ viewport on purpose).
178
+
179
+ Making them follow the app column too means narrowing the container rect
180
+ placement is computed against, inside `pickPositionRelativeTo` — worth doing the
181
+ day a side panel or a fixed bar has to live inside a simulated screen.
182
+
183
+ Note that an app can already get all of it, placement included, by rendering
184
+ itself in an iframe of the target width: the viewport then genuinely _is_ the
185
+ app's screen and no token is needed at all. `--navi-app-max-width` is the answer
186
+ for an app that does not want to pay that price.
187
+
123
188
  ### 3. Direct rule override (avoid unless necessary)
124
189
 
125
190
  Overriding the actual CSS rules (not the variables) is intentionally hard — that is by design. If you find yourself needing to do this, it usually means a CSS variable should be exposed for that property. Open an issue or add the variable yourself and contribute it back.
@@ -133,4 +198,5 @@ Overriding the actual CSS rules (not the variables) is intentionally hard — th
133
198
  | One component instance | Component prop or `style` attribute |
134
199
  | All instances of a component | `--component-*` in unlayered app CSS, on a selector matching the component |
135
200
  | A global design token | `--navi-*` on `:root` |
201
+ | How wide popups may ever get | `--navi-app-max-width` on `:root` |
136
202
  | A structural layout rule | Expose a new CSS variable (contribute) |
@@ -7,6 +7,7 @@ as an answer the form already holds, and what to do on a screen whose fields are
7
7
  filled a request later.
8
8
 
9
9
  - [Sending nothing is the default](#sending-nothing-is-the-default)
10
+ - [What follows a send](#what-follows-a-send)
10
11
  - [What the form is measured against](#what-the-form-is-measured-against)
11
12
  - [What counts as already held](#what-counts-as-already-held)
12
13
  - [A screen filled after it opened: `pristineKey`](#a-screen-filled-after-it-opened-pristinekey)
@@ -31,6 +32,50 @@ duplicates are fine.
31
32
  <Form action={notify} canSendWhileUnchanged>
32
33
  ```
33
34
 
35
+ ## What follows a send
36
+
37
+ The form has answered its question; `command` says what the screen does about
38
+ it — dismiss the popup (`--navi-close`), move on the slide map
39
+ (`--navi-left`…), go to a page (`--navi-nav-to:/games/42`), stay put
40
+ (`--navi-void`). Left out, the surface the form sits in decides: a popup closes,
41
+ a slide goes on, a form on a page does nothing.
42
+
43
+ It runs **whether or not there was anything to send** — that is the other half
44
+ of the rule above: the person is done either way, and a submit that ran no
45
+ action still closes the popup, still moves on, still navigates. Which is why
46
+ this is a prop, decided before the send: the form has to know where it goes even
47
+ when nothing happened.
48
+
49
+ Nothing runs when the send fails, or when a constraint refuses it. The form then
50
+ stays in front of the person, showing what it is waiting for.
51
+
52
+ ### When only the response knows where to go
53
+
54
+ A creation lands on the page the server just made, and its id comes back with
55
+ the response — too late for a prop. Do it in the action, which is where the
56
+ answer is:
57
+
58
+ ```jsx
59
+ <Form
60
+ action={async (value) => {
61
+ const game = await createGame(value);
62
+ navTo(`/games/${game.id}`);
63
+ }}
64
+ >
65
+ ```
66
+
67
+ Nothing to declare: a creation always has something to send, so there is no
68
+ "the press did nothing" case for `command` to cover.
69
+
70
+ If you would rather it go through the command machinery all the same (to reuse
71
+ whatever a command does on that surface), the form carries what follows the send
72
+ as `data-after-send`, read once the send has succeeded — so an action can write
73
+ it while it runs:
74
+
75
+ ```js
76
+ formRef.current.setAttribute("data-after-send", `--navi-nav-to:/games/${id}`);
77
+ ```
78
+
34
79
  ## What the form is measured against
35
80
 
36
81
  One value, called the baseline here: **what the form held the last time it had
@@ -90,6 +135,11 @@ Its submit is live, and pressing it sends back the resource untouched.
90
135
  Change it **once**, when the screen is ready. Taken again after someone started
91
136
  typing, it would call what they wrote the reference.
92
137
 
138
+ No need to delay it by a tick: the reference is taken when the fields have
139
+ settled, and again at the end of that same tick — so a row that arrives in a
140
+ render of its own (a value computed from signals, a memoized row) is part of it
141
+ without the screen having to know which of its fields settle late.
142
+
93
143
  Do not use a `key` on the `<Form>` for this: it remounts every control and every
94
144
  popup inside it, and anything half-typed goes with them.
95
145
 
@@ -126,4 +176,6 @@ It is then neither collected nor complained about.
126
176
  nothing, a bound `signal`, or you
127
177
  - [control_object.md](./control_object.md) — one value made of several
128
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
129
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