@jsenv/navi 0.29.45 → 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.
- package/dist/jsenv_navi.js +268 -69
- package/dist/jsenv_navi.js.map +30 -19
- package/dist/jsenv_navi_side_effects.js +26 -3
- package/dist/jsenv_navi_side_effects.js.map +2 -2
- package/docs/AI_INSTRUCTIONS.md +9 -0
- package/docs/actions.md +22 -0
- package/docs/control_value.md +6 -0
- package/docs/create_and_edit.md +468 -0
- package/docs/form_changed.md +2 -0
- package/docs/navigation.md +15 -0
- package/docs/scroll.md +14 -5
- package/docs/z_index.md +21 -8
- package/package.json +1 -1
|
@@ -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
|
package/docs/form_changed.md
CHANGED
|
@@ -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
|
package/docs/navigation.md
CHANGED
|
@@ -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 `
|
|
50
|
-
|
|
51
|
-
|
|
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="…"
|
|
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
|
|
78
|
-
|
|
|
79
|
-
| Top layer (`Dialog`/`Popover` with `layer="top"`)
|
|
80
|
-
| `Dialog`/`Popover` with `layer="local"`, their backdrop, callouts
|
|
81
|
-
| `FixedBar`
|
|
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
|
|
84
|
-
| `Table` sticky cells, drag, resize
|
|
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
|