@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
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,114 +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}` as the user scrolls.
161
- - A list with an `id` **comes back where it was** when its screen is left and
162
- come back to, the way the page does: the position is kept under the list's
163
- `id` and the page's url, for the session, and put back by name the way
164
- `defaultScrolled={{id, offset}}` is. A fresh arrival at the page still opens
165
- at `defaultScrolled`. **`scrollResetOnNavigation`** opts out: the list then
166
- opens the same way every time.
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.
167
130
 
168
131
  ### A search moves the list, and gives it back
169
132
 
@@ -173,47 +136,33 @@ makes that possible is `searchText` on the `List` — without it the list sees n
173
136
  children and nothing else.
174
137
 
175
138
  - **While the search is on**, the list scrolls back to its first row every time
176
- the best matches change. "Best matches" is the top `renderBudget` rows, taken
177
- by id and by `matchInfo.matchScore`, so a letter that promotes nobody new
178
- 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.
179
142
  - **When the search is emptied**, the list returns to the offset it was at when
180
- the search started, render window included. It goes back by offset, not by
181
- row: the collection is the one from before again, in the order it was in.
143
+ the search started, render window included.
182
144
 
183
145
  Both rest on each row knowing where it stands, which for rows declared one by
184
- one is the order they are written in — `useSearchText` hands the collection back
185
- reordered, the caller renders it in that order, and the places follow. Two things
186
- 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:
187
148
 
188
149
  - **a stable `key` on every row**, which is what says a row moved rather than a
189
150
  row changed;
190
151
  - **the rows as the list's own children**. The places are read off the children
191
152
  the list is given, so a component of yours rendering the rows is one child
192
- however many rows come out of it — they all take the same place and keep the
193
- order they first mounted in. Hand the list the rows, or a `<List.Items>`.
194
-
195
- One case is knowingly left out: a row selected during the search does not hold
196
- the view. Emptying the search takes the list back to where it was, which may be
197
- 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>`.
198
155
 
199
- ### Sticky rows inside the list
200
-
201
- `<List.Item header>` / `<List.Item footer>` are sticky rows inside the list.
202
- They publish their measured size as `--list-header-height` /
203
- `--list-footer-height`, which feeds the `scroll-margin` of the rows — this is
204
- 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.
205
158
 
206
159
  ### Loading: two different situations
207
160
 
208
- - **`loading` / `loadingFallback` / `loadingSkeletonCount` / `renderSkeleton`**
209
- — "I have nothing at all to show yet". Placeholder rows (or a `"loader"`
210
- spinner) stand in for the whole list; the rows are held to the render
211
- window like the ones they stand for.
212
- - **`<List.Items count>`** — "I know how many rows are coming". The rows not
213
- held yet are drawn as skeletons _in their own place_, virtualized like the
214
- rest, and asked for as they enter the render window.
215
-
216
- 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.
217
166
 
218
167
  Reference: `src/control/list/list.jsx` (JSDoc on `List` and `List.Items`).
219
168
 
@@ -226,7 +175,7 @@ everyone else does, by asking for the overflow — and it already asks, on itsel
226
175
  So the parts are direct children of the `Dialog`:
227
176
 
228
177
  ```jsx
229
- <Dialog id="…" dockedOnSmallTouchScreen scrollCapture>
178
+ <Dialog id="…" dockedOnSmallTouchScreen>
230
179
  <Box header>title + close</Box>
231
180
  <Box body>
232
181
  <List scroller="parent" /> {/* NOT "self" */}
@@ -235,15 +184,9 @@ So the parts are direct children of the `Dialog`:
235
184
  </Dialog>
236
185
  ```
237
186
 
238
- A dialog is already bounded by the room its container leaves it
239
- (`--dialog-maxmax-height`), so a `maxHeight` is only for making it smaller than
240
- that.
241
-
242
- `dialog.jsx` deliberately declares no `overflow` of its own: the
243
- `[data-scrollable]` the dialog carries is its scrolling rule. A modal dialog
244
- would get `auto` from the UA stylesheet anyway, but a `layer="local"` one gets
245
- nothing, and without that rule its `max-height` would only decide how big the
246
- 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)).
247
190
 
248
191
  ### `scrollCapture`
249
192
 
@@ -252,16 +195,13 @@ box looks while the content kept painting straight through it.
252
195
  ```
253
196
 
254
197
  Traps wheel/touch gestures inside the popup so the page behind it cannot
255
- scroll. **Without it, on mobile, reaching the end of the content keeps going and
256
- the screen underneath scrolls** — the sheet stays put while the content it
257
- covers changes. It does not look like a scroll bug, and it is one.
258
-
259
- Two details:
260
-
261
- - a `layer="local"` dialog **always** locks its own positioned ancestor's scroll
262
- while open (its backdrop only covers the scrollport, so scrolling there would
263
- reveal uncovered content); `scrollCapture` extends the lock to the whole page;
264
- - `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.
265
205
 
266
206
  ### `SlideContainer` inside a popup
267
207
 
@@ -270,11 +210,11 @@ All slides live in **the same grid cell**, so the box measures itself on the
270
210
  between slides — and it also means a short slide shows empty room below it. It
271
211
  is a trade, not a leak.
272
212
 
273
- **The slide IS the body.** This is the one thing to get right, and the shape
274
- everyone writes first gets it wrong: a `Dialog` with a `<Box body>` around the
275
- slides puts a scroller ABOVE them, and that scroller's content is the grid —
276
- measured on the tallest slide. Stand on a short slide and it carries the
277
- 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.
278
218
 
279
219
  ```jsx
280
220
  // WRONG — the dialog's body scrolls the tallest slide, on every slide
@@ -299,48 +239,35 @@ scrollbar of a neighbour, scrolling through emptiness.
299
239
  </Dialog>
300
240
  ```
301
241
 
302
- Why it then behaves: the cap on the height comes from above and must reach the
303
- slides as a **constraint**, never as a scroller. `SlideContainer` is
304
- `flex: 0 1 auto` — it shrinks into what is left (growing is the caller's
305
- decision, `expandY`) — the grid hands that height to **every** slide, and a
306
- slide with an `overflow` of its own scrolls only when ITS content is taller than
307
- that. The tall slide scrolls; the short ones are tall boxes with a short content
308
- in them, which is what one wants: they take the height the context imposes and
309
- ignore the height of their neighbour.
310
-
311
- So: nothing scrollable between the cap and the slides. A `<Box body>` around
312
- them is a scroller (see the table at the top of this file) — and so is a bare
313
- `overflow="auto"` on a wrapper. The dialog keeps a shared `header` if the tabs
314
- are shared, with an explicit `flexShrink="0"` since the rule that gives it for
315
- free applies only next to a `body`.
316
-
317
- **Padding goes on the slide** — or on its parts, since the slide is now the
318
- scroller (see the top of this file) — but never on the container nor on
319
- anything above it.
320
- Overflow clips at the _padding_ edge, so a padding on the container is a band
321
- the clipping does not cover: the arriving slide is seen there before it has
322
- reached the frame. And a padding above the slides does not travel — the two
323
- contents cross each other flush, instead of each arriving already inset. On the
324
- 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.
325
257
 
326
258
  Pass `travelByKeyboard={false}` when the arrow keys belong to the content (a list
327
259
  one walks through, a picker whose slides are steps): otherwise the right arrow
328
260
  changes screen mid-reading.
329
261
 
330
- Reference: `src/layout/dialog.jsx`, `src/layout/popover.jsx`,
331
- `src/layout/slide_container.jsx`, and the "One slide much taller than the
332
- 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`.
333
264
 
334
265
  ## Many rows: `List.Items` and the render window
335
266
 
336
- What a list costs must not follow the size of its collection. A row is a
337
- `<List.Item>` with a checkbox, a text and a button or two — around 150 to 250
338
- components once every resolver, box and context provider is counted — and the
339
- 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
340
269
  given to a list that opens in a click are forty rows drawn before anything is
341
- seen, and a hundred are a hundred: with the CPU throttled to a phone's, the
342
- click that opens a sheet of 40 users took 536 ms, and 952 ms for 100 (wematch,
343
- September 2026). A screen shows fourteen of them.
270
+ seen, for a screen that shows a dozen.
344
271
 
345
272
  So the rule: **rows as `<List.Item>` children are all drawn**, and that is the
346
273
  right shape only for a list the caller knows to be short — a menu, a settings
@@ -362,43 +289,32 @@ sheet, a handful of tabs. A collection whose size the caller does not decide
362
289
  The run draws only the rows inside the **render window** — `renderBudget` of
363
290
  them, 100 by default — and holds the room of the others with fillers, so the
364
291
  scrollbar says how long the collection is and the DOM says how many rows fit a
365
- screen and some. The window slides as the user scrolls: it keeps three
366
- quarters of its spare rows ahead of the direction the user goes, and moves
367
- once those fall under half a screen — a row crossed is not a window rebuilt,
368
- and a row reached is never a blank one. So the budget has to exceed what the
369
- scroller shows at once, with room for that lookahead: the list warns below
370
- 30, and when a budget leaves fewer than two rows beyond the screen.
371
-
372
- ### The window follows one box, and `scroller` names it
373
-
374
- That box is the list's own with the default `scroller="self"`, the ancestor or
375
- the page with `"parent"`/`"document"` (see
376
- [2. A part of the document scrolls](#2-a-part-of-the-document-scrolls)). So
377
- `scroller` is not only where the scrollbar appears: it is what decides whether
378
- the window moves at all. A box with nothing to scroll reports nothing, the
379
- window stays where it was drawn, and the rows it does not cover stay as
380
- fillers — the list shows its first `renderBudget` rows and then blank space,
381
- down to where it ends.
382
-
383
- A list told `"self"` with no height to scroll in is exactly such a box: as tall
384
- as its rows, scrolling nothing. The window then follows the box that does show
385
- the list, measured the way `"parent"` is, and dev warns when nothing around it
386
- scrolls either. Both are recoveries, not the shape to aim for — say which box
387
- scrolls: a `maxHeight` on a list that IS the scrolling area,
388
- `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.
389
300
 
390
301
  ### The first paint of a list that opens in a click
391
302
 
392
- A popup's content is built in the click that opens it (see `popup_open.md`),
393
- and rows below the fold cost the same there as rows on screen. `renderBudget`
394
- takes `{ initial, after }` for exactly this: `initial` rows in the commit the
395
- browser paints first — what a phone screen shows, plus a few — and `after` from
396
- the paint on. The switch waits for the paint itself, not for an effect: preact
397
- runs a component's pending effects early when that component renders again,
398
- and something always re-renders before a popup has painted. Do not rebuild
399
- this by hand with a `useEffect` that widens a slice: that effect is one preact
400
- runs early, and it fails for that reason. The runs ask their source for `after`
401
- 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.
402
318
 
403
319
  ### Cmd/Ctrl + F: `findText`
404
320
 
@@ -414,20 +330,17 @@ them, nothing else.
414
330
  />
415
331
  ```
416
332
 
417
- With `findText`, the fillers carry the text of the rows they hold the room of,
418
- one line per row at the row size, inside `hidden="until-found"` elements:
419
- nothing of it is laid out or painted, and find still reaches it. On a match the
420
- browser reveals the element and scrolls to the line — which is where the row
421
- stands — and the render window, following that scroll like any other, draws
422
- the row. A row inside the window has no hidden copy, so every row is found
423
- 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.
424
337
 
425
338
  What it costs, and where it stops:
426
339
 
427
340
  - **A cost that follows the collection again.** The text of every row outside
428
341
  the window is rebuilt when the window slides (one `findText` call per row)
429
- and sits in the DOM as text, one element per 64 rows. Give it to a list a
430
- 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.
431
344
  - **One line per row**, and only that line is found while the row is not
432
345
  drawn: return what a user would type, not everything the row shows.
433
346
  - **What the client holds, no more.** A row a paginated run has not loaded has
@@ -437,17 +350,16 @@ What it costs, and where it stops:
437
350
 
438
351
  Two things not to write beside it:
439
352
 
440
- - **A transparent copy of the list's text laid over it** (a `<textarea>` or a
441
- `<div>` in `color: transparent`). Every row in the window is then found
442
- twice, drawn and copied, and the copy only lines up with rows of one fixed
443
- height. It is the first idea everyone has; the demo keeps it for comparison.
444
- - **A `beforematch` listener moving the window.** The list re-renders in the
445
- microtask after the event, which removes the element before the browser has
446
- 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.
447
359
 
448
360
  And one trap outside the list: a CSS reset forcing `[hidden] { display: none }`
449
361
  must leave `[hidden="until-found"]` out — an element hidden with `display: none`
450
- is never revealed. navi's own does (`src/box/box.jsx`).
362
+ is never revealed. navi's own does.
451
363
 
452
364
  Reference: `src/control/demos/19_list_find_in_page_demo.html`.
453
365
 
@@ -456,60 +368,35 @@ Reference: `src/control/demos/19_list_find_in_page_demo.html`.
456
368
  - **A stable `id` on every item.** The run keys its rows on `item.id` — it is
457
369
  what addresses a row from outside (`--navi-select`, `scrolled={{ id }}`), and
458
370
  what tells a row that moved from a row that changed.
459
- - **A stable `renderItem`.** The run keeps the vnode it drew for an item and
460
- hands preact the same one on its next render — the window sliding, the
461
- first paint's budget giving way — so an unchanged row is not walked again.
462
- It can only do that for the same function as last time: `renderItem` written
463
- inline in a component that re-renders is a new function each time, and every
464
- row is redrawn with it. Define it outside the component, or `useCallback` it,
465
- and let it read the item and the index it is given rather than closing over
466
- state.
467
- - **`renderItem` returns a `<List.Item>` carrying its own props** —
468
- `selectable`, `value`, `selected`, the buttons in it — there is no second
469
- place describing the row.
470
- - **`virtualItemSize` when the rows are uniform.** Without it the list measures
471
- a row: once at mount, again when the popup around it opens (a row inside a
472
- closed dialog measures 0), and after each commit while rows are held off
473
- screen. Given, nothing is measured, and the fillers are right from the first
474
- commit.
475
- - **A `key` on the run when the collection changes** as a whole (`itemsAction`
476
- with another scope): another collection is another run. With `items`,
477
- another array is already another collection.
478
- - **`scroller="parent"` inside a popup body**, as everywhere else in this file.
479
- - **Groups come from the data**: `groupBy` on the run, `renderGroupLabel` for
480
- the label — the only way a list discovering its rows page by page has
481
- sections.
482
-
483
- A run whose rows all fit the window is just rows: nothing is virtualized, no
484
- row is ever a skeleton with `items`, and it costs what the same rows would as
485
- children. There is no reason to hold back from it for a list that might grow.
486
-
487
- Reference: `src/control/list/list.jsx` (JSDoc on `List` — `renderBudget`,
488
- `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:
489
383
  `src/control/demos/17_virtual_scroll_and_filter_demo.html` (a run in memory,
490
- searched), `src/control/demos/integration/1_list_loaded_by_scroll_demo.html`
491
- (a run reading a slice at a time). What a run reads back after a write is in
492
- [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).
493
386
 
494
387
  ## Hover while scrolling
495
388
 
496
- A scroll moves the content under a pointer that does not move. The browser
497
- reports that as hover: it fires `mouseleave` + `mouseenter` for **every element
498
- crossing the cursor** — a dozen per wheel tick. None of it was asked for; the
499
- user asked to scroll.
500
-
501
- It is free as long as hover only paints a background. It stops being free the
502
- moment hover triggers real work — a highlight somewhere else in the tree, a
503
- prefetch, a map redrawing a layer — because that work then lands on the main
504
- 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.
505
394
 
506
395
  ### The fact is in the DOM: `navi-scrolling`
507
396
 
508
- While an element scrolls it carries `navi-scrolling`, written by one capturing
509
- listener on the document (`scroll` does not bubble, but it does propagate in
510
- the capture phase) and removed once it has been quiet for a moment — scroll
511
- events stop before the movement does. Nothing subscribes to anything: whoever
512
- 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:
513
400
 
514
401
  ```css
515
402
  /* my rows answer the pointer only when nothing is moving them */
@@ -518,11 +405,10 @@ is concerned says so in CSS.
518
405
  }
519
406
  ```
520
407
 
521
- `pointer-events` is what does the work, and it does the whole of it: enter,
522
- move and leave at once, in the browser, at no cost per element. Hand-written in
523
- JS the same suppression takes three handlers — once `mouseenter` has been
524
- swallowed the pointer is already inside the element, so only `mousemove` can
525
- 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.
526
412
 
527
413
  The page scroll carries the attribute on `document.scrollingElement`, so an
528
414
  ancestor rule covers it too. In JS the same fact reads as `isScrolling()` /
@@ -531,17 +417,9 @@ ancestor rule covers it too. In JS the same fact reads as `isScrolling()` /
531
417
  ### In a `List`: nothing to do
532
418
 
533
419
  `List` rows leave hit-testing while anything scrolling them moves — its own
534
- scroll box, the panel around it, the page.
535
-
536
- ```jsx
537
- <List hoverWhileScrolling> {/* opt back in */}
538
- ```
539
-
540
- The default costs one thing, and it is the honest half of the same trade: right
541
- after a scroll, the row under the pointer lights up only once the pointer moves
542
- by a pixel.
543
-
544
- 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.
545
423
 
546
424
  ## The list border
547
425
 
@@ -556,45 +434,25 @@ read as a box in a box. `borderWidth="0"` removes it — the prop writes
556
434
  itself the content of a `[popover]`/`<dialog>` already drops the default on its
557
435
  own.
558
436
 
559
- ### The corners: the frame clips, not the scroll box
560
-
561
- A list clips its content twice, at the same edge, for two different reasons:
562
-
563
- - the **scroll box** (`.navi_list_scroll_container`, square, `overflow: auto`)
564
- cuts what scrolls;
565
- - the **frame** (`.navi_list_container`, `overflow: hidden` plus the radius)
566
- rounds that cut at the corners.
567
-
568
- The curve belongs on the frame. Do not move it onto the scroll box. A scroll
569
- container does not clip its own scrollbar to its `border-radius`: measured in
570
- Chromium with classic scrollbars, the scrollbar paints square over the rounded
571
- corner, while the frame's clip cuts it. The scroll box would also need the
572
- inner radius (the outer radius minus the border width). CSS cannot know that
573
- radius when the border was removed by the `border` prop.
574
-
575
- The curve cuts whatever sits on it. For rows drawn as full-width stripes, that
576
- is what you want: a selected row's background follows the corner. For rows that
577
- are framed cards themselves (a grid of hours, a column of game cards), the
578
- list's curve cuts their outer corners whenever its radius is larger than
579
- theirs. At a fractional pixel ratio, the frame's clip can also blur the last
580
- row's bottom edge (measured in WebKit at DPR 2.625).
581
-
582
437
  ### Without a border, no corner
583
438
 
584
- The default radius is the border's radius. A list given `border="none"`,
585
- `border="0"` or `borderWidth="0"`, and no `borderRadius`, is marked
586
- `data-borderless`. It then has no radius, and its frame stops clipping, because
587
- rounding the corners was that clip's only job. The scroll box still clips. A
588
- badge crossing the list's edge is still cut there, and `overflow="visible"`
589
- 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.
590
446
 
591
447
  - An explicit `borderRadius` keeps both the curve and the frame's clip: a
592
448
  borderless list painting a background of its own may want that surface
593
449
  rounded, with the rows clipped to it.
594
450
  - Only the props turn it on. A border removed from CSS (the popup's own
595
- `-default`, an app class) keeps the default radius. In a dialog, the corners
596
- that touch the dialog's squared edge are squared by rules keyed on the
597
- 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.
598
457
 
599
- Reference: `src/control/list/list.jsx` (the `.navi_list_container` CSS and
600
- `isBorderless`).
458
+ Reference: `src/control/list/list.jsx` (the `.navi_list_container` CSS).