@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
package/dist/index.ts
CHANGED
|
@@ -259,3 +259,20 @@ export type {
|
|
|
259
259
|
NavigationMenuRootProps,
|
|
260
260
|
NavigationMenuTriggerProps,
|
|
261
261
|
} from "./navigation-menu/navigation-menu.tsx";
|
|
262
|
+
export {
|
|
263
|
+
createVirtualizer,
|
|
264
|
+
VirtualGrid,
|
|
265
|
+
VirtualList,
|
|
266
|
+
VirtualTable,
|
|
267
|
+
} from "./virtualizer/virtual.tsx";
|
|
268
|
+
export type {
|
|
269
|
+
Virtualizer,
|
|
270
|
+
VirtualizerOptions,
|
|
271
|
+
VirtualizerScrollOptions,
|
|
272
|
+
VirtualizerValue,
|
|
273
|
+
VirtualGridProps,
|
|
274
|
+
VirtualItem,
|
|
275
|
+
VirtualListProps,
|
|
276
|
+
VirtualProps,
|
|
277
|
+
VirtualTableProps,
|
|
278
|
+
} from "./virtualizer/virtual.tsx";
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
// VirtualList, VirtualGrid and VirtualTable (TASK-565, decision-84): thin components on
|
|
2
|
+
// createVirtualizer for the three layouts a long collection takes, each rendering its container
|
|
3
|
+
// and one element per item, which carries the item's key, its place and its ARIA; the children
|
|
4
|
+
// function renders the element's content.
|
|
5
|
+
//
|
|
6
|
+
// - Rows are keyed by `itemKey`, so a kept row keeps its DOM and its components while its item
|
|
7
|
+
// updates in place (decision-83): the children function runs once per row, with an item whose
|
|
8
|
+
// `item` and `index` are getters, read like props.
|
|
9
|
+
// - Before mount (the server, hydration) the first `initial` items render in flow; once mounted the
|
|
10
|
+
// list and the grid place them, keeping the same keyed elements, and the table fills its spacer
|
|
11
|
+
// rows.
|
|
12
|
+
import { computed, effect, untrack } from "lumiose";
|
|
13
|
+
import { createVirtualizer, type Virtualizer } from "./virtualizer.ts";
|
|
14
|
+
|
|
15
|
+
export { createVirtualizer } from "./virtualizer.ts";
|
|
16
|
+
export type {
|
|
17
|
+
Virtualizer,
|
|
18
|
+
VirtualizerOptions,
|
|
19
|
+
VirtualizerScrollOptions,
|
|
20
|
+
VirtualizerValue,
|
|
21
|
+
} from "./virtualizer.ts";
|
|
22
|
+
|
|
23
|
+
/** What the children function gets: the item and its index, read like props (getters). */
|
|
24
|
+
export interface VirtualItem<T> {
|
|
25
|
+
readonly item: T;
|
|
26
|
+
readonly index: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The props the three components share. */
|
|
30
|
+
export interface VirtualProps<T> {
|
|
31
|
+
/** The items. */
|
|
32
|
+
items: readonly T[];
|
|
33
|
+
/** The item's identity, such as its record's id: a kept key keeps its row. */
|
|
34
|
+
itemKey: (item: T) => string | number;
|
|
35
|
+
/** Renders an item's content, once per row: read `row.item` and `row.index` where they are used. */
|
|
36
|
+
children: (row: VirtualItem<T>) => JSX.Element;
|
|
37
|
+
/**
|
|
38
|
+
* A row's size along the scroll axis, in px: fixed, or with `measure` the estimate of a row not
|
|
39
|
+
* measured yet. A function gets the row's index.
|
|
40
|
+
*/
|
|
41
|
+
size: number | ((row: number) => number);
|
|
42
|
+
/** Measures the rendered rows: their sizes replace `size`. */
|
|
43
|
+
measure?: boolean;
|
|
44
|
+
/** Rows rendered past each edge of the viewport. Default 4. */
|
|
45
|
+
overscan?: number;
|
|
46
|
+
/** The element that scrolls. Default: the window. */
|
|
47
|
+
scroller?: () => Element | null | undefined;
|
|
48
|
+
/** How many items render before mount: the server's HTML. Default 0. */
|
|
49
|
+
initial?: number;
|
|
50
|
+
/** Receives the virtualizer, for `scrollToIndex()`. */
|
|
51
|
+
virtualizer?: (virtualizer: Virtualizer) => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The window's items, in order: a new array when the window or the items change, which the keyed
|
|
56
|
+
* rows reconcile against (an array of indexes would keep rows by position).
|
|
57
|
+
*/
|
|
58
|
+
const shownOf = <T,>(virtualizer: Virtualizer, items: () => readonly T[]) =>
|
|
59
|
+
computed(() => virtualizer.items().map((index) => items()[index]!));
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The getters a row's children function reads. The item goes through a computed, which tracks the
|
|
63
|
+
* row's item as any read does: a binding that reads the item lazily, as decision-83's rows let it,
|
|
64
|
+
* misses the item's change when it also reads a computed the same write leaves unchanged, such as
|
|
65
|
+
* the index (TASK-567).
|
|
66
|
+
*/
|
|
67
|
+
const rowOf = <T,>(item: () => T, index: () => number): VirtualItem<T> => {
|
|
68
|
+
const current = computed(item);
|
|
69
|
+
return {
|
|
70
|
+
get item() {
|
|
71
|
+
return current();
|
|
72
|
+
},
|
|
73
|
+
get index() {
|
|
74
|
+
return index();
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The container's `ref`. With `measure`, it observes the container's rows: after each change of the
|
|
81
|
+
* window, an effect observes the rows that appeared and lets go of the ones that left. (An `attach`
|
|
82
|
+
* on the row would not survive the spread of `itemProps` next to it, TASK-568.)
|
|
83
|
+
*/
|
|
84
|
+
const containerRef = (virtualizer: Virtualizer, shown: () => unknown, measure?: boolean) => {
|
|
85
|
+
let box: HTMLElement | undefined;
|
|
86
|
+
if (measure) {
|
|
87
|
+
const stops = new Map<Element, () => void>();
|
|
88
|
+
effect(() => {
|
|
89
|
+
shown();
|
|
90
|
+
untrack(() => {
|
|
91
|
+
for (const [row, stop] of stops) {
|
|
92
|
+
if (row.parentNode !== box) {
|
|
93
|
+
stop();
|
|
94
|
+
stops.delete(row);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
for (const row of box?.querySelectorAll<HTMLElement>(":scope > [data-row]") ?? []) {
|
|
98
|
+
if (!stops.has(row)) stops.set(row, virtualizer.measure(row));
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return (element: HTMLElement) => {
|
|
104
|
+
box = element;
|
|
105
|
+
virtualizer.container(element);
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const px = (value: number) => `${value}px`;
|
|
110
|
+
|
|
111
|
+
/** The container's style: the caller's (an object) with the component's properties over it. */
|
|
112
|
+
const styled = (style: unknown, own: Record<string, string | undefined>) =>
|
|
113
|
+
style !== null && typeof style === "object" ? { ...style, ...own } : own;
|
|
114
|
+
|
|
115
|
+
/** `VirtualList`'s props: a `<ul>`'s, and the shared ones. */
|
|
116
|
+
export type VirtualListProps<T> = Omit<JSX.IntrinsicElements["ul"], symbol | "children"> &
|
|
117
|
+
VirtualProps<T> & {
|
|
118
|
+
/** The px between rows. */
|
|
119
|
+
gap?: number;
|
|
120
|
+
/** Keeps the row that holds focus rendered while the list scrolls away from it. Default `true`. */
|
|
121
|
+
keepFocused?: boolean;
|
|
122
|
+
/** Props of each item's `<li>` (no `style`): a class, data attributes. */
|
|
123
|
+
itemProps?: (item: T, index: number) => JSX.IntrinsicElements["li"];
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A `<ul>` of rows placed from the virtualizer, each an `<li>` with `aria-posinset` and
|
|
128
|
+
* `aria-setsize`.
|
|
129
|
+
*
|
|
130
|
+
* @example
|
|
131
|
+
* <VirtualList items={people()} itemKey={(p) => p.id} size={40} aria-label="People">
|
|
132
|
+
* {(row) => <a href={`/people/${row.item.id}`}>{row.item.name}</a>}
|
|
133
|
+
* </VirtualList>
|
|
134
|
+
*/
|
|
135
|
+
export function VirtualList<T>({
|
|
136
|
+
items,
|
|
137
|
+
itemKey,
|
|
138
|
+
children,
|
|
139
|
+
size,
|
|
140
|
+
measure,
|
|
141
|
+
gap,
|
|
142
|
+
overscan,
|
|
143
|
+
scroller,
|
|
144
|
+
initial,
|
|
145
|
+
keepFocused,
|
|
146
|
+
virtualizer,
|
|
147
|
+
itemProps,
|
|
148
|
+
style,
|
|
149
|
+
...rest
|
|
150
|
+
}: VirtualListProps<T>) {
|
|
151
|
+
const list = createVirtualizer({
|
|
152
|
+
count: () => items.length,
|
|
153
|
+
size: (row) => (typeof size === "function" ? size(row) : size),
|
|
154
|
+
gap: () => gap ?? 0,
|
|
155
|
+
overscan: () => overscan ?? 4,
|
|
156
|
+
scroller: () => scroller?.(),
|
|
157
|
+
initial: () => initial ?? 0,
|
|
158
|
+
keepFocused: keepFocused ?? true,
|
|
159
|
+
});
|
|
160
|
+
virtualizer?.(list);
|
|
161
|
+
const shown = shownOf(list, () => items);
|
|
162
|
+
return (
|
|
163
|
+
<ul
|
|
164
|
+
{...rest}
|
|
165
|
+
ref={containerRef(list, shown, measure)}
|
|
166
|
+
data-mounted={list.mounted() ? "" : undefined}
|
|
167
|
+
style={styled(
|
|
168
|
+
style,
|
|
169
|
+
list.mounted()
|
|
170
|
+
? { position: "relative", height: px(list.total()) }
|
|
171
|
+
: { "padding-block-end": px(list.after()) },
|
|
172
|
+
)}
|
|
173
|
+
>
|
|
174
|
+
{shown().map((item, position) => {
|
|
175
|
+
const index = () => list.items()[position]!;
|
|
176
|
+
return (
|
|
177
|
+
<li
|
|
178
|
+
key={itemKey(item)}
|
|
179
|
+
{...itemProps?.(item, index())}
|
|
180
|
+
data-row={index()}
|
|
181
|
+
aria-setsize={items.length}
|
|
182
|
+
aria-posinset={index() + 1}
|
|
183
|
+
style={{
|
|
184
|
+
position: list.mounted() ? "absolute" : undefined,
|
|
185
|
+
inset: list.mounted() ? "0 0 auto" : undefined,
|
|
186
|
+
transform: list.mounted() ? `translateY(${px(list.start(index()))})` : undefined,
|
|
187
|
+
height: measure ? undefined : px(list.size(index())),
|
|
188
|
+
}}
|
|
189
|
+
>
|
|
190
|
+
{untrack(() => children(rowOf(() => item, index)))}
|
|
191
|
+
</li>
|
|
192
|
+
);
|
|
193
|
+
})}
|
|
194
|
+
</ul>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** `VirtualGrid`'s props: a `<ul>`'s, and the shared ones. */
|
|
199
|
+
export type VirtualGridProps<T> = Omit<JSX.IntrinsicElements["ul"], symbol | "children"> &
|
|
200
|
+
VirtualProps<T> & {
|
|
201
|
+
/** Items per row; or leave it out and give `minItemWidth`. */
|
|
202
|
+
columns?: number;
|
|
203
|
+
/** As many columns of at least this many px as the width fits (CSS's `auto-fill`). */
|
|
204
|
+
minItemWidth?: number;
|
|
205
|
+
/** The fewest columns with `minItemWidth`. Default 1. */
|
|
206
|
+
minColumns?: number;
|
|
207
|
+
/** The px between rows. */
|
|
208
|
+
gap?: number;
|
|
209
|
+
/** The px between columns. Default `gap`. */
|
|
210
|
+
columnGap?: number;
|
|
211
|
+
/** Keeps the row that holds focus rendered while the grid scrolls away from it. Default `true`. */
|
|
212
|
+
keepFocused?: boolean;
|
|
213
|
+
/** Props of each item's `<li>` (no `style`): a class, data attributes. */
|
|
214
|
+
itemProps?: (item: T, index: number) => JSX.IntrinsicElements["li"];
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* A `<ul>` whose items are placed in rows of columns, each an `<li>` as wide as a column. Before
|
|
219
|
+
* mount the items are in flow: lay them out with its `style` or with CSS (`:not([data-mounted])`),
|
|
220
|
+
* such as a grid with the same columns.
|
|
221
|
+
*
|
|
222
|
+
* @example
|
|
223
|
+
* <VirtualGrid items={cards()} itemKey={(c) => c.id} minItemWidth={192} gap={16} size={160} measure>
|
|
224
|
+
* {(cell) => <Card card={cell.item} />}
|
|
225
|
+
* </VirtualGrid>
|
|
226
|
+
*/
|
|
227
|
+
export function VirtualGrid<T>({
|
|
228
|
+
items,
|
|
229
|
+
itemKey,
|
|
230
|
+
children,
|
|
231
|
+
size,
|
|
232
|
+
measure,
|
|
233
|
+
columns,
|
|
234
|
+
minItemWidth,
|
|
235
|
+
minColumns,
|
|
236
|
+
gap,
|
|
237
|
+
columnGap,
|
|
238
|
+
overscan,
|
|
239
|
+
scroller,
|
|
240
|
+
initial,
|
|
241
|
+
keepFocused,
|
|
242
|
+
virtualizer,
|
|
243
|
+
itemProps,
|
|
244
|
+
style,
|
|
245
|
+
...rest
|
|
246
|
+
}: VirtualGridProps<T>) {
|
|
247
|
+
const grid = createVirtualizer({
|
|
248
|
+
count: () => items.length,
|
|
249
|
+
size: (row) => (typeof size === "function" ? size(row) : size),
|
|
250
|
+
columns:
|
|
251
|
+
minItemWidth === undefined
|
|
252
|
+
? () => columns ?? 1
|
|
253
|
+
: { minWidth: () => minItemWidth ?? 0, min: minColumns },
|
|
254
|
+
gap: () => gap ?? 0,
|
|
255
|
+
columnGap: () => columnGap ?? gap ?? 0,
|
|
256
|
+
overscan: () => overscan ?? 4,
|
|
257
|
+
scroller: () => scroller?.(),
|
|
258
|
+
initial: () => initial ?? 0,
|
|
259
|
+
keepFocused: keepFocused ?? true,
|
|
260
|
+
});
|
|
261
|
+
virtualizer?.(grid);
|
|
262
|
+
const shown = shownOf(grid, () => items);
|
|
263
|
+
const rowAt = (index: number) => Math.floor(index / grid.columns());
|
|
264
|
+
const left = (index: number) =>
|
|
265
|
+
px((index % grid.columns()) * (grid.itemWidth() + (columnGap ?? gap ?? 0)));
|
|
266
|
+
return (
|
|
267
|
+
<ul
|
|
268
|
+
{...rest}
|
|
269
|
+
ref={containerRef(grid, shown, measure)}
|
|
270
|
+
data-mounted={grid.mounted() ? "" : undefined}
|
|
271
|
+
// Before mount, the caller's style may lay out the server's cells (a CSS grid, say).
|
|
272
|
+
style={
|
|
273
|
+
grid.mounted() ? styled(style, { position: "relative", height: px(grid.total()) }) : style
|
|
274
|
+
}
|
|
275
|
+
>
|
|
276
|
+
{shown().map((item, position) => {
|
|
277
|
+
const index = () => grid.items()[position]!;
|
|
278
|
+
return (
|
|
279
|
+
<li
|
|
280
|
+
key={itemKey(item)}
|
|
281
|
+
{...itemProps?.(item, index())}
|
|
282
|
+
data-row={rowAt(index())}
|
|
283
|
+
aria-setsize={items.length}
|
|
284
|
+
aria-posinset={index() + 1}
|
|
285
|
+
style={
|
|
286
|
+
grid.mounted()
|
|
287
|
+
? {
|
|
288
|
+
position: "absolute",
|
|
289
|
+
top: "0",
|
|
290
|
+
// The column by layout (`left`), which snaps fractional px as a CSS grid's
|
|
291
|
+
// tracks do; a translated x would blur the cell's edges.
|
|
292
|
+
left: left(index()),
|
|
293
|
+
width: px(grid.itemWidth()),
|
|
294
|
+
height: measure ? undefined : px(grid.size(rowAt(index()))),
|
|
295
|
+
transform: `translateY(${px(grid.start(rowAt(index())))})`,
|
|
296
|
+
}
|
|
297
|
+
: undefined
|
|
298
|
+
}
|
|
299
|
+
>
|
|
300
|
+
{untrack(() => children(rowOf(() => item, index)))}
|
|
301
|
+
</li>
|
|
302
|
+
);
|
|
303
|
+
})}
|
|
304
|
+
</ul>
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** `VirtualTable`'s props: a `<table>`'s, and the shared ones. */
|
|
309
|
+
export type VirtualTableProps<T> = Omit<JSX.IntrinsicElements["table"], symbol | "children"> &
|
|
310
|
+
VirtualProps<T> & {
|
|
311
|
+
/** What comes before the body: `<caption>`, `<colgroup>`, `<thead>`. */
|
|
312
|
+
head?: JSX.Element;
|
|
313
|
+
/** How many rows `head` has, for `aria-rowcount` and the rows' `aria-rowindex`. Default 1. */
|
|
314
|
+
headerRows?: number;
|
|
315
|
+
/** The columns a spacer row spans. Default 1. */
|
|
316
|
+
colSpan?: number;
|
|
317
|
+
/** A row shown in the body while there are no items. */
|
|
318
|
+
empty?: JSX.Element;
|
|
319
|
+
/** Props of each item's `<tr>`: a class, data attributes. */
|
|
320
|
+
rowProps?: (item: T, index: number) => JSX.IntrinsicElements["tr"];
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* A `<table>` whose body renders the rows near the scroller's viewport between two spacer rows,
|
|
325
|
+
* with `aria-rowcount` on the table and `aria-rowindex` on every row. The children function renders
|
|
326
|
+
* a row's cells.
|
|
327
|
+
*
|
|
328
|
+
* @example
|
|
329
|
+
* <VirtualTable items={releases()} itemKey={(r) => r.id} size={32} scroller={() => box}
|
|
330
|
+
* head={<thead><tr aria-rowindex={1}><th scope="col">Version</th></tr></thead>}>
|
|
331
|
+
* {(row) => <td>{row.item.version}</td>}
|
|
332
|
+
* </VirtualTable>
|
|
333
|
+
*/
|
|
334
|
+
export function VirtualTable<T>({
|
|
335
|
+
items,
|
|
336
|
+
itemKey,
|
|
337
|
+
children,
|
|
338
|
+
size,
|
|
339
|
+
measure,
|
|
340
|
+
overscan,
|
|
341
|
+
scroller,
|
|
342
|
+
initial,
|
|
343
|
+
virtualizer,
|
|
344
|
+
head,
|
|
345
|
+
headerRows,
|
|
346
|
+
colSpan,
|
|
347
|
+
empty,
|
|
348
|
+
rowProps,
|
|
349
|
+
...rest
|
|
350
|
+
}: VirtualTableProps<T>) {
|
|
351
|
+
const table = createVirtualizer({
|
|
352
|
+
count: () => items.length,
|
|
353
|
+
size: (row) => (typeof size === "function" ? size(row) : size),
|
|
354
|
+
overscan: () => overscan ?? 4,
|
|
355
|
+
scroller: () => scroller?.(),
|
|
356
|
+
initial: () => initial ?? 0,
|
|
357
|
+
});
|
|
358
|
+
virtualizer?.(table);
|
|
359
|
+
const shown = shownOf(table, () => items);
|
|
360
|
+
const header = () => headerRows ?? 1;
|
|
361
|
+
return (
|
|
362
|
+
<table {...rest} aria-rowcount={items.length + header()}>
|
|
363
|
+
{head}
|
|
364
|
+
<tbody ref={containerRef(table, shown, measure)}>
|
|
365
|
+
{table.before() > 0 ? (
|
|
366
|
+
<tr aria-hidden="true" data-spacer="">
|
|
367
|
+
<td
|
|
368
|
+
colspan={colSpan ?? 1}
|
|
369
|
+
style={{ height: px(table.before()), padding: "0", border: "0" }}
|
|
370
|
+
/>
|
|
371
|
+
</tr>
|
|
372
|
+
) : null}
|
|
373
|
+
{shown().map((item, position) => {
|
|
374
|
+
const index = () => table.items()[position]!;
|
|
375
|
+
return (
|
|
376
|
+
<tr
|
|
377
|
+
key={itemKey(item)}
|
|
378
|
+
{...rowProps?.(item, index())}
|
|
379
|
+
data-row={index()}
|
|
380
|
+
aria-rowindex={index() + header() + 1}
|
|
381
|
+
>
|
|
382
|
+
{untrack(() => children(rowOf(() => item, index)))}
|
|
383
|
+
</tr>
|
|
384
|
+
);
|
|
385
|
+
})}
|
|
386
|
+
{table.after() > 0 ? (
|
|
387
|
+
<tr aria-hidden="true" data-spacer="">
|
|
388
|
+
<td
|
|
389
|
+
colspan={colSpan ?? 1}
|
|
390
|
+
style={{ height: px(table.after()), padding: "0", border: "0" }}
|
|
391
|
+
/>
|
|
392
|
+
</tr>
|
|
393
|
+
) : null}
|
|
394
|
+
{items.length === 0 ? empty : null}
|
|
395
|
+
</tbody>
|
|
396
|
+
</table>
|
|
397
|
+
);
|
|
398
|
+
}
|