@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.
- package/dist/jsenv_navi.js +1070 -783
- package/dist/jsenv_navi.js.map +49 -33
- package/dist/jsenv_navi_side_effects.js +111 -3
- package/dist/jsenv_navi_side_effects.js.map +3 -3
- package/docs/AI_INSTRUCTIONS.md +12 -2
- package/docs/actions.md +22 -0
- package/docs/control_value.md +6 -0
- package/docs/create_and_edit.md +468 -0
- package/docs/css_architecture.md +66 -0
- package/docs/form_changed.md +52 -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
package/docs/control_value.md
CHANGED
|
@@ -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
|
package/docs/css_architecture.md
CHANGED
|
@@ -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) |
|
package/docs/form_changed.md
CHANGED
|
@@ -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
|
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
|