@jsenv/navi 0.29.417 → 0.29.419

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.
Files changed (45) hide show
  1. package/README.md +1 -1
  2. package/dist/dev/jsenv_navi.js +347 -83
  3. package/dist/dev/jsenv_navi.js.map +12 -12
  4. package/dist/jsenv_navi.js +345 -81
  5. package/dist/jsenv_navi.js.map +12 -12
  6. package/docs/AI_INSTRUCTIONS.md +177 -258
  7. package/docs/actions.md +164 -251
  8. package/docs/autofocus.md +66 -83
  9. package/docs/control_object.md +117 -214
  10. package/docs/control_value.md +121 -185
  11. package/docs/create_and_edit.md +159 -254
  12. package/docs/css_architecture.md +196 -260
  13. package/docs/data_states.md +41 -44
  14. package/docs/dialog_shape.md +91 -134
  15. package/docs/drag_interactions.md +291 -454
  16. package/docs/drag_to_travel.md +152 -748
  17. package/docs/dynamic_import.md +119 -136
  18. package/docs/error_handling.md +75 -127
  19. package/docs/field_validation.md +49 -89
  20. package/docs/form_changed.md +61 -78
  21. package/docs/group.md +141 -0
  22. package/docs/i18n.md +31 -76
  23. package/docs/interactions.md +170 -326
  24. package/docs/list_action.md +36 -51
  25. package/docs/list_refresh.md +73 -115
  26. package/docs/mobile_touch.md +56 -62
  27. package/docs/navigation.md +377 -723
  28. package/docs/network_policy.md +61 -75
  29. package/docs/pan_zoom.md +62 -103
  30. package/docs/popup_backdrop.md +128 -168
  31. package/docs/popup_lift.md +179 -268
  32. package/docs/popup_open.md +380 -651
  33. package/docs/resource.md +228 -408
  34. package/docs/route_transitions.md +333 -473
  35. package/docs/safe_area.md +102 -89
  36. package/docs/scroll.md +175 -317
  37. package/docs/state_binding.md +40 -84
  38. package/docs/testid.md +18 -23
  39. package/docs/typography.md +111 -141
  40. package/docs/view_transitions.md +106 -104
  41. package/docs/z_index.md +61 -109
  42. package/package.json +2 -2
  43. package/docs/badge_list.md +0 -83
  44. package/docs/control_group.md +0 -168
  45. package/docs/mobile_layout_pitfalls.md +0 -145
@@ -26,63 +26,36 @@ Where the focus goes once it is open is its own subject — see
26
26
  A `Dialog`/`Popover` with no `open` prop keeps its own open state and listens
27
27
  for requests to change it. That is the default way to use one, and it buys
28
28
  something a `useState` in the parent cannot give back: **a popup refuses to
29
- close while a control inside it is mid-action**.
30
-
31
- ```js
32
- // what both do on every close request (popup_busy.js)
33
- const controlsHolding = findControlsHoldingPopup(popupEl);
34
- if (controlsHolding.length) {
35
- dispatchRequestInteraction(controlsHolding[0].element, { ... });
36
- requestCloseEvent.preventDefault();
37
- }
38
- ```
39
-
40
- A form that is sending holds an answer that is neither committed nor given up.
41
- Escape, the backdrop, a close button — all of them ask, and the busy control
42
- answers, the same way it would answer anyone else.
43
-
44
- That is the right answer for a run bounded by a request that comes back. A run
45
- deliberately left going is the other case — activating a service worker update,
46
- which lands only once the browser switches over and can take as long as the
47
- page's own in-flight work takes. Nothing is held there: the app stays usable
48
- while it settles, and a panel that waits for it is a panel that may never close
49
- again. The control says so, and no popup around it is told:
50
-
51
- ```jsx
52
- <Button action={() => activateUpdate()} actionStandalone>
53
- Activate
54
- </Button>
55
- ```
56
-
57
- It is still busy for itself — the spinner, the second press refused, the error
58
- callout — which is the whole point of keeping `action` (see
29
+ close while a control inside it is mid-action**. A form that is sending holds an
30
+ answer that is neither committed nor given up. Escape, the backdrop, a close
31
+ button — all of them ask, and the busy control answers, the same way it would
32
+ answer anyone else.
33
+
34
+ A run deliberately left going — activating a service worker update, which lands
35
+ only once the browser switches over — must not hold a panel that may then never
36
+ close. It says `actionStandalone`: still busy for itself, and no popup around it
37
+ is told (see
59
38
  [interactions.md](./interactions.md#the-fourth-question-whose-wait-is-it)).
60
39
 
61
- Between the two sits a run that IS being waited on and may never end. A request
62
- over a network that stopped answering settles neither way, and the hold then
63
- lasts exactly as long as the run: the popup closes for nothing and nobody, and
64
- the page has to be reloaded. `actionAbortable` gives that hold a release — the
65
- person waiting decides the answer is not coming, and closing is how they say
66
- it:
40
+ Between the two sits a run that IS being waited on and may never end: a request
41
+ over a network that stopped answering settles neither way, and the popup no
42
+ longer closes until the page is reloaded. `actionAbortable` gives that hold a
43
+ release — the person waiting decides the answer is not coming, and closing is
44
+ how they say it:
67
45
 
68
46
  ```jsx
69
47
  <Form action={saveScore} actionAbortable>
70
48
  ```
71
49
 
72
- Every close request then goes through and calls the run off on the way out:
73
- Escape, the backdrop, the cross, the phone's back gesture. No gesture is singled
74
- out, because the cancel/keep distinction below is about the VALUE a popup holds,
75
- and there is nothing left to keep or put back once the answer is already on the
76
- wire — each of those gestures means "I want out of here", which is the only
77
- thing being answered.
78
-
79
- What it costs is written down: aborting frees the client and nothing more, so
80
- the write may have landed anyway (see
81
- [actions.md](./actions.md#aborting-saves-resources-it-does-not-undo)). Say it
50
+ Every close request then goes through and calls the run off on the way out —
51
+ Escape, the backdrop, the cross, the phone's back gesture alike: the cancel/keep
52
+ distinction below is about the VALUE a popup holds, and once the answer is on
53
+ the wire each of them means "I want out of here". Aborting frees the client and
54
+ nothing more, so the write may have landed anyway (see
55
+ [actions.md](./actions.md#aborting-saves-resources-it-does-not-undo)): say it
82
56
  where the screen can be re-opened on what is actually there, never where the
83
- popup is the only place the outcome could be read. Everything inside the form
84
- inherits it along with the wait itself; a popup holding one run that may be
85
- given up on and one that may not still refuses, since calling off half of them
57
+ popup is the only place the outcome could be read. A popup holding one run that
58
+ may be given up on and one that may not still refuses: calling off half of them
86
59
  would cost an answer and change nothing on screen.
87
60
 
88
61
  So the question is never "should this popup be controlled?" but "what triggers
@@ -106,13 +79,12 @@ where that state lives:
106
79
  ```
107
80
 
108
81
  The available commands: `--navi-open`, `--navi-close`, `--navi-toggle`,
109
- `--navi-cancel` (closes, telling the popup the close means "revert"),
110
- `--navi-confirm` (says yes, then closes). What "revert" does to what is inside
111
- is [its own section](#escape-cancels-the-other-gestures-keep) — it is also what
112
- Escape says. `--navi-close:all` closes every popup above the button, nearest
113
- first; a popup that refuses to close keeps the ones above it open too. A link
114
- that leaves is the usual case — a badge shown over a sheet, both left in one
115
- press:
82
+ `--navi-cancel` (closes, telling the popup the close means "revert" — what Escape
83
+ says, see [below](#escape-cancels-the-other-gestures-keep)), `--navi-confirm`
84
+ (says yes, then closes). `--navi-close:all` closes every popup above the button,
85
+ nearest first; a popup that refuses to close keeps the ones above it open too. A
86
+ link that leaves is the usual case — a badge shown over a sheet, both left in
87
+ one press:
116
88
 
117
89
  ```jsx
118
90
  <Link href={PLAYER_ROUTE.buildUrl({ playerId })} command="--navi-close:all">
@@ -122,48 +94,42 @@ press:
122
94
 
123
95
  ## Something else opens it: `triggerNaviCommand`
124
96
 
125
- The attributes fire on every click of the element that carries them, and they
126
- cover more than "this button opens that dialog": `commandFor` says to whom,
127
- `value` says what it is about, `--navi-x:argument` says how. Before writing any
128
- JS here, check none of those is the answer — `triggerNaviCommand` is the last
129
- resort, not the general way to run a command.
130
-
131
- What it is for is a decision rather than a click — a long press, the end of a
132
- drag, a double-click, a keyboard shortcut, a server answer, an
133
- `IntersectionObserver`:
97
+ The attributes cover more than "this button opens that dialog": `commandFor`
98
+ says to whom, `value` says what it is about, `--navi-x:argument` says how. Check
99
+ none of those is the answer before writing JS: `triggerNaviCommand` is the last
100
+ resort, for a decision rather than a press on the element carrying the
101
+ attributes — a long press, the end of a drag, a double-click, a keyboard
102
+ shortcut, a server answer, an `IntersectionObserver`. A plain `Box` has no
103
+ command wiring of its own, so its click is one too:
134
104
 
135
105
  ```jsx
136
106
  import { triggerNaviCommand } from "@jsenv/navi";
137
107
 
138
108
  const dialogRef = useRef(null);
139
- const open = (event) => {
140
- if (draggedRef.current) {
141
- // the click that ends a throw is not a request to open
142
- return;
143
- }
144
- triggerNaviCommand(dialogRef.current, "--navi-open", event);
145
- };
146
109
 
147
- <Box role="button" onClick={open}>…</Box>
110
+ <Box
111
+ interactions={{
112
+ click: (event) =>
113
+ triggerNaviCommand(dialogRef.current, "--navi-open", event),
114
+ }}
115
+ >
116
+ …
117
+ </Box>
148
118
  <Dialog ref={dialogRef}>…</Dialog>
149
119
  ```
150
120
 
151
- This is the same entry point the attributes go through: same target resolution,
152
- same command proxies, same events. The popup stays uncontrolled, and keeps its
153
- say over closing.
121
+ No guard against a drag: beside a `move` on the same box, the click a drag
122
+ leaves behind is already suppressed. It is the entry point the attributes go
123
+ through — same target resolution, same events — so the popup stays uncontrolled,
124
+ and keeps its say over closing.
154
125
 
155
126
  ### The event is forwarded, not invented
156
127
 
157
128
  `event` is what caused the decision, and it is mandatory — triggering a command
158
- without one throws. It is chained into the request event, and that chain is what
159
- lets the popup handle focus correctly (which element to give focus back to,
160
- whether a mousedown's click must be swallowed) and what the debug panel groups
161
- the whole sequence under.
162
-
163
- So the event to pass is **the one that is already there**, threaded down through
164
- every function between the handler and the call. A `CustomEvent` built on the
165
- spot satisfies the signature and defeats its purpose: the origin is a name
166
- instead of a gesture, and everything read off the real event is gone.
129
+ without one throws. The popup reads its chain to give the focus back and to know
130
+ whether a mousedown's click must be swallowed. So the event to pass is **the one
131
+ that is already there**, threaded down through every function between the
132
+ handler and the call:
167
133
 
168
134
  ```js
169
135
  // ✗ the handler drops the event, the command is told a story instead
@@ -178,34 +144,19 @@ const openMenu = (event) => {
178
144
  };
179
145
  ```
180
146
 
181
- A helper several presses share takes the event as a parameter like any other;
182
- the same holds one layer deeper, when the decision is taken by something navi
183
- handed an event to — an `interactions` detector, an `onOpen`, an action's
184
- callback. Follow it back: there is a gesture at the start of nearly every
185
- sequence.
186
-
187
- The exception is the sequence that genuinely started on its own — a timer
188
- firing, an action settling, a signal changing. Then say what happened with a
189
- `CustomEvent` named after it, rather than leaving the origin unsaid:
190
-
191
- ```js
192
- import { chainEvent, triggerNaviCommand } from "@jsenv/navi";
193
-
194
- const expiredEvent = new CustomEvent("session_expired");
195
- chainEvent(expiredEvent, causeEvent); // when something did precede it
196
- triggerNaviCommand(dialogRef.current, "--navi-open", expiredEvent);
197
- ```
147
+ Only a sequence that genuinely started on its own — a timer firing, an action
148
+ settling, a signal changing — builds one: a `CustomEvent` named after what
149
+ happened, chained (`chainEvent`) to whatever preceded it, as the
150
+ `triggerNaviCommand` JSDoc shows.
198
151
 
199
152
  ### Opening while the finger is still down
200
153
 
201
- A menu opened by a `longpress` appears **during** the press that asked for it —
202
- that is what a long press is for, and it is the ordinary case here. The popup is
203
- told: it remembers which press it opened during, and the release of that press
204
- does not dismiss it, whatever the browser makes of it afterwards (a tap ends with
205
- a synthesized `mousedown`, `mouseup` and `click`, fired after `touchend` at the
206
- place the finger left — landing on a backdrop that did not exist when the finger
207
- came down). Nothing to do, and nothing to give up: the very next press outside
208
- closes the popup as usual.
154
+ A menu opened by a `longpress` appears **during** the press that asked for it,
155
+ and the release of that press does not dismiss it, whatever the browser makes of
156
+ it afterwards (a tap ends with a synthesized `mousedown`, `mouseup` and `click`
157
+ at the place the finger left — on a backdrop that did not exist when the finger
158
+ came down). Nothing to do: the very next press outside closes the popup as
159
+ usual.
209
160
 
210
161
  ```jsx
211
162
  <Row
@@ -217,55 +168,36 @@ closes the popup as usual.
217
168
  />
218
169
  ```
219
170
 
220
- It is one more reason to forward the real event: what the popup reads to tell its
221
- own opening press from somebody else's comes from that gesture.
222
-
223
171
  ## Which element receives the command
224
172
 
225
173
  The first argument is the command's **source** — the element it is triggered
226
174
  _from_. The target is resolved from it, in this order:
227
175
 
228
- 1. `commandFor="someId"` on the source (written to the DOM as the HTML
229
- `commandfor` attribute, which is what is read),
176
+ 1. `commandFor="someId"` on the source (the HTML `commandfor` attribute),
230
177
  2. `navi-command-target="parent-control" | "child-control"`,
231
178
  3. the command's own fallback — for the popup commands, `closest("[aria-expanded]")`.
232
179
 
233
- A popup carries `aria-expanded` from its very first render, so passing the popup
234
- element itself as the source resolves to that popup: `closest()` starts at the
235
- element itself. That is the short form used above, and it is enough whenever the
236
- JS that decides already holds the popup's ref.
237
-
238
- To trigger from another element instead, give that element a `commandFor`
239
- pointing at the popup's `id` — attribute-driven target resolution, JS-driven
240
- timing.
180
+ A popup carries `aria-expanded` from its first render, and `closest()` starts at
181
+ the element itself, so the popup element passed as the source resolves to that
182
+ popup — the short form above. To trigger from another element, give it a
183
+ `commandFor` pointing at the popup's `id`.
241
184
 
242
185
  ## The anchor
243
186
 
244
187
  A popup opens on the place the open names, on the `anchor` prop when the open
245
188
  names none, and on whoever asked when nothing else says. In that order:
246
189
 
247
- 1. **`detail.anchor`** — what `triggerNaviCommand`'s own `anchor` option puts
248
- there. It is a statement about that one opening: a menu belongs at the point
249
- the press happened, and the press is the only thing that knows that point.
190
+ 1. **`detail.anchor`** — `triggerNaviCommand`'s `anchor` option: a statement
191
+ about that one opening. A menu belongs at the point the press happened, and
192
+ the press is the only thing that knows that point.
250
193
  2. **the `anchor` prop** — where this popup opens when nobody says.
251
194
  3. **`detail.source`** — who asked. A button therefore opens the popup on
252
195
  itself, and a popup passed as its own source is its own anchor.
253
196
 
254
- For `Dialog` the anchor does nothing at all unless `sizeFromAnchor` asks for
255
- it (it then feeds the `--anchor-width`/`--anchor-height` CSS vars) or
256
- `animation="lifting"` does (the box the dialog comes out of, see
257
- [popup_lift.md](./popup_lift.md)); for `Popover`, which really is positioned
258
- relative to its anchor, say what the anchor is:
259
-
260
- ```jsx
261
- <Popover ref={popoverRef} anchor={rowRef}>
262
- ```
263
-
264
- `anchorCustomEventDetail="ignore"` drops the open's side of that order
265
- entirely, leaving the `anchor` prop alone — for a popover that must never be
266
- anchored to whatever opened it (`SidePanel` does this), and for a dialog that
267
- must never be sized from it (`sizeFromAnchor`) or lifted out of it
268
- (`animation="lifting"`).
197
+ A `Popover` is positioned against it; a `Dialog` only reads it for
198
+ `sizeFromAnchor` and `animation="lifting"` (see [popup_lift.md](./popup_lift.md)).
199
+ `anchorCustomEventDetail="ignore"` drops the open's side of that order — for a
200
+ popup that must never be anchored to whatever opened it (`SidePanel` does this).
269
201
 
270
202
  ## Opening it ON something
271
203
 
@@ -288,14 +220,11 @@ which one — so the press says it, with its own value:
288
220
  ```
289
221
 
290
222
  That subject travels as the command's **value**, not as an argument after a
291
- colon (`--navi-open:radar-42`). The two places say different things and the
292
- distinction holds across every command: an argument says WHAT the command does —
293
- `--navi-go-to-slide:edit` needs one, "go" without a destination is not an
294
- instruction — and `value` says what it is about. "Open" is already a complete
295
- instruction; the radar is what it is about. A button therefore says it the way it
296
- says it everywhere else, with `value`, and nothing has to be parsed.
297
-
298
- A JS decision says the same thing through the same door:
223
+ colon (`--navi-open:radar-42`). The distinction holds across every command: an
224
+ argument says WHAT the command does — `--navi-go-to-slide:edit` needs one, "go"
225
+ without a destination is not an instruction — and `value` says what it is about.
226
+ "Open" is already a complete instruction. A JS decision says it through the same
227
+ door, and `--navi-toggle` carries it too, on the half that opens:
299
228
 
300
229
  ```js
301
230
  triggerNaviCommand(dialogRef.current, "--navi-open", event, {
@@ -303,11 +232,9 @@ triggerNaviCommand(dialogRef.current, "--navi-open", event, {
303
232
  });
304
233
  ```
305
234
 
306
- `--navi-toggle` carries it too, on the half that opens.
307
-
308
235
  ### `onOpen` runs before the popup has built anything
309
236
 
310
- The order is the whole point, and it is a guarantee, not a coincidence:
237
+ On a `Dialog`/`Popover` the order is a guarantee, not a coincidence:
311
238
 
312
239
  ```
313
240
  onOpen(openEvent) ← the subject is decided here
@@ -316,79 +243,29 @@ positioned, shown
316
243
  ```
317
244
 
318
245
  So a dialog whose content is seeded once — an uncontrolled field on a
319
- `defaultValue`, a form keyed on what it edits — reads the right thing on its very
320
- first render. Learning it afterwards would mean mounting on the previous subject
321
- and correcting it, which is a flicker at best and stale fields at worst.
322
-
323
- The two other places one could listen are not that moment, and it is worth
324
- knowing why:
325
-
326
- | where | when it runs | chained to the caller |
327
- | ----------------------------- | ------------------------------ | --------------------- |
328
- | `onOpen` | before the content is built | yes — this prop |
329
- | `onnavi_command` on the popup | after the command has been run | yes |
330
- | `navi_request_open` listener | before the popup's own handler | on the element only |
331
-
332
- `onnavi_command` receives the whole command string and its value, but it runs
333
- **after** the opening: whatever it writes lands on a popup that is already open.
334
- That works only as long as nothing has read the state yet — which
335
- `mount="while-opened"` makes a real race rather than a theoretical one.
336
-
337
- A `navi_request_open` listener added on the element is the request itself, ahead
338
- of the popup acting on it — but it is ordered against the popup's own handler by
339
- registration, and it has to be attached in an effect on a ref. `onOpen` is that
340
- moment, said as a prop.
246
+ `defaultValue`, a form keyed on what it edits — reads the right thing on its
247
+ very first render, instead of mounting on the previous subject and being
248
+ corrected (a flicker at best, stale fields at worst). Only
249
+ `mount="while-opened"` makes it true every time: content kept from an earlier
250
+ opening, or warmed by a hover, is already built when `onOpen` runs (see
251
+ [what the popup holds while it is closed](#what-the-popup-holds-while-it-is-closed)).
252
+ A `Picker`'s `onOpen` is not this moment: it runs once its popup is built and
253
+ shown.
254
+
255
+ The other places one could listen are not that moment: `onnavi_command` on the
256
+ popup runs **after** the opening, so what it writes lands on a popup already
257
+ open — a real race under `mount="while-opened"`; a `navi_request_open` listener
258
+ is ordered against the popup's own handler by registration.
341
259
 
342
260
  ## A press that opens a popup and acts on it
343
261
 
344
262
  A press that opens something and then does something with what came of it — a
345
263
  "save this guest" prompt on a row, which replaces the guest once the profile
346
- exists — is not a dialog plus a way home. It is a `Picker`: a trigger, a popup,
347
- and an `action` that runs on what the popup settled.
348
-
349
- ```jsx
350
- // one per row: the trigger IS the thing that receives the answer
351
- <Picker
352
- variant="icon"
353
- rightSlotIcon={<DisketteSvg />}
354
- action={async (created) => {
355
- await USERS.GET_MANY.rerun();
356
- replaceGuest(guest, created);
357
- }}
358
- >
359
- <GuestSavePrompt kind="player" name={guest.name} />
360
- </Picker>
361
- ```
362
-
363
- Two things fall out of writing it this way, and both are the reason to prefer it
364
- over a shared dialog opened by `--navi-open`:
365
-
366
- - **what the popup needs to know travels as props**, because the popup is
367
- written where the press is. No value to carry through the command, nothing to
368
- read back out of an event;
369
- - **the popup is built the first time it opens**, not once per row on the render
370
- that draws the list (see [what a popup holds while it is
371
- closed](#what-the-popup-holds-while-it-is-closed)). A hundred rows is a
372
- hundred triggers, not a hundred dialogs.
373
-
374
- The same component can of course be written once and used in every picker —
375
- `<GuestSavePrompt>` above is one — so "the prompt exists once" is a question
376
- about components, not about the DOM.
377
-
378
- ### Composing a value, or doing work
379
-
380
- A picker mirrors **one** control in its popup — the first one that is not a
381
- button, a link or a control that answers for itself (`standalone`). That
382
- mirror is what makes `<Picker><List selectable/></Picker>` work with nothing
383
- wired: the picker's value IS the list's, both ways, and the picker's `action`
384
- runs on it when the popup closes.
385
-
386
- That is the shape for a popup that **composes a value**. A popup that **does
387
- work** — creates a profile, uploads a file — is the other shape, and it does not
388
- need the picker's `action` at all: the work is written where the press is, so
389
- its callback already has everything around it.
264
+ exists — is not a dialog plus a way home. It is a `Picker`: a trigger and a
265
+ popup, written where the press is.
390
266
 
391
267
  ```jsx
268
+ // one per row: the popup is written where the press is
392
269
  <Picker variant="icon" rightSlotIcon={<DisketteSvg />}>
393
270
  <Form
394
271
  action={async (fields) => {
@@ -401,22 +278,50 @@ its callback already has everything around it.
401
278
  </Picker>
402
279
  ```
403
280
 
404
- Nothing travels back, because nothing left. This is the difference a shared
405
- dialog hides: a popup written once, far from every press that opens it, has to
406
- be told what it is about and has to answer somebody — and neither question
407
- exists once the popup is written where it is used.
281
+ What the popup needs to know travels as props, and what it does has the row in
282
+ scope: no value carried through the command, nothing read back out of an event,
283
+ nobody to answer. That is the difference a shared dialog hides — written once,
284
+ far from every press that opens it, it has to be told what it is about and has
285
+ to answer somebody. The content can still be one component used in every picker:
286
+ "the prompt exists once" is a question about components, not about the DOM.
287
+
288
+ What each row costs depends on what the picker is told. A picker told no value —
289
+ no `value`, `defaultValue` or `signal` — builds its popup at render, closed: it
290
+ reads its value off the control in there, which has to exist before anything
291
+ opens. A hundred rows is then a hundred closed popups. One heavy enough to
292
+ matter can say `mount="from-first-open"`, and the picker then knows nothing of
293
+ what that control holds until the first opening: its trigger cannot draw it, and
294
+ nothing reading the picker sees it. A picker told a value, a `type="confirm"`
295
+ and a `mode="callout"` build on the first open already.
296
+
297
+ ### Composing a value, or doing work
298
+
299
+ A picker mirrors **one** control in its popup — the first one that is not a
300
+ button, a link or a control that answers for itself (`standalone`). That mirror
301
+ is what makes `<Picker><List selectable/></Picker>` work with nothing wired: the
302
+ picker's value IS the list's, both ways, and the picker's `action` runs on it
303
+ when the popup closes. That is the shape for a popup that **composes a value**.
304
+ A popup that **does work** — creates a profile, uploads a file — is the one
305
+ above: the work is written where the press is, its callback already has
306
+ everything around it, and the picker needs no `action`. Nothing travels back,
307
+ because nothing left.
308
+
309
+ **Do not mix the two.** A `<Form>` at the root of a picker's popup IS the
310
+ mirrored control, so its value is the picker's value: handing the picker
311
+ something else (a created profile, say) pushes it back down into the form's
312
+ named fields and comes back as the form's aggregate. When the popup does work,
313
+ let the work keep its result.
408
314
 
409
315
  ### The trigger wears the wait
410
316
 
411
- The picker's `action` runs on the trigger, not in the popup: it is dispatched
412
- on the close that keeps, and the popup goes while it runs. So what is still on
317
+ The picker's `action` runs on the trigger, not in the popup: it is dispatched on
318
+ the close that keeps, and the popup goes while it runs. So what is still on
413
319
  screen answers for the write — `aria-busy` and the loading outline on the
414
- trigger while the request is out, the error callout on it if the server
415
- refuses, and the value rolled back to the last accepted one — on by default
416
- for a picker, and what one wants: nobody is left mid-edit behind a closed
417
- popup, and the card goes on saying what the server knows. A
418
- card that IS the trigger (`variant="bare"`, the card as `ui`) therefore waits
419
- as a card and is refused as a card, wherever in the tree the sheet was written.
320
+ trigger while the request is out, the error callout on it if the server refuses,
321
+ and the value rolled back to the last accepted one (`resetOnError`, on by
322
+ default for a picker). Nobody is left mid-edit behind a closed popup, and a card
323
+ that IS the trigger (`variant="bare"`, the card as `ui`) waits as a card and is
324
+ refused as a card, wherever in the tree the sheet was written.
420
325
 
421
326
  ```jsx
422
327
  <Picker
@@ -435,50 +340,32 @@ as a card and is refused as a card, wherever in the tree the sheet was written.
435
340
  </Picker>
436
341
  ```
437
342
 
438
- The card shows the answer the moment the sheet leaves when its `ui` is drawn
439
- from what the picker holds rather than from what the caller knows — the same
440
- state navi's own default `ui` reads, rolled back by `resetOnError` (and
441
- `resetOnAbort`), so nothing
442
- about the pending value is the caller's to keep or to put back. Two ways to
443
- read it: `ui={MatchCard}` hands the component `value`, `loading` and
444
- `interactive` as props; `ui={<MatchCard match={match} />}` keeps the caller's
445
- own props and reads the same three with `usePickerState()` inside. The
446
- `loading` is the wait: navi's loading outline is a two-pixel run around the
447
- box, which a card-sized trigger moves clear of its frame with
448
- `loadingOutlineInset`, or draws its own waiting state for, with
449
- `loadingOutline="custom"` so the two do not add up.
343
+ A `ui` reading what the picker holds — `ui={MatchCard}` is handed `value`,
344
+ `loading` and `interactive`, an element reads them with `usePickerState()` —
345
+ shows the answer the moment the sheet leaves and takes it back on a refusal (see
346
+ [control_value.md](./control_value.md#drawing-what-a-control-holds)). Drawn from
347
+ the caller's own state, as [a settings sheet](./control_object.md#a-settings-sheet)
348
+ is, the trigger keeps showing the saved answer until that state moves: which to
349
+ draw from is whether the trigger should show the pending answer or the saved
350
+ one.
450
351
 
451
352
  What the picker measures as "changed since open" is what its mirrored group
452
- holds. A piece of the answer living outside the controls — a seating
453
- rearranged by drag, kept in component state — has to be held by a control in
454
- the group too (a named control bound to that state), or a close over it reads
455
- as nothing changed and nothing runs.
456
-
457
- Two props finish the construct. `openOn="longpress"` (or
458
- `["longpress", "contextmenu"]`) makes the hold what opens it, so a tap on the
459
- card stays a tap — what a card in a list needs: the drawing gets its pointer
460
- back, a link in it navigates, a button in it presses, a picker in it opens on
461
- its own click, with nothing to declare. Two pickers on one card is the score
462
- sheet inside the edit card (`12_picker_card_demo.html`): the inner one names
463
- the card as its `anchor` so it lifts the whole card, and says `standalone` so
464
- its value stays its own. One card, one wait: each picker wears the other's
465
- run through `loading` (from its `onActionStart`/`onActionEnd`), so while
466
- either write is out the hold and the click are both refused, with the busy
467
- reason where the finger is — the click only once the score picker says
468
- `openWhileReadOnly={false}`: a busy picker otherwise still opens, to be read,
469
- and a sheet of fields to fill in is not that. One wait, one outline as well:
470
- the inner picker says `loadingOutline="custom"`, and the card's is drawn by
471
- the picker whose box the card is. A hold declared inside the card answers before the
472
- card's own: the nearer hold takes the press. `animation="lifting"` with
473
- `dialogSizeFromAnchor` (as wide as the card, floor and ceiling),
474
- `popupBackgroundColor="transparent"` and `popupBoxShadow="none"` lifts the
475
- card out of its place and puts it back (`data-lift` on the card inside the
476
- sheet) — what to respect for that movement to be right and quick is
477
- `popup_lift.md`. `12_picker_card_demo.html` shows all of it, against a
478
- backend that answers when told to.
479
-
480
- A gesture inside the sheet that is not a field — cancel the game, delete it —
481
- is still an answer the sheet gives. A named button says which:
353
+ holds. A piece of the answer living outside the controls — a seating rearranged
354
+ by drag, kept in component state — has to be held by a control in the group too
355
+ (a named control bound to that state), or a close over it reads as nothing
356
+ changed and nothing runs.
357
+
358
+ A card in a list opens on `openOn="longpress"`, so a tap on it stays a tap. Two
359
+ pickers on one card — the score sheet inside the edit card — are one wait: the
360
+ inner one names the card as its `anchor` and says `standalone`, each wears the
361
+ other's run through `loading` (from its `onActionStart`/`onActionEnd`), the
362
+ score picker says `openWhileReadOnly={false}` (a busy picker otherwise still
363
+ opens, to be read), and the inner one's `loadingOutline="custom"` leaves one
364
+ outline. `12_picker_card_demo.html` shows all of it, lifting included
365
+ ([popup_lift.md](./popup_lift.md)).
366
+
367
+ A gesture inside the sheet that is not a field — cancel the game, delete it — is
368
+ still an answer the sheet gives. A named button says which:
482
369
 
483
370
  ```jsx
484
371
  <Button name="op" value="cancel" command="--navi-send">
@@ -486,18 +373,15 @@ is still an answer the sheet gives. A named button says which:
486
373
  </Button>
487
374
  ```
488
375
 
489
- Its name and value travel with the fields (the group takes the sender's
490
- value, see `wantRequesterButtonState`), the sheet leaves on the send, and the
491
- picker's `action` receives `{ …fields, op: "cancel" }` — so this write is
492
- worn by the trigger exactly like the others, callout included, and a drawing
493
- reading `usePickerState()` can already show the "cancelled" stamp while the
494
- request is out. No button inside the popup needs an action of its own: a run
495
- started in there would belong to a control the close takes away.
376
+ Its name and value travel with the fields, the sheet leaves on the send, and the
377
+ picker's `action` receives `{ …fields, op: "cancel" }` — worn by the trigger
378
+ like any other write, and a drawing reading `usePickerState()` can already show
379
+ the "cancelled" stamp. No button inside the popup needs an action of its own: a
380
+ run started in there would belong to a control the close takes away.
496
381
 
497
- This is not `optimistic`. `optimistic` on a control is "draw no wait at all";
498
- here the wait is drawn, on the trigger, and the popup was never what held it.
499
- An optimistic picker is for a write not worth showing — the card then reads
500
- the store and says nothing until the answer lands.
382
+ This is not `optimistic`, which is "draw no wait at all": here the wait is drawn,
383
+ on the trigger. An optimistic picker is for a write not worth showing — the card
384
+ then reads the store and says nothing until the answer lands.
501
385
 
502
386
  The same shape with nothing to write is a **door**: a drawing that grows to be
503
387
  looked at — a weather scene, a plan — and comes back. `picksNothing`, no
@@ -519,73 +403,21 @@ looked at — a weather scene, a plan — and comes back. `picksNothing`, no
519
403
  ```
520
404
 
521
405
  A `Button` opening a `Dialog` with an `anchor` does the same and is not wrong;
522
- what the picker removes is the id plumbing (the trigger is the anchor, the
523
- popup is its own), and what it keeps is the day the drawing becomes editable —
524
- it is already the thing that wears the wait. The button + shared dialog stays
525
- the answer for [the two cases where a popup must be
526
- shared](#when-a-shared-popup-is-still-the-right-answer).
527
-
528
- ### A trigger that is only an icon
529
-
530
- `variant="icon"` draws no value, and therefore no slot beside one either: the
531
- whole trigger is its `ui`.
532
-
533
- ```jsx
534
- <Picker variant="icon" ui={<DisketteSvg />} />
535
- ```
536
-
537
- Left out, that `ui` is the icon the slot would have shown — the chevron, or the
538
- one the picker's type carries (a pencil for `type="text"`, a calendar for
539
- `type="date"`), so `<Picker type="date" variant="icon" />` is a calendar and
540
- nothing else. `rightSlotIcon`/`rightSlot` belong to the shapes that DO draw a
541
- value and want something beside it; under `variant="icon"` the first is only the
542
- default for `ui`, and the second has nowhere to go — the clear cross included.
406
+ what the picker removes is the id plumbing (the trigger is the anchor, the popup
407
+ is its own), and what it keeps is the day the drawing becomes editable — it is
408
+ already the thing that wears the wait. The button + shared dialog stays the
409
+ answer for
410
+ [the cases where a popup must be shared](#when-a-shared-popup-is-still-the-right-answer).
543
411
 
544
412
  ### A trigger that draws nothing, pressed from elsewhere
545
413
 
546
- Some things cannot be a picker's façade. An object placed on a map — absolutely
547
- positioned, dragged by its own `move`, drawn by pieces that sit outside its own
548
- box — owns its press: catching it under an invisible field is exactly what must
549
- not happen. The press stays the object's, and it opens the picker itself:
550
-
551
- ```jsx
552
- <Box
553
- id={court.id}
554
- interactions={{
555
- move: (event) => placeCourt(court, event.detail),
556
- click: (event) =>
557
- triggerNaviCommand(pickerRef.current, "--navi-open", event, {
558
- anchor: event.currentTarget,
559
- value: court.settings,
560
- }),
561
- }}
562
- />
563
-
564
- // out of the map's flow: what draws nothing still takes a box (below)
565
- <div style={{ position: "relative" }}>
566
- <Picker
567
- ref={pickerRef}
568
- variant="headless"
569
- value={court.settings}
570
- uiAction={(settings) => showCourt(court, settings)}
571
- action={(settings) => saveCourt(court, settings)}
572
- >
573
- <CourtSettings />
574
- </Picker>
575
- </div>
576
- ```
577
-
578
- Everything a picker promises still holds — the value at open, "changed since
579
- open", the revert on Escape, the action on a close that keeps — and the popup
580
- lands on the object because the open named it (see [the
581
- anchor](#the-anchor)).
582
-
583
- One thing to know about `variant="headless"`: it is not the `ui`'s box but its
584
- parent's. It stretches to the nearest positioned element around it, so that a
585
- picker put INSIDE what opens it — a button, a row — hangs its popup off that
586
- whole element. A picker opened from something it does not sit in must therefore
587
- be given an element of its own to stretch into; dropped straight into the map
588
- frame it would cover it, and every press on the empty plan would open it.
414
+ An object placed on a map, dragged by its own `move`, owns its press and cannot
415
+ be a picker's façade: it opens the picker itself from its `click`
416
+ (`triggerNaviCommand(pickerRef.current, "--navi-open", event, { anchor, value })`).
417
+ That picker is `variant="headless"`, which stretches to the nearest positioned
418
+ element around it: opened from something it does not sit in, it needs an element
419
+ of its own to stretch into — dropped straight into the map frame it would cover
420
+ it, and every press on the empty plan would open it.
589
421
 
590
422
  ### When a shared popup is still the right answer
591
423
 
@@ -603,31 +435,20 @@ Three cases, and only three:
603
435
 
604
436
  None of them is "one popup per row of a list", which is what a picker is for.
605
437
 
606
- Do not mix the two. A `<Form>` at the root of a picker's popup IS the mirrored
607
- control, so its value is the picker's value: handing the picker something else
608
- (a created profile, say) pushes it back down into the form's named fields and
609
- comes back as the form's aggregate. When the popup does work, let the work keep
610
- its result.
611
-
612
438
  ## Reacting to open and close
613
439
 
614
- `onOpen` is called on every open, before the popup builds anything (see
615
- [above](#opening-it-on-something)); `onClose` is called on every real close, and
616
- carries `detail.isCancel` when the close meant "revert".
617
-
618
- ```jsx
619
- <Dialog onOpen={(e) => {}} onClose={(e) => {}}>
620
- ```
621
-
622
- Neither can veto: `onClose` is the close happening, not a request to close.
623
- Refusing a close is `onRequestClose`, which belongs to whoever owns an
624
- `openController` (see `open_controller.js`) — an uncontrolled popup already
625
- refuses the one close that matters, the one over a control mid-action.
440
+ `onOpen` is called on every open (for when, see
441
+ [above](#onopen-runs-before-the-popup-has-built-anything)), and `onClose` on
442
+ every real close, with `detail.isCancel` when the close meant "revert". Neither
443
+ can veto: `onClose` is the close happening, not a request to close. The refusals
444
+ are navi's: a close over a control mid-action, and a `Picker`'s close over a
445
+ value that does not validate.
626
446
 
627
447
  ### Closing when a button also runs an action
628
448
 
629
449
  A button that carries both runs them in that order: the action first, the
630
- command once it succeeded.
450
+ command once it succeeded (see
451
+ [actions.md](./actions.md#a-press-that-opens-something-and-waits-for-the-answer)).
631
452
 
632
453
  ```jsx
633
454
  // Stays open while save() runs, closes when it resolves, stays open if it
@@ -637,67 +458,48 @@ command once it succeeded.
637
458
  </Button>
638
459
  ```
639
460
 
640
- The command is what the press means AFTER the work, so it waits for the work:
641
- closing first would take the form off the screen over a request that can still
642
- fail, and the error callout it raises would land on a button nobody can see any
643
- more. An action that ends in an error or an abort — a `confirm` answered "no" is
644
- an abort — leaves the popup where it is. Same rule a form already follows for
645
- what comes after its send (`data-after-send`, see `resolveAfterSend` in
646
- commands.js).
647
-
648
- The action is free to replace that button while it runs — and it usually does:
649
- what came back is put in the store, the tree re-renders, and the branch the
650
- button stood in goes with it. The popup closes all the same. What the command
651
- aims at is read at the press, while the button is still in the document (see
652
- `resolveNaviCommand` in commands.js); only the running of it waits. The popup
653
- pressed in is the popup that closes, even when the press is what emptied it.
654
-
655
- The wait is why closing **from inside** the action still does not close: while
656
- the action runs, the button that started it is busy, and a busy control is
657
- exactly what a popup refuses to close over (see the top of this page). The
658
- refusal is not silent — the busy control raises a callout saying so — but the
659
- popup stays open, and the first Escape afterwards dismisses that callout rather
660
- than the popup, which reads as a popup that no longer closes at all. Nothing has
661
- to be hand-written to work around it: `command` next to `action` is that
662
- workaround, done at the one moment where the action has settled and the button
663
- is no longer busy.
664
-
665
- When what opened the popup is still on screen and should answer for the write
666
- — its wait, its refusal — the popup was a picker's all along: see [the trigger
667
- wears the wait](#the-trigger-wears-the-wait). What follows is for the other
668
- case, a popup nobody stands in for. To close on the press — the answer taken as
669
- soon as it is given, the save running on its own behind a closed popup — say so
670
- on the control:
461
+ Closing first would take the form off the screen over a request that can still
462
+ fail, and put its error callout on a button nobody can see; an error or an abort
463
+ — a `confirm` answered "no" is an abort — leaves the popup where it is. The
464
+ action may replace that button while it runs: what the command aims at is read
465
+ at the press, so the popup pressed in is the popup that closes, even when the
466
+ press is what emptied it.
467
+
468
+ The wait is why closing **from inside** the action does not close: while it
469
+ runs, the button that started it is busy, and a busy control is exactly what a
470
+ popup refuses to close over. The busy control raises a callout, the popup stays
471
+ open, and the first Escape afterwards dismisses that callout rather than the
472
+ popup — which reads as a popup that no longer closes at all. `command` next to
473
+ `action` is the way: it runs at the one moment the action has settled.
474
+
475
+ When what opened the popup is still on screen and should answer for the write,
476
+ the popup was a picker's all along (see
477
+ [the trigger wears the wait](#the-trigger-wears-the-wait)). For a popup nobody
478
+ stands in for, closing on the press — the save running on its own behind a
479
+ closed popup — is said on the control:
671
480
 
672
481
  ```jsx
673
482
  <Form command="--navi-close" action={saveScore} optimistic resetOnError>
674
483
  ```
675
484
 
676
- `optimistic` is the control taking its action as done on its own word, and
677
- what it holds goes with it: the popup closes at once, the slide moves on, and
678
- the run continues detached — still watched, still able to fail. Know what it
679
- costs: **a save that fails does so behind a closed popup**. `resetOnError` puts
680
- the control back, and the error callout, with no sheet left to point at, is
681
- drawn on what surrounds the closed popup — the card it opened from, typically,
682
- which keeps the refusal next to what it is about. What the page shows meanwhile — a card reading
683
- the store — is the app's to draw and to take back. For a write not worth being
684
- waited for; never for one whose refusal changes what the person does next.
685
-
686
- Nobody wanting to close, but everybody wanting to be ABLE to, is a different
687
- need and has its own answer: `actionStandalone` (see the top of this page) frees
688
- the popup without closing it, and the error callout stays where the press was.
485
+ Know what `optimistic` costs: **a save that fails does so behind a closed
486
+ popup**. `resetOnError` puts the control back, and the error callout is drawn on
487
+ what surrounds the closed popup — the card it opened from, typically; what the
488
+ page shows meanwhile is the app's to draw and to take back. For a write not
489
+ worth being waited for; never for one whose refusal changes what the person
490
+ does next.
689
491
 
690
492
  ## Escape cancels, the other gestures keep
691
493
 
692
494
  The gestures that close a popup do not all mean the same thing, and that is on
693
495
  purpose:
694
496
 
695
- | gesture | what it means | who decides |
696
- | ------------------------------ | ------------- | ------------------------------------------------------------------------------------------------------ |
697
- | Escape | cancel | always, for a `Dialog`/`Popover`; `escapeEffect="cancel"` (the default) for a `Picker` |
698
- | a press outside | close, keep | `pressOutside="close"` — the default of a `Dialog` and of a `Picker`; a bare `Popover` says `"ignore"` |
699
- | a close cross (`--navi-close`) | close, keep | [the cross](#the-close-cross) |
700
- | `--navi-cancel` on a button | cancel | the button |
497
+ | gesture | means | who decides |
498
+ | ------------------------------ | ----------- | -------------------------------------------------------------------------------- |
499
+ | Escape | cancel | always on a `Dialog`/`Popover`; a `Picker`'s `escapeEffect="cancel"` (default) |
500
+ | a press outside | close, keep | `pressOutside="close"`, default of `Dialog` and `Picker` (`Popover`: `"ignore"`) |
501
+ | a close cross (`--navi-close`) | close, keep | [the cross](#the-close-cross) |
502
+ | `--navi-cancel` on a button | cancel | the button |
701
503
 
702
504
  Escape says "forget it". It is the one gesture that has meant that everywhere,
703
505
  for as long as there have been dialogs, and navi keeps it that way. **A popup
@@ -710,12 +512,13 @@ something else.
710
512
  Cancelling is not itself an undo: it marks the close, and whoever holds a value
711
513
  decides what to do with the mark.
712
514
 
713
- - `Dialog` and `Popover` hold nothing, so they undo nothing. The close event
714
- carries `detail.isCancel` and `onClose` receives it — reverting is then the
715
- caller's own business.
515
+ - `Dialog` and `Popover` hold nothing, so they undo nothing: `onClose` receives
516
+ `detail.isCancel`, and reverting is the caller's own business.
716
517
  - `Picker` holds a value, so it puts back **the value it held when it opened**.
717
518
  That is what makes a picker a picker: opening one is trying something on, and
718
- Escape is putting it back.
519
+ Escape is putting it back. What the outside moved while it was open stays
520
+ moved (see
521
+ [control_value.md](./control_value.md#a-defaultvalue-follows-what-it-was-read-from)).
719
522
 
720
523
  ```jsx
721
524
  // Escape here puts back the level the picker held at open, and the list's
@@ -725,22 +528,14 @@ decides what to do with the mark.
725
528
  </Picker>
726
529
  ```
727
530
 
728
- The trap is the FIRST open. A picker holds what its popup told it, and before
729
- the popup has ever been open it has been told nothing — so "the value at open"
730
- is nothing, even when the control inside starts on a `defaultValue` or on a
731
- value the app passes it. Escape on that first pass goes back to empty, not to
732
- what was on screen when the popup opened. From the second open onwards it puts
733
- back what was really there.
734
-
735
531
  ### A picker that holds nothing and shows nothing
736
532
 
737
533
  The gestures that KEEP (a click outside, a close cross) let a picker send what
738
534
  it is showing: a picker sitting on a `defaultValue` holds nothing, so closing on
739
- it untouched IS the answer ("yes, 1h30"), and the action runs.
740
-
741
- A picker used as a **menu of gestures** — no `value`, no `defaultValue`, no
742
- signal, each row a command — shows nothing, so there is nothing to confirm.
743
- Closing it without choosing runs no action; only an explicit choice sends.
535
+ it untouched IS the answer ("yes, 1h30"), and the action runs. A picker used as
536
+ a **menu of gestures** — no `value`, no `defaultValue`, no signal, each row a
537
+ command — shows nothing, so there is nothing to confirm: closing it without
538
+ choosing runs no action, and only an explicit choice sends.
744
539
 
745
540
  ```jsx
746
541
  // Clicking outside closes this and sends nothing.
@@ -753,28 +548,30 @@ Closing it without choosing runs no action; only an explicit choice sends.
753
548
  </Picker>
754
549
  ```
755
550
 
756
- Note this is about a picker holding NOTHING. Passing `value={undefined}` is not
757
- that: a `value` prop is held whatever is in it, and navi puts it back after each
758
- click — the rows then appear to do nothing. Drop the prop instead of passing it
759
- empty.
551
+ `value={undefined}` is not holding nothing: a `value` prop is held whatever is in
552
+ it, and navi puts it back after each click — the rows then appear to do nothing.
553
+ Drop the prop instead of passing it empty.
760
554
 
761
555
  ### `escapeEffect="close"`, and why it is a last resort
762
556
 
763
557
  `escapeEffect="close"` — a `Picker` prop; a `Dialog`/`Popover` has no such
764
- switch — makes Escape say what a click outside says. It exists,
765
- and it is almost never what you want: it takes away the only key that undoes,
766
- and a popup with no way back is one people stop opening. Reach for a close
767
- cross first.
558
+ switch — makes Escape say what a click outside says. It takes away the only key
559
+ that undoes, and a popup with no way back is one people stop opening: reach for
560
+ a close cross first.
768
561
 
769
- A dialog picker's cancel also goes back in history, so anything written to the
770
- url while it was open (a route `stateSignal`, a search param) goes back with it
771
- — one more reason Escape and the click outside are not interchangeable.
562
+ A cancel can undo the address too: under a pushed history entry — a dialog
563
+ picker with an `id`, a `navState={{ type: "push" }}` — it goes back in history,
564
+ taking with it anything written to the url while the popup was open (a route
565
+ `stateSignal`, a search param). One more reason Escape and the click outside are
566
+ not interchangeable.
772
567
 
773
568
  ## The close cross
774
569
 
775
570
  The cross is navi's own: `<Dialog.Close />`, `<Popover.Close />`,
776
- `<Popup.Close />` — one component under three names, reached from whichever
777
- popup is being written. The caller places it, and nothing else:
571
+ `<Popup.Close />` — one component under three names. The caller places it, and
572
+ nothing else; it brings the `aria-label` in the active language (`label`
573
+ overrides it) and the padding that turns a three-millimetre glyph into a target
574
+ a thumb can hit:
778
575
 
779
576
  ```jsx
780
577
  <Dialog id="duration">
@@ -786,27 +583,18 @@ popup is being written. The caller places it, and nothing else:
786
583
  </Dialog>
787
584
  ```
788
585
 
789
- It comes with the things a hand-written cross has to remember: the `aria-label`
790
- in the active language (`label` overrides it), and the padding that turns a
791
- three-millimetre glyph into a target a thumb can hit.
792
-
793
586
  ### It is the way out, so the state around it does not reach it
794
587
 
795
588
  **A close button written by hand refuses the press inside a read-only control.**
796
- A read-only `Picker` still opens — what it holds is often a shape only its popup
797
- draws, and reading it changes nothing — and it hands its popup that same
798
- read-only so every control in there refuses on its own terms. The cross is not
799
- one of those controls: closing writes nothing to the picker. `<Dialog.Close />`
800
- says so (`whenSelfInteractionsBlocked="ignore"`, see
801
- [interactions.md](./interactions.md)); a `<Button command="--navi-close" />`
802
- does not, wears the read-only it was handed, and answers a press aimed at the
803
- way out with "this action is not available right now".
804
-
805
- The same holds for a disabled zone, and for a form busy sending: the way out of
806
- a popup is never held by the state of what the popup belongs to.
807
-
808
- If a cross really has to be hand-written — it is drawn into a bigger affordance
809
- of the app's own, say — it carries the claim itself:
589
+ A read-only `Picker` still opens, to be read, and hands its popup that same
590
+ read-only. Closing writes nothing to the picker, and `<Dialog.Close />` says so
591
+ (`whenSelfInteractionsBlocked="ignore"`, see
592
+ [interactions.md](./interactions.md#where-the-zone-blocks-does-it-write-to-the-control-it-sits-in));
593
+ a `<Button command="--navi-close" />` does not, wears the read-only it was
594
+ handed, and answers a press aimed at the way out with "this action is not
595
+ available right now". The same holds for a disabled zone and for a form busy
596
+ sending: the way out of a popup is never held by the state of what the popup
597
+ belongs to. A cross that really has to be hand-written carries the claim itself:
810
598
 
811
599
  ```jsx
812
600
  <Button command="--navi-close" icon whenSelfInteractionsBlocked="ignore">
@@ -817,30 +605,20 @@ of the app's own, say — it carries the claim itself:
817
605
  ### It asks, it does not force
818
606
 
819
607
  Being exempt from the state around it is not being exempt from the popup's
820
- answer. The cross sends `--navi-close`, which reaches the popup as a close
821
- REQUEST — the same one Escape and the click outside make — and the popup
822
- decides:
823
-
824
- - a control inside it is mid-action → the close is refused, and that control
825
- says why ([above](#the-popup-owns-its-open-state));
826
- - `onRequestClose` calls `preventDefault()` → the popup stays open;
827
- - a `Picker` takes the close as an answer, so it validates what its popup holds
828
- and runs its action on the way out — and an invalid value keeps it open.
829
-
830
- The cross therefore lands exactly where a click outside lands: same request,
831
- same refusals, same "close, keep". A popup that must not be dismissed at a given
832
- moment says so in its own `onRequestClose`, where Escape and the backdrop are
833
- refused too — not by hiding or disabling the cross, which only removes the one
834
- way out that is visible.
608
+ answer. The cross sends `--navi-close`, the same close REQUEST as a click
609
+ outside — same refusals, same "close, keep": a control inside mid-action refuses
610
+ it and says why, and a `Picker` validates what its popup holds and runs its
611
+ action on the way out, an invalid value keeping it open. Hiding or disabling the
612
+ cross to hold a popup open only removes the one way out that is visible.
835
613
 
836
614
  ## When the app holds the open state
837
615
 
838
616
  A popup whose being-open is a fact about the application rather than about the
839
617
  user's last gesture — a sheet an address can be reloaded into, an error the app
840
618
  decides to show — needs somewhere to keep that fact. Three props say it, and
841
- what separates them is where the state lives, not how hard the popup is driven:
842
- all three go through the same `requestClose`, so a busy control inside still
843
- refuses to be left mid-action. What changes is whether anyone hears the refusal.
619
+ what separates them is where the state lives: all three go through the same
620
+ close request, so a busy control inside still refuses to be left mid-action.
621
+ What changes is whether anyone hears the refusal.
844
622
 
845
623
  ### `signal` — the app holds it, both ways
846
624
 
@@ -849,49 +627,33 @@ refuses to be left mid-action. What changes is whether anyone hears the refusal.
849
627
  ```
850
628
 
851
629
  The popup opens and closes to match the signal, and writes into it whenever it
852
- opens or closes on its own: Escape, the backdrop, a `--navi-close` command — and
853
- the close a busy control denied, after which the signal says "open", because
854
- that is what is true. One binding both drives the popup and knows where it is,
855
- which is what every navi control does with its value (see
856
- [state_binding.md](./state_binding.md)); `Dialog`, `Popover` and what is built
857
- on them (`SidePanel`) all take it. A signal already `true` at mount means the
858
- popup was already open when the page appeared: no entrance plays.
630
+ opens or closes on its own — Escape, the backdrop, a `--navi-close` — and after
631
+ a close a busy control denied, when the signal says "open" again, because that
632
+ is what is true (see [state_binding.md](./state_binding.md)). A signal already
633
+ `true` at mount means the popup was already open when the page appeared: no
634
+ entrance plays.
859
635
 
860
- `value` makes the signal say WHICH popup is open, for several sharing it —
861
- one sheet per card in a feed, with a single `?seat=<gameId>` for all of them:
636
+ `value` makes the signal say WHICH popup is open, for several sharing it — one
637
+ sheet per card in a feed, with a single `?seat=<gameId>` for all of them:
862
638
 
863
639
  ```jsx
864
640
  <Dialog signal={seatSheetSignal} value={game.id} />
865
641
  ```
866
642
 
867
- The popup is open while the signal holds its value and closed otherwise;
868
- opening writes the value, closing writes `undefined`, which a state signal reads
869
- as its default and takes out of the url. Everything above still holds — the
870
- same writes, the same history rules — only what "open" is worth in the signal
871
- changes. A popup is open on exactly one value: what varies while it is open (a
872
- tab inside it) is a param of its own, see
643
+ Opening writes the value, closing writes `undefined`, which a state signal reads
644
+ as its default and takes out of the url. A popup is open on exactly one value:
645
+ what varies while it is open (a tab inside it) is a param of its own, see
873
646
  [navigation.md](./navigation.md#places-inside-the-layer).
874
647
 
875
648
  The other way round — ONE sheet showing whichever card the address names — is
876
- the same signal with no `value`: a popup without one reads anything the signal
877
- holds as open, except `false`, `null` and `undefined`. The panel's content reads
878
- the id from the signal, a card's press writes it, and the popup never writes
879
- over it: opening writes `true` only into a signal that reads closed, closing
880
- writes `undefined` (or `false`, where the signal held `true`).
881
-
882
- When the popup is opened ON a card (`<Button command="--navi-open" value={id}>`),
883
- `onOpen` is where the id is written: it runs before the popup writes its own
884
- open, and a signal that already reads open is left as it is — the address holds
885
- the card, never `true`. With the route's search-param `stateSignal` as the walk
886
- signal, that is how [a row of
887
- cards](./popup_lift.md#a-row-of-cards-one-popup-that-walks) survives leaving
888
- the page: the address names the card, and the sheet reopens on it.
889
-
890
- ```jsx
891
- <SidePanel signal={errorOpenSignal} side="right">
892
- <ErrorPanel /> {/* reads errorOpenSignal.value to know which card */}
893
- </SidePanel>
894
- ```
649
+ the same signal with no `value`: anything but `false`, `null` and `undefined`
650
+ reads as open, opening writes `true` only into a signal that reads closed, and
651
+ closing writes `undefined` (`false` where it held `true`). The content reads the
652
+ id from the signal and a card's press writes it — in `onOpen` when the popup is
653
+ opened ON the card, which runs before the popup writes its own open. With the
654
+ route's search-param `stateSignal` as the signal, that is how
655
+ [a row of cards](./popup_lift.md#a-row-of-cards-one-popup-that-walks) survives
656
+ leaving the page: the address names the card, and the sheet reopens on it.
895
657
 
896
658
  ### `navState` — the history entry holds it
897
659
 
@@ -900,35 +662,32 @@ the page: the address names the card, and the sheet reopens on it.
900
662
  ```
901
663
 
902
664
  The open state goes into the history entry, so a screen left and come back to
903
- finds the popup as it was, and so does a reload. `true` stores it under the
904
- popup's own `id`; a string names the key instead. `{ type: "push" }` makes the
905
- opening an entry of its own — the back button then closes the popup rather than
906
- leaving the screen, and the cancel takes back with it whatever was written to
907
- the url while it was open. A `Picker` given an `id` needs none of this: its
908
- popup's open state is nav state by construction, under that id, and in dialog
909
- mode the opening is an entry of its own. Without an `id` the key is a generated
910
- one, which names one mount: the page left with the picker open and come back to
911
- is a new mount with a new id, and the state stays in the entry with nothing to
912
- read it — navi warns when a mount finds such a key. A picker whose popup leads
913
- somewhere, a link inside it, has an `id`.
665
+ finds the popup as it was, and so does a reload. `{ type: "push" }` makes the
666
+ opening an entry of its own: the back button closes the popup rather than
667
+ leaving the screen, and a cancel goes back with the entry
668
+ ([above](#escapeeffectclose-and-why-it-is-a-last-resort)). A `Picker` needs none
669
+ of this, only an `id`: its open state is nav state under that id, pushed in
670
+ dialog mode. Without an `id` the key names one mount — the page left with the
671
+ picker open and come back to is a new mount, and the state stays in the entry
672
+ with nothing to read it (navi warns). A picker whose popup leads somewhere, a
673
+ link inside it, has an `id`.
914
674
 
915
675
  The two meet when the signal IS a route's: a search-param `stateSignal` given to
916
676
  `signal` puts the open state in the address itself, where a link can point at
917
- it. That is also the shape a popup takes when it is a LAYER over the screen —
918
- settings opened from every screen and closed back onto the one the reader was on
919
- — and what the address may and may not claim there is a decision of its own:
920
- [navigation.md](./navigation.md#a-layer-over-the-screen-what-its-address-may-say).
677
+ it — the shape of a popup that is a LAYER over the screen, whose address is
678
+ [navigation.md](./navigation.md#a-layer-over-the-screen-what-its-address-may-say)'s
679
+ decision.
921
680
 
922
681
  ### `open`, and what it costs
923
682
 
924
683
  `open` is the one-way half of `signal` — the parent re-renders with a boolean
925
- and the popup follows — and the refusal is what it costs. `open={false}` goes
926
- through the same `requestClose`, and when a busy control denies it, the parent's
927
- state says closed while the popup stayed open. The two disagree from then on:
928
- the effect only reacts to `open` _changing_, so setting it back to `true`
929
- matches the popup's real state and does nothing, and the popup can no longer be
930
- closed by the prop at all until it closes on its own. A `signal` has no such
931
- gap, since the popup writes back what really happened.
684
+ and the popup follows — and the refusal is what it costs. `open={false}` asks
685
+ for the close as a cancel, and when a busy control denies it, the parent's state
686
+ says closed while the popup stayed open. The two disagree from then on: only a
687
+ _change_ of `open` is read, so setting it back to `true` matches the popup's
688
+ real state and does nothing, and nothing closes it until the prop goes to
689
+ `false` again or the popup closes on its own. A `signal` has no such gap, since
690
+ the popup writes back what really happened.
932
691
 
933
692
  `defaultOpen` is the middle ground: mount-only, and the popup owns everything
934
693
  afterwards. `defaultOpen="interaction"` means the mount _is_ the opening (the
@@ -939,9 +698,10 @@ the page appeared (no entrance).
939
698
 
940
699
  A board with a detail pane wants one panel that stays open as long as something
941
700
  asks to be shown in it, and whose content changes from one card to the next
942
- without the panel closing and reopening. That is a slot: `createSlot(Renderer)`
943
- keeps the renderer mounted whether or not anything fills it, and the panel is
944
- the renderer.
701
+ without closing and reopening. That is a slot: `createSlot(Renderer)` keeps the
702
+ renderer mounted whether or not anything fills it, and the panel is the
703
+ renderer. `<PanelSlot />` sits once at the board level, and so does the one
704
+ `<PanelSlotFill>`, rendered by the board with the named card's content:
945
705
 
946
706
  ```jsx
947
707
  const Panel = ({ children }) => (
@@ -950,13 +710,7 @@ const Panel = ({ children }) => (
950
710
  </SidePanel>
951
711
  );
952
712
  const [PanelSlot, PanelSlotFill] = createSlot(Panel);
953
- ```
954
713
 
955
- `<PanelSlot />` sits once at the board level, and so does the one
956
- `<PanelSlotFill>`, rendered by the board with the named card's content as its
957
- children:
958
-
959
- ```jsx
960
714
  const openCardId = openCardIdSignal.value;
961
715
  return (
962
716
  <Box>
@@ -973,35 +727,20 @@ return (
973
727
  );
974
728
  ```
975
729
 
976
- Two rules hold this shape together, and each guards against a trap the other
977
- shape falls into.
978
-
979
- **One `SlotFill`, rendered where the choice is made.** The tempting shape is a
980
- `SlotFill` inside each card, rendered by the card that is open. It works until
981
- two cards cross: each card reads the signal on its own, so switching from A to
982
- B is two separate renders, A's fill unmounting in one and B's mounting in the
983
- other. Between the two the slot is empty, and its renderer is rendered on that
984
- empty frame — whichever of A or B the tree walks first, the gap exists in one
985
- of the two directions. The slot cannot tell that gap from a real emptying, and
986
- does not try to: a fix there would have to guess the scheduler. Rendered once,
987
- by the component that holds the choice, the fill never leaves; only its
988
- children change, and there is no gap to fall into.
989
-
990
- **The panel is bound to the screen's state, not to `isFilled`.** `isFilled` is
991
- a fact about the render — whether a fill is mounted right now — and the signal
992
- is a fact about the screen — which card the address names. `open={isFilled}`
993
- turns any empty frame into a close, and its `onClose` then clears the very
994
- state the next fill was waiting for. Bound to the signal, the panel is open
995
- exactly while a card is named, its own ways out (the close cross, Escape, an
996
- outside press) clear the signal themselves, and the fill only has to say what
997
- the panel shows. `isFilled` stays the right `open` for a renderer that has no
998
- such state — a toolbar or a status shown while anything fills it — fed by the
999
- one fill above.
730
+ Two rules hold this shape together. **One `SlotFill`, rendered where the choice
731
+ is made** — not one in each card, rendered by the card that is open: two cards
732
+ crossing are two renders, and the slot is empty between them. **The panel is
733
+ bound to the screen's state, not to `isFilled`**: `open={isFilled}` turns that
734
+ empty frame into a close, whose `onClose` then clears the state the next fill was
735
+ waiting for. Bound to the signal, the panel is open exactly while a card is
736
+ named, and its own ways out clear the signal themselves. `isFilled` stays the
737
+ right `open` for a renderer with no such state — a toolbar shown while anything
738
+ fills it.
1000
739
 
1001
740
  A press on another card is not an outside press once the card names the panel
1002
- with `data-navi-popup-inside` (see [popup_backdrop.md](./popup_backdrop.md)).
1003
- The full example is `src/layout/demos/7_slot_demo.html`, "Two side-panel
1004
- slots".
741
+ with `data-navi-popup-inside` (see
742
+ [popup_backdrop.md](./popup_backdrop.md#a-box-of-the-page-that-is-not-outside)).
743
+ The full example is `src/layout/demos/7_slot_demo.html`, "Two side-panel slots".
1005
744
 
1006
745
  ## A popup that loads data
1007
746
 
@@ -1016,6 +755,9 @@ const groupSheetOpenSignal = stateSignal(false, {
1016
755
  id: "group_sheet",
1017
756
  type: "boolean",
1018
757
  });
758
+ export const GAME_ROUTE = route("/games/:gameId", {
759
+ searchParams: { group_sheet: groupSheetOpenSignal },
760
+ });
1019
761
 
1020
762
  // asked while the sheet is open
1021
763
  export const GROUP_MEMBERS = routeAction(GAME_ROUTE, USER.GET_MANY, () => {
@@ -1042,10 +784,9 @@ const GroupMembers = () => {
1042
784
  ```
1043
785
 
1044
786
  The same shape when the popup is one of many — a sheet on every card, opened
1045
- from wherever the card is read. Its open state then says which one, and the
1046
- route action reads the id straight from it, while each card's content reads the
1047
- question of its own card. Declared on the root route, since a card is not a
1048
- page:
787
+ from wherever the card is read: its open state says which one, the route action
788
+ reads the id straight from it, and each card's content reads the question of
789
+ its own card. Declared on the root route, since a card is not a page:
1049
790
 
1050
791
  ```js
1051
792
  const seatSheetSignal = stateSignal(undefined, {
@@ -1076,17 +817,15 @@ const SeatableUsers = ({ gameId }) => {
1076
817
  ```
1077
818
 
1078
819
  **The content reads its question, never the route action.** The route action's
1079
- params are `false` while the popup is closed, and for the route action that is
1080
- right: it says _when_ to ask. Read by the content, the same `false` says there
1081
- is nothing to show — `useAsyncData` hands back `undefined` the moment the popup
1082
- closes, the list empties while hidden, and the next opening builds it again from
1083
- the top: the scroll position is lost, and every row is rendered once more for an
1084
- answer that never left the store. When the popup is one of many, it is worse: the
1085
- route action follows whichever popup is open, so a card's sheet kept mounted
1086
- since an earlier opening redraws with another card's rows each time that one
1087
- opens. Closing a popup does not change what it is about, so what it reads leaves
1088
- the open state out. Equal params share one instance — the content reads the very
1089
- one the route action runs, so nothing is asked twice and nothing is copied.
820
+ `false` says _when_ to ask. Read by the content, the same `false` says there is
821
+ nothing to show: the list empties the moment the popup closes, and the next
822
+ opening builds it again from the top — the scroll position lost, every row
823
+ rendered again for an answer that never left the store. When the popup is one
824
+ of many it is worse: the route action follows whichever popup is open, so a
825
+ card's sheet kept mounted since an earlier opening redraws with another card's
826
+ rows each time that one opens. Closing a popup does not change what it is about.
827
+ Equal params share one instance, so the content reads the very one the route
828
+ action runs, and nothing is asked twice.
1090
829
 
1091
830
  **A popup that waits holds its own `<Loading>`**, like every other part of a
1092
831
  screen that can wait — it is not built into `Dialog`/`Popover`, because only the
@@ -1100,52 +839,45 @@ code inside knows whether anything in there loads at all:
1100
839
  </Dialog>
1101
840
  ```
1102
841
 
1103
- Without it the nearest boundary is one that holds the popup itself, and what
1104
- happens then is worth knowing because nothing says it: the request goes out and
1105
- **the popup does not open at all** — no fallback drawn, no error raised. A
1106
- boundary waiting on an update keeps the page as it was, as a copy; the popup
1107
- being opened is part of that copy, so `showModal()` reaches a node the app no
1108
- longer owns, and once the data is there the subtree is rebuilt from scratch —
1109
- closed, and empty. The press is simply lost, and the next one works. In dev the popup says so
1110
- rather than leaving it to be guessed. The alternative is the component drawing its
1111
- own wait (`useAsyncData(action, { loading: true })`), which needs no boundary of
1112
- its own; what is not an option is neither of the two.
1113
-
1114
- Both, and what each one looks like, are on
1115
- `src/layout/demos/13_popup_loading_demo.html`.
1116
-
1117
- What the popup gains is everything a page has:
1118
-
1119
- - the request leaves **with the screen**, in parallel with the rest of what the
1120
- address needs, instead of behind the gesture that opens the popup;
1121
- - `useAsyncData` reads it — the wait delegated to `<Loading>`, the failure to
1122
- `<ErrorBoundary>`, `onLoad` for what is seeded once (see
1123
- [actions.md](./actions.md#reading-an-action));
1124
- - the action layer's rerun rules, its dependencies
1125
- ([resource.md](./resource.md#dependencies-rerun-after-another-resource-writes))
1126
- and the
1127
- aborted-not-reset treatment a route action gets when the screen is left
1128
- ([network_policy.md](./network_policy.md));
1129
- - a reload keeps the open state, so it keeps the request too;
1130
- - and the routes keep saying what an address needs, which is why they declare
1131
- it.
1132
-
1133
- The fallback — the popup owning its request, `useAsyncData(action, { run: true })`
1134
- — is for the parameter chosen inside the popup and dying with it: a filter the
1135
- sheet itself holds and nothing else remembers. It cannot do better than the
1136
- gesture that opened the popup, since it starts with the component that draws it;
1137
- a route action left with the screen. Hand-written (`bindParams` in the component,
1138
- `run()` from a `useEffect`, `data === undefined` as the loading flag) it looks
1139
- harmless, and that is the problem: nothing at the call site says which of the two
1140
- it is, so the day the parameter comes from the address, screen data has quietly
1141
- become a popup's private request — asked for late, and asked for alone.
842
+ Without it the nearest boundary is one that holds the popup itself, and the
843
+ opening is lost: the popup does not open at all, and dev says why. The other
844
+ option is the component drawing its own wait
845
+ (`useAsyncData(action, { loading: true })`); what is not an option is neither.
846
+ Both are on `src/layout/demos/13_popup_loading_demo.html`.
847
+
848
+ What the popup gains is everything a page has: the request leaves **with the
849
+ screen**, in parallel with the rest of what the address needs, instead of behind
850
+ the gesture that opens the popup; `useAsyncData` reads it (see
851
+ [actions.md](./actions.md#reading-an-action)); the rerun rules, dependencies
852
+ ([resource.md](./resource.md#dependencies-rerun-after-another-resource-writes))
853
+ and aborts of a screen left ([network_policy.md](./network_policy.md)) apply; a
854
+ reload keeps the open state, so it keeps the request too.
855
+
856
+ The popup owning its request — `useAsyncData(action, { run: true })` — is the
857
+ fallback, for a parameter chosen inside the popup and dying with it: a filter
858
+ the sheet itself holds and nothing else remembers. Hand-written, with `run()`
859
+ from a `useEffect`, nothing at the call site says which of the two it is, and
860
+ screen data quietly becomes a popup's private request — asked for late, and
861
+ alone.
1142
862
 
1143
863
  ## What the popup holds while it is closed
1144
864
 
1145
- A closed popup builds nothing: `children` are mounted on the first open, and
1146
- stay mounted afterwards — a reopened popup finds its scroll position and its
1147
- half-typed form where it left them, as long as the data it draws does not leave
1148
- with the close (see [A popup that loads data](#a-popup-that-loads-data)).
865
+ A closed `Dialog`/`Popover` builds nothing: `children` are mounted on the first
866
+ open, and stay mounted afterwards — a reopened popup finds its scroll position
867
+ and its half-typed form where it left them, as long as the data it draws does
868
+ not leave with the close. A `Picker` told no value is the exception: it builds
869
+ its popup at render (see
870
+ [above](#a-press-that-opens-a-popup-and-acts-on-it)).
871
+
872
+ A `Dialog` nobody has asked for yet is not even built. It stands in as its bare
873
+ `<dialog>` — the id a command finds, the element a press announces itself on —
874
+ and the dialog proper is built at the first request: an open, a press on what
875
+ opens it, its `signal` or `open` saying open. From then on it stays built. So a
876
+ list whose rows each hold a few dialogs (a sheet per seat, a share sheet per
877
+ card) pays one element per closed dialog at every mount, not a dialog: a row
878
+ may carry the dialogs of its own actions. It is built at once when something
879
+ needs it from the start — open at mount, a `navState`, an `anchor` (a hover
880
+ builds the content ahead of the click), or a `mount` other than the default.
1149
881
 
1150
882
  "Closed" is two states, not one — never opened yet, and closed again after an
1151
883
  opening — so the `mount` prop answers both at once:
@@ -1158,28 +890,25 @@ opening — so the `mount` prop answers both at once:
1158
890
  | `"while-opened"` | not mounted | not mounted |
1159
891
 
1160
892
  `"always"` is for content something depends on before any opening: a value read
1161
- off it, fields a surrounding form submits, a size measured from outside.
1162
- `"idle"` is `"always"` minus the cost on the render that draws the page.
893
+ off it (the default of a picker told no value), fields a surrounding form
894
+ submits, a size measured from outside. `"idle"` is `"always"` minus the cost on
895
+ the render that draws the page.
1163
896
 
1164
897
  Whatever the value but `"while-opened"`, intent on the anchor — a pointer
1165
- entering it, focus landing in it — builds the content ahead of the click that
1166
- will open it. A `"while-opened"` popup is warmed only by a press on what opens
1167
- it — a `--navi-open` button, a picker's trigger when a press opens it, an
1168
- expandable's UI part — and a press that ends without opening it (a scroll, a
1169
- drag, a release elsewhere) throws the content away: there is still one content
1170
- at a time. A popup with an `onOpen` is never warmed, so the content is always
1171
- built after it (see
1172
- [`onOpen` runs before the popup has built anything](#onopen-runs-before-the-popup-has-built-anything)).
1173
-
1174
- `"while-opened"` is what an uncontrolled field seeded from a `defaultValue`
1175
- needs: without it, a popup reopened after the underlying value changed still
1176
- shows what it showed at closing time.
1177
-
1178
- ```jsx
1179
- <Dialog ref={dialogRef} mount="while-opened">
1180
- <Textarea defaultValue={note.text} />
1181
- </Dialog>
1182
- ```
898
+ entering it, focus landing in it — builds the content ahead of the click,
899
+ `onOpen` or not. A `"while-opened"` popup is warmed only by a press on what
900
+ opens it (a `--navi-open` button, a picker's trigger when a press opens it, an
901
+ expandable's UI part), throws that content away when the press ends without
902
+ opening it — one content at a time — and is never warmed on a
903
+ `Dialog`/`Popover` with an `onOpen`: the one value where the content is always
904
+ built after `onOpen`.
905
+
906
+ `"while-opened"` is for content whose fresh state is its initial state. An
907
+ untouched field does not need it to show a new `defaultValue`: it follows one
908
+ while it holds no edit (see
909
+ [control_value.md](./control_value.md#a-defaultvalue-follows-what-it-was-read-from)).
910
+ What kept content carries into the next opening is an unsent edit, and
911
+ `"while-opened"` is what throws that away.
1183
912
 
1184
913
  The content is dropped only once the exit transition is over, so the popup never
1185
914
  plays it on a blank surface; a popup reopened while it was leaving keeps the