@lumiose/ui 0.1.2 → 0.2.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.
@@ -0,0 +1,354 @@
1
+ // Virtualizer (TASK-565, decision-84): renders only the rows of a long list, table or grid that are
2
+ // near the viewport. Headless: it computes which rows to render and where they go; the component
3
+ // renders them, places them (absolutely from `start(row)`, or in flow between `before()` and
4
+ // `after()`), and styles them.
5
+ //
6
+ // - Rows are the unit: a list is a grid of one column. Each row's size is fixed or estimated
7
+ // (`size`), and measured once a rendered row carries `attach={[v.measure]}` and `data-row`.
8
+ // Several measured elements may share a row (a grid's cells): the row is the tallest.
9
+ // - One ResizeObserver measures every row, and one observer callback writes all its sizes at once:
10
+ // a size per row, or a write per row, would render the list once per row.
11
+ // - `rows()` and `items()` are arrays of indexes that keep their identity while the window does
12
+ // not move, so a measurement or a scroll inside the overscan renders no list.
13
+ // - Before it mounts (on the server, and while an island hydrates) it reads nothing from the
14
+ // browser: it renders the first `initial` items, so both render the same HTML. Once mounted it
15
+ // measures, and takes over the same keyed rows.
16
+ import { batch, computed, onMount, signal } from "lumiose";
17
+
18
+ /** A value, or a function that returns it (read reactively). */
19
+ export type VirtualizerValue<T> = T | (() => T);
20
+
21
+ export interface VirtualizerOptions {
22
+ /** How many items. */
23
+ count: VirtualizerValue<number>;
24
+ /**
25
+ * The size of a row along the scroll axis, in px: fixed, or the estimate of rows not measured
26
+ * yet. A function gets the row's index, and may read signals (`v.itemWidth()`).
27
+ */
28
+ size: number | ((row: number) => number);
29
+ /**
30
+ * Items per row. A number, or `{ minWidth }`: as many items of at least `minWidth` px as the
31
+ * container's width fits, with `columnGap` between them (CSS's `repeat(auto-fill, minmax(…))`),
32
+ * and at least `min` (default 1). Default 1, a list.
33
+ */
34
+ columns?: VirtualizerValue<number> | { minWidth: VirtualizerValue<number>; min?: number };
35
+ /** The px between rows. Default 0. */
36
+ gap?: VirtualizerValue<number>;
37
+ /** The px between columns, for `columns: { minWidth }` and `itemWidth()`. Default `gap`. */
38
+ columnGap?: VirtualizerValue<number>;
39
+ /** Rows rendered past each edge of the viewport. Default 4. */
40
+ overscan?: VirtualizerValue<number>;
41
+ /** The scrolling element, read once mounted. Default: the window. */
42
+ scroller?: () => Element | null | undefined;
43
+ /**
44
+ * How many items render before the virtualizer mounts: the server's HTML, and hydration's.
45
+ * Default 0.
46
+ */
47
+ initial?: VirtualizerValue<number>;
48
+ /**
49
+ * Keeps the row that holds focus rendered (the closest `[data-row]` of the focused element), so
50
+ * scrolling away does not drop focus to the page. Only for rows placed from `start(row)`: an
51
+ * in-flow window cannot hold a row outside it. Default `false`.
52
+ */
53
+ keepFocused?: boolean;
54
+ }
55
+
56
+ export interface VirtualizerScrollOptions {
57
+ /** Where the row ends up: `"auto"` (default) scrolls only as far as it takes to show it. */
58
+ align?: "auto" | "start" | "center" | "end";
59
+ behavior?: ScrollBehavior;
60
+ }
61
+
62
+ export interface Virtualizer {
63
+ /** `ref={v.container}`: the element that holds the rows. Its top is where row 0 starts. */
64
+ container: (element: HTMLElement) => void;
65
+ /**
66
+ * `attach={[v.measure]}` on a row element with `data-row={row}`: its size replaces the estimate.
67
+ * Several elements may carry the same row (a grid's cells): the row is as tall as the tallest.
68
+ */
69
+ measure: (element: HTMLElement) => () => void;
70
+ /** Whether the virtualizer runs in the browser yet (`false` on the server and while hydrating). */
71
+ mounted: () => boolean;
72
+ /** Items per row. */
73
+ columns: () => number;
74
+ /** The container's width in px (0 until mounted). */
75
+ width: () => number;
76
+ /** The width of an item in a grid: the container's width, less the column gaps, per column. */
77
+ itemWidth: () => number;
78
+ /** The indexes of the rows to render, in order. */
79
+ rows: () => number[];
80
+ /** The indexes of the items to render, in order: the items of `rows()`. */
81
+ items: () => number[];
82
+ /** The items of the window, `[start, end)` (without a kept focused row). */
83
+ range: () => { start: number; end: number };
84
+ /** Where a row starts, in px from the container's top. */
85
+ start: (row: number) => number;
86
+ /** A row's size: measured, or the estimate. */
87
+ size: (row: number) => number;
88
+ /** The size of every row and gap: the container's height. */
89
+ total: () => number;
90
+ /** The px of the rows before the window, for a spacer or a padding. */
91
+ before: () => number;
92
+ /** The px of the rows after the window. */
93
+ after: () => number;
94
+ /** Scrolls the scroller to show the item at `index`. */
95
+ scrollToIndex: (index: number, options?: VirtualizerScrollOptions) => void;
96
+ }
97
+
98
+ const read = <T>(value: VirtualizerValue<T>): T =>
99
+ typeof value === "function" ? (value as () => T)() : value;
100
+
101
+ const same = (a: number[], b: number[]) => a.length === b.length && a.every((x, i) => x === b[i]);
102
+
103
+ /** The top of a scroller's viewport on screen (the window's without one), and its height. */
104
+ const viewport = (scroller: Element | undefined) =>
105
+ scroller
106
+ ? [scroller.getBoundingClientRect().top + scroller.clientTop, scroller.clientHeight]
107
+ : [0, innerHeight];
108
+
109
+ /** The row of the closest `[data-row]` element around `node`, or -1. */
110
+ const rowOf = (node: EventTarget | null) =>
111
+ Number((node as Element | null)?.closest?.("[data-row]")?.getAttribute("data-row") ?? -1);
112
+
113
+ /** The `data-row` of a measured element, or NaN without one. */
114
+ const rowAt = (element: Element) => Number(element.getAttribute("data-row") ?? Number.NaN);
115
+
116
+ /**
117
+ * Creates a virtualizer in a component: call it in the component's body, give the rows'
118
+ * container `ref={v.container}`, and render `v.rows()` (or `v.items()`) keyed by the record's id.
119
+ *
120
+ * @example
121
+ * const list = createVirtualizer({ count: () => props.people.length, size: 48 });
122
+ * <ul ref={list.container} style={{ position: "relative", height: `${list.total()}px` }}>
123
+ * {list.items().map((i) => (
124
+ * <li key={props.people[i]!.id} style={{ position: "absolute", top: `${list.start(i)}px` }}>
125
+ * {props.people[i]!.name}
126
+ * </li>
127
+ * ))}
128
+ * </ul>
129
+ */
130
+ export function createVirtualizer(options: VirtualizerOptions): Virtualizer {
131
+ let element: HTMLElement | undefined;
132
+ let observer: ResizeObserver | undefined;
133
+ let frame = 0;
134
+ /** Measured sizes by row; `version` changes once per observer callback that changed any. */
135
+ const measured: number[] = [];
136
+ /** The measured elements and their last sizes. */
137
+ const observed = new Map<Element, number>();
138
+ let measuredColumns = 0;
139
+ const version = signal(0);
140
+ const mounted = signal(false);
141
+ const width = signal(0);
142
+ /** The scroller's viewport, from the container's top: `[top, bottom]`. */
143
+ const view = signal([0, 0]);
144
+ const focused = signal(-1);
145
+
146
+ const count = () => Math.max(0, read(options.count));
147
+ const gap = () => read(options.gap ?? 0);
148
+ const columnGap = () => read(options.columnGap ?? options.gap ?? 0);
149
+ const columns = computed(() => {
150
+ const given = options.columns ?? 1;
151
+ if (typeof given !== "object") return Math.max(1, Math.floor(read(given)));
152
+ const between = columnGap();
153
+ const fit = Math.floor((width() + between) / (read(given.minWidth) + between));
154
+ return Math.max(given.min ?? 1, fit || 0);
155
+ });
156
+ const itemWidth = () => Math.max(0, (width() - columnGap() * (columns() - 1)) / columns());
157
+
158
+ const layout = computed(() => {
159
+ version();
160
+ const perRow = columns();
161
+ if (perRow !== measuredColumns) {
162
+ // Other columns regroup the items: the sizes measured belong to rows that are gone. The
163
+ // rendered rows are observed again, so the ones that stay report their sizes too.
164
+ measuredColumns = perRow;
165
+ measured.length = 0;
166
+ for (const row of observed.keys()) {
167
+ observer!.unobserve(row);
168
+ observer!.observe(row);
169
+ }
170
+ }
171
+ const rows = Math.ceil(count() / perRow);
172
+ const between = gap();
173
+ const size = options.size;
174
+ const sizes = new Float64Array(rows);
175
+ const starts = new Float64Array(rows + 1);
176
+ for (let row = 0; row < rows; row++) {
177
+ sizes[row] = measured[row] ?? (typeof size === "number" ? size : size(row));
178
+ starts[row + 1] = starts[row]! + sizes[row]! + between;
179
+ }
180
+ return { rows, sizes, starts, total: rows ? starts[rows]! - between : 0 };
181
+ });
182
+
183
+ /** The rows of the window, `[first, last)`, overscan included. */
184
+ const span = computed(
185
+ () => {
186
+ const { rows, starts } = layout();
187
+ if (!mounted()) return [0, Math.min(rows, Math.ceil(read(options.initial ?? 0) / columns()))];
188
+ // How many rows start at or before `y`.
189
+ const find = (y: number) => {
190
+ let low = 0;
191
+ let high = rows;
192
+ while (low < high) {
193
+ const middle = (low + high) >> 1;
194
+ if (starts[middle]! <= y) low = middle + 1;
195
+ else high = middle;
196
+ }
197
+ return low;
198
+ };
199
+ const [top, bottom] = view();
200
+ const overscan = read(options.overscan ?? 4);
201
+ return [Math.max(0, find(top!) - 1 - overscan), Math.min(rows, find(bottom!) + overscan)];
202
+ },
203
+ { equals: same },
204
+ );
205
+
206
+ const rows = computed(
207
+ () => {
208
+ const [first, last] = span() as [number, number];
209
+ const kept = options.keepFocused ? focused() : -1;
210
+ const out: number[] = kept >= 0 && kept < first ? [kept] : [];
211
+ for (let row = first; row < last; row++) out.push(row);
212
+ if (kept >= last && kept < layout().rows) out.push(kept);
213
+ return out;
214
+ },
215
+ { equals: same },
216
+ );
217
+
218
+ const items = computed(
219
+ () => {
220
+ const perRow = columns();
221
+ const total = count();
222
+ const out: number[] = [];
223
+ for (const row of rows()) {
224
+ for (let i = row * perRow; i < Math.min(total, row * perRow + perRow); i++) out.push(i);
225
+ }
226
+ return out;
227
+ },
228
+ { equals: same },
229
+ );
230
+
231
+ const start = (row: number) => layout().starts[row] ?? 0;
232
+ const size = (row: number) => layout().sizes[row] ?? 0;
233
+ const scrollerOf = () => options.scroller?.() ?? undefined;
234
+
235
+ const update = () => {
236
+ frame = 0;
237
+ if (!element) return;
238
+ const box = element.getBoundingClientRect();
239
+ const [top, height] = viewport(scrollerOf()) as [number, number];
240
+ batch(() => {
241
+ width.set(box.width);
242
+ view.set([top - box.top, top - box.top + height]);
243
+ });
244
+ };
245
+ const schedule = () => {
246
+ frame ||= requestAnimationFrame(update);
247
+ };
248
+ const onFocusIn = (event: FocusEvent) => focused.set(rowOf(event.target));
249
+ const onFocusOut = (event: FocusEvent) => {
250
+ if (!element?.contains(event.relatedTarget as Node | null)) focused.set(-1);
251
+ };
252
+
253
+ onMount(() => {
254
+ const scroller = scrollerOf();
255
+ const target = scroller ?? window;
256
+ const resize = new ResizeObserver(schedule);
257
+ for (const box of [element, scroller]) if (box) resize.observe(box);
258
+ target.addEventListener("scroll", schedule, { passive: true });
259
+ addEventListener("resize", schedule);
260
+ if (options.keepFocused) {
261
+ element?.addEventListener("focusin", onFocusIn);
262
+ element?.addEventListener("focusout", onFocusOut);
263
+ }
264
+ batch(() => {
265
+ update();
266
+ mounted.set(true);
267
+ });
268
+ return () => {
269
+ resize.disconnect();
270
+ observer?.disconnect();
271
+ target.removeEventListener("scroll", schedule);
272
+ removeEventListener("resize", schedule);
273
+ element?.removeEventListener("focusin", onFocusIn);
274
+ element?.removeEventListener("focusout", onFocusOut);
275
+ cancelAnimationFrame(frame);
276
+ };
277
+ });
278
+
279
+ return {
280
+ container: (el) => {
281
+ element = el;
282
+ },
283
+ measure: (el) => {
284
+ observer ??= new ResizeObserver((entries) => {
285
+ const touched = new Set<number>();
286
+ for (const { target, borderBoxSize } of entries) {
287
+ observed.set(target, borderBoxSize[0]!.blockSize);
288
+ touched.add(rowAt(target));
289
+ }
290
+ let changed = false;
291
+ for (const row of touched) {
292
+ let tallest = 0;
293
+ for (const [target, height] of observed)
294
+ if (rowAt(target) === row) tallest = Math.max(tallest, height);
295
+ if (row >= 0 && !(Math.abs((measured[row] ?? -1) - tallest) < 0.5)) {
296
+ measured[row] = tallest;
297
+ changed = true;
298
+ }
299
+ }
300
+ if (changed) version.set(version.peek() + 1);
301
+ });
302
+ observer.observe(el);
303
+ observed.set(el, 0);
304
+ return () => {
305
+ observer!.unobserve(el);
306
+ observed.delete(el);
307
+ };
308
+ },
309
+ mounted,
310
+ columns,
311
+ width,
312
+ itemWidth,
313
+ rows,
314
+ items,
315
+ range: () => {
316
+ const [first, last] = span() as [number, number];
317
+ return { start: first * columns(), end: Math.min(count(), last * columns()) };
318
+ },
319
+ start,
320
+ size,
321
+ total: () => layout().total,
322
+ before: () => start(span()[0]!),
323
+ after: () => {
324
+ const { total, starts } = layout();
325
+ const last = span()[1]!;
326
+ return last ? Math.max(0, total - starts[last]! + gap()) : total;
327
+ },
328
+ scrollToIndex: (index, { align = "auto", behavior } = {}) => {
329
+ let tries = 0;
330
+ const go = () => {
331
+ if (!element) return;
332
+ const scroller = scrollerOf();
333
+ const style = getComputedStyle(scroller ?? document.documentElement);
334
+ const padTop = Number.parseFloat(style.scrollPaddingTop) || 0;
335
+ const padBottom = Number.parseFloat(style.scrollPaddingBottom) || 0;
336
+ const [viewTop, height] = viewport(scroller) as [number, number];
337
+ const row = Math.floor(index / columns());
338
+ // The row's edges from the viewport's, past the scroll padding (a sticky header).
339
+ const top = element.getBoundingClientRect().top + start(row) - viewTop - padTop;
340
+ const bottom = top + size(row) - (height - padTop - padBottom);
341
+ if (align === "auto") {
342
+ if (top >= 0 && bottom <= 0) return;
343
+ align = top < 0 ? "start" : "end";
344
+ }
345
+ const delta = align === "start" ? top : align === "end" ? bottom : (top + bottom) / 2;
346
+ if (Math.abs(delta) < 1) return;
347
+ (scroller ?? window).scrollBy({ top: delta, behavior });
348
+ // Rows measured on the way can move the target: check it again for a few frames.
349
+ if (behavior !== "smooth" && ++tries < 5) requestAnimationFrame(go);
350
+ };
351
+ go();
352
+ },
353
+ };
354
+ }
package/docs/README.md CHANGED
@@ -31,7 +31,13 @@ Anatomy, API, Example, Styling, Keyboard and focus, Without JavaScript, Browser
31
31
  runtime.
32
32
  - **Parts.** Each component is a set of parts named as in Base UI (`Dialog.Root`,
33
33
  `Dialog.Trigger`, `Dialog.Popup`, …). Every part that renders an element takes that element's
34
- props (`class`, `style`, `aria-*`, events) and passes them through.
34
+ props (`class`, `style`, `aria-*`, events) and passes them through. A part finds its `Root`
35
+ through context, so a `Root` and its parts render on the same side of an island boundary, even
36
+ where the JSX nests them. A part the page passes to an island as a slot (children or a JSX prop)
37
+ renders in the page, outside the `Root` the island renders, and the server throws "Combobox
38
+ parts must be inside <Combobox.Root>"; an island inside a `Root` the page renders does not see
39
+ that `Root` either. Pass the data as props and render the `Root` and its parts inside the
40
+ island ([Islands](https://itsjavi.com/lumiose/docs/concepts/islands/)).
35
41
  - **Signals.** A component's state is a prop and a callback: `open` with `onOpenChange`, `checked`
36
42
  with `onCheckedChange`, `value` with `onValueChange`. Pass a signal's value and write it back in
37
43
  the callback to keep both in sync, or leave both out and the element keeps its own state. Custom
@@ -101,6 +107,10 @@ custom properties, named as in shadcn/ui, are listed in [Theming](theming.md#the
101
107
  | [Avatar](avatar.md) | Available: an image over its fallback, `<lu-avatar>` follows its loading status |
102
108
  | [Menubar](menubar.md) | Available: Menus in a `role="menubar"`, `<lu-menubar>` moves between them |
103
109
  | [Navigation Menu](navigation-menu.md) | Available: a `<nav>` whose triggers open panels of links, works without JavaScript |
110
+ | [Virtual List](virtual-list.md) | Available: a `<ul>` that renders the rows near the viewport, in islands |
111
+ | [Virtual Grid](virtual-grid.md) | Available: cells in auto-fill columns, fixed or measured rows, in islands |
112
+ | [Virtual Table](virtual-table.md) | Available: table rows between spacer rows, with `aria-rowcount` and `aria-rowindex` |
113
+ | [Virtualizer](virtualizer.md) | Available: `createVirtualizer()`, the headless primitive under the three, in islands |
104
114
 
105
115
  ## Ids
106
116