@jsenv/navi 0.29.418 → 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 +192 -43
  3. package/dist/dev/jsenv_navi.js.map +10 -10
  4. package/dist/jsenv_navi.js +190 -41
  5. package/dist/jsenv_navi.js.map +10 -10
  6. package/docs/AI_INSTRUCTIONS.md +177 -261
  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 +115 -174
  16. package/docs/drag_to_travel.md +10 -9
  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 +6 -4
  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 -482
  35. package/docs/safe_area.md +102 -89
  36. package/docs/scroll.md +175 -335
  37. package/docs/state_binding.md +2 -1
  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
package/docs/scroll.md CHANGED
@@ -47,7 +47,7 @@ Two shapes, and they do not behave the same:
47
47
  | `header` / `footer` alone | the container itself scrolls, and they are `position: sticky` at its edges — the content scrolls under them |
48
48
  | a `body` as well | the container becomes a flex column, its own overflow turns to `hidden`, and the **body is the only thing that scrolls**; header and footer sit outside it (`position: static`, `flex-shrink: 0`) |
49
49
 
50
- Three consequences worth knowing before fighting them:
50
+ Before fighting them:
51
51
 
52
52
  - the body is `flex: 0 1 auto` — **it shrinks, it never grows**. A short body
53
53
  leaves the footer right under it rather than pushed to the bottom of a box it
@@ -56,126 +56,77 @@ Three consequences worth knowing before fighting them:
56
56
  footer), not a `box-shadow`: a shadow is drawn outside the box and lost to
57
57
  whatever is painted after it, so the body would cover the very line meant to
58
58
  separate them. Don't add a border of your own — you get two lines.
59
- - header and footer sit in the sticky band
60
- (`var(--navi-z-index-sticky)`), so everything the box contains passes under
61
- them — positioned or not. Write `style={{ "--box-header-z-index": "auto" }}`
62
- (`--box-footer-z-index` likewise) at the call site that needs the opposite: a
63
- badge or a stamp overflowing a row is otherwise sliced by a header it never
64
- scrolls under. The box is `isolation: isolate` already, so either value stays
65
- local to it.
66
- See `docs/z_index.md` and `src/box/demos/9_scrollable_z_index_demo.html`.
59
+ - a sticky header or footer (the shape without a body) is in the sticky
60
+ z-index band, so everything the box contains passes under it, positioned or
61
+ not; beside a body it is a plain block. What that costs and the way out are
62
+ in [z_index.md](./z_index.md#a-sticky-part-is-only-in-the-band-while-it-is-stuck).
67
63
 
68
64
  Padding belongs on the parts, not on the scrolling box: padding on a scroller
69
65
  sits inside the scrollbars, and a control flush against the edge of a scrolling
70
66
  area raises a scrollbar of its own (a focus outline is drawn outside the control
71
67
  it belongs to).
72
68
 
69
+ The scrolling box — the body, when there is one — has
70
+ `scroll-padding: var(--navi-scroll-padding, var(--navi-s))`: what the browser
71
+ scrolls into view lands inside the area with room for its focus ring, rather
72
+ than flush on the edge where a footer or the edge of a popup half-swallows it.
73
+ `--navi-scroll-padding` changes that room.
74
+
73
75
  Reference: `src/box/box.jsx` (the `[data-scrollable]` CSS),
74
76
  `src/box/demos/8_scrollable_demo.html`,
75
77
  `src/box/demos/9_scrollable_z_index_demo.html` (sticky parts and stacking).
76
78
 
77
79
  ## 1. The document scrolls
78
80
 
79
- The default case: nothing to do, the document scrolls.
80
-
81
- What needs wiring is whatever covers the viewport — fixed bars (a top bar, a
82
- bottom nav, the normal shape of a mobile app), the device's own notch, a band an
83
- app reserves for itself. Each publishes what it takes, navi adds them up on
84
- `<html>` as `--navi-safe-area-inset-*`, and the container that scrolls under
85
- them says so with `data-navi-safe-area`, which gives it back both the `padding`
86
- and the `scroll-padding` the bars took. The two levels of inset, what the marker
87
- does and why both paddings are needed are in
88
- [safe_area.md](./safe_area.md#something-that-scrolls-under-the-furniture); the
89
- one way to turn that container into a scroller by accident (an `overflow-x:
90
- auto` where a `clip` was meant) is in
91
- [mobile_layout_pitfalls.md](./mobile_layout_pitfalls.md).
81
+ The default case: nothing to do, the document scrolls. What covers it — fixed
82
+ bars, the device's notch — is published as `--navi-safe-area-inset-*`, and the
83
+ container that scrolls under it says so with `data-navi-safe-area`: see
84
+ [safe_area.md](./safe_area.md#something-that-scrolls-under-the-furniture), and
85
+ [A document wider than the screen](./safe_area.md#a-document-wider-than-the-screen)
86
+ for the one way to turn that container into a scroller by accident.
92
87
 
93
88
  A `List` in this case takes `scroller="document"` (in dev it warns when it finds
94
89
  itself inside a scrollport anyway, and names the element).
95
90
 
96
91
  ## 2. A part of the document scrolls
97
92
 
98
- ```jsx
99
- <Box overflow="auto" maxHeight="60vh">
100
- <Box header>…</Box>
101
- <Box body>…</Box>
102
- </Box>
103
- ```
93
+ A `Box` with an `overflow`, the shape at the top of this file. What needs
94
+ saying is a `List` inside it.
104
95
 
105
96
  ### `List` and its `scroller`
106
97
 
107
- `List` has a `scroller` prop, and the default is not the one most call sites
108
- want:
109
-
110
- | value | which box scrolls |
111
- | ----------------------- | ---------------------------------------------------------- |
112
- | `"self"` _(default)_ | the list gets a scroll box **of its own** |
113
- | `"parent"` | it virtualizes against the scrollable ancestor it lives in |
114
- | `"document"` | the page |
115
- | `Element` / `{current}` | that element, nothing is guessed |
98
+ `List` has a `scroller` prop, and its default, `"self"` — a scroll box of the
99
+ list's own — is not the one most call sites want:
116
100
 
117
101
  > If the list already lives in a box that scrolls (a dialog's `body`, a panel),
118
102
  > it is `scroller="parent"`. `"self"` is for the list that IS the scrolling
119
- > area.
120
-
121
- `"self"` is also the value that needs something from the call site: a height to
122
- scroll in (`maxHeight`, or `expandY` inside a bounded parent). A list given none
123
- is exactly as tall as its rows, so its own box scrolls nothing — and what the
124
- list's render window follows is that box (see "Many rows" below). The list then
125
- follows whatever box does show it, measured as for `"parent"`, and dev warns
126
- when nothing scrolls it at all.
127
-
128
- With `"self"` the list nests a scroll box inside the surrounding one and sizes
129
- itself independently of it — its `maxHeight` then decides how tall that inner
130
- box is allowed to get, and a virtualized run holds the room of the rows it
131
- stands for (see `List.Items count` below), so the surrounding popup or panel
132
- ends up sized on that rather than on the rows actually drawn. With anything
133
- other than `"self"`, the list's own scroll box is made transparent to layout
134
- (`max-height: none; overflow: visible`) — there is no nested scrollport and no
135
- height to compute.
136
-
137
- `"parent"` finds the ancestor **by measuring**: the nearest one whose content
138
- actually overflows it, the page if none does. Declaring an `overflow` is not
139
- enough to be picked (a box with `overflow-x: auto` that grows with its content
140
- computes `overflow-y: auto` without ever scrolling). The answer is taken again
141
- as the geometry moves, so an ancestor that starts scrolling once it fills up is
142
- picked up then. When it is still not the box you mean, say so explicitly with
143
- `"document"` or the element itself.
103
+ > area, and it needs a height to scroll in (`maxHeight`, or `expandY` inside a
104
+ > bounded parent).
105
+
106
+ A `"self"` list inside a scrolling box nests a second scroller sized on its
107
+ own — and a virtualized run holds the room of every row it stands for, so the
108
+ popup or panel around it is sized on that rather than on the rows drawn.
109
+
110
+ `scroller` also names the box the render window follows (see
111
+ [Many rows](#many-rows-listitems-and-the-render-window)). A `"self"` list given
112
+ no height scrolls nothing; the window then follows whatever box does show the
113
+ list, and dev warns when nothing scrolls it at all — a recovery, not the shape
114
+ to aim for. When `"parent"` (found by measuring) is still not the box you mean,
115
+ say so with `"document"` or the element itself.
144
116
 
145
117
  ### Where the list opens, and where it is
146
118
 
147
- - **`defaultScrolled`** — `"start"` (default), `"end"`, an index, or
148
- `{id, offset}`. The `{id, offset}` form is what `onScrolledChange` hands out:
149
- it asks for the row BY NAME, then puts it back by MEASURING it, so it lands
150
- where it was even if rows were inserted before it, and whatever the screen it
151
- was saved on. That is "reopen a thread where I left it", already provided.
152
- `offset` is counted from where the row lands on its own — past the scroller's
153
- `scroll-padding` (the room a fixed bar publishes) and the row's own
154
- `scroll-margin` (the sticky header, the group label above it) — so `offset: 0`
155
- is exactly where `scrollIntoView()` would put it and none of that room has to
156
- be restated as a number.
157
- - **`scrolled`** is the controlled form of the same thing — same pair as
158
- `open`/`defaultOpen` elsewhere in navi. The list goes back there every time it
159
- changes, even after the user scrolled.
160
- - **`onScrolledChange`** gives `{id, index, offset, visibleCount}` as the user
161
- scrolls. `visibleCount` is how many rows were on screen from that one on:
162
- handed back whole (`defaultScrolled={position}`), the position also says what
163
- to draw first — the list's first commit draws those rows (and the one above,
164
- when it stood partly in view) and the rest of its window once painted. An app
165
- keeping only the `id` (in its url, say) keeps the place and drops that: the
166
- list then draws its whole window before its first picture.
167
- - A list with an `id` **comes back where it was** when its screen is left and
168
- come back to, the way the page does: the position is kept under the list's
169
- `id` and the page's url, for the session, and put back by name the way
170
- `defaultScrolled={{id, offset}}` is — `visibleCount` included, and kept even
171
- when the list was never scrolled. A fresh arrival at the page still opens at
172
- `defaultScrolled`. **`scrollResetOnNavigation`** opts out: the list then
173
- opens the same way every time.
174
- - A list scrolling the document and opening on a row **places the document
175
- itself**, on the row: the offset the page kept for its url is not put back
176
- over it. Those are pixels of the page as it was drawn, and the rows the
177
- window holds off screen are fillers of an estimated height — the same pixels
178
- now fall on other rows.
119
+ `defaultScrolled` (or `scrolled`, held) takes `{id, offset}` — what
120
+ `onScrolledChange` hands out — and asks for the row BY NAME, then puts it back
121
+ by MEASURING it: it lands where it was even if rows were inserted before it,
122
+ whatever the screen it was saved on. "Reopen a thread where I left it" is
123
+ already provided; keep the position whole, its `visibleCount` sizes the first
124
+ paint. A list with an `id` also **comes back where it was** when its screen is
125
+ left and come back to, the way the page does; **`scrollResetOnNavigation`**
126
+ opts out. A list scrolling the document that opens on a row places the
127
+ document itself: the offset the page kept for its url is not put back over it
128
+ — the rows held off screen are fillers of an estimated height, and the same
129
+ pixels now fall on other rows.
179
130
 
180
131
  ### A search moves the list, and gives it back
181
132
 
@@ -185,47 +136,33 @@ makes that possible is `searchText` on the `List` — without it the list sees n
185
136
  children and nothing else.
186
137
 
187
138
  - **While the search is on**, the list scrolls back to its first row every time
188
- the best matches change. "Best matches" is the top `renderBudget` rows, taken
189
- by id and by `matchInfo.matchScore`, so a letter that promotes nobody new
190
- leaves the list where the user put it.
139
+ the best matches change (the top `renderBudget` rows, by id and
140
+ `matchInfo.matchScore`), so a letter that promotes nobody new leaves the list
141
+ where the user put it.
191
142
  - **When the search is emptied**, the list returns to the offset it was at when
192
- the search started, render window included. It goes back by offset, not by
193
- row: the collection is the one from before again, in the order it was in.
143
+ the search started, render window included.
194
144
 
195
145
  Both rest on each row knowing where it stands, which for rows declared one by
196
- one is the order they are written in — `useSearchText` hands the collection back
197
- reordered, the caller renders it in that order, and the places follow. Two things
198
- are needed for that, and the list warns in dev when the second is missing:
146
+ one is the order they are written in. Two things are needed for that, and the
147
+ list warns in dev when the second is missing:
199
148
 
200
149
  - **a stable `key` on every row**, which is what says a row moved rather than a
201
150
  row changed;
202
151
  - **the rows as the list's own children**. The places are read off the children
203
152
  the list is given, so a component of yours rendering the rows is one child
204
- however many rows come out of it — they all take the same place and keep the
205
- order they first mounted in. Hand the list the rows, or a `<List.Items>`.
206
-
207
- One case is knowingly left out: a row selected during the search does not hold
208
- the view. Emptying the search takes the list back to where it was, which may be
209
- nowhere near the row just chosen.
153
+ however many rows come out of it — they all take the same place. Hand the
154
+ list the rows, or a `<List.Items>`.
210
155
 
211
- ### Sticky rows inside the list
212
-
213
- `<List.Item header>` / `<List.Item footer>` are sticky rows inside the list.
214
- They publish their measured size as `--list-header-height` /
215
- `--list-footer-height`, which feeds the `scroll-margin` of the rows — this is
216
- what keeps a `scrollIntoView()` on a row from landing under the sticky header.
156
+ A row selected during the search does not hold the view: emptying the search
157
+ takes the list back to where it was, which may be nowhere near that row.
217
158
 
218
159
  ### Loading: two different situations
219
160
 
220
- - **`loading` / `loadingFallback` / `loadingSkeletonCount` / `renderSkeleton`**
221
- — "I have nothing at all to show yet". Placeholder rows (or a `"loader"`
222
- spinner) stand in for the whole list; the rows are held to the render
223
- window like the ones they stand for.
224
- - **`<List.Items count>`** — "I know how many rows are coming". The rows not
225
- held yet are drawn as skeletons _in their own place_, virtualized like the
226
- rest, and asked for as they enter the render window.
227
-
228
- A list that knows its count has no use for the first one.
161
+ `loading` (with `loadingFallback`, `loadingSkeletonCount`, `renderSkeleton`)
162
+ says "I have nothing at all to show yet": placeholder rows stand in for the
163
+ whole list. `<List.Items count>` says "I know how many rows are coming": the
164
+ rows not held yet are skeletons in their own place, asked for as they enter the
165
+ render window — a list that knows its count has no use for the first one.
229
166
 
230
167
  Reference: `src/control/list/list.jsx` (JSDoc on `List` and `List.Items`).
231
168
 
@@ -238,7 +175,7 @@ everyone else does, by asking for the overflow — and it already asks, on itsel
238
175
  So the parts are direct children of the `Dialog`:
239
176
 
240
177
  ```jsx
241
- <Dialog id="…" dockedOnSmallTouchScreen scrollCapture>
178
+ <Dialog id="…" dockedOnSmallTouchScreen>
242
179
  <Box header>title + close</Box>
243
180
  <Box body>
244
181
  <List scroller="parent" /> {/* NOT "self" */}
@@ -247,15 +184,9 @@ So the parts are direct children of the `Dialog`:
247
184
  </Dialog>
248
185
  ```
249
186
 
250
- A dialog is already bounded by the room its container leaves it
251
- (`--dialog-maxmax-height`), so a `maxHeight` is only for making it smaller than
252
- that.
253
-
254
- `dialog.jsx` deliberately declares no `overflow` of its own: the
255
- `[data-scrollable]` the dialog carries is its scrolling rule. A modal dialog
256
- would get `auto` from the UA stylesheet anyway, but a `layer="local"` one gets
257
- nothing, and without that rule its `max-height` would only decide how big the
258
- box looks while the content kept painting straight through it.
187
+ A dialog is already bounded by the room its container leaves it, so a
188
+ `maxHeight` is only for making it smaller than that (see
189
+ [dialog_shape.md](./dialog_shape.md#the-ceiling-nobody-sets)).
259
190
 
260
191
  ### `scrollCapture`
261
192
 
@@ -264,16 +195,13 @@ box looks while the content kept painting straight through it.
264
195
  ```
265
196
 
266
197
  Traps wheel/touch gestures inside the popup so the page behind it cannot
267
- scroll. **Without it, on mobile, reaching the end of the content keeps going and
268
- the screen underneath scrolls** — the sheet stays put while the content it
269
- covers changes. It does not look like a scroll bug, and it is one.
270
-
271
- Two details:
272
-
273
- - a `layer="local"` dialog **always** locks its own positioned ancestor's scroll
274
- while open (its backdrop only covers the scrollport, so scrolling there would
275
- reveal uncovered content); `scrollCapture` extends the lock to the whole page;
276
- - `Popover` has the same prop, plus `focusCapture` for Tab.
198
+ scroll. **Without it, reaching the end of the content keeps going and the
199
+ screen underneath scrolls** — the sheet stays put while the content it covers
200
+ changes. It does not look like a scroll bug, and it is one. A dialog docked by
201
+ `dockedOnSmallTouchScreen` — the phone's sheet — has it by default (see
202
+ [dialog_shape.md](./dialog_shape.md#one-dialog-two-shapes)), so the prop above
203
+ matters for the other shapes. `Popover` has the same prop, plus `focusCapture`
204
+ for Tab.
277
205
 
278
206
  ### `SlideContainer` inside a popup
279
207
 
@@ -282,11 +210,11 @@ All slides live in **the same grid cell**, so the box measures itself on the
282
210
  between slides — and it also means a short slide shows empty room below it. It
283
211
  is a trade, not a leak.
284
212
 
285
- **The slide IS the body.** This is the one thing to get right, and the shape
286
- everyone writes first gets it wrong: a `Dialog` with a `<Box body>` around the
287
- slides puts a scroller ABOVE them, and that scroller's content is the grid —
288
- measured on the tallest slide. Stand on a short slide and it carries the
289
- scrollbar of a neighbour, scrolling through emptiness.
213
+ **The slide IS the body.** The shape everyone writes first gets it wrong: a
214
+ `Dialog` with a `<Box body>` around the slides puts a scroller ABOVE them, and
215
+ that scroller's content is the grid — measured on the tallest slide. Stand on a
216
+ short slide and it carries the scrollbar of a neighbour, scrolling through
217
+ emptiness.
290
218
 
291
219
  ```jsx
292
220
  // WRONG — the dialog's body scrolls the tallest slide, on every slide
@@ -311,48 +239,35 @@ scrollbar of a neighbour, scrolling through emptiness.
311
239
  </Dialog>
312
240
  ```
313
241
 
314
- Why it then behaves: the cap on the height comes from above and must reach the
315
- slides as a **constraint**, never as a scroller. `SlideContainer` is
316
- `flex: 0 1 auto` — it shrinks into what is left (growing is the caller's
317
- decision, `expandY`) — the grid hands that height to **every** slide, and a
318
- slide with an `overflow` of its own scrolls only when ITS content is taller than
319
- that. The tall slide scrolls; the short ones are tall boxes with a short content
320
- in them, which is what one wants: they take the height the context imposes and
321
- ignore the height of their neighbour.
322
-
323
- So: nothing scrollable between the cap and the slides. A `<Box body>` around
324
- them is a scroller (see the table at the top of this file) — and so is a bare
325
- `overflow="auto"` on a wrapper. The dialog keeps a shared `header` if the tabs
326
- are shared, with an explicit `flexShrink="0"` since the rule that gives it for
327
- free applies only next to a `body`.
328
-
329
- **Padding goes on the slide** — or on its parts, since the slide is now the
330
- scroller (see the top of this file) — but never on the container nor on
331
- anything above it.
332
- Overflow clips at the _padding_ edge, so a padding on the container is a band
333
- the clipping does not cover: the arriving slide is seen there before it has
334
- reached the frame. And a padding above the slides does not travel — the two
335
- contents cross each other flush, instead of each arriving already inset. On the
336
- slide, the inset travels with what it insets.
242
+ The cap on the height must reach the slides as a **constraint**, never as a
243
+ scroller: `SlideContainer` shrinks into what is left (`flex: 0 1 auto`; growing
244
+ is `expandY`), the grid hands that height to **every** slide, and a slide with
245
+ an `overflow` of its own scrolls only when ITS content is taller. So nothing
246
+ scrollable between the cap and the slides — a `<Box body>` around them is a
247
+ scroller, and so is a bare `overflow="auto"` on a wrapper. A shared `header`
248
+ takes an explicit `flexShrink="0"`: the rule that gives it for free applies
249
+ only next to a `body`.
250
+
251
+ **Padding goes on the slide** — or on its parts, since the slide is the
252
+ scroller — never on the container nor on anything above it. Overflow clips at
253
+ the _padding_ edge, so a padding on the container is a band the clipping does
254
+ not cover: the arriving slide is seen there before it has reached the frame.
255
+ And a padding above the slides does not travel — the two contents cross each
256
+ other flush, instead of each arriving already inset.
337
257
 
338
258
  Pass `travelByKeyboard={false}` when the arrow keys belong to the content (a list
339
259
  one walks through, a picker whose slides are steps): otherwise the right arrow
340
260
  changes screen mid-reading.
341
261
 
342
- Reference: `src/layout/dialog.jsx`, `src/layout/popover.jsx`,
343
- `src/layout/slide_container.jsx`, and the "One slide much taller than the
344
- others" case in `src/layout/demos/8_slide_container_demo.html`.
262
+ Reference: `src/layout/slide_container.jsx`, and the "One slide much taller
263
+ than the others" case in `src/layout/demos/8_slide_container_demo.html`.
345
264
 
346
265
  ## Many rows: `List.Items` and the render window
347
266
 
348
- What a list costs must not follow the size of its collection. A row is a
349
- `<List.Item>` with a checkbox, a text and a button or two — around 150 to 250
350
- components once every resolver, box and context provider is counted — and the
351
- browser paints nothing until the render that draws them has ended. Forty rows
267
+ What a list costs must not follow the size of its collection: the browser
268
+ paints nothing until the render that draws the rows has ended, so forty rows
352
269
  given to a list that opens in a click are forty rows drawn before anything is
353
- seen, and a hundred are a hundred: with the CPU throttled to a phone's, the
354
- click that opens a sheet of 40 users took 536 ms, and 952 ms for 100 (wematch,
355
- September 2026). A screen shows fourteen of them.
270
+ seen, for a screen that shows a dozen.
356
271
 
357
272
  So the rule: **rows as `<List.Item>` children are all drawn**, and that is the
358
273
  right shape only for a list the caller knows to be short — a menu, a settings
@@ -374,49 +289,32 @@ sheet, a handful of tabs. A collection whose size the caller does not decide
374
289
  The run draws only the rows inside the **render window** — `renderBudget` of
375
290
  them, 100 by default — and holds the room of the others with fillers, so the
376
291
  scrollbar says how long the collection is and the DOM says how many rows fit a
377
- screen and some. The window slides as the user scrolls: it keeps three
378
- quarters of its spare rows ahead of the direction the user goes, and moves
379
- once those fall under half a screen — a row crossed is not a window rebuilt,
380
- and a row reached is never a blank one. So the budget has to exceed what the
381
- scroller shows at once, with room for that lookahead: the list warns below
382
- 30, and when a budget leaves fewer than two rows beyond the screen.
383
-
384
- ### The window follows one box, and `scroller` names it
385
-
386
- That box is the list's own with the default `scroller="self"`, the ancestor or
387
- the page with `"parent"`/`"document"` (see
388
- [2. A part of the document scrolls](#2-a-part-of-the-document-scrolls)). So
389
- `scroller` is not only where the scrollbar appears: it is what decides whether
390
- the window moves at all. A box with nothing to scroll reports nothing, the
391
- window stays where it was drawn, and the rows it does not cover stay as
392
- fillers — the list shows its first `renderBudget` rows and then blank space,
393
- down to where it ends.
394
-
395
- A list told `"self"` with no height to scroll in is exactly such a box: as tall
396
- as its rows, scrolling nothing. The window then follows the box that does show
397
- the list, measured the way `"parent"` is, and dev warns when nothing around it
398
- scrolls either. Both are recoveries, not the shape to aim for — say which box
399
- scrolls: a `maxHeight` on a list that IS the scrolling area,
400
- `scroller="parent"` in a dialog body or a panel, `"document"` on a page.
292
+ screen and some. The window slides as the user scrolls, ahead of the direction
293
+ the user goes, so the budget has to exceed what the scroller shows at once,
294
+ with room for that lookahead: the list warns below 30, and when a budget leaves
295
+ fewer than two rows beyond the screen.
296
+
297
+ A run whose rows all fit the window is just rows: nothing is virtualized, and
298
+ it costs what the same rows would as children. There is no reason to hold back
299
+ from it for a list that might grow.
401
300
 
402
301
  ### The first paint of a list that opens in a click
403
302
 
404
- A popup's content is built in the click that opens it (see `popup_open.md`),
405
- and rows below the fold cost the same there as rows on screen. A page coming
406
- back in a route transition is the same case: it is built in the transition's
407
- update callback, and every row drawn there delays the movement. `renderBudget`
408
- takes `{ initial, after }` for exactly this: `initial` rows in the commit the
409
- browser paints first — what a phone screen shows, plus a few — and `after` from
410
- the paint on. They are counted from the row the list opens on: the first
411
- picture of a list opening on a row shows that row and what is below it, and
412
- the window is centered on it only from the paint on. A position handed back
413
- with its `visibleCount` sizes that first window itself, whatever `initial`
414
- guessed. The switch waits for the paint itself, not for an effect: preact
415
- runs a component's pending effects early when that component renders again,
416
- and something always re-renders before a popup has painted. Do not rebuild
417
- this by hand with a `useEffect` that widens a slice: that effect is one preact
418
- runs early, and it fails for that reason. The runs ask their source for `after`
419
- rows from the start, so the smaller first window costs no second request.
303
+ A popup's content is built in the click that opens it (see
304
+ [popup_open.md](./popup_open.md)), and a page coming back in a route transition
305
+ is built in the transition's update callback: rows below the fold cost the same
306
+ there as rows on screen, and delay the movement. `renderBudget` takes
307
+ `{ initial, after }` for exactly this: `initial` rows in the commit the browser
308
+ paints first — what a phone screen shows, plus a few — counted from the row the
309
+ list opens on, and `after` from the paint on. A position handed back with its
310
+ `visibleCount` sizes that first window itself, whatever `initial` guessed. The
311
+ runs ask their source for `after` rows from the start, so the smaller first
312
+ window costs no second request.
313
+
314
+ The switch waits for the paint itself. Do not rebuild it with a `useEffect`
315
+ that widens a slice: preact runs a component's pending effects early when that
316
+ component renders again, and something always re-renders before a popup has
317
+ painted.
420
318
 
421
319
  ### Cmd/Ctrl + F: `findText`
422
320
 
@@ -432,20 +330,17 @@ them, nothing else.
432
330
  />
433
331
  ```
434
332
 
435
- With `findText`, the fillers carry the text of the rows they hold the room of,
436
- one line per row at the row size, inside `hidden="until-found"` elements:
437
- nothing of it is laid out or painted, and find still reaches it. On a match the
438
- browser reveals the element and scrolls to the line — which is where the row
439
- stands — and the render window, following that scroll like any other, draws
440
- the row. A row inside the window has no hidden copy, so every row is found
441
- exactly once and the count in the find bar is right.
333
+ With `findText`, the fillers carry the text of the rows they stand for, inside
334
+ `hidden="until-found"` elements: on a match the browser scrolls there and the
335
+ render window, following that scroll like any other, draws the row. A row
336
+ inside the window has no hidden copy, so every row is found exactly once.
442
337
 
443
338
  What it costs, and where it stops:
444
339
 
445
340
  - **A cost that follows the collection again.** The text of every row outside
446
341
  the window is rebuilt when the window slides (one `findText` call per row)
447
- and sits in the DOM as text, one element per 64 rows. Give it to a list a
448
- user would search with the browser, not to every run.
342
+ and sits in the DOM as text. Give it to a list a user would search with the
343
+ browser, not to every run.
449
344
  - **One line per row**, and only that line is found while the row is not
450
345
  drawn: return what a user would type, not everything the row shows.
451
346
  - **What the client holds, no more.** A row a paginated run has not loaded has
@@ -455,17 +350,16 @@ What it costs, and where it stops:
455
350
 
456
351
  Two things not to write beside it:
457
352
 
458
- - **A transparent copy of the list's text laid over it** (a `<textarea>` or a
459
- `<div>` in `color: transparent`). Every row in the window is then found
460
- twice, drawn and copied, and the copy only lines up with rows of one fixed
461
- height. It is the first idea everyone has; the demo keeps it for comparison.
462
- - **A `beforematch` listener moving the window.** The list re-renders in the
463
- microtask after the event, which removes the element before the browser has
464
- scrolled to it. The browser's own scroll is what moves the window.
353
+ - **A transparent copy of the list's text laid over it** (a `<textarea>`, a
354
+ `<div>` in `color: transparent`): every row in the window is then found
355
+ twice, and the copy only lines up with rows of one fixed height.
356
+ - **A `beforematch` listener moving the window**: the list re-renders after the
357
+ event, which removes the element before the browser has scrolled to it. The
358
+ browser's own scroll is what moves the window.
465
359
 
466
360
  And one trap outside the list: a CSS reset forcing `[hidden] { display: none }`
467
361
  must leave `[hidden="until-found"]` out — an element hidden with `display: none`
468
- is never revealed. navi's own does (`src/box/box.jsx`).
362
+ is never revealed. navi's own does.
469
363
 
470
364
  Reference: `src/control/demos/19_list_find_in_page_demo.html`.
471
365
 
@@ -474,60 +368,35 @@ Reference: `src/control/demos/19_list_find_in_page_demo.html`.
474
368
  - **A stable `id` on every item.** The run keys its rows on `item.id` — it is
475
369
  what addresses a row from outside (`--navi-select`, `scrolled={{ id }}`), and
476
370
  what tells a row that moved from a row that changed.
477
- - **A stable `renderItem`.** The run keeps the vnode it drew for an item and
478
- hands preact the same one on its next render — the window sliding, the
479
- first paint's budget giving way — so an unchanged row is not walked again.
480
- It can only do that for the same function as last time: `renderItem` written
481
- inline in a component that re-renders is a new function each time, and every
482
- row is redrawn with it. Define it outside the component, or `useCallback` it,
483
- and let it read the item and the index it is given rather than closing over
484
- state.
485
- - **`renderItem` returns a `<List.Item>` carrying its own props** —
486
- `selectable`, `value`, `selected`, the buttons in it — there is no second
487
- place describing the row.
488
- - **`virtualItemSize` when the rows are uniform.** Without it the list measures
489
- a row: once at mount, again when the popup around it opens (a row inside a
490
- closed dialog measures 0), and after each commit while rows are held off
491
- screen. Given, nothing is measured, and the fillers are right from the first
492
- commit.
493
- - **A `key` on the run when the collection changes** as a whole (`itemsAction`
494
- with another scope): another collection is another run. With `items`,
495
- another array is already another collection.
496
- - **`scroller="parent"` inside a popup body**, as everywhere else in this file.
497
- - **Groups come from the data**: `groupBy` on the run, `renderGroupLabel` for
498
- the label — the only way a list discovering its rows page by page has
499
- sections.
500
-
501
- A run whose rows all fit the window is just rows: nothing is virtualized, no
502
- row is ever a skeleton with `items`, and it costs what the same rows would as
503
- children. There is no reason to hold back from it for a list that might grow.
504
-
505
- Reference: `src/control/list/list.jsx` (JSDoc on `List` — `renderBudget`,
506
- `virtualItemSize` — and on `List.Items` — `findText`),
371
+ - **A stable `renderItem`.** The run hands preact the vnode it drew for an item
372
+ last time, so an unchanged row is not walked again — only for the same
373
+ function as last time: `renderItem` written inline in a component that
374
+ re-renders is a new function each time, and every row is redrawn with it.
375
+ Define it outside the component, or `useCallback` it, and let it read the
376
+ item and the index it is given rather than closing over state.
377
+
378
+ The rest — `virtualItemSize` for uniform rows, a `key` on the run when the
379
+ collection changes as a whole (never to refresh it, see
380
+ [list_refresh.md](./list_refresh.md#a-paginated-list-stays-on-screen-too)),
381
+ `groupBy` for sections — is in the JSDoc of `List` and `List.Items`
382
+ (`src/control/list/list.jsx`). Demos:
507
383
  `src/control/demos/17_virtual_scroll_and_filter_demo.html` (a run in memory,
508
- searched), `src/control/demos/integration/1_list_loaded_by_scroll_demo.html`
509
- (a run reading a slice at a time). What a run reads back after a write is in
510
- [list_refresh.md](./list_refresh.md).
384
+ searched), `src/control/demos/integration/1_list_loaded_by_scroll_demo.html` (a
385
+ run reading a slice at a time).
511
386
 
512
387
  ## Hover while scrolling
513
388
 
514
- A scroll moves the content under a pointer that does not move. The browser
515
- reports that as hover: it fires `mouseleave` + `mouseenter` for **every element
516
- crossing the cursor** — a dozen per wheel tick. None of it was asked for; the
517
- user asked to scroll.
518
-
519
- It is free as long as hover only paints a background. It stops being free the
520
- moment hover triggers real work — a highlight somewhere else in the tree, a
521
- prefetch, a map redrawing a layer — because that work then lands on the main
522
- thread exactly while a scroll animation is running, and the scroll stutters.
389
+ A scroll moves the content under a pointer that does not move, and the browser
390
+ reports that as hover on **every element crossing the cursor**. Free while hover
391
+ only paints a background; once hover triggers real work — a highlight
392
+ elsewhere, a prefetch, a map redrawing a layer — that work lands on the main
393
+ thread mid-scroll, and the scroll stutters.
523
394
 
524
395
  ### The fact is in the DOM: `navi-scrolling`
525
396
 
526
- While an element scrolls it carries `navi-scrolling`, written by one capturing
527
- listener on the document (`scroll` does not bubble, but it does propagate in
528
- the capture phase) and removed once it has been quiet for a moment — scroll
529
- events stop before the movement does. Nothing subscribes to anything: whoever
530
- is concerned says so in CSS.
397
+ While an element scrolls it carries `navi-scrolling`, removed once it has been
398
+ quiet for a moment — scroll events stop before the movement does. Whoever is
399
+ concerned says so in CSS:
531
400
 
532
401
  ```css
533
402
  /* my rows answer the pointer only when nothing is moving them */
@@ -536,11 +405,10 @@ is concerned says so in CSS.
536
405
  }
537
406
  ```
538
407
 
539
- `pointer-events` is what does the work, and it does the whole of it: enter,
540
- move and leave at once, in the browser, at no cost per element. Hand-written in
541
- JS the same suppression takes three handlers — once `mouseenter` has been
542
- swallowed the pointer is already inside the element, so only `mousemove` can
543
- ever bring the hover back.
408
+ `pointer-events` does the whole of it: enter, move and leave at once, in the
409
+ browser, at no cost per element. Hand-written in JS the same suppression takes
410
+ three handlers — once `mouseenter` has been swallowed, only `mousemove` can
411
+ bring the hover back.
544
412
 
545
413
  The page scroll carries the attribute on `document.scrollingElement`, so an
546
414
  ancestor rule covers it too. In JS the same fact reads as `isScrolling()` /
@@ -549,17 +417,9 @@ ancestor rule covers it too. In JS the same fact reads as `isScrolling()` /
549
417
  ### In a `List`: nothing to do
550
418
 
551
419
  `List` rows leave hit-testing while anything scrolling them moves — its own
552
- scroll box, the panel around it, the page.
553
-
554
- ```jsx
555
- <List hoverWhileScrolling> {/* opt back in */}
556
- ```
557
-
558
- The default costs one thing, and it is the honest half of the same trade: right
559
- after a scroll, the row under the pointer lights up only once the pointer moves
560
- by a pixel.
561
-
562
- Reference: `src/utils/scroll_activity.js`.
420
+ scroll box, the panel around it, the page. `hoverWhileScrolling` opts back in.
421
+ The default costs one thing: right after a scroll, the row under the pointer
422
+ lights up only once the pointer moves by a pixel.
563
423
 
564
424
  ## The list border
565
425
 
@@ -574,45 +434,25 @@ read as a box in a box. `borderWidth="0"` removes it — the prop writes
574
434
  itself the content of a `[popover]`/`<dialog>` already drops the default on its
575
435
  own.
576
436
 
577
- ### The corners: the frame clips, not the scroll box
578
-
579
- A list clips its content twice, at the same edge, for two different reasons:
580
-
581
- - the **scroll box** (`.navi_list_scroll_container`, square, `overflow: auto`)
582
- cuts what scrolls;
583
- - the **frame** (`.navi_list_container`, `overflow: hidden` plus the radius)
584
- rounds that cut at the corners.
585
-
586
- The curve belongs on the frame. Do not move it onto the scroll box. A scroll
587
- container does not clip its own scrollbar to its `border-radius`: measured in
588
- Chromium with classic scrollbars, the scrollbar paints square over the rounded
589
- corner, while the frame's clip cuts it. The scroll box would also need the
590
- inner radius (the outer radius minus the border width). CSS cannot know that
591
- radius when the border was removed by the `border` prop.
592
-
593
- The curve cuts whatever sits on it. For rows drawn as full-width stripes, that
594
- is what you want: a selected row's background follows the corner. For rows that
595
- are framed cards themselves (a grid of hours, a column of game cards), the
596
- list's curve cuts their outer corners whenever its radius is larger than
597
- theirs. At a fractional pixel ratio, the frame's clip can also blur the last
598
- row's bottom edge (measured in WebKit at DPR 2.625).
599
-
600
437
  ### Without a border, no corner
601
438
 
602
- The default radius is the border's radius. A list given `border="none"`,
603
- `border="0"` or `borderWidth="0"`, and no `borderRadius`, is marked
604
- `data-borderless`. It then has no radius, and its frame stops clipping, because
605
- rounding the corners was that clip's only job. The scroll box still clips. A
606
- badge crossing the list's edge is still cut there, and `overflow="visible"`
607
- remains the only way to let content out.
439
+ The default radius is the border's, and the frame (not the scroll box) clips
440
+ the rows to its curve. A list given `border="none"`, `border="0"` or
441
+ `borderWidth="0"`, and no `borderRadius`, is marked `data-borderless`: it has
442
+ no radius, and its frame stops clipping, because rounding the corners was that
443
+ clip's only job. The scroll box still clips: a badge crossing the list's edge
444
+ is still cut there, and `overflow="visible"` remains the only way to let
445
+ content out.
608
446
 
609
447
  - An explicit `borderRadius` keeps both the curve and the frame's clip: a
610
448
  borderless list painting a background of its own may want that surface
611
449
  rounded, with the rows clipped to it.
612
450
  - Only the props turn it on. A border removed from CSS (the popup's own
613
- `-default`, an app class) keeps the default radius. In a dialog, the corners
614
- that touch the dialog's squared edge are squared by rules keyed on the
615
- dialog's `data-flush-*` attributes, in `list.jsx`.
451
+ `-default`, an app class) keeps the default radius.
452
+
453
+ The curve cuts whatever sits on it: a selected row's background follows the
454
+ corner, and rows that are framed cards themselves lose their outer corners
455
+ whenever the list's radius is larger than theirs. At a fractional pixel ratio,
456
+ the frame's clip can also blur the last row's bottom edge.
616
457
 
617
- Reference: `src/control/list/list.jsx` (the `.navi_list_container` CSS and
618
- `isBorderless`).
458
+ Reference: `src/control/list/list.jsx` (the `.navi_list_container` CSS).