@ixfx/components 0.13.2 → 0.13.3

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 (76) hide show
  1. package/bundle/index.d.ts +97 -14
  2. package/bundle/index.d.ts.map +1 -1
  3. package/bundle/index.js +2183 -636
  4. package/bundle/index.js.map +1 -1
  5. package/dist/ac-token.js +1 -1
  6. package/dist/{data-grid-Ck1Qx2H1.d.ts → data-grid-DHWJN66w.d.ts} +7 -5
  7. package/dist/{data-grid-Ck1Qx2H1.d.ts.map → data-grid-DHWJN66w.d.ts.map} +1 -1
  8. package/dist/data-grid.d.ts +1 -1
  9. package/dist/data-grid.js +210 -120
  10. package/dist/data-grid.js.map +1 -1
  11. package/dist/detail-list-C96B5Txz.js +2001 -0
  12. package/dist/detail-list-C96B5Txz.js.map +1 -0
  13. package/dist/{detail-list-C1xzr1z7.d.ts → detail-list-dX7jLO6b.d.ts} +12 -3
  14. package/dist/{detail-list-C1xzr1z7.d.ts.map → detail-list-dX7jLO6b.d.ts.map} +1 -1
  15. package/dist/detail-list.d.ts +2 -2
  16. package/dist/detail-list.js +1 -1680
  17. package/dist/{flat-list-Ohxqsy0g.d.ts → flat-list-Y98IqH00.d.ts} +2 -2
  18. package/dist/{flat-list-Ohxqsy0g.d.ts.map → flat-list-Y98IqH00.d.ts.map} +1 -1
  19. package/dist/flat-list.d.ts +1 -1
  20. package/dist/flat-list.js +5 -1
  21. package/dist/flat-list.js.map +1 -1
  22. package/dist/{grid-list-pefLORIr.d.ts → grid-list-CoT9e1jB.d.ts} +9 -2
  23. package/dist/{grid-list-pefLORIr.d.ts.map → grid-list-CoT9e1jB.d.ts.map} +1 -1
  24. package/dist/{grid-list-DjIl910A.js → grid-list-DeqAd9-h.js} +189 -40
  25. package/dist/grid-list-DeqAd9-h.js.map +1 -0
  26. package/dist/grid-list.d.ts +1 -1
  27. package/dist/grid-list.js +1 -1
  28. package/dist/{incr-search-D91M4Ozq.js → incr-search-JIhwB49y.js} +27 -5
  29. package/dist/incr-search-JIhwB49y.js.map +1 -0
  30. package/dist/incr-search.d.ts +1 -1
  31. package/dist/incr-search.js +1 -1
  32. package/dist/index-CnQPCywE.d.ts +325 -0
  33. package/dist/index-CnQPCywE.d.ts.map +1 -0
  34. package/dist/{index-CxZ61DO1.d.ts → index-Dlah7Zfi.d.ts} +8 -1
  35. package/dist/{index-CxZ61DO1.d.ts.map → index-Dlah7Zfi.d.ts.map} +1 -1
  36. package/dist/index.d.ts +9 -9
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +46 -25
  39. package/dist/index.js.map +1 -1
  40. package/dist/miller.d.ts +1 -1
  41. package/dist/miller.d.ts.map +1 -1
  42. package/dist/miller.js +146 -100
  43. package/dist/miller.js.map +1 -1
  44. package/dist/narrowed-text.js +1 -1
  45. package/dist/split-layout-C5SYbDrq.d.ts.map +1 -1
  46. package/dist/{split-layout-CXotkdbx.js → split-layout-Deoxy47d.js} +6 -1
  47. package/dist/{split-layout-CXotkdbx.js.map → split-layout-Deoxy47d.js.map} +1 -1
  48. package/dist/split-layout.js +1 -1
  49. package/dist/{tab-list-v8mKX7Fu.d.ts → tab-list-D_dmImJM.d.ts} +2 -3
  50. package/dist/tab-list-D_dmImJM.d.ts.map +1 -0
  51. package/dist/tabs.d.ts +1 -1
  52. package/dist/tabs.js +22 -0
  53. package/dist/tabs.js.map +1 -1
  54. package/dist/{tree-DGveys4K.js → tree-DD8OaqsK.js} +1010 -230
  55. package/dist/tree-DD8OaqsK.js.map +1 -0
  56. package/dist/tree.d.ts +4 -267
  57. package/dist/tree.js +1 -1
  58. package/dist/{vertical-list-CqBc-t8w.d.ts → vertical-list-lj_19DCx.d.ts} +3 -2
  59. package/dist/{vertical-list-CqBc-t8w.d.ts.map → vertical-list-lj_19DCx.d.ts.map} +1 -1
  60. package/dist/vertical-list.d.ts +1 -1
  61. package/dist/vertical-list.js +113 -24
  62. package/dist/vertical-list.js.map +1 -1
  63. package/docs-user/data-grid.md +74 -4
  64. package/docs-user/detail-list.md +65 -0
  65. package/docs-user/grid-list.md +81 -4
  66. package/docs-user/index.json +1 -1
  67. package/docs-user/miller.md +59 -3
  68. package/docs-user/tree.md +63 -2
  69. package/docs-user/vertical-list.md +9 -1
  70. package/package.json +6 -1
  71. package/dist/detail-list.js.map +0 -1
  72. package/dist/grid-list-DjIl910A.js.map +0 -1
  73. package/dist/incr-search-D91M4Ozq.js.map +0 -1
  74. package/dist/tab-list-v8mKX7Fu.d.ts.map +0 -1
  75. package/dist/tree-DGveys4K.js.map +0 -1
  76. package/dist/tree.d.ts.map +0 -1
@@ -112,6 +112,13 @@ grid.getRowKey = (row, i) => row.key ?? row.id ?? String(i);
112
112
 
113
113
  Reassign `rows` to update. Sorting is derived from `sortState`.
114
114
 
115
+ The sorted / filtered / grouped row model is only re-derived when one of its
116
+ inputs is **reassigned** — `rows`, `sortState`, `columns`, `filterPredicate`,
117
+ `groupBy`, `groupLabel`, `getRowKey` (or a group is collapsed). Scroll, hover,
118
+ selection and edit updates skip it, so they cost O(visible rows), not O(rows).
119
+ Mutating a row object in place and calling `requestUpdate()` re-renders the
120
+ cell but does not re-filter, re-group or re-sort — reassign `rows` for that.
121
+
115
122
  When the row set changes without a wholesale replace — rows spliced in, rows
116
123
  filtered out — the new rows fade in and, for ~0.3s afterwards, every row slides
117
124
  to its new position instead of snapping (so an inserted row's neighbours make
@@ -136,6 +143,63 @@ over the appended slice only. When a sort is active the new rows show unsorted
136
143
  for one frame, then a single coalesced re-sort runs. Scroll position, selection
137
144
  and cursor are preserved across the append.
138
145
 
146
+ ### Performance
147
+
148
+ **Any change to `data-grid.ts` that touches `willUpdate`, `render`, the row
149
+ model (`_rebuildFlat`, sorting, grouping, filtering), scrolling or
150
+ `appendRows` should be checked with both tools below.** The per-frame paths are
151
+ easy to make O(rows) by accident: before these guards existed, one stray
152
+ rebuild-per-update made scrolling 50k grouped rows cost ~146 ms a frame.
153
+
154
+ There are two layers:
155
+
156
+ | | `data-grid.perf.test.ts` | `pnpm bench:data-grid` |
157
+ |---|---|---|
158
+ | Runs | with `pnpm test` (happy-dom) | against the dev server, in headless Chrome |
159
+ | Measures | grid JS work: call counts and 2k → 20k scaling | real main-thread ms per frame: script + style/layout/paint |
160
+ | Catches | O(n) / O(n²) creeping into scroll, hover, add/remove | real-browser slowdowns, including rendering cost |
161
+ | Use it | always (it's part of CI) | before / after any perf-sensitive change |
162
+
163
+ **Unit-level guards** — `data-grid.perf.test.ts` asserts that a scroll frame /
164
+ hover does work proportional to the visible window (flat from 2k to 20k rows,
165
+ including sorted / grouped / filtered), that a sub-row scroll doesn't
166
+ re-render, and that adding / removing rows scales at most linearly. They use
167
+ call counts and size ratios rather than absolute times, so they don't flake on
168
+ slow machines. `pnpm test:perf` prints the per-scenario timings.
169
+
170
+ **Real-browser benchmark** — `scripts/bench/data-grid.ts` drives
171
+ `demo/data-grid.html` in an isolated headless Chrome (its own temp profile; set
172
+ `CHROME_PATH` if Chrome isn't in a standard location). It needs the dev server
173
+ running (`pnpm start`). For 1k / 50k rows, plain and grouped by `status`, it
174
+ reports:
175
+
176
+ - `scroll script` / `scroll render` — main-thread ms per frame at 40 px/frame
177
+ (wheel / trackpad), from a timeline trace.
178
+ - `jump script` / `jump render` — the same at 1200 px/frame (scrollbar drag /
179
+ fling: every row in the window is replaced each frame).
180
+ - `insert3Ms`, `removeOneMs`, `appendRows500Ms`, `selectMs` — median time from
181
+ the call until Lit has rendered and style + layout are flushed.
182
+
183
+ Workflow — record a baseline on the unchanged code, make the change, compare:
184
+
185
+ ```sh
186
+ pnpm bench:data-grid --out /tmp/grid-base.json # before
187
+ pnpm bench:data-grid --compare /tmp/grid-base.json # after
188
+ ```
189
+
190
+ `--compare` prints each metric's % change and lists anything over
191
+ `--threshold` (default 50%, and never changes under 1 ms — run-to-run noise is
192
+ ~±25%). Add `--fail-on-regression` to exit non-zero. Other options: `--rows
193
+ 1000,50000`, `--group-by status` (empty to skip grouped runs), `--url`.
194
+ Baselines are machine-specific, so keep them local rather than committing
195
+ them. The dev server runs Lit in dev mode, so absolute numbers are higher than
196
+ a production build — compare runs against each other, not against a budget.
197
+
198
+ Reference numbers (M-series Mac, dev mode, 50k rows): a smooth-scroll frame is
199
+ ~1.5 ms script + ~2 ms render; a jump frame ~6 ms + ~4.5 ms; add / remove one
200
+ row ~7 ms; select ~1 ms. All flat across row count and grouping, except
201
+ add / remove, which is linear in rows.
202
+
139
203
  ---
140
204
 
141
205
  ## Selection & interaction
@@ -395,9 +459,9 @@ Demo: `Score` is random float `0–100` with 0–3 decimals, `align:'right'`, `f
395
459
 
396
460
  Gear menu (`ixfx-menu-trigger` placement `bottom-end`, `inset-area: block-end span-inline-end`) lists `Columns` (check-items) and `Group by` (radio). Toggle visibility via `col.visible=false` or menu. Event: `datagrid-column-visibility` `{key, visible}`.
397
461
 
398
- `column-menu` attribute: `auto` (default) renders the gear menu; `none`
399
- suppresses it entirely. `col.visible` + `datagrid-column-visibility` still give
400
- full external control, so `none` lets a host own the column-visibility UI.
462
+ `column-menu` attribute: `none` (default) hides the gear menu; `auto` opts in to
463
+ rendering it. `col.visible` + `datagrid-column-visibility` give full external
464
+ control, so a host can own the column-visibility UI.
401
465
 
402
466
  Other events: `datagrid-column-reorder` `{from,to,columns}`, `datagrid-column-resize` `{key,width}`, `datagrid-cell-click` `{rowKey,colKey,rowIndex,colIndex}`, `datagrid-cell-edit` `{rowKey,colKey,oldValue,newValue}`.
403
467
 
@@ -410,12 +474,18 @@ Grid is focusable (`tabindex=0`). When not editing:
410
474
  | Key | Action |
411
475
  |---|---|
412
476
  | `ArrowDown/Up` | Move tickled cursor, scroll into view; with `Shift` + `vscode`/`standard` extends range |
413
- | `Home/End` | First/last |
477
+ | `Home/End` | First/last data row |
414
478
  | `Enter` | If `editability=user` and row has editable cell, open editor (prefers `lastClickedCell`); else select row |
415
479
  | `Space` | Toggle selection |
416
480
  | `Escape` | Clear selection (or cancel edit) |
417
481
  | `Ctrl/Cmd+A` | Select all (clamped to anchor group if `!crossGroupSelection`) |
418
482
 
483
+ The cursor skips group header rows. Moving it scrolls synchronously, so the target row is rendered in the same update, and moving up onto the first row of a group reveals its header.
484
+
485
+ **Accessibility:** the grid is `role="grid"` with `aria-rowcount` / `aria-colcount`; header cells are `columnheader` (with `aria-sort`), rows carry `aria-rowindex` (absolute, so it stays correct while windowed) and `aria-selected`, and group rows have a `rowheader` with `aria-expanded`.
486
+
487
+ **Editing while scrolling:** the row being edited is pinned in the DOM even if scrolled out of the window, so its input keeps focus and the edit isn't lost. It is not listed in `renderedKeys()`.
488
+
419
489
  ---
420
490
 
421
491
  ## Incremental search
@@ -40,6 +40,7 @@ Selection semantics match `ixfx-vertical-list` — same modes, same `list-*` eve
40
40
  - [Streaming provider](#streaming-provider)
41
41
  - [CSS variables](#css-variables)
42
42
  - [CSS parts](#css-parts)
43
+ - [Performance](#performance)
43
44
 
44
45
  ---
45
46
 
@@ -168,6 +169,13 @@ list.formatter = (item, index) => {
168
169
  };
169
170
  ```
170
171
 
172
+ Reassigning `items` keeps the row of every object that is still in the new
173
+ array (matched by identity), so `list.items = [...list.items, next]` creates
174
+ one `<li>` rather than rebuilding the list — selection, cursor and scroll stay
175
+ put, and the new row animates in like `addItem()`. Pass new objects (or change
176
+ `displayProperty`, `formatter`, …) to rebuild rows. With a `formatter`, a row
177
+ is only kept at the same index, since the formatter receives the index.
178
+
171
179
  Retrieve the original data for an element:
172
180
 
173
181
  ```typescript
@@ -625,3 +633,60 @@ ixfx-detail-list::part(search-overlay) {
625
633
  background: rgba(0, 0, 0, 0.8);
626
634
  }
627
635
  ```
636
+
637
+ ---
638
+
639
+ ## Performance
640
+
641
+ Every item is a real `<li>` in the light DOM — there is no virtual window —
642
+ so the per-frame and per-hover paths must never scale with the item count.
643
+ They don't: scrolling binary-searches the column flow (`visibleKeys()` reads
644
+ O(log n) rects), the column grid used for keyboard navigation is measured
645
+ lazily and re-validated with a few probe reads, hover and selection update
646
+ only the rows that changed, and each row has `contain: paint` so restyling
647
+ one row doesn't repaint the list.
648
+
649
+ `contain: paint` means row content that overflows the row box is clipped to
650
+ it (and each row is its own stacking context). Rows are fixed-size, so this
651
+ only matters if a badge or custom node is wider than the column. It costs
652
+ ~0.6 ms more compositor work per scroll frame at 10k rows and saves ~30 ms of
653
+ paint per hover / selection change.
654
+
655
+ **Any change to `detail-list.ts` that touches item caching, geometry,
656
+ `_syncItemStates`, scrolling or the add / remove paths should be checked with
657
+ both tools below.** At 10k items these paths used to cost ~30 ms of script per
658
+ scroll frame and 180 ms per arrow-key press.
659
+
660
+ | | `detail-list.perf.test.ts` (+ `detail-list-geometry.test.ts`) | `pnpm bench:detail-list` |
661
+ |---|---|---|
662
+ | Runs | with `pnpm test` (happy-dom, stubbed layout) | against the dev server, in headless Chrome |
663
+ | Measures | work counters: layout reads per scroll / keypress, rows created per append, attribute writes per hover; 1k → 10k scaling | real main-thread ms: script + style / layout / paint |
664
+ | Catches | O(n) creeping into scroll, hover, navigation, add / remove; stale caches | real-browser slowdowns, including paint |
665
+ | Use it | always (part of CI) | before / after any perf-sensitive change |
666
+
667
+ **Real-browser benchmark** — `scripts/bench/detail-list.ts` drives
668
+ `#list-objects` in `demo/detail-list.html` with 1k / 10k generated items,
669
+ plain and grouped by `kind`, and reports:
670
+
671
+ - `scroll` / `jump` `script` + `render` — main-thread ms per frame scrolling
672
+ the columns at 40 px / 1200 px per frame, from a timeline trace.
673
+ - `hover`, `keyNav`, `select`, `addItem`, `removeItem`, `itemsAppend`
674
+ (`items = [...items, x]`) — main-thread ms per operation (script + render),
675
+ run with `prefers-reduced-motion: reduce` so row animations don't count.
676
+
677
+ Record a baseline on the unchanged code, make the change, compare:
678
+
679
+ ```sh
680
+ pnpm start # dev server, separate terminal
681
+ pnpm bench:detail-list --out /tmp/list-base.json # before
682
+ pnpm bench:detail-list --compare /tmp/list-base.json # after
683
+ ```
684
+
685
+ `--compare` lists metrics over `--threshold` (default 50%, ignoring changes
686
+ under 1 ms); add `--fail-on-regression` to exit non-zero. Other options:
687
+ `--items 1000,10000`, `--group-by kind` (empty to skip grouped runs), `--url`.
688
+ Baselines are machine-specific and the dev server runs Lit in dev mode, so
689
+ compare runs against each other rather than against fixed budgets. The data
690
+ grid has the same tooling (`pnpm bench:data-grid`); shared harness code is in
691
+ `scripts/bench/`.
692
+
@@ -21,8 +21,9 @@ single vertical column, or a horizontally-scrolling strip.
21
21
  10. [Keyboard](#keyboard)
22
22
  11. [Grouping](#grouping)
23
23
  12. [Virtualisation](#virtualisation)
24
- 13. [CSS parts](#css-parts)
25
- 14. [Choosing a list component](#choosing-a-list-component)
24
+ 13. [Performance](#performance)
25
+ 14. [CSS parts](#css-parts)
26
+ 15. [Choosing a list component](#choosing-a-list-component)
26
27
 
27
28
  ---
28
29
 
@@ -46,8 +47,10 @@ single vertical column, or a horizontally-scrolling strip.
46
47
  ```
47
48
 
48
49
  Items are **light-DOM children**. Each is projected into its own virtualised
49
- cell via a named `<slot>`. Add or remove children at any time — a
50
- `MutationObserver` picks the change up and re-renders.
50
+ cell via a named `<slot>`: the list sets each rendered item's `slot` attribute
51
+ (and removes it from items outside the window), so don't set `slot` on items
52
+ yourself. Add or remove children at any time — a `MutationObserver` picks the
53
+ change up and re-renders.
51
54
 
52
55
  ```typescript
53
56
  import type { GridListElement } from '@ixfx/components/grid-list';
@@ -463,6 +466,13 @@ grid.visibleKeys(); // keys strictly intersecting the viewport (no overscan)
463
466
  keyless light-DOM items (auto-assigned internally) are omitted from
464
467
  `renderedKeys()` / `visibleKeys()` / `selectedKeys`.
465
468
 
469
+ ### Accessibility
470
+
471
+ Cells are `role="option"` with `aria-setsize` (the filtered, non-collapsed count)
472
+ and `aria-posinset`, so assistive technology sees the true list size even though
473
+ only a window of cells is in the DOM. Group headers are `role="presentation"`.
474
+ A container resize that changes the column count keeps the centre item in view.
475
+
466
476
  ### Hidden containers
467
477
 
468
478
  `refreshLayout()` re-reads the viewport after an ancestor's visibility or size
@@ -491,6 +501,73 @@ it's suppressed under `prefers-reduced-motion: reduce`. Duration:
491
501
 
492
502
  ---
493
503
 
504
+ ## Performance
505
+
506
+ **Any change to `grid-list.ts`, `grid-list-source.ts` or `grid-list-layout.ts`
507
+ that touches `render`, the per-cell template, the item source, scrolling,
508
+ cursor movement or item insertion should be checked with `pnpm bench:grid-list`.**
509
+ A per-frame path that quietly becomes O(items) is easy to introduce — see the
510
+ list at the end of this section for the ones that have bitten before.
511
+
512
+ `scripts/bench/grid-list.ts` drives `demo/grid-list.html` in an isolated
513
+ headless Chrome (its own temp profile; set `CHROME_PATH` if Chrome isn't in a
514
+ standard location). It needs the dev server running (`pnpm start`). It mounts
515
+ its own full-window list in **data mode** (adapter + `items`, default 5k and 50k
516
+ items) and **element mode** (light-DOM children, default 2k and 10k), and
517
+ reports main-thread ms from a timeline trace:
518
+
519
+ - `scroll` — script + render per frame at 40 px/frame (wheel / trackpad).
520
+ - `jump` — the same at 1200 px/frame (scrollbar drag / fling: the whole window
521
+ is replaced every frame).
522
+ - `key arrow` / `key page` / `key jump` — script + render per key press for
523
+ Arrow keys, PageUp/PageDown, and End/Home (cursor crosses the whole list).
524
+ - `append1`, `append100`, `insertMid`, `removeMid` — script + render per dynamic
525
+ change (data mode: `appendItems` / `items` / `removeItems`; element mode:
526
+ light-DOM `append` / `insertBefore` / `remove`). Run with
527
+ `prefers-reduced-motion` so the 200 ms slide animation isn't measured.
528
+
529
+ Workflow — record a baseline on the unchanged code, make the change, compare:
530
+
531
+ ```sh
532
+ pnpm bench:grid-list --out /tmp/grid-list-base.json # before
533
+ pnpm bench:grid-list --compare /tmp/grid-list-base.json # after
534
+ ```
535
+
536
+ `--compare` prints each metric's % change and lists anything over
537
+ `--threshold` (default 50%, and never changes under 1 ms — run-to-run noise is
538
+ a few %). Add `--fail-on-regression` to exit non-zero. `--data-items 5000,100000`
539
+ and `--element-items 2000,20000` change the sizes. Baselines are
540
+ machine-specific, so keep them local rather than committing them. The dev
541
+ server runs Lit in dev mode, so absolute numbers are higher than a production
542
+ build — compare runs against each other, not against a budget.
543
+
544
+ Reference numbers (M-series Mac, dev mode; all flat in item count except
545
+ inserts): a smooth-scroll frame ~2–3 ms script + ~2.5 ms render; a jump-scroll
546
+ frame ~5 ms script (data) / ~9–16 ms (element) + ~7 ms render; an Arrow / Page
547
+ key ~2–3 ms + ~3 ms; End / Home ~6 ms (data) / ~9–13 ms (element) + ~7 ms.
548
+ Inserting is O(items): one `appendItems` costs ~5 ms at 5k items and ~21 ms at
549
+ 50k (the source and search index are rebuilt, and `items` is copied); the
550
+ element-mode equivalents are ~8 ms at 2k and ~16 ms at 10k.
551
+
552
+ Things that keep the per-frame paths flat, and that the benchmark guards:
553
+
554
+ - **Cell recycling.** Cells are keyed by a stable cell id, not the item key, so
555
+ a cell scrolled out of the window is handed to an item scrolling in rather
556
+ than destroyed and recreated. (Item-keyed `repeat` gets steadily slower in
557
+ Chrome when every key in the window is new — a scrollbar drag or fling.) A
558
+ recycled cell carries the `recycled` class for one render, which disables its
559
+ transitions so it never animates from the previous item's state or position.
560
+ - **Per-cell slots (element mode).** Each cell has a fixed
561
+ `<slot name="gl-cell-<id>">`; the item it shows gets a matching `slot`
562
+ attribute, and items outside the window have none. Renaming slots instead
563
+ makes the browser re-match every light-DOM child.
564
+ - **The light DOM is only re-read when it changes** (child list, or an item's
565
+ `hidden` / `data-key` / `data-value`), not on every scroll / hover / cursor
566
+ update; `data-tickled` / `data-selected` are updated only on the items whose
567
+ state changed.
568
+
569
+ ---
570
+
494
571
  ## CSS parts
495
572
 
496
573
  | Part | Element |
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ixfx/components",
3
3
  "version": "0.13.2",
4
- "generated": "2026-10-01T21:51:41.922Z",
4
+ "generated": "2026-10-03T23:39:03.024Z",
5
5
  "components": [
6
6
  {
7
7
  "name": "ac-text",
@@ -104,7 +104,9 @@ Explicit multi-select similar to a file manager:
104
104
  | Shift+Click | Contiguous range from anchor to item (within same depth) |
105
105
  | Cmd/Ctrl+Click | Toggle item without expanding (no anchor change) |
106
106
  | Arrow Up/Down | Move focus; re-seats anchor |
107
- | Shift+Arrow | Extend range from anchor (bootstraps anchor from current position if unset) |
107
+ | Home / End | Move focus to the first / last item in the column |
108
+ | Page Up / Page Down | Move focus up / down by one visible page (clamped to the column) |
109
+ | Shift+Arrow / Home / End / Page keys | Extend range from anchor (bootstraps anchor from current position if unset) |
108
110
  | Enter | Select focused item (sets anchor) |
109
111
  | Shift+Enter | Extend range to focused item |
110
112
 
@@ -118,7 +120,8 @@ Extends `standard` with persistent multi-selection across cursor movement:
118
120
  | Shift+Click | Contiguous range from anchor — same as `standard` |
119
121
  | Cmd/Ctrl+Click | Toggle item without expanding — same as `standard` |
120
122
  | Arrow Up/Down | Move cursor **only** — selection is preserved, anchor is not re-seated |
121
- | Shift+Arrow | Extend range from anchor — same as `standard` |
123
+ | Home / End / Page Up / Page Down | Move cursor only, as Arrow Up/Down |
124
+ | Shift+Arrow / Home / End / Page keys | Extend range from anchor — same as `standard` |
122
125
  | Arrow Right | Expand/navigate into branch — selection is preserved (does not clear) |
123
126
  | Enter | Add focused item to selection (sets anchor) |
124
127
  | Shift+Enter | Extend range to focused item |
@@ -426,13 +429,66 @@ Miller columns support full keyboard navigation when the component has focus:
426
429
  | `Arrow Down` | Move focus to next item in column |
427
430
  | `Arrow Right` | Select focused item and open its children (if any) |
428
431
  | `Arrow Left` | Go back to previous column |
432
+ | `Home` / `End` | Move focus to the first / last item in the column |
433
+ | `Page Up` / `Page Down` | Move focus up / down by one visible page |
429
434
  | `Enter` | Fire `select` event on focused item; in `standard` mode also selects and sets anchor |
430
- | `Shift+Arrow` | In `standard` mode: extend selection range from anchor |
435
+ | `Shift+Arrow` / `Home` / `End` / `Page` keys | In `standard` mode: extend selection range from anchor |
431
436
  | `Shift+Enter` | In `standard` mode: extend selection range to focused item |
432
437
  | `Escape` | Cancel pending lazy load / go back one column |
433
438
 
434
439
  ---
435
440
 
441
+ ## Performance
442
+
443
+ **Any change to `miller-base.ts` or `miller-list.ts` that touches `render`,
444
+ the per-item template, selection handling, cursor movement, column opening or
445
+ lazy loading should be checked with `pnpm bench:miller`.** Every update
446
+ re-renders every item in every open column, so an accidental O(items) (or
447
+ O(descendants), in `checked` mode) per-keystroke cost is easy to introduce and
448
+ shows up directly as key-repeat lag.
449
+
450
+ `scripts/bench/miller.ts` drives `demo/miller.html` in an isolated headless
451
+ Chrome (its own temp profile; set `CHROME_PATH` if Chrome isn't in a standard
452
+ location). It needs the dev server running (`pnpm start`). It mounts its own
453
+ list and runs two scenarios per item count (default 500 and 2000 rows per
454
+ column): `standard`, and `checked` with a loaded 20 × 10 subtree under most
455
+ rows (which the checkbox state scan must walk). It reports:
456
+
457
+ - `arrow` / `page` / `hover` — main-thread `script` and `render` ms per
458
+ Arrow Down, Page Up/Down, or hover (from a timeline trace).
459
+ - `resize` — the same, per frame of a column-resize drag.
460
+ - `openMs` — median ms to click a branch whose children are already loaded and
461
+ render the new column.
462
+ - `lazyOpenMs` — the same for an unloaded branch resolved by an instant
463
+ `loadChildren`. This exposes any artificial delay in the lazy-load path
464
+ (clicks must start loading immediately; only keyboard navigation is
465
+ debounced).
466
+ - `checkToggleMs` — median ms to toggle a branch checkbox (`checked` only).
467
+
468
+ Workflow — record a baseline on the unchanged code, make the change, compare:
469
+
470
+ ```sh
471
+ pnpm bench:miller --out /tmp/miller-base.json # before
472
+ pnpm bench:miller --compare /tmp/miller-base.json # after
473
+ ```
474
+
475
+ `--compare` prints each metric's % change and lists anything over
476
+ `--threshold` (default 50%, and never changes under 1 ms — run-to-run noise is
477
+ a few %). Add `--fail-on-regression` to exit non-zero. `--items 500,5000`
478
+ changes the column sizes. Baselines are machine-specific, so keep them local
479
+ rather than committing them. The dev server runs Lit in dev mode, so absolute
480
+ numbers are higher than a production build — compare runs against each other,
481
+ not against a budget.
482
+
483
+ Reference numbers (M-series Mac, dev mode, 2000 rows per column, `standard`):
484
+ an Arrow / Page / hover update is ~4 ms script + ~1 ms render; a resize frame
485
+ ~9.5 ms render; opening a loaded column ~20 ms, a lazy one ~7 ms. In `checked`
486
+ mode with the 20 × 10 subtrees, key and hover updates are ~11-12 ms and a
487
+ checkbox toggle ~27 ms, because each render scans every visible row's loaded
488
+ subtree.
489
+
490
+ ---
491
+
436
492
  ## Subclassing MillerBaseElement
437
493
 
438
494
  Create custom column renderers by subclassing `MillerBaseElement`. Override
package/docs-user/tree.md CHANGED
@@ -11,6 +11,7 @@ Three components share a common `TreeComponent` interface: **`ixfx-tree-list`**,
11
11
  1. [Data model](#data-model)
12
12
  2. [Properties](#properties)
13
13
  - [Custom checkbox (`checkboxRenderer`)](#custom-checkbox-ixfx-tree-list-only--checkboxrenderer)
14
+ - [Virtualisation](#virtualisation-ixfx-tree-list)
14
15
  3. [Selection](#selection)
15
16
  4. [Events](#events)
16
17
  5. [Keyboard navigation](#keyboard-navigation)
@@ -45,6 +46,8 @@ type TreeNode = {
45
46
 
46
47
  `TreeNode` is **immutable** — structural changes always produce a new root. Never mutate a node in place; use `TreeDataModel` convenience methods or replace `root` directly.
47
48
 
49
+ **Escape hatch for in-place edits.** The component itself fills in `node.children` when lazy-loading, and `TreeController` does the same. If you must edit `node.children` or `node.item` (e.g. an icon) in place, call `el.invalidateStructure()` afterwards. It drops the cached whole-tree indexes (parent map, icon depths, filter matches, checkbox states) and re-renders. Visible rows are re-derived on every update, so ordinary expand / collapse never needs it.
50
+
48
51
  ---
49
52
 
50
53
  ### Static trees
@@ -140,9 +143,11 @@ These properties are present on all three components (`ixfx-tree-list`, `ixfx-mi
140
143
  | `selectedNodes` | `ReadonlySet<TreeNode>` | `{}` | Full selection set (read-only) |
141
144
  | `interactionMode` | `TreeInteractionMode` | `'standard'` | How gestures map to selection changes (`ixfx-tree-list` / `ixfx-miller-list` only) |
142
145
  | `selectionFilter` | `'none' \| 'leaf' \| 'branch'` | `'leaf'` | Which node kinds can be selected (reflected attribute) |
143
- | `filterPredicate` | `TreeFilterPredicate \| undefined` | `undefined` | Filter; only nodes returning `true` are shown |
146
+ | `filterPredicate` | `TreeFilterPredicate \| undefined` | `undefined` | Filter; only matching nodes and their ancestors are rendered (non-matching rows are not in the DOM, keyboard navigation, `selectAll` and Shift-range skip them) |
144
147
  | `exclusivity` | `'none' \| 'depth' \| 'global'` | `'none'` | Expansion exclusivity (reflected attribute) |
145
148
  | `stickyHeaders` (`sticky-headers`) | `boolean` | `true` | VS Code-style sticky headers; set `sticky-headers="false"` to opt out (`ixfx-tree-list` only) |
149
+ | `virtualize` | `'auto' \| 'on' \| 'off'` | `'auto'` | Render only the rows near the viewport; `auto` switches on above `virtualizeThreshold` visible rows (`ixfx-tree-list` only). See [Virtualisation](#virtualisation-ixfx-tree-list) |
150
+ | `virtualizeThreshold` (`virtualize-threshold`) | `number` | `200` | Visible-row count above which `virtualize="auto"` windows the list |
146
151
 
147
152
  ### `interactionMode` (`ixfx-tree-list` / `ixfx-miller-list`)
148
153
 
@@ -220,6 +225,51 @@ On by default: while scrolling vertically, the ancestors of the top-visible row
220
225
  - An ancestor that is itself still visible is never duplicated in the header.
221
226
  - Style via `--tree-sticky-bg`, `--tree-sticky-border`, `--tree-sticky-shadow`, and the `sticky-headers` / `sticky-header` parts.
222
227
 
228
+ ### Virtualisation (`ixfx-tree-list`)
229
+
230
+ A node with 10,000 children needs only about 25 `<li>` elements in the DOM. When
231
+ the number of visible rows (expanded rows, after any filter) exceeds
232
+ `virtualize-threshold` (default 200), `ixfx-tree-list` renders only the rows
233
+ near the viewport, plus 5 rows of overscan either side. Two spacer `<li>`s
234
+ hold the scroll height for the rest. Force it with `virtualize="on"` or disable
235
+ it with `virtualize="off"`; `data-virtual` is set on the host while the list is
236
+ windowed.
237
+
238
+ ```html
239
+ <ixfx-tree-list virtualize="on" style="--tree-item-height: 28px"></ixfx-tree-list>
240
+ ```
241
+
242
+ Everything is keyed by node, not by DOM element, so these behave exactly as
243
+ they do without windowing and cover rows that are not rendered:
244
+
245
+ - **Cursor, selection, `selectAll`, Shift-range** and **checkbox states**.
246
+ - **Keyboard**: arrows move across the whole list, and Home / End / PageUp / PageDown
247
+ jump by index. The cursor row is scrolled into view in the same frame, so it
248
+ is never missing.
249
+ - **Incremental search**: `navigate`, `select` and `filter` modes search the whole tree.
250
+ Match highlights are repainted as the window moves.
251
+ - **`navigateTo` / `revealKey`**: expand ancestors and scroll to the row.
252
+ - **Drag and drop**: drop targets are computed from the layout, so auto-scroll while dragging works.
253
+ - **Sticky headers**: found by arithmetic, with no per-row measuring.
254
+
255
+ Constraints while windowed:
256
+
257
+ - Every row must render at the same height. The list measures a rendered row's
258
+ box (which includes its padding and border, so it is taller than
259
+ `--tree-item-height` alone) and its top margin, and places rows by that stride.
260
+ Keep labels single-line (they truncate with an ellipsis) and custom row content within the row.
261
+ - **Expand / collapse and insert / remove rows do not animate.** The
262
+ fading "ghost" rows need variable heights. Below the threshold, or with
263
+ `virtualize="off"`, animation works as before.
264
+ - The sticky-header overlay is taken out of the layout flow so it cannot shift row positions.
265
+ - Rows outside the window do not exist in the DOM. Use the component's API
266
+ (`getNodes()`, `selectedNodes`, `revealKey()`) rather than `querySelector` to find a node.
267
+
268
+ Every row gets `aria-level`, `aria-setsize` and `aria-posinset`, counted over
269
+ **all** siblings (those matching the filter), so assistive technology
270
+ reports "5,000 of 10,000" even though most siblings are not in the DOM. The list
271
+ also exposes `aria-rowcount`.
272
+
223
273
  ---
224
274
 
225
275
  ## Selection
@@ -250,6 +300,8 @@ el.deselectAll();
250
300
  | `checked` | Checks the checkbox of each visible node (recursively toggles descendants) | Unchecks each visible node |
251
301
  | All other modes | Adds every visible selectable node to the selection | Clears the selection |
252
302
 
303
+ While a `filterPredicate` is active, `selectAll()` / `deselectAll()` and Shift-range selection cover only the rows that are shown (as in `ixfx-vertical-list`).
304
+
253
305
  Both methods dispatch a single `select` event with the full old and new selection sets. `selectAll` respects `selectionFilter` — only nodes that pass the filter are selected.
254
306
 
255
307
  ### Reading the selection
@@ -423,9 +475,12 @@ The element must be focused (click it or tab to it) before keyboard navigation w
423
475
  | `Arrow Right` (on leaf) | No-op |
424
476
  | `Arrow Left` (on expanded branch) | Collapse branch |
425
477
  | `Arrow Left` (on leaf or collapsed branch) | Move to parent |
478
+ | `Home` / `End` | Move cursor to the first / last visible item |
479
+ | `Page Up` / `Page Down` | Move cursor up / down by about one viewport |
426
480
  | `Enter` | Select and activate focused item |
427
481
  | `Escape` | Cancel in-flight lazy load |
428
482
 
483
+ Holding `Shift` with any movement key extends the range selection (in `standard` / `vscode` modes).
429
484
  Moving the cursor fires a `tickle` event. Pressing Enter fires `select` then `activate`.
430
485
 
431
486
  ### `ixfx-miller-list`
@@ -540,6 +595,8 @@ Each tree-like component must implement the following. These are enforced by the
540
595
  - [ ] `role="tree"` on the outermost interactive container
541
596
  - [ ] `aria-label` or `aria-labelledby` to name the tree
542
597
 
598
+ `ixfx-tree-list` sets all of the item attributes below, including `aria-setsize` / `aria-posinset` (see [Virtualisation](#virtualisation-ixfx-tree-list)); the checklist is still outstanding for the other tree components.
599
+
543
600
  **Each item element**
544
601
 
545
602
  - [ ] `role="treeitem"`
@@ -573,6 +630,9 @@ at once the animation is skipped, as it is entirely under
573
630
  `prefers-reduced-motion: reduce`. Duration: `--ixfx-list-item-move-duration`
574
631
  (default `200ms`).
575
632
 
633
+ Typing in a filter search does not animate, and neither does a windowed
634
+ (virtualised) list: see [Virtualisation](#virtualisation-ixfx-tree-list).
635
+
576
636
  ---
577
637
 
578
638
  ## CSS variables
@@ -603,7 +663,8 @@ These are set by the global theme and inherited by all tree components.
603
663
  | Variable | Default | Description |
604
664
  |----------|---------|-------------|
605
665
  | `--tree-indent` | `20px` | Indentation per depth level |
606
- | `--tree-item-height` | `30px` | Height of each row |
666
+ | `--tree-item-height` | `30px` | Content height of each row (padding and border add to the rendered box) |
667
+ | `--tree-item-gap` | `1px` | Margin above each row |
607
668
  | `--tree-caret-size` | `30px` | Expand/collapse caret size |
608
669
  | `--ixfx-list-item-move-duration` | `200ms` | Row enter / leave animation duration |
609
670
  | `--tree-drop-line` | `var(--accent)` | Colour of the before/after insertion line + nub |
@@ -588,6 +588,8 @@ The element must be focused (click it or tab to it) before keyboard navigation w
588
588
  |---|---|
589
589
  | `Arrow Down` | Move cursor to next visible item |
590
590
  | `Arrow Up` | Move cursor to previous visible item |
591
+ | `Home` / `End` | Move cursor to the first / last item (across the whole list, including pooled rows when virtualized) |
592
+ | `Page Down` / `Page Up` | Move cursor by one viewport of rows |
591
593
  | `Enter` | Activate the cursor item — fires `list-item-click` then `list-activate`; selects the item (except in `manual` mode) |
592
594
  | `Space` | Toggle selection on the cursor item |
593
595
  | `Shift+Arrow Down/Up` | Extend range from anchor (`vscode` mode only) |
@@ -756,7 +758,13 @@ dispatch. Mirrors `ixfx-grid-list`'s `grid-list-viewport-change`.
756
758
 
757
759
  - **Drag-select is disabled** — the window shifts under a drag, so the marquee band can't be tracked reliably.
758
760
  - **Incremental-search `filter` mode is excluded** — it collapses rows via `hidden`, corrupting the fixed-stride geometry. `navigate` and `select` modes still work.
759
- - **Keyboard navigation stays within the rendered window** — scroll first.
761
+
762
+ ### Search, keyboard and accessibility while virtualized
763
+
764
+ - **Incremental search covers every row**, rendered or pooled. Matches in `navigate` / `select` mode don't change as you scroll, and highlights repaint as the window moves.
765
+ - **Keyboard navigation spans the whole list** (arrows, Home/End, Page Up/Down, `item-wrap="wrap"`). The cursor is scrolled to the nearest edge only when it would otherwise leave the viewport.
766
+ - **Native multi-row drag** includes selected rows that are currently pooled; `ListDragEntry.index` is the row's position in the full list.
767
+ - **Accessibility:** attached rows get `aria-setsize` / `aria-posinset` for the whole list. Rows without a `role` get `role="option"` and `aria-selected`; a role you set yourself is preserved. The inner list is `role="listbox"` unless the host has its own `role`.
760
768
 
761
769
  ---
762
770
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ixfx/components",
3
3
  "type": "module",
4
- "version": "0.13.2",
4
+ "version": "0.13.3",
5
5
  "packageManager": "pnpm@10.0.0",
6
6
  "description": "Web components for ixfx — Lit-based UI toolkit",
7
7
  "author": "Clint Heyer",
@@ -216,6 +216,11 @@
216
216
  "scripts": {
217
217
  "typecheck": "tsc --noEmit",
218
218
  "test": "vitest run",
219
+ "bench:grid-list": "node --experimental-strip-types ./scripts/bench/grid-list.ts",
220
+ "bench:miller": "node --experimental-strip-types ./scripts/bench/miller.ts",
221
+ "bench:data-grid": "node --experimental-strip-types ./scripts/bench/data-grid.ts",
222
+ "bench:detail-list": "node --experimental-strip-types ./scripts/bench/detail-list.ts",
223
+ "test:perf": "PERF_REPORT=1 vitest run --reporter=verbose .perf.test",
219
224
  "start": "vite serve",
220
225
  "dev": "vite serve",
221
226
  "lint": "eslint",