@jsenv/navi 0.29.316 → 0.29.317

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.
@@ -202,8 +202,10 @@ to start when unsure which export solves a problem.
202
202
  a virtual keyboard. Read before hand-writing an offset to clear a `FixedBar`.
203
203
  - `scroll.md` — where scrolling happens: `header`/`body`/`footer`, `List`'s
204
204
  `scroller`, where a list opens and what a search does to where it is, a popup
205
- that scrolls, hover while scrolling. Read before writing CSS to make something
206
- scroll.
205
+ that scrolls, hover while scrolling; many rows: `<List.Items>` and the render
206
+ window (`renderBudget`, its `{ initial, after }` form for a list opening in
207
+ a click, `virtualItemSize`). Read before writing CSS to make something
208
+ scroll, and before rendering a collection as `<List.Item>` children.
207
209
  - `mobile_layout_pitfalls.md` — a horizontal overflow inflates the layout
208
210
  viewport on Chrome Android and miscenters every dialog; `overflow-x: clip`.
209
211
  - `z_index.md` — DOM order first; a `z-index` without `isolation: isolate`
package/docs/scroll.md CHANGED
@@ -12,6 +12,7 @@ it was read at: see
12
12
  - [1. The document scrolls](#1-the-document-scrolls)
13
13
  - [2. A part of the document scrolls](#2-a-part-of-the-document-scrolls)
14
14
  - [3. A popup scrolls](#3-a-popup-scrolls)
15
+ - [Many rows: `List.Items` and the render window](#many-rows-listitems-and-the-render-window)
15
16
  - [Hover while scrolling](#hover-while-scrolling)
16
17
  - [The list border](#the-list-border)
17
18
 
@@ -314,6 +315,95 @@ Reference: `src/layout/dialog.jsx`, `src/layout/popover.jsx`,
314
315
  `src/layout/slide_container.jsx`, and the "One slide much taller than the
315
316
  others" case in `src/layout/demos/8_slide_container_demo.html`.
316
317
 
318
+ ## Many rows: `List.Items` and the render window
319
+
320
+ What a list costs must not follow the size of its collection. A row is a
321
+ `<List.Item>` with a checkbox, a text and a button or two — around 150 to 250
322
+ components once every resolver, box and context provider is counted — and the
323
+ browser paints nothing until the render that draws them has ended. Forty rows
324
+ given to a list that opens in a click are forty rows drawn before anything is
325
+ seen, and a hundred are a hundred: with the CPU throttled to a phone's, the
326
+ click that opens a sheet of 40 users took 536 ms, and 952 ms for 100 (wematch,
327
+ September 2026). A screen shows fourteen of them.
328
+
329
+ So the rule: **rows as `<List.Item>` children are all drawn**, and that is the
330
+ right shape only for a list the caller knows to be short — a menu, a settings
331
+ sheet, a handful of tabs. A collection whose size the caller does not decide
332
+ (users, messages, search results, anything read from a resource) goes to a run:
333
+
334
+ ```jsx
335
+ // in memory: the whole collection, in order; the list draws a window of it
336
+ <List renderBudget={{ initial: 17, after: 30 }} virtualItemSize={45}>
337
+ <List.Items items={users} renderItem={renderUser} />
338
+ </List>
339
+
340
+ // read a slice at a time: the run asks its source for what it is about to draw
341
+ <List>
342
+ <List.Items itemsAction={USER.GET_RANGE.bindParams({ q })} renderItem={renderUser} />
343
+ </List>
344
+ ```
345
+
346
+ The run draws only the rows inside the **render window** — `renderBudget` of
347
+ them, 100 by default — and holds the room of the others with fillers, so the
348
+ scrollbar says how long the collection is and the DOM says how many rows fit a
349
+ screen and some. The window slides as the user scrolls, and moves only when
350
+ what is on screen nears one of its edges: a row crossed is not a window
351
+ rebuilt. Below 30 the list warns, because a window shorter than a tall screen
352
+ shows blank fillers under the last row.
353
+
354
+ ### The first paint of a list that opens in a click
355
+
356
+ A popup's content is built in the click that opens it (see `popup_open.md`),
357
+ and rows below the fold cost the same there as rows on screen. `renderBudget`
358
+ takes `{ initial, after }` for exactly this: `initial` rows in the commit the
359
+ browser paints first — what a phone screen shows, plus a few — and `after` from
360
+ the paint on. The switch waits for the paint itself, not for an effect: preact
361
+ runs a component's pending effects early when that component renders again,
362
+ and something always re-renders before a popup has painted. Do not rebuild
363
+ this by hand with a `useEffect` that widens a slice — that is the thing it
364
+ replaces, and it fails for that reason. The runs ask their source for `after`
365
+ rows from the start, so the smaller first window costs no second request.
366
+
367
+ ### Doing it well
368
+
369
+ - **A stable `id` on every item.** The run keys its rows on `item.id` — it is
370
+ what addresses a row from outside (`--navi-select`, `scrolled={{ id }}`), and
371
+ what tells a row that moved from a row that changed.
372
+ - **A stable `renderItem`.** The run keeps the vnode it drew for an item and
373
+ hands preact the same one on its next render — the window sliding, the
374
+ first paint's budget giving way — so an unchanged row is not walked again.
375
+ It can only do that for the same function as last time: `renderItem` written
376
+ inline in a component that re-renders is a new function each time, and every
377
+ row is redrawn with it. Define it outside the component, or `useCallback` it,
378
+ and let it read the item and the index it is given rather than closing over
379
+ state.
380
+ - **`renderItem` returns a `<List.Item>` carrying its own props** —
381
+ `selectable`, `value`, `selected`, the buttons in it — there is no second
382
+ place describing the row.
383
+ - **`virtualItemSize` when the rows are uniform.** Without it the list measures
384
+ a row: once at mount, again when the popup around it opens (a row inside a
385
+ closed dialog measures 0), and after each commit while rows are held off
386
+ screen. Given, nothing is measured, and the fillers are right from the first
387
+ commit.
388
+ - **A `key` on the run when the collection changes** as a whole (`itemsAction`
389
+ with another scope): another collection is another run. With `items`,
390
+ another array is already another collection.
391
+ - **`scroller="parent"` inside a popup body**, as everywhere else in this file.
392
+ - **Groups come from the data**: `groupBy` on the run, `renderGroupLabel` for
393
+ the label — the only way a list discovering its rows page by page has
394
+ sections.
395
+
396
+ A run whose rows all fit the window is just rows: nothing is virtualized, no
397
+ row is ever a skeleton with `items`, and it costs what the same rows would as
398
+ children. There is no reason to hold back from it for a list that might grow.
399
+
400
+ Reference: `src/control/list/list.jsx` (JSDoc on `List` — `renderBudget`,
401
+ `virtualItemSize` — and on `List.Items`),
402
+ `src/control/demos/17_virtual_scroll_and_filter_demo.html` (a run in memory,
403
+ searched), `src/control/demos/integration/1_list_loaded_by_scroll_demo.html`
404
+ (a run reading a slice at a time). What a run reads back after a write is in
405
+ [list_refresh.md](./list_refresh.md).
406
+
317
407
  ## Hover while scrolling
318
408
 
319
409
  A scroll moves the content under a pointer that does not move. The browser
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/navi",
3
- "version": "0.29.316",
3
+ "version": "0.29.317",
4
4
  "type": "module",
5
5
  "description": "Library of components including navigation to create frontend applications",
6
6
  "repository": {
@@ -34,7 +34,7 @@
34
34
  "preact": "^11.0.0-rc.2"
35
35
  },
36
36
  "dependencies": {
37
- "@jsenv/dom": "0.17.63",
37
+ "@jsenv/dom": "0.17.64",
38
38
  "@jsenv/humanize": "1.8.3",
39
39
  "@jsenv/validity": "0.4.4"
40
40
  },