@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.
@@ -0,0 +1,306 @@
1
+ # Virtualizer
2
+
3
+ > A headless virtualizer for long lists, tables and grids in islands: `createVirtualizer()` computes which rows are near the viewport and where they go, and your component renders, places and styles them. The window or an element scrolls; rows have a fixed or an estimated size, or are measured as they render (one ResizeObserver for all of them); grids fit their columns to the container's width. The server renders the first items, and the island takes those same keyed rows over. 1.6 kB brotli, no script and no CSS of its own. For a list, a grid of cells or a table, start with [Virtual List](virtual-list.md), [Virtual Grid](virtual-grid.md) or [Virtual Table](virtual-table.md), which run on it.
4
+
5
+ ```ts
6
+ import { createVirtualizer } from "@lumiose/ui"; // or "@lumiose/ui/virtualizer"
7
+ ```
8
+
9
+ ## Usage
10
+
11
+ A virtualizer works in rows (decision-84): a list is a grid of one column, where each item is a row. Call it in a
12
+ component's body, give the element that holds the rows `ref={v.container}`, and render the rows it
13
+ lists, keyed by the record's id:
14
+
15
+ ```tsx
16
+ const v = createVirtualizer({ count: () => items().length, size: 48 });
17
+
18
+ <ul ref={v.container} style={{ position: "relative", height: `${v.total()}px` }}>
19
+ {v.items().map((i) => (
20
+ <li
21
+ key={items()[i]!.id}
22
+ style={{ position: "absolute", inset: "0 0 auto", transform: `translateY(${v.start(i)}px)` }}
23
+ >
24
+ {items()[i]!.name}
25
+ </li>
26
+ ))}
27
+ </ul>;
28
+ ```
29
+
30
+ | Piece | What it is |
31
+ | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
32
+ | `ref={v.container}` | The element that holds the rows. Its top is where row 0 starts; its width sets `{ minWidth }` columns. |
33
+ | `v.rows()`, `v.items()` | The indexes to render: rows (for a row element around several items) or items, in order. |
34
+ | `v.start(row)`, `v.total()` | Placed rows: each row's offset from the container's top, and the container's height. |
35
+ | `v.before()`, `v.after()` | Rows in flow: the px of the rows before and after the window, for spacers or a padding. |
36
+ | `data-row={row}` | On a row element that is measured, or that `keepFocused` keeps: the row's index. Several elements may share a row (a grid's cells): a measured row is as tall as its tallest. |
37
+ | `attach={[v.measure]}` | On a measured row: its size replaces the estimate. |
38
+
39
+ ## API
40
+
41
+ ### createVirtualizer(options)
42
+
43
+ A value marked `Value<T>` is the value or a function that returns it, read reactively: a function
44
+ that reads signals or props recomputes the window when they change.
45
+
46
+ | Option | Type | Default | |
47
+ | ------------- | ------------------------------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
48
+ | `count` | `Value<number>` | | How many items. |
49
+ | `size` | `number \| (row: number) => number` | | A row's size along the scroll axis, in px: fixed, or the estimate of a row not measured yet. A function gets the row's index and may read signals (`v.itemWidth()`). |
50
+ | `columns` | `Value<number> \| { minWidth: Value<number>; min?: number }` | `1` | Items per row. `{ minWidth }` fits as many items of at least `minWidth` px as the container's width does, with `columnGap` between them, and at least `min` (default 1), like CSS's `repeat(auto-fill, minmax(…))`. |
51
+ | `gap` | `Value<number>` | `0` | The px between rows. |
52
+ | `columnGap` | `Value<number>` | `gap` | The px between columns, for `{ minWidth }` and `itemWidth()`. |
53
+ | `overscan` | `Value<number>` | `4` | Rows rendered past each edge of the viewport. |
54
+ | `scroller` | `() => Element \| null \| undefined` | window | The element that scrolls, read once mounted. |
55
+ | `initial` | `Value<number>` | `0` | How many items render before the virtualizer mounts: in the server's HTML and while the island hydrates (see [Server rendering](#server-rendering)). |
56
+ | `keepFocused` | `boolean` | `false` | Keeps the row that holds focus rendered while the window scrolls away from it. For placed rows only (see [Keyboard and focus](#keyboard-and-focus)). |
57
+
58
+ ### The virtualizer
59
+
60
+ | Member | Returns |
61
+ | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
62
+ | `container` | A `ref` callback for the element that holds the rows. |
63
+ | `measure` | An `attach` function for a measured row element, which carries `data-row`. |
64
+ | `rows()` | The indexes of the rows to render, in order. The same array while the window does not move, so measuring or scrolling inside the overscan renders no list. |
65
+ | `items()` | The indexes of the items of those rows, in order, never past `count`. |
66
+ | `range()` | `{ start, end }`: the window's items, `end` excluded (without a kept focused row), for `array.slice(start, end)`. |
67
+ | `start(row)`, `size(row)` | Where a row starts, in px from the container's top, and its size (measured, or the estimate). |
68
+ | `total()` | Every row and gap: the container's height. |
69
+ | `before()`, `after()` | The px of the rows before and after the window, gaps included. |
70
+ | `columns()` | Items per row. |
71
+ | `width()`, `itemWidth()` | The container's width (0 until mounted), and an item's: the width less the column gaps, per column. |
72
+ | `mounted()` | `false` on the server and while hydrating, then `true`: switch between the server's layout and the virtual one with it. |
73
+ | `scrollToIndex(i, options?)` | Scrolls the scroller to show item `i`. `align`: `"auto"` (default: only as far as it takes), `"start"`, `"center"` or `"end"`; `behavior` as in `scrollTo`. It honours the scroller's `scroll-padding` (a sticky header), and checks the position again for a few frames while rows on the way are measured (not with `behavior: "smooth"`). |
74
+
75
+ ## Example
76
+
77
+ A list of people the server renders the first 20 of; the island takes them over:
78
+
79
+ ```tsx check
80
+ import { createVirtualizer } from "@lumiose/ui";
81
+
82
+ type Person = { id: string; name: string };
83
+
84
+ export function People(props: { people: Person[] }) {
85
+ const list = createVirtualizer({ count: () => props.people.length, size: 40, initial: 20 });
86
+ return (
87
+ <ul
88
+ ref={list.container}
89
+ aria-label="People"
90
+ style={{
91
+ "padding-block-start": `${list.before()}px`,
92
+ "padding-block-end": `${list.after()}px`,
93
+ }}
94
+ >
95
+ {props.people.slice(list.range().start, list.range().end).map((person, offset) => (
96
+ <li
97
+ key={person.id}
98
+ aria-setsize={props.people.length}
99
+ aria-posinset={list.range().start + offset + 1}
100
+ style={{ height: "40px" }}
101
+ >
102
+ {person.name}
103
+ </li>
104
+ ))}
105
+ </ul>
106
+ );
107
+ }
108
+ ```
109
+
110
+ A grid of cards whose rows have different heights, as many columns of at least 12rem as fit:
111
+
112
+ ```tsx check
113
+ import { createVirtualizer } from "@lumiose/ui";
114
+
115
+ type Card = { id: string; title: string; body: string };
116
+
117
+ export function Cards(props: { cards: Card[] }) {
118
+ const grid = createVirtualizer({
119
+ count: () => props.cards.length,
120
+ columns: { minWidth: 192 },
121
+ gap: 16,
122
+ size: 120, // the estimate, until a row is measured
123
+ });
124
+ const cardsOf = (row: number) =>
125
+ props.cards.slice(row * grid.columns(), (row + 1) * grid.columns());
126
+ return (
127
+ <div
128
+ ref={grid.container}
129
+ role="list"
130
+ aria-label="Cards"
131
+ style={{ position: "relative", height: `${grid.total()}px` }}
132
+ >
133
+ {grid.rows().map((row) => (
134
+ <div
135
+ key={row}
136
+ role="none"
137
+ data-row={row}
138
+ attach={[grid.measure]}
139
+ style={{
140
+ position: "absolute",
141
+ inset: "0 0 auto",
142
+ display: "grid",
143
+ gap: "16px",
144
+ "grid-template-columns": `repeat(${grid.columns()}, minmax(0, 1fr))`,
145
+ transform: `translateY(${grid.start(row)}px)`,
146
+ }}
147
+ >
148
+ {cardsOf(row).map((card, i) => (
149
+ <div
150
+ key={card.id}
151
+ role="listitem"
152
+ aria-setsize={props.cards.length}
153
+ aria-posinset={row * grid.columns() + i + 1}
154
+ >
155
+ <h3>{card.title}</h3>
156
+ <p>{card.body}</p>
157
+ </div>
158
+ ))}
159
+ </div>
160
+ ))}
161
+ </div>
162
+ );
163
+ }
164
+ ```
165
+
166
+ A table in its own scroller, with spacer rows:
167
+
168
+ ```tsx check
169
+ import { createVirtualizer } from "@lumiose/ui";
170
+
171
+ type Release = { id: string; version: string };
172
+
173
+ export function Releases(props: { releases: Release[] }) {
174
+ let scroller: HTMLDivElement | undefined;
175
+ const table = createVirtualizer({
176
+ count: () => props.releases.length,
177
+ size: 32,
178
+ scroller: () => scroller,
179
+ initial: 15,
180
+ });
181
+ return (
182
+ <div
183
+ ref={(el) => (scroller = el)}
184
+ role="region"
185
+ aria-label="Releases"
186
+ tabindex={0}
187
+ style={{ height: "16rem", overflow: "auto" }}
188
+ >
189
+ <table aria-rowcount={props.releases.length + 1} style={{ "border-collapse": "collapse" }}>
190
+ <thead>
191
+ <tr aria-rowindex={1}>
192
+ <th scope="col">Version</th>
193
+ </tr>
194
+ </thead>
195
+ <tbody ref={table.container}>
196
+ {table.before() > 0 ? (
197
+ <tr aria-hidden="true">
198
+ <td style={{ height: `${table.before()}px`, padding: "0" }} />
199
+ </tr>
200
+ ) : null}
201
+ {props.releases.slice(table.range().start, table.range().end).map((release, offset) => (
202
+ <tr
203
+ key={release.id}
204
+ aria-rowindex={table.range().start + offset + 2}
205
+ style={{ height: "32px" }}
206
+ >
207
+ <td>{release.version}</td>
208
+ </tr>
209
+ ))}
210
+ {table.after() > 0 ? (
211
+ <tr aria-hidden="true">
212
+ <td style={{ height: `${table.after()}px`, padding: "0" }} />
213
+ </tr>
214
+ ) : null}
215
+ </tbody>
216
+ </table>
217
+ </div>
218
+ );
219
+ }
220
+ ```
221
+
222
+ ## Styling
223
+
224
+ Nothing is styled: the virtualizer only computes. Lay the rows out one of two ways.
225
+
226
+ - **Placed rows**: the container is `position: relative` and `total()` tall; each row is
227
+ `position: absolute` at `transform: translateY(${start(row)}px)` (a cell of a grid adds its
228
+ column: `translate(${column * (itemWidth() + gap)}px, …)`). A row out of order (`keepFocused`)
229
+ still sits in its place, and a size that changes moves only the rows after it.
230
+ - **Rows in flow**: the rows of the window stay in the document's flow between `before()` and
231
+ `after()`, as a padding of the container or as spacer elements. Tables need this (a `<tr>` cannot
232
+ be placed), and it is the simplest server-rendered list: the same layout before and after mount.
233
+ In a CSS grid, use a padding: a spacer element would add a gap.
234
+
235
+ Measured rows are placed in the frame they are measured in, before it paints: the observer's
236
+ callback writes their sizes and the rows move at once, so they never show at their estimates. A
237
+ row rendered by that move (the window grew) is measured in the next frame, which browsers report
238
+ as an `error` event, "ResizeObserver loop completed with undelivered notifications". It is
239
+ harmless, and error trackers ignore it.
240
+
241
+ `size` must match the CSS: a fixed size is exactly the row's height, without the gap (that is
242
+ `gap`), and an estimate only has to be close (measured rows correct it). When the CSS sets the
243
+ height, such as a custom property that a breakpoint changes, keep it in a signal that a
244
+ ResizeObserver updates, and return that from `size`.
245
+
246
+ ## Server rendering
247
+
248
+ Before it mounts, on the server and while an island hydrates, the virtualizer reads nothing from
249
+ the browser: `mounted()` is `false`, `width()` is 0, `{ minWidth }` columns are `min`, and it
250
+ renders the first `initial` items (in whole rows). Both sides render the same HTML, so hydration
251
+ matches. Once mounted it measures the container and the viewport, and renders the window instead:
252
+ rows keyed by the same ids are kept, so the server's elements stay and only move. Options that
253
+ change the pre-mount window (`count`, `initial`, `columns`) must not read the browser either.
254
+
255
+ - **No layout jump.** The server's rows should take the place the virtual layout gives them. With
256
+ rows in flow and a fixed size, it holds by itself: render `after()` as a padding before mount too.
257
+ A server-only layout (a CSS grid with `auto-fill`, say) works when its geometry is the
258
+ virtualizer's: switch the container's class or style on `mounted()`, keep the same keyed rows.
259
+ - **Every item on the server.** For a list that must be complete in the HTML (indexing, find in
260
+ page, no JavaScript), render all of it on the server into an island's `children` slot and let the
261
+ island virtualize once its data loads, or skip the virtualizer and give the rows
262
+ `content-visibility: auto` with a `contain-intrinsic-size`: the browser skips their rendering
263
+ while they are off screen, with no script.
264
+
265
+ ## Keyboard and focus
266
+
267
+ - **Key rows by the record's id**, never by the index: a kept row is the same record, so its
268
+ focus, selection and typed text stay with it ([Lists and keys](https://itsjavi.com/lumiose/docs/concepts/jsx/#lists-and-keys)).
269
+ A row element that only wraps a grid's items may be keyed by its row index (`key={row}`): its
270
+ items keep their own keys.
271
+ - **Tab** moves through the rendered rows in DOM order; the browser scrolls the next one into view,
272
+ and the window follows it. The overscan keeps the next rows rendered.
273
+ - **`keepFocused`**: scrolled away with the wheel or the scrollbar, a focused row would leave the
274
+ document and focus would fall back to the page. With `keepFocused`, the row of the focused
275
+ element (its closest `[data-row]`) stays in `rows()` until focus leaves the container, placed
276
+ where it belongs. Rows in flow cannot hold a row outside their window: leave it off there.
277
+ - **`scrollToIndex`** moves the scroller, not focus: focus the row afterwards when the user asked
278
+ to go to it.
279
+
280
+ ## Accessibility
281
+
282
+ - **Lists**: give each rendered item `aria-setsize` (the count) and `aria-posinset` (its position
283
+ from 1), so assistive technology announces "3 of 1,000" rather than the rows rendered.
284
+ - **Tables**: `aria-rowcount` on the table (rows plus the header rows) and `aria-rowindex` on every
285
+ row, the header's too. Spacer rows are `aria-hidden="true"`.
286
+ - **Grids of items**: a row element that only lays out items gets `role="none"`, so the items are
287
+ the list's (`role="list"` on the container, `role="listitem"` on the items).
288
+ - Find in page and assistive technology reach only the rendered rows. Render everything on the
289
+ server when they must reach every row (above).
290
+
291
+ ## Without JavaScript
292
+
293
+ Only the server's first `initial` items render. Link to a full page, or render every item on the
294
+ server, when the list must work without JavaScript.
295
+
296
+ ## Browser support
297
+
298
+ ResizeObserver with `borderBoxSize`, and `scroll-padding`: every supported browser. The virtualizer
299
+ scrolls along the block axis only (vertical in horizontal writing modes).
300
+
301
+ ## Related
302
+
303
+ - [Virtual List](virtual-list.md), [Virtual Grid](virtual-grid.md) and
304
+ [Virtual Table](virtual-table.md): the three layouts as components on this primitive.
305
+ - [Scroll Area](scroll-area.md): custom scrollbars over a scroller; give its `Viewport` as `scroller`.
306
+ - [Lists and keys](https://itsjavi.com/lumiose/docs/concepts/jsx/#lists-and-keys): how keyed rows update in place.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumiose/ui",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Accessible, unstyled UI primitives for Lumiose: native elements first (dialog, popover, invoker commands, anchor positioning), light-DOM custom elements only where the platform falls short.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -80,6 +80,7 @@
80
80
  "./menubar.css": "./dist/menubar/menubar.css",
81
81
  "./navigation-menu": "./dist/navigation-menu/navigation-menu.tsx",
82
82
  "./navigation-menu.css": "./dist/navigation-menu/navigation-menu.css",
83
+ "./virtualizer": "./dist/virtualizer/virtual.tsx",
83
84
  "./themes/default.css": "./dist/themes/default.css",
84
85
  "./package.json": "./package.json"
85
86
  },