@lumiose/ui 0.1.3 → 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.
- package/dist/index.ts +17 -0
- package/dist/virtualizer/virtual.tsx +398 -0
- package/dist/virtualizer/virtualizer.ts +354 -0
- package/docs/README.md +4 -0
- package/docs/llms-full.txt +721 -0
- package/docs/llms.txt +4 -0
- package/docs/virtual-grid.md +136 -0
- package/docs/virtual-list.md +121 -0
- package/docs/virtual-table.md +142 -0
- package/docs/virtualizer.md +306 -0
- package/package.json +2 -1
|
@@ -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
|
@@ -107,6 +107,10 @@ custom properties, named as in shadcn/ui, are listed in [Theming](theming.md#the
|
|
|
107
107
|
| [Avatar](avatar.md) | Available: an image over its fallback, `<lu-avatar>` follows its loading status |
|
|
108
108
|
| [Menubar](menubar.md) | Available: Menus in a `role="menubar"`, `<lu-menubar>` moves between them |
|
|
109
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 |
|
|
110
114
|
|
|
111
115
|
## Ids
|
|
112
116
|
|