@ultimat3/ui 29.4.0 → 30.0.0

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 (168) hide show
  1. package/CATALOG.md +491 -24
  2. package/CLAUDE.md +23 -16
  3. package/README.md +338 -33
  4. package/package.json +16 -5
  5. package/src/components/AppShell.module.scss +23 -7
  6. package/src/components/AppShell.tsx +9 -0
  7. package/src/components/BarChart.module.scss +26 -0
  8. package/src/components/BarChart.tsx +242 -93
  9. package/src/components/Board.module.scss +30 -0
  10. package/src/components/Board.tsx +47 -0
  11. package/src/components/BottomNav.module.scss +49 -0
  12. package/src/components/BottomNav.tsx +57 -0
  13. package/src/components/Calendar.module.scss +118 -0
  14. package/src/components/Calendar.tsx +246 -0
  15. package/src/components/ChartFrame.module.scss +40 -3
  16. package/src/components/ChartFrame.tsx +84 -28
  17. package/src/components/CodeBlock.module.scss +101 -0
  18. package/src/components/CodeBlock.tsx +76 -0
  19. package/src/components/CommandPalette.module.scss +25 -0
  20. package/src/components/CommandPalette.tsx +50 -22
  21. package/src/components/ConfirmDialog.module.scss +36 -0
  22. package/src/components/ConfirmDialog.tsx +188 -0
  23. package/src/components/DataTable.tsx +92 -33
  24. package/src/components/DatePicker.module.scss +37 -0
  25. package/src/components/DatePicker.tsx +134 -0
  26. package/src/components/DateRangePicker.module.scss +54 -0
  27. package/src/components/DateRangePicker.tsx +156 -0
  28. package/src/components/DescriptionList.module.scss +46 -0
  29. package/src/components/DescriptionList.tsx +38 -0
  30. package/src/components/DiffView.module.scss +124 -0
  31. package/src/components/DiffView.tsx +169 -0
  32. package/src/components/DonutChart.tsx +13 -4
  33. package/src/components/Drawer.module.scss +40 -0
  34. package/src/components/Drawer.tsx +56 -0
  35. package/src/components/Gauge.module.scss +8 -0
  36. package/src/components/Gauge.tsx +40 -13
  37. package/src/components/HeatStrip.module.scss +133 -0
  38. package/src/components/HeatStrip.tsx +64 -0
  39. package/src/components/JsonView.module.scss +64 -0
  40. package/src/components/JsonView.tsx +68 -0
  41. package/src/components/LineChart.tsx +45 -18
  42. package/src/components/LiveIndicator.module.scss +44 -0
  43. package/src/components/LiveIndicator.tsx +45 -0
  44. package/src/components/LogViewer.module.scss +102 -0
  45. package/src/components/LogViewer.tsx +123 -0
  46. package/src/components/Markdown.module.scss +87 -0
  47. package/src/components/Markdown.tsx +84 -0
  48. package/src/components/Meter.module.scss +7 -0
  49. package/src/components/Meter.tsx +16 -0
  50. package/src/components/NavList.module.scss +66 -0
  51. package/src/components/NavList.tsx +95 -0
  52. package/src/components/Pagination.module.scss +6 -2
  53. package/src/components/Pagination.tsx +57 -13
  54. package/src/components/Popover.tsx +12 -2
  55. package/src/components/PullToRefresh.module.scss +41 -0
  56. package/src/components/PullToRefresh.tsx +108 -0
  57. package/src/components/Radio.module.scss +51 -0
  58. package/src/components/Radio.tsx +12 -1
  59. package/src/components/SearchableSelect.module.scss +216 -0
  60. package/src/components/SearchableSelect.tsx +331 -0
  61. package/src/components/SearchableSelectChips.tsx +41 -0
  62. package/src/components/SearchableSelectNative.tsx +89 -0
  63. package/src/components/ShortcutSheet.module.scss +33 -0
  64. package/src/components/ShortcutSheet.tsx +67 -0
  65. package/src/components/Slider.module.scss +50 -0
  66. package/src/components/Slider.tsx +68 -0
  67. package/src/components/SortableList.module.scss +72 -0
  68. package/src/components/SortableList.tsx +42 -0
  69. package/src/components/Sparkline.module.scss +10 -2
  70. package/src/components/Sparkline.tsx +27 -10
  71. package/src/components/SplitPane.module.scss +39 -0
  72. package/src/components/SplitPane.tsx +96 -0
  73. package/src/components/StatTile.module.scss +29 -3
  74. package/src/components/StatTile.tsx +75 -16
  75. package/src/components/Stepper.module.scss +78 -0
  76. package/src/components/Stepper.tsx +73 -0
  77. package/src/components/Tabs.module.scss +13 -1
  78. package/src/components/Tabs.tsx +77 -1
  79. package/src/components/TerminalView.module.scss +68 -0
  80. package/src/components/TerminalView.tsx +97 -0
  81. package/src/components/TimeRangePicker.module.scss +85 -0
  82. package/src/components/TimeRangePicker.tsx +91 -0
  83. package/src/components/Timeline.module.scss +74 -0
  84. package/src/components/Timeline.tsx +60 -0
  85. package/src/components/Tree.module.scss +98 -0
  86. package/src/components/Tree.tsx +185 -0
  87. package/src/components/VirtualList.module.scss +60 -0
  88. package/src/components/VirtualList.tsx +251 -0
  89. package/src/components/app-shell-view.ts +4 -1
  90. package/src/components/bar-chart-view.ts +121 -39
  91. package/src/components/board-view.ts +25 -0
  92. package/src/components/bottom-nav-view.ts +18 -0
  93. package/src/components/calendar-view.ts +226 -0
  94. package/src/components/chart-checks.ts +59 -0
  95. package/src/components/chart-frame-view.ts +33 -2
  96. package/src/components/chart-reference-view.ts +41 -0
  97. package/src/components/command-palette-view.ts +47 -6
  98. package/src/components/confirm-dialog-view.ts +47 -0
  99. package/src/components/data-table-rows.ts +93 -0
  100. package/src/components/date-picker-view.ts +38 -0
  101. package/src/components/date-range-view.ts +46 -0
  102. package/src/components/diff-view-view.ts +85 -0
  103. package/src/components/drawer-gesture-view.ts +33 -0
  104. package/src/components/gauge-view.ts +29 -0
  105. package/src/components/heat-strip-view.ts +68 -0
  106. package/src/components/json-view-view.ts +54 -0
  107. package/src/components/line-chart-view.ts +87 -25
  108. package/src/components/log-viewer-view.ts +51 -0
  109. package/src/components/markdown-nodes.tsx +150 -0
  110. package/src/components/nav-list-view.ts +37 -0
  111. package/src/components/pull-refresh-view.ts +93 -0
  112. package/src/components/rank-options.ts +61 -0
  113. package/src/components/searchable-select-keys.ts +118 -0
  114. package/src/components/searchable-select-view.ts +187 -0
  115. package/src/components/slider-view.ts +47 -0
  116. package/src/components/sort-pointer.ts +141 -0
  117. package/src/components/sort-surface.tsx +231 -0
  118. package/src/components/sparkline-view.ts +74 -14
  119. package/src/components/split-pane-view.ts +69 -0
  120. package/src/components/stat-delta.ts +54 -11
  121. package/src/components/stepper-view.ts +38 -0
  122. package/src/components/thunk.ts +9 -0
  123. package/src/components/timeline-view.ts +29 -0
  124. package/src/components/tree-node.tsx +133 -0
  125. package/src/components/tree-view.ts +185 -0
  126. package/src/components/use-command-palette.ts +153 -0
  127. package/src/components/virtual-list-anchor.ts +35 -0
  128. package/src/components/virtual-list-observer.ts +58 -0
  129. package/src/components/virtual-list-view.ts +145 -0
  130. package/src/content/ansi.ts +144 -0
  131. package/src/content/heading-slug.ts +32 -0
  132. package/src/content/highlight.ts +129 -0
  133. package/src/content/languages/all.ts +21 -0
  134. package/src/content/languages/diff.ts +14 -0
  135. package/src/content/languages/html.ts +16 -0
  136. package/src/content/languages/json.ts +15 -0
  137. package/src/content/languages/shell.ts +20 -0
  138. package/src/content/languages/sql.ts +27 -0
  139. package/src/content/languages/ts.ts +26 -0
  140. package/src/content/languages/yaml.ts +18 -0
  141. package/src/content/line-diff.ts +191 -0
  142. package/src/content/markdown-inline.ts +368 -0
  143. package/src/content/markdown-parse.ts +267 -0
  144. package/src/content/markdown-tree.ts +58 -0
  145. package/src/content/unified-patch.ts +53 -0
  146. package/src/drag/pointer-drag.ts +185 -0
  147. package/src/drag/sort-controller.ts +196 -0
  148. package/src/drag/sort-view.ts +201 -0
  149. package/src/errors.ts +1 -1
  150. package/src/form/field-path.ts +7 -33
  151. package/src/form/form-binding.ts +5 -8
  152. package/src/form/form-issue.ts +30 -36
  153. package/src/form/form-values.ts +3 -2
  154. package/src/hotkeys-view.ts +150 -0
  155. package/src/i18n-keys.ts +7 -50
  156. package/src/index.ts +214 -15
  157. package/src/list-url.ts +123 -0
  158. package/src/live-window.ts +23 -0
  159. package/src/roving.ts +2 -0
  160. package/src/theme/inert-runtime.ts +4 -0
  161. package/src/theme/runtime-slot.ts +2 -2
  162. package/src/theme/solid-adapter.ts +11 -5
  163. package/src/time-range-url.ts +200 -0
  164. package/src/tokens/_mixins.scss +1 -1
  165. package/src/tokens/_touch.scss +30 -1
  166. package/src/tokens/contrast-pairs.ts +12 -0
  167. package/src/ui-keys.ts +126 -0
  168. package/src/use-hotkeys.ts +145 -0
@@ -0,0 +1,98 @@
1
+ @use '../tokens' as t;
2
+
3
+ .tree {
4
+ font-size: t.text(sm);
5
+ }
6
+
7
+ .root,
8
+ .group {
9
+ margin: 0;
10
+ padding: 0;
11
+ list-style: none;
12
+ }
13
+
14
+ // Indent caps at a few levels: past it, a deep path still fits a phone and stays readable.
15
+ .group {
16
+ padding-inline-start: min(t.space(4), 4vw);
17
+ }
18
+
19
+ .item {
20
+ min-inline-size: 0;
21
+ }
22
+
23
+ .link,
24
+ .summary,
25
+ .row {
26
+ display: flex;
27
+ align-items: center;
28
+ gap: t.space(2);
29
+ min-block-size: t.touch(target);
30
+ @include t.padding-inline(t.space(2));
31
+ border-radius: t.radius(sm);
32
+ color: t.role('fg');
33
+ overflow-wrap: anywhere;
34
+ cursor: pointer;
35
+
36
+ &:hover {
37
+ background: t.role('bg-soft');
38
+ }
39
+ }
40
+
41
+ .link {
42
+ text-decoration: none;
43
+
44
+ &[aria-current='page'] {
45
+ color: t.role('accent-strong');
46
+ font-weight: t.weight(semibold);
47
+ background: t.role('accent', 0.12);
48
+ }
49
+ }
50
+
51
+ .link:focus-visible,
52
+ .summary:focus-visible,
53
+ .item[role='treeitem']:focus-visible > .row {
54
+ outline: t.stroke(thick) solid t.role('accent');
55
+ outline-offset: calc(-1 * #{t.stroke(thick)});
56
+ }
57
+
58
+ .item[role='treeitem'] {
59
+ outline: none;
60
+ }
61
+
62
+ .item[aria-selected='true'] > .row {
63
+ color: t.role('accent-strong');
64
+ background: t.role('accent', 0.12);
65
+ }
66
+
67
+ .item[aria-disabled='true'] > .row {
68
+ color: t.role('fg-muted');
69
+ cursor: not-allowed;
70
+ }
71
+
72
+ .twisty {
73
+ display: inline-block;
74
+ inline-size: t.space(3);
75
+ color: t.role('fg-muted');
76
+
77
+ &[data-open] {
78
+ transform: rotate(90deg);
79
+ }
80
+ }
81
+
82
+ // Under `dir="rtl"` the closed marker points inward from the other side.
83
+ :global([dir='rtl']) .twisty:not([data-open]) {
84
+ transform: scaleX(-1);
85
+ }
86
+
87
+ .label {
88
+ min-inline-size: 0;
89
+ }
90
+
91
+ .loading {
92
+ color: t.role('fg-muted');
93
+ font-size: t.text(xs);
94
+ }
95
+
96
+ .branch {
97
+ margin: 0;
98
+ }
@@ -0,0 +1,185 @@
1
+ // Files, folders, org charts: one node list, two renderings. With `hrefFor` it is nested `<ul>` and
2
+ // `<details>` with links — open and close are the platform's, 0 B of script. With `onSelect` it is
3
+ // an APG `role="tree"` with one tab stop, arrows, type-ahead and lazy children: an island's.
4
+
5
+ import type { JSX } from 'solid-js';
6
+ import { cx } from '../cx';
7
+ import { TREE_ITEM_SELECTOR } from '../roving';
8
+ import { useUi } from '../theme/context';
9
+ import { solid } from '../theme/solid-adapter';
10
+ import styles from './Tree.module.scss';
11
+ import { type TreeItemState, TreeLinkNode, TreeSelectNode } from './tree-node';
12
+ import {
13
+ flattenTree,
14
+ type TreeNode,
15
+ treeAncestors,
16
+ treeMove,
17
+ treeTypeahead,
18
+ visibleTreeIds,
19
+ withLoadedChildren,
20
+ } from './tree-view';
21
+
22
+ interface TreeBaseProps {
23
+ nodes: readonly TreeNode[];
24
+ /** Already-translated accessible name. */
25
+ label: string;
26
+ /** The current node: `aria-current="page"` in link mode, `aria-selected` in select mode. Its ancestors open. */
27
+ selected?: string | undefined;
28
+ /** Branches open on first render, beside the selected node's ancestors. */
29
+ open?: readonly string[] | undefined;
30
+ class?: string | undefined;
31
+ }
32
+
33
+ /** Link mode: every leaf is a link. Works with scripting off. */
34
+ export interface TreeLinkProps extends TreeBaseProps {
35
+ hrefFor: (node: TreeNode) => string;
36
+ onSelect?: undefined;
37
+ onExpand?: undefined;
38
+ }
39
+
40
+ /** Select mode, in an island: `role="tree"`, and `onExpand` fetches a `lazy` node's children. */
41
+ export interface TreeSelectProps extends TreeBaseProps {
42
+ onSelect: (id: string) => void;
43
+ onExpand?: ((id: string) => Promise<readonly TreeNode[]>) | undefined;
44
+ hrefFor?: undefined;
45
+ }
46
+
47
+ export type TreeProps = TreeLinkProps | TreeSelectProps;
48
+
49
+ const MODIFIED = (event: KeyboardEvent): boolean => event.ctrlKey || event.metaKey || event.altKey;
50
+
51
+ export function Tree(props: TreeProps): JSX.Element {
52
+ const rt = solid();
53
+ const ui = useUi();
54
+ const [loaded, setLoaded] = rt.createSignal<ReadonlyMap<string, readonly TreeNode[]>>(new Map());
55
+ const [loading, setLoading] = rt.createSignal<ReadonlySet<string>>(new Set());
56
+ const nodes = rt.createMemo(() => withLoadedChildren(props.nodes, loaded()));
57
+ const flat = rt.createMemo(() => flattenTree(nodes()));
58
+ const initialOpen = (): ReadonlySet<string> =>
59
+ new Set([...(props.open ?? []), ...treeAncestors(flat(), props.selected)]);
60
+ const [open, setOpen] = rt.createSignal<ReadonlySet<string>>(initialOpen());
61
+ const [active, setActive] = rt.createSignal<string | undefined>(undefined);
62
+ let root: HTMLUListElement | undefined;
63
+
64
+ const states = rt.createMemo(
65
+ () =>
66
+ new Map<string, TreeItemState>(
67
+ flat().map((entry) => [
68
+ entry.node.id,
69
+ {
70
+ level: entry.level,
71
+ setsize: entry.setsize,
72
+ posinset: entry.posinset,
73
+ branch: entry.branch,
74
+ },
75
+ ]),
76
+ ),
77
+ );
78
+
79
+ /** One tab stop: the node last moved to, else the selected one if reachable, else the first. */
80
+ const stop = (): string | undefined => {
81
+ const visible = visibleTreeIds(flat(), open());
82
+ const chosen = active() ?? props.selected;
83
+ return chosen !== undefined && visible.includes(chosen) ? chosen : visible[0];
84
+ };
85
+
86
+ const focusNode = (id: string): void => {
87
+ setActive(id);
88
+ const items =
89
+ root === undefined ? [] : [...root.querySelectorAll<HTMLElement>(TREE_ITEM_SELECTOR)];
90
+ items.find((item) => item.dataset['treeId'] === id)?.focus();
91
+ };
92
+
93
+ const setNodeOpen = (id: string, next: boolean): void => {
94
+ const copy = new Set(open());
95
+ if (next) copy.add(id);
96
+ else copy.delete(id);
97
+ setOpen(copy);
98
+ const node = flat().find((entry) => entry.node.id === id)?.node;
99
+ const onExpand = props.onExpand;
100
+ if (!next || onExpand === undefined || node?.lazy !== true || loaded().has(id)) return;
101
+ setLoading(new Set([...loading(), id]));
102
+ const settle = (): void => setLoading(new Set([...loading()].filter((each) => each !== id)));
103
+ void onExpand(id).then(
104
+ (children) => {
105
+ setLoaded(new Map([...loaded(), [id, children]]));
106
+ settle();
107
+ },
108
+ (error: unknown) => {
109
+ // Closed again, and the rejection kept: a fetch that failed is the caller's to report.
110
+ settle();
111
+ setOpen(new Set([...open()].filter((each) => each !== id)));
112
+ throw error;
113
+ },
114
+ );
115
+ };
116
+
117
+ const activate = (id: string): void => {
118
+ setActive(id);
119
+ props.onSelect?.(id);
120
+ if (states().get(id)?.branch === true) setNodeOpen(id, !open().has(id));
121
+ };
122
+
123
+ const onKeyDown = (event: KeyboardEvent): void => {
124
+ const item = (event.target as Element | null)?.closest?.('[role="treeitem"]');
125
+ const current = item instanceof HTMLElement ? item.dataset['treeId'] : undefined;
126
+ if (current === undefined) return;
127
+ if (event.key === 'Enter' || event.key === ' ') {
128
+ event.preventDefault();
129
+ activate(current);
130
+ return;
131
+ }
132
+ const move = treeMove(flat(), open(), current, event.key, ui.dir);
133
+ if (move !== undefined) {
134
+ event.preventDefault();
135
+ if (move.kind === 'focus') focusNode(move.id);
136
+ else setNodeOpen(move.id, move.kind === 'open');
137
+ return;
138
+ }
139
+ if (event.key.length === 1 && !MODIFIED(event)) {
140
+ const found = treeTypeahead(flat(), open(), current, event.key, ui.locale);
141
+ if (found !== undefined) focusNode(found);
142
+ }
143
+ };
144
+
145
+ return (
146
+ <div class={cx(styles['tree'], props.class)}>
147
+ {props.hrefFor !== undefined ? (
148
+ <ul class={styles['root']} aria-label={props.label}>
149
+ {nodes().map((node) => (
150
+ <TreeLinkNode
151
+ node={node}
152
+ hrefFor={props.hrefFor as (node: TreeNode) => string}
153
+ selected={props.selected}
154
+ open={initialOpen()}
155
+ />
156
+ ))}
157
+ </ul>
158
+ ) : (
159
+ <ul
160
+ class={styles['root']}
161
+ // biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: APG's tree is a list of items, one keydown on the root
162
+ role="tree"
163
+ aria-label={props.label}
164
+ onKeyDown={onKeyDown}
165
+ ref={(el: HTMLUListElement) => {
166
+ root = el;
167
+ }}
168
+ >
169
+ {nodes().map((node) => (
170
+ <TreeSelectNode
171
+ node={node}
172
+ state={(id) => states().get(id)}
173
+ isOpen={(id) => open().has(id)}
174
+ isLoading={(id) => loading().has(id)}
175
+ isStop={(id) => stop() === id}
176
+ selected={props.selected}
177
+ childrenOf={(each) => each.children ?? []}
178
+ onActivate={activate}
179
+ />
180
+ ))}
181
+ </ul>
182
+ )}
183
+ </div>
184
+ );
185
+ }
@@ -0,0 +1,60 @@
1
+ @use '../tokens' as t;
2
+
3
+ // One row that may SHRINK: an `auto` row grows to every row's height whenever the caller's size is
4
+ // a cap (`max-block-size`) or a flex share rather than a fixed block size, and the list then
5
+ // measures itself as tall as all its rows and draws each one (`ui-virtual-list.e2e.test.ts`).
6
+ .wrap {
7
+ position: relative;
8
+ display: grid;
9
+ grid-template-rows: minmax(0, 1fr);
10
+ block-size: 100%;
11
+ min-block-size: 0;
12
+ }
13
+
14
+ // The caller's box sets the height; this one scrolls inside it. The platform's scroll anchoring
15
+ // is off because the component keeps the reader's row itself, and two anchors fight.
16
+ .box {
17
+ @include t.custom-scrollbar;
18
+ min-block-size: 0;
19
+ overflow-y: auto;
20
+ overflow-anchor: none;
21
+ overscroll-behavior: contain;
22
+ -webkit-overflow-scrolling: touch;
23
+
24
+ &:focus-visible {
25
+ outline: t.stroke(thick) solid t.role('accent');
26
+ outline-offset: calc(-1 * #{t.stroke(thick)});
27
+ }
28
+ }
29
+
30
+ // The padding stands in for the rows not drawn, so the scrollbar is the whole list's.
31
+ .list {
32
+ margin: 0;
33
+ padding-block: var(--virtual-before, 0) var(--virtual-after, 0);
34
+ padding-inline: 0;
35
+ list-style: none;
36
+ }
37
+
38
+ .row {
39
+ display: block;
40
+ }
41
+
42
+ .toEnd {
43
+ @include t.touch-target;
44
+ @include t.padding-inline(t.space(4));
45
+ position: absolute;
46
+ inset-block-end: t.space(3);
47
+ inset-inline-end: t.space(3);
48
+ border: 1px solid t.role('line');
49
+ border-radius: t.radius(pill);
50
+ color: t.role('fg-strong');
51
+ background: t.role('surface-raised');
52
+ box-shadow: t.shadow(md);
53
+ font-size: t.text(sm);
54
+ cursor: pointer;
55
+
56
+ &:focus-visible {
57
+ outline: t.stroke(thick) solid t.role('accent');
58
+ outline-offset: t.stroke(thick);
59
+ }
60
+ }
@@ -0,0 +1,251 @@
1
+ // A long list that mounts only what is on screen: a real `<ul>` in a scroll box, padded above and
2
+ // below by what is not drawn. Server-rendered, and with scripting off, every row renders — the
3
+ // document carries the whole list. The arithmetic is `virtual-list-view.ts`; this file is wiring.
4
+
5
+ import { finiteCount, finiteOption } from '@ultimat3/core';
6
+ import type { JSX } from 'solid-js';
7
+ import { cx } from '../cx';
8
+ import { invalidValueError } from '../errors';
9
+ import { UI_KEYS } from '../i18n-keys';
10
+ import { useUi } from '../theme/context';
11
+ import type { Accessor } from '../theme/solid-adapter';
12
+ import { solid } from '../theme/solid-adapter';
13
+ import { keyedRows } from './data-table-rows';
14
+ import styles from './VirtualList.module.scss';
15
+ import { domRowOffset, readDomAnchor } from './virtual-list-anchor';
16
+ import { createVirtualListObserver, type VirtualListObserver } from './virtual-list-observer';
17
+ import {
18
+ anchorAt,
19
+ anchorCorrection,
20
+ anchoredScrollTop,
21
+ assertUniqueKeys,
22
+ endDistance,
23
+ nextAtEnd,
24
+ pruneHeights,
25
+ rowOffsets,
26
+ type ScrollAnchor,
27
+ VIRTUAL_LIST_ESTIMATE,
28
+ VIRTUAL_LIST_OVERSCAN,
29
+ visibleRange,
30
+ } from './virtual-list-view';
31
+
32
+ export interface VirtualListProps<T> {
33
+ items: readonly T[];
34
+ /** Unique per item: a visible row keeps its node — and its focus — across a live prepend. */
35
+ itemKey: (item: T) => string;
36
+ renderItem: (item: T) => JSX.Element;
37
+ /** A row's block size in CSS px until it is measured. A right estimate IS fixed-height mode. */
38
+ estimate?: number | undefined;
39
+ /** Rows drawn beyond each edge of the box. */
40
+ overscan?: number | undefined;
41
+ /** Stay at the end as items arrive, while the reader is there; offer a way back when not. */
42
+ follow?: boolean | undefined;
43
+ /** Already-translated accessible name for the list. */
44
+ label: string;
45
+ /** Size the box here: the list scrolls inside the caller's height, and a box with none renders every row. */
46
+ class?: string | undefined;
47
+ }
48
+
49
+ const px = (value: number): string => `${String(Math.max(0, Math.round(value)))}px`;
50
+
51
+ /** No smooth scroll by default, and none at all for a reader who asked the platform for less motion. */
52
+ const scrollBehavior = (): ScrollBehavior =>
53
+ typeof matchMedia === 'function' && !matchMedia('(prefers-reduced-motion: reduce)').matches
54
+ ? 'smooth'
55
+ : 'auto';
56
+
57
+ export function VirtualList<T>(props: VirtualListProps<T>): JSX.Element {
58
+ const rt = solid();
59
+ const ui = useUi();
60
+ const estimate = (): number => {
61
+ const value = finiteOption('VirtualList', 'estimate', props.estimate ?? VIRTUAL_LIST_ESTIMATE);
62
+ if (value <= 0) throw invalidValueError('VirtualList estimate', value, 'a block size above 0');
63
+ return value;
64
+ };
65
+ const overscan = (): number =>
66
+ finiteCount('VirtualList', 'overscan', props.overscan ?? VIRTUAL_LIST_OVERSCAN, 0);
67
+
68
+ const heights = new Map<string, number>();
69
+ const [measured, setMeasured] = rt.createSignal(0);
70
+ // `undefined` is a server render, which draws every row. In a browser the window stands in for
71
+ // the box until the box has been measured, so the first paint is a screenful, not the list.
72
+ const [viewport, setViewport] = rt.createSignal<number | undefined>(
73
+ typeof window === 'undefined' ? undefined : window.innerHeight,
74
+ );
75
+ const [scrollTop, setScrollTop] = rt.createSignal(0);
76
+ const [behind, setBehind] = rt.createSignal(false);
77
+ let box: HTMLDivElement | undefined;
78
+ let observer: VirtualListObserver | undefined;
79
+ const mounted = new Set<HTMLElement>();
80
+ let atEnd = true;
81
+ let anchor: ScrollAnchor | undefined;
82
+
83
+ interface Layout {
84
+ readonly keys: readonly string[];
85
+ readonly positions: ReadonlyMap<string, number>;
86
+ readonly offsets: readonly number[];
87
+ }
88
+ /** The layout the DOM is drawn from right now: the one a new layout's anchor is read against. */
89
+ let shown: Layout | undefined;
90
+
91
+ /**
92
+ * Where the reader is, read off the DOM before it changes. Called as a new layout is computed,
93
+ * so a scroll whose event has not fired yet — a `focus()` that scrolled a row into view, a fling
94
+ * in the same frame as a live update — is still where the anchor is taken. The DOM, not the
95
+ * layout: a row drawn but not yet measured sits where the browser put it, not where the estimate
96
+ * said, and the reader sees the browser's.
97
+ */
98
+ const capture = (): void => {
99
+ if (box === undefined) return;
100
+ // The DOM's own height, not the layout's, for the same reason.
101
+ atEnd = nextAtEnd(atEnd, endDistance(box.scrollTop, box.clientHeight, box.scrollHeight));
102
+ anchor =
103
+ readDomAnchor(box, mounted) ??
104
+ (shown === undefined ? undefined : anchorAt(shown.keys, shown.offsets, box.scrollTop));
105
+ };
106
+
107
+ const layout = rt.createMemo((): Layout => {
108
+ measured();
109
+ const keys = props.items.map((item) => props.itemKey(item));
110
+ assertUniqueKeys(keys);
111
+ const size = estimate();
112
+ capture();
113
+ const positions = new Map(keys.map((key, index) => [key, index]));
114
+ pruneHeights(heights, positions);
115
+ shown = {
116
+ keys,
117
+ positions,
118
+ offsets: rowOffsets(keys.map((key) => heights.get(key) ?? size)),
119
+ };
120
+ return shown;
121
+ });
122
+
123
+ /**
124
+ * Where the box WILL be scrolled under this layout: at the end for a following reader there,
125
+ * else wherever keeps the anchored row in place. The range is drawn for that position, not the
126
+ * stale one — drawn for the stale one, a prepend would unmount the very row being read (and the
127
+ * focus inside it) before the scroll correction put it back.
128
+ */
129
+ const targetTop = (keys: readonly string[], offsets: readonly number[], box: number): number => {
130
+ // Read before any branch: a memo tracks what its LAST run read, and a run that returned at the
131
+ // end without reading the scroll position would never hear the reader scroll away from it.
132
+ const scrolled = scrollTop();
133
+ const total = offsets[offsets.length - 1] ?? 0;
134
+ if (props.follow === true && atEnd) return Math.max(0, total - box);
135
+ const anchored = anchor === undefined ? undefined : anchoredScrollTop(anchor, keys, offsets);
136
+ return anchored ?? scrolled;
137
+ };
138
+
139
+ const range = rt.createMemo(() => {
140
+ const { keys, offsets } = layout();
141
+ const box = viewport();
142
+ if (box === undefined) return { start: 0, end: offsets.length - 1 };
143
+ return visibleRange(offsets, targetTop(keys, offsets, box), box, overscan());
144
+ });
145
+
146
+ const rowNode = (item: Accessor<T>, key: string): JSX.Element => (
147
+ <li
148
+ class={styles['row']}
149
+ data-key={key}
150
+ aria-setsize={layout().keys.length}
151
+ aria-posinset={(layout().positions.get(key) ?? 0) + 1}
152
+ ref={(el: HTMLLIElement) => {
153
+ mounted.add(el);
154
+ observer?.observe(el);
155
+ rt.onCleanup(() => {
156
+ mounted.delete(el);
157
+ observer?.unobserve(el);
158
+ });
159
+ }}
160
+ >
161
+ {props.renderItem(item())}
162
+ </li>
163
+ );
164
+ const rowNodes = keyedRows<T, JSX.Element>(rt, (item) => props.itemKey(item), rowNode);
165
+
166
+ /** Where the box is now, as the next layout change will need it. */
167
+ const settle = (): void => {
168
+ if (box === undefined) return;
169
+ layout();
170
+ capture();
171
+ setScrollTop(box.scrollTop);
172
+ setBehind(props.follow === true && !atEnd);
173
+ };
174
+
175
+ rt.createEffect(() => {
176
+ if (box === undefined) return;
177
+ const target = box;
178
+ observer = createVirtualListObserver(target, (sizes) => {
179
+ let changed = false;
180
+ for (const [key, size] of sizes.rows) {
181
+ if (heights.get(key) !== size) {
182
+ heights.set(key, size);
183
+ changed = true;
184
+ }
185
+ }
186
+ if (sizes.viewport !== undefined) setViewport(sizes.viewport);
187
+ if (changed) setMeasured(measured() + 1);
188
+ });
189
+ for (const element of mounted) observer?.observe(element);
190
+ setViewport(target.clientHeight);
191
+ rt.onCleanup(() => observer?.disconnect());
192
+ });
193
+
194
+ // After every layout change AND every move of the drawn range: follow the end if the reader is
195
+ // there, otherwise put the row they were reading back where it was. The range moves on its own
196
+ // (the box measured, a scroll settled), and each move swaps drawn rows for estimated padding:
197
+ // rows measured 31 px against a 48 px estimate left a follower 52 px short, which the hysteresis
198
+ // then read as scrolled away. The platform's `overflow-anchor` is off; this is the one rule.
199
+ rt.createEffect(() => {
200
+ const { keys, offsets } = layout();
201
+ range();
202
+ if (box === undefined) return;
203
+ if (props.follow === true && atEnd) {
204
+ box.scrollTop = box.scrollHeight;
205
+ } else if (anchor !== undefined) {
206
+ const drawn = domRowOffset(box, mounted, anchor.key);
207
+ const delta =
208
+ drawn === undefined
209
+ ? targetTop(keys, offsets, box.clientHeight) - box.scrollTop
210
+ : anchorCorrection(anchor, drawn);
211
+ if (Math.abs(delta) >= 1) box.scrollTop += delta;
212
+ }
213
+ settle();
214
+ });
215
+
216
+ const toEnd = (): void => {
217
+ box?.scrollTo({ top: box.scrollHeight, behavior: scrollBehavior() });
218
+ };
219
+
220
+ return (
221
+ <div class={cx(styles['wrap'], props.class)}>
222
+ <div
223
+ class={styles['box']}
224
+ ref={(el: HTMLDivElement) => {
225
+ box = el;
226
+ }}
227
+ onScroll={settle}
228
+ >
229
+ <ul
230
+ class={styles['list']}
231
+ // biome-ignore lint/a11y/noRedundantRoles: `list-style: none` drops the implicit role in WebKit
232
+ role="list"
233
+ aria-label={props.label}
234
+ style={{
235
+ '--virtual-before': px(layout().offsets[range().start] ?? 0),
236
+ '--virtual-after': px(
237
+ (layout().offsets[layout().keys.length] ?? 0) - (layout().offsets[range().end] ?? 0),
238
+ ),
239
+ }}
240
+ >
241
+ {rowNodes(props.items.slice(range().start, range().end))}
242
+ </ul>
243
+ </div>
244
+ {behind() ? (
245
+ <button type="button" class={styles['toEnd']} onClick={toEnd}>
246
+ {ui.t(UI_KEYS.listToEnd)}
247
+ </button>
248
+ ) : null}
249
+ </div>
250
+ );
251
+ }
@@ -16,6 +16,8 @@ export interface ShellSlots {
16
16
  readonly header: boolean;
17
17
  readonly sidebar: boolean;
18
18
  readonly footer: boolean;
19
+ /** `AppShell bottomNav`: a second `navigation` landmark, after the footer in DOM order. */
20
+ readonly bottomNav?: boolean | undefined;
19
21
  }
20
22
 
21
23
  export function shellIds(base: string): ShellIds {
@@ -25,7 +27,7 @@ export function shellIds(base: string): ShellIds {
25
27
 
26
28
  /**
27
29
  * DOM order, which is also screen-reader order: banner, then navigation, then main, then
28
- * contentinfo. `main` is unconditional — a shell with no main region is not a page.
30
+ * contentinfo, then the bottom bar's navigation. `main` is unconditional — a shell with no main region is not a page.
29
31
  */
30
32
  export function shellLandmarks(slots: ShellSlots): readonly ShellLandmark[] {
31
33
  const out: ShellLandmark[] = [];
@@ -33,5 +35,6 @@ export function shellLandmarks(slots: ShellSlots): readonly ShellLandmark[] {
33
35
  if (slots.sidebar) out.push('navigation');
34
36
  out.push('main');
35
37
  if (slots.footer) out.push('contentinfo');
38
+ if (slots.bottomNav === true) out.push('navigation');
36
39
  return out;
37
40
  }