@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.
@@ -118,6 +118,10 @@ custom properties, named as in shadcn/ui, are listed in [Theming](theming.md#the
118
118
  | [Avatar](avatar.md) | Available: an image over its fallback, `<lu-avatar>` follows its loading status |
119
119
  | [Menubar](menubar.md) | Available: Menus in a `role="menubar"`, `<lu-menubar>` moves between them |
120
120
  | [Navigation Menu](navigation-menu.md) | Available: a `<nav>` whose triggers open panels of links, works without JavaScript |
121
+ | [Virtual List](virtual-list.md) | Available: a `<ul>` that renders the rows near the viewport, in islands |
122
+ | [Virtual Grid](virtual-grid.md) | Available: cells in auto-fill columns, fixed or measured rows, in islands |
123
+ | [Virtual Table](virtual-table.md) | Available: table rows between spacer rows, with `aria-rowcount` and `aria-rowindex` |
124
+ | [Virtualizer](virtualizer.md) | Available: `createVirtualizer()`, the headless primitive under the three, in islands |
121
125
 
122
126
  ## Ids
123
127
 
@@ -396,6 +400,315 @@ Each component's page has an example stylesheet to start from.
396
400
  - [Installation](installation.md): adding the package and importing its stylesheets.
397
401
  - [Styles](https://itsjavi.com/lumiose/docs/concepts/styles/): scoped CSS and global stylesheets in Lumiose.
398
402
 
403
+ <!-- source: virtualizer.md -->
404
+
405
+ # Virtualizer
406
+
407
+ > 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.
408
+
409
+ ```ts
410
+ import { createVirtualizer } from "@lumiose/ui"; // or "@lumiose/ui/virtualizer"
411
+ ```
412
+
413
+ ## Usage
414
+
415
+ 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
416
+ component's body, give the element that holds the rows `ref={v.container}`, and render the rows it
417
+ lists, keyed by the record's id:
418
+
419
+ ```tsx
420
+ const v = createVirtualizer({ count: () => items().length, size: 48 });
421
+
422
+ <ul ref={v.container} style={{ position: "relative", height: `${v.total()}px` }}>
423
+ {v.items().map((i) => (
424
+ <li
425
+ key={items()[i]!.id}
426
+ style={{ position: "absolute", inset: "0 0 auto", transform: `translateY(${v.start(i)}px)` }}
427
+ >
428
+ {items()[i]!.name}
429
+ </li>
430
+ ))}
431
+ </ul>;
432
+ ```
433
+
434
+ | Piece | What it is |
435
+ | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
436
+ | `ref={v.container}` | The element that holds the rows. Its top is where row 0 starts; its width sets `{ minWidth }` columns. |
437
+ | `v.rows()`, `v.items()` | The indexes to render: rows (for a row element around several items) or items, in order. |
438
+ | `v.start(row)`, `v.total()` | Placed rows: each row's offset from the container's top, and the container's height. |
439
+ | `v.before()`, `v.after()` | Rows in flow: the px of the rows before and after the window, for spacers or a padding. |
440
+ | `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. |
441
+ | `attach={[v.measure]}` | On a measured row: its size replaces the estimate. |
442
+
443
+ ## API
444
+
445
+ ### createVirtualizer(options)
446
+
447
+ A value marked `Value<T>` is the value or a function that returns it, read reactively: a function
448
+ that reads signals or props recomputes the window when they change.
449
+
450
+ | Option | Type | Default | |
451
+ | ------------- | ------------------------------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
452
+ | `count` | `Value<number>` | | How many items. |
453
+ | `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()`). |
454
+ | `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(…))`. |
455
+ | `gap` | `Value<number>` | `0` | The px between rows. |
456
+ | `columnGap` | `Value<number>` | `gap` | The px between columns, for `{ minWidth }` and `itemWidth()`. |
457
+ | `overscan` | `Value<number>` | `4` | Rows rendered past each edge of the viewport. |
458
+ | `scroller` | `() => Element \| null \| undefined` | window | The element that scrolls, read once mounted. |
459
+ | `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)). |
460
+ | `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)). |
461
+
462
+ ### The virtualizer
463
+
464
+ | Member | Returns |
465
+ | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
466
+ | `container` | A `ref` callback for the element that holds the rows. |
467
+ | `measure` | An `attach` function for a measured row element, which carries `data-row`. |
468
+ | `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. |
469
+ | `items()` | The indexes of the items of those rows, in order, never past `count`. |
470
+ | `range()` | `{ start, end }`: the window's items, `end` excluded (without a kept focused row), for `array.slice(start, end)`. |
471
+ | `start(row)`, `size(row)` | Where a row starts, in px from the container's top, and its size (measured, or the estimate). |
472
+ | `total()` | Every row and gap: the container's height. |
473
+ | `before()`, `after()` | The px of the rows before and after the window, gaps included. |
474
+ | `columns()` | Items per row. |
475
+ | `width()`, `itemWidth()` | The container's width (0 until mounted), and an item's: the width less the column gaps, per column. |
476
+ | `mounted()` | `false` on the server and while hydrating, then `true`: switch between the server's layout and the virtual one with it. |
477
+ | `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"`). |
478
+
479
+ ## Example
480
+
481
+ A list of people the server renders the first 20 of; the island takes them over:
482
+
483
+ ```tsx check
484
+ import { createVirtualizer } from "@lumiose/ui";
485
+
486
+ type Person = { id: string; name: string };
487
+
488
+ export function People(props: { people: Person[] }) {
489
+ const list = createVirtualizer({ count: () => props.people.length, size: 40, initial: 20 });
490
+ return (
491
+ <ul
492
+ ref={list.container}
493
+ aria-label="People"
494
+ style={{
495
+ "padding-block-start": `${list.before()}px`,
496
+ "padding-block-end": `${list.after()}px`,
497
+ }}
498
+ >
499
+ {props.people.slice(list.range().start, list.range().end).map((person, offset) => (
500
+ <li
501
+ key={person.id}
502
+ aria-setsize={props.people.length}
503
+ aria-posinset={list.range().start + offset + 1}
504
+ style={{ height: "40px" }}
505
+ >
506
+ {person.name}
507
+ </li>
508
+ ))}
509
+ </ul>
510
+ );
511
+ }
512
+ ```
513
+
514
+ A grid of cards whose rows have different heights, as many columns of at least 12rem as fit:
515
+
516
+ ```tsx check
517
+ import { createVirtualizer } from "@lumiose/ui";
518
+
519
+ type Card = { id: string; title: string; body: string };
520
+
521
+ export function Cards(props: { cards: Card[] }) {
522
+ const grid = createVirtualizer({
523
+ count: () => props.cards.length,
524
+ columns: { minWidth: 192 },
525
+ gap: 16,
526
+ size: 120, // the estimate, until a row is measured
527
+ });
528
+ const cardsOf = (row: number) =>
529
+ props.cards.slice(row * grid.columns(), (row + 1) * grid.columns());
530
+ return (
531
+ <div
532
+ ref={grid.container}
533
+ role="list"
534
+ aria-label="Cards"
535
+ style={{ position: "relative", height: `${grid.total()}px` }}
536
+ >
537
+ {grid.rows().map((row) => (
538
+ <div
539
+ key={row}
540
+ role="none"
541
+ data-row={row}
542
+ attach={[grid.measure]}
543
+ style={{
544
+ position: "absolute",
545
+ inset: "0 0 auto",
546
+ display: "grid",
547
+ gap: "16px",
548
+ "grid-template-columns": `repeat(${grid.columns()}, minmax(0, 1fr))`,
549
+ transform: `translateY(${grid.start(row)}px)`,
550
+ }}
551
+ >
552
+ {cardsOf(row).map((card, i) => (
553
+ <div
554
+ key={card.id}
555
+ role="listitem"
556
+ aria-setsize={props.cards.length}
557
+ aria-posinset={row * grid.columns() + i + 1}
558
+ >
559
+ <h3>{card.title}</h3>
560
+ <p>{card.body}</p>
561
+ </div>
562
+ ))}
563
+ </div>
564
+ ))}
565
+ </div>
566
+ );
567
+ }
568
+ ```
569
+
570
+ A table in its own scroller, with spacer rows:
571
+
572
+ ```tsx check
573
+ import { createVirtualizer } from "@lumiose/ui";
574
+
575
+ type Release = { id: string; version: string };
576
+
577
+ export function Releases(props: { releases: Release[] }) {
578
+ let scroller: HTMLDivElement | undefined;
579
+ const table = createVirtualizer({
580
+ count: () => props.releases.length,
581
+ size: 32,
582
+ scroller: () => scroller,
583
+ initial: 15,
584
+ });
585
+ return (
586
+ <div
587
+ ref={(el) => (scroller = el)}
588
+ role="region"
589
+ aria-label="Releases"
590
+ tabindex={0}
591
+ style={{ height: "16rem", overflow: "auto" }}
592
+ >
593
+ <table aria-rowcount={props.releases.length + 1} style={{ "border-collapse": "collapse" }}>
594
+ <thead>
595
+ <tr aria-rowindex={1}>
596
+ <th scope="col">Version</th>
597
+ </tr>
598
+ </thead>
599
+ <tbody ref={table.container}>
600
+ {table.before() > 0 ? (
601
+ <tr aria-hidden="true">
602
+ <td style={{ height: `${table.before()}px`, padding: "0" }} />
603
+ </tr>
604
+ ) : null}
605
+ {props.releases.slice(table.range().start, table.range().end).map((release, offset) => (
606
+ <tr
607
+ key={release.id}
608
+ aria-rowindex={table.range().start + offset + 2}
609
+ style={{ height: "32px" }}
610
+ >
611
+ <td>{release.version}</td>
612
+ </tr>
613
+ ))}
614
+ {table.after() > 0 ? (
615
+ <tr aria-hidden="true">
616
+ <td style={{ height: `${table.after()}px`, padding: "0" }} />
617
+ </tr>
618
+ ) : null}
619
+ </tbody>
620
+ </table>
621
+ </div>
622
+ );
623
+ }
624
+ ```
625
+
626
+ ## Styling
627
+
628
+ Nothing is styled: the virtualizer only computes. Lay the rows out one of two ways.
629
+
630
+ - **Placed rows**: the container is `position: relative` and `total()` tall; each row is
631
+ `position: absolute` at `transform: translateY(${start(row)}px)` (a cell of a grid adds its
632
+ column: `translate(${column * (itemWidth() + gap)}px, …)`). A row out of order (`keepFocused`)
633
+ still sits in its place, and a size that changes moves only the rows after it.
634
+ - **Rows in flow**: the rows of the window stay in the document's flow between `before()` and
635
+ `after()`, as a padding of the container or as spacer elements. Tables need this (a `<tr>` cannot
636
+ be placed), and it is the simplest server-rendered list: the same layout before and after mount.
637
+ In a CSS grid, use a padding: a spacer element would add a gap.
638
+
639
+ Measured rows are placed in the frame they are measured in, before it paints: the observer's
640
+ callback writes their sizes and the rows move at once, so they never show at their estimates. A
641
+ row rendered by that move (the window grew) is measured in the next frame, which browsers report
642
+ as an `error` event, "ResizeObserver loop completed with undelivered notifications". It is
643
+ harmless, and error trackers ignore it.
644
+
645
+ `size` must match the CSS: a fixed size is exactly the row's height, without the gap (that is
646
+ `gap`), and an estimate only has to be close (measured rows correct it). When the CSS sets the
647
+ height, such as a custom property that a breakpoint changes, keep it in a signal that a
648
+ ResizeObserver updates, and return that from `size`.
649
+
650
+ ## Server rendering
651
+
652
+ Before it mounts, on the server and while an island hydrates, the virtualizer reads nothing from
653
+ the browser: `mounted()` is `false`, `width()` is 0, `{ minWidth }` columns are `min`, and it
654
+ renders the first `initial` items (in whole rows). Both sides render the same HTML, so hydration
655
+ matches. Once mounted it measures the container and the viewport, and renders the window instead:
656
+ rows keyed by the same ids are kept, so the server's elements stay and only move. Options that
657
+ change the pre-mount window (`count`, `initial`, `columns`) must not read the browser either.
658
+
659
+ - **No layout jump.** The server's rows should take the place the virtual layout gives them. With
660
+ rows in flow and a fixed size, it holds by itself: render `after()` as a padding before mount too.
661
+ A server-only layout (a CSS grid with `auto-fill`, say) works when its geometry is the
662
+ virtualizer's: switch the container's class or style on `mounted()`, keep the same keyed rows.
663
+ - **Every item on the server.** For a list that must be complete in the HTML (indexing, find in
664
+ page, no JavaScript), render all of it on the server into an island's `children` slot and let the
665
+ island virtualize once its data loads, or skip the virtualizer and give the rows
666
+ `content-visibility: auto` with a `contain-intrinsic-size`: the browser skips their rendering
667
+ while they are off screen, with no script.
668
+
669
+ ## Keyboard and focus
670
+
671
+ - **Key rows by the record's id**, never by the index: a kept row is the same record, so its
672
+ focus, selection and typed text stay with it ([Lists and keys](https://itsjavi.com/lumiose/docs/concepts/jsx/#lists-and-keys)).
673
+ A row element that only wraps a grid's items may be keyed by its row index (`key={row}`): its
674
+ items keep their own keys.
675
+ - **Tab** moves through the rendered rows in DOM order; the browser scrolls the next one into view,
676
+ and the window follows it. The overscan keeps the next rows rendered.
677
+ - **`keepFocused`**: scrolled away with the wheel or the scrollbar, a focused row would leave the
678
+ document and focus would fall back to the page. With `keepFocused`, the row of the focused
679
+ element (its closest `[data-row]`) stays in `rows()` until focus leaves the container, placed
680
+ where it belongs. Rows in flow cannot hold a row outside their window: leave it off there.
681
+ - **`scrollToIndex`** moves the scroller, not focus: focus the row afterwards when the user asked
682
+ to go to it.
683
+
684
+ ## Accessibility
685
+
686
+ - **Lists**: give each rendered item `aria-setsize` (the count) and `aria-posinset` (its position
687
+ from 1), so assistive technology announces "3 of 1,000" rather than the rows rendered.
688
+ - **Tables**: `aria-rowcount` on the table (rows plus the header rows) and `aria-rowindex` on every
689
+ row, the header's too. Spacer rows are `aria-hidden="true"`.
690
+ - **Grids of items**: a row element that only lays out items gets `role="none"`, so the items are
691
+ the list's (`role="list"` on the container, `role="listitem"` on the items).
692
+ - Find in page and assistive technology reach only the rendered rows. Render everything on the
693
+ server when they must reach every row (above).
694
+
695
+ ## Without JavaScript
696
+
697
+ Only the server's first `initial` items render. Link to a full page, or render every item on the
698
+ server, when the list must work without JavaScript.
699
+
700
+ ## Browser support
701
+
702
+ ResizeObserver with `borderBoxSize`, and `scroll-padding`: every supported browser. The virtualizer
703
+ scrolls along the block axis only (vertical in horizontal writing modes).
704
+
705
+ ## Related
706
+
707
+ - [Virtual List](virtual-list.md), [Virtual Grid](virtual-grid.md) and
708
+ [Virtual Table](virtual-table.md): the three layouts as components on this primitive.
709
+ - [Scroll Area](scroll-area.md): custom scrollbars over a scroller; give its `Viewport` as `scroller`.
710
+ - [Lists and keys](https://itsjavi.com/lumiose/docs/concepts/jsx/#lists-and-keys): how keyed rows update in place.
711
+
399
712
  <!-- source: accordion.md -->
400
713
 
401
714
  # Accordion
@@ -6962,3 +7275,411 @@ in browsers without CSS anchor positioning.
6962
7275
  - [UI components](README.md): the approach, styling, the default theme and the other components.
6963
7276
  - [Popover](popover.md): a popup with interactive content, opened by a click.
6964
7277
 
7278
+ <!-- source: virtual-grid.md -->
7279
+
7280
+ # Virtual Grid
7281
+
7282
+ > A long grid that renders only the rows near the viewport, in islands: a `<ul>` of `<li>` cells placed in rows of as many columns as the width fits (`minItemWidth`, like CSS's `auto-fill`) or of `columns`, by [createVirtualizer](virtualizer.md). Rows have a fixed height, or each is as tall as its tallest measured cell. The server renders the first `initial` cells in flow for your CSS grid to lay out, and the island takes those same keyed cells over. 2.3 kB brotli with the virtualizer.
7283
+
7284
+ ```ts
7285
+ import { VirtualGrid } from "@lumiose/ui"; // or "@lumiose/ui/virtualizer"
7286
+ ```
7287
+
7288
+ ## Anatomy
7289
+
7290
+ ```tsx
7291
+ <VirtualGrid items={cards()} itemKey={(card) => card.id} minItemWidth={192} gap={16} size={160}>
7292
+ {(cell) => <Card card={cell.item} />}
7293
+ </VirtualGrid>
7294
+ ```
7295
+
7296
+ | Part | Renders |
7297
+ | ------------- | ---------------------------------------------------------------------------------------------------------- |
7298
+ | `VirtualGrid` | `<ul>`, `total()` tall once mounted; an `<li data-row aria-posinset aria-setsize>` per cell, a column wide |
7299
+
7300
+ ## API
7301
+
7302
+ ### VirtualGrid
7303
+
7304
+ | Prop | Type | Default | |
7305
+ | -------------- | --------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------- |
7306
+ | `items` | `readonly T[]` | | The items. |
7307
+ | `itemKey` | `(item: T) => string \| number` | | The item's identity: a cell stays while its key does. |
7308
+ | `children` | `(cell: VirtualItem<T>) => JSX.Element` | | A cell's content, rendered once per cell; `cell.item` and `cell.index` are getters. |
7309
+ | `size` | `number \| (row: number) => number` | | A row's height in px: fixed (each cell gets it), or with `measure` the estimate. A function may read the virtualizer's `itemWidth()`. |
7310
+ | `measure` | `boolean` | `false` | Measures the cells: each row is as tall as its tallest. |
7311
+ | `minItemWidth` | `number` | | As many columns of at least this many px as the container's width fits. |
7312
+ | `minColumns` | `number` | `1` | The fewest columns with `minItemWidth`, before mount too. |
7313
+ | `columns` | `number` | `1` | A fixed number of columns, without `minItemWidth`. |
7314
+ | `gap` | `number` | `0` | The px between rows. |
7315
+ | `columnGap` | `number` | `gap` | The px between columns. |
7316
+ | `overscan` | `number` | `4` | Rows rendered past each edge of the viewport. |
7317
+ | `scroller` | `() => Element \| null \| undefined` | window | The element that scrolls. |
7318
+ | `initial` | `number` | `0` | The cells rendered before mount (in whole rows of `minColumns`): the server's HTML. |
7319
+ | `keepFocused` | `boolean` | `true` | Keeps the row of the focused cell rendered while the grid scrolls away from it. |
7320
+ | `itemProps` | `(item: T, index: number) => li props` | | Attributes of each `<li>`. Not `style`, which places the cell. |
7321
+ | `virtualizer` | `(virtualizer: Virtualizer) => void` | | Receives the [virtualizer](virtualizer.md): `scrollToIndex()`, `itemWidth()`, `columns()`. |
7322
+
7323
+ Every other `<ul>` prop passes through. A `style` lays out the server's cells before mount (a
7324
+ CSS grid); once mounted, a `style` object stays under the grid's own position and height.
7325
+
7326
+ ## Example
7327
+
7328
+ 1,200 entries in a scroll box. The server renders the first 48 in a CSS grid of the same columns
7329
+ (the `style`, kept until the grid mounts), and the island places the same cells:
7330
+
7331
+ ```tsx check
7332
+ import { VirtualGrid } from "@lumiose/ui";
7333
+
7334
+ const ENTRIES = Array.from({ length: 1200 }, (_, i) => ({ id: `e${i + 1}`, label: `#${i + 1}` }));
7335
+
7336
+ export function EntryGrid() {
7337
+ let box: HTMLDivElement | undefined;
7338
+ return (
7339
+ <div
7340
+ ref={(el) => (box = el)}
7341
+ role="region"
7342
+ aria-label="Entries"
7343
+ tabindex={0}
7344
+ style={{ "inline-size": "100%", "max-block-size": "18rem", overflow: "auto" }}
7345
+ >
7346
+ <VirtualGrid
7347
+ aria-label="Entries"
7348
+ style={{
7349
+ margin: "0",
7350
+ padding: "0",
7351
+ "list-style": "none",
7352
+ display: "grid",
7353
+ "grid-template-columns": "repeat(auto-fill, minmax(96px, 1fr))",
7354
+ gap: "8px",
7355
+ }}
7356
+ items={ENTRIES}
7357
+ itemKey={(entry) => entry.id}
7358
+ minItemWidth={96}
7359
+ gap={8}
7360
+ size={64}
7361
+ initial={48}
7362
+ scroller={() => box}
7363
+ >
7364
+ {(cell) => (
7365
+ <button type="button" style={{ "inline-size": "100%", "block-size": "64px" }}>
7366
+ {cell.item.label}
7367
+ </button>
7368
+ )}
7369
+ </VirtualGrid>
7370
+ </div>
7371
+ );
7372
+ }
7373
+ ```
7374
+
7375
+ The same server layout in CSS, for a grid whose columns change at breakpoints:
7376
+
7377
+ ```css
7378
+ .entries:not([data-mounted]) {
7379
+ display: grid;
7380
+ grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
7381
+ gap: 8px;
7382
+ }
7383
+ ```
7384
+
7385
+ ## Styling
7386
+
7387
+ Unstyled. Before mount the cells are in flow: lay them out with a CSS grid, in the grid's `style`
7388
+ or under `:not([data-mounted])`, with the columns and gaps the grid computes (`repeat(auto-fill,
7389
+ minmax(<minItemWidth>px, 1fr))` and `gap`), and the cells take the same places once mounted.
7390
+ Mounted, the `<ul>` gets `data-mounted`, `position: relative` and its total height, and each `<li>`
7391
+ `position: absolute`, its column as `left`, the column's width, its row as a `translateY`, and with
7392
+ a fixed `size` the row's height. Give the content `block-size: 100%` to fill a fixed cell. The cells
7393
+ are the component's elements: reach them from the grid's class with `:deep()`
7394
+ (`.entries :deep(li)`); `[data-row]` is each cell's row.
7395
+
7396
+ ## Keyboard and focus
7397
+
7398
+ Cells are in DOM order, row by row, so Tab moves through them and the window follows. The row of
7399
+ the focused cell stays rendered while the grid scrolls away (`keepFocused`, on by default, decision-84). Cells are keyed by
7400
+ the record, so focus and state stay with their record through a filter, a sort or a refetch.
7401
+
7402
+ ## Without JavaScript
7403
+
7404
+ The server renders the first `initial` cells, which your CSS lays out. See
7405
+ [Virtualizer](virtualizer.md#server-rendering) for grids that must be complete in the HTML.
7406
+
7407
+ ## Browser support
7408
+
7409
+ ResizeObserver (for `measure` and the width): every supported browser. Cells are placed left to
7410
+ right.
7411
+
7412
+ ## Related
7413
+
7414
+ - [Virtualizer](virtualizer.md): the primitive the grid runs on.
7415
+ - [Virtual List](virtual-list.md) and [Virtual Table](virtual-table.md).
7416
+
7417
+ <!-- source: virtual-list.md -->
7418
+
7419
+ # Virtual List
7420
+
7421
+ > A long list that renders only the rows near the viewport, in islands: a `<ul>` whose rows are `<li>`s placed by [createVirtualizer](virtualizer.md), each keyed by `itemKey` and carrying `aria-posinset` and `aria-setsize`. The children function renders a row's content once; a row kept under its key keeps its DOM and state while its item updates in place. The server renders the first `initial` rows in flow. 2.2 kB brotli with the virtualizer.
7422
+
7423
+ ```ts
7424
+ import { VirtualList } from "@lumiose/ui"; // or "@lumiose/ui/virtualizer"
7425
+ ```
7426
+
7427
+ ## Anatomy
7428
+
7429
+ ```tsx
7430
+ <VirtualList items={people()} itemKey={(person) => person.id} size={40}>
7431
+ {(row) => <a href={`/people/${row.item.id}`}>{row.item.name}</a>}
7432
+ </VirtualList>
7433
+ ```
7434
+
7435
+ | Part | Renders |
7436
+ | ------------- | ------------------------------------------------------------------------------------------------------------------- |
7437
+ | `VirtualList` | `<ul>`, `total()` tall and `position: relative` once mounted; an `<li data-row aria-posinset aria-setsize>` per row |
7438
+
7439
+ ## API
7440
+
7441
+ ### VirtualList
7442
+
7443
+ | Prop | Type | Default | |
7444
+ | ------------- | -------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------- |
7445
+ | `items` | `readonly T[]` | | The items. |
7446
+ | `itemKey` | `(item: T) => string \| number` | | The item's identity, such as its record's id: a row stays while its key does. |
7447
+ | `children` | `(row: VirtualItem<T>) => JSX.Element` | | A row's content, rendered once per row. `row.item` and `row.index` are getters: read them where they are used. |
7448
+ | `size` | `number \| (row: number) => number` | | A row's height in px: fixed (the `<li>` gets it), or with `measure` the estimate. |
7449
+ | `measure` | `boolean` | `false` | Measures the rendered rows (one ResizeObserver): their heights replace `size`. |
7450
+ | `gap` | `number` | `0` | The px between rows. |
7451
+ | `overscan` | `number` | `4` | Rows rendered past each edge of the viewport. |
7452
+ | `scroller` | `() => Element \| null \| undefined` | window | The element that scrolls. |
7453
+ | `initial` | `number` | `0` | The rows rendered before mount: the server's HTML. |
7454
+ | `keepFocused` | `boolean` | `true` | Keeps the row that holds focus rendered while the list scrolls away from it. |
7455
+ | `itemProps` | `(item: T, index: number) => li props` | | Attributes of each `<li>`: data attributes, a title. Not `style`, which places the row. |
7456
+ | `virtualizer` | `(virtualizer: Virtualizer) => void` | | Receives the [virtualizer](virtualizer.md), for `scrollToIndex()` and `range()`. |
7457
+
7458
+ Every other `<ul>` prop passes through: `class`, `aria-label`, `aria-labelledby`, `id`. A `style`
7459
+ object applies too, with the list's own properties (its padding, position and height) over it.
7460
+
7461
+ ## Example
7462
+
7463
+ A thousand people in a scroll box, the first 20 in the server's HTML:
7464
+
7465
+ ```tsx check
7466
+ import { VirtualList, type Virtualizer } from "@lumiose/ui";
7467
+
7468
+ const PEOPLE = Array.from({ length: 1000 }, (_, i) => ({
7469
+ id: `p${i + 1}`,
7470
+ name: `Person ${i + 1}`,
7471
+ }));
7472
+
7473
+ export function PeopleList() {
7474
+ let box: HTMLDivElement | undefined;
7475
+ let list: Virtualizer | undefined;
7476
+ return (
7477
+ <>
7478
+ <button type="button" onClick={() => list?.scrollToIndex(499, { align: "start" })}>
7479
+ Go to person 500
7480
+ </button>
7481
+ <div
7482
+ ref={(el) => (box = el)}
7483
+ role="region"
7484
+ aria-label="People"
7485
+ tabindex={0}
7486
+ style={{ "inline-size": "100%", "max-block-size": "16rem", overflow: "auto" }}
7487
+ >
7488
+ <VirtualList
7489
+ aria-label="People"
7490
+ style={{ margin: "0", padding: "0", "list-style": "none" }}
7491
+ items={PEOPLE}
7492
+ itemKey={(person) => person.id}
7493
+ size={40}
7494
+ initial={20}
7495
+ scroller={() => box}
7496
+ virtualizer={(v) => (list = v)}
7497
+ >
7498
+ {(row) => (
7499
+ <span>
7500
+ {row.index + 1}. {row.item.name}
7501
+ </span>
7502
+ )}
7503
+ </VirtualList>
7504
+ </div>
7505
+ </>
7506
+ );
7507
+ }
7508
+ ```
7509
+
7510
+ ## Styling
7511
+
7512
+ Unstyled. Before mount the rows are in flow (each `size` tall, the rest of the list's height a
7513
+ `padding-block-end`), so the server's list already has its full height. Once mounted the `<ul>`
7514
+ gets `data-mounted`, `position: relative` and its total height, and each `<li>` `position:
7515
+ absolute`, `inset: 0 0 auto` and a `transform`. Style with `class` on the list, `[data-mounted]`,
7516
+ and the rows through the list's class: they are the component's elements, so a scoped selector
7517
+ reaches them with `:deep()`, as in `.people :deep(li)`. The content you render is yours to style.
7518
+
7519
+ ## Keyboard and focus
7520
+
7521
+ The rows are in DOM order, so Tab moves through them, and the browser scrolls the next one into
7522
+ view. A focused row stays rendered while the list scrolls away from it (`keepFocused`, on by default for placed rows, decision-84), so focus
7523
+ never falls back to the page. Rows are keyed by the record: a row's focus, typed text or toggled
7524
+ state stays with its record when items are added, removed, reordered or fetched again.
7525
+
7526
+ ## Without JavaScript
7527
+
7528
+ The server renders the first `initial` rows, in flow, with the list's full height reserved. See
7529
+ [Virtualizer](virtualizer.md#server-rendering) for the takeover and for lists that must be
7530
+ complete in the HTML.
7531
+
7532
+ ## Browser support
7533
+
7534
+ ResizeObserver (for `measure`): every supported browser.
7535
+
7536
+ ## Related
7537
+
7538
+ - [Virtualizer](virtualizer.md): the primitive the list runs on, for other layouts.
7539
+ - [Virtual Grid](virtual-grid.md) and [Virtual Table](virtual-table.md).
7540
+
7541
+ <!-- source: virtual-table.md -->
7542
+
7543
+ # Virtual Table
7544
+
7545
+ > A long table that renders only the rows near its scroller's viewport, in islands: a `<table>` with `aria-rowcount`, your head, and a body whose `<tr aria-rowindex>` rows sit between two spacer rows, by [createVirtualizer](virtualizer.md). The children function renders a row's cells once; a row kept under its key updates in place. The server renders the first `initial` rows and a spacer for the rest. 2.3 kB brotli with the virtualizer.
7546
+
7547
+ ```ts
7548
+ import { VirtualTable } from "@lumiose/ui"; // or "@lumiose/ui/virtualizer"
7549
+ ```
7550
+
7551
+ ## Anatomy
7552
+
7553
+ ```tsx
7554
+ <VirtualTable
7555
+ items={releases()}
7556
+ itemKey={(release) => release.id}
7557
+ size={32}
7558
+ scroller={() => box}
7559
+ head={
7560
+ <thead>
7561
+ <tr aria-rowindex={1}>
7562
+ <th scope="col">Version</th>
7563
+ </tr>
7564
+ </thead>
7565
+ }
7566
+ >
7567
+ {(row) => <td>{row.item.version}</td>}
7568
+ </VirtualTable>
7569
+ ```
7570
+
7571
+ | Part | Renders |
7572
+ | -------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
7573
+ | `VirtualTable` | `<table aria-rowcount>`, `head`, and a `<tbody>` of `<tr data-row aria-rowindex>` rows between `<tr aria-hidden data-spacer>` rows |
7574
+
7575
+ ## API
7576
+
7577
+ ### VirtualTable
7578
+
7579
+ | Prop | Type | Default | |
7580
+ | ------------- | -------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------- |
7581
+ | `items` | `readonly T[]` | | The items. |
7582
+ | `itemKey` | `(item: T) => string \| number` | | The item's identity: a row stays while its key does. |
7583
+ | `children` | `(row: VirtualItem<T>) => JSX.Element` | | A row's cells, rendered once per row; `row.item` and `row.index` are getters. |
7584
+ | `size` | `number \| (row: number) => number` | | A row's height in px: fixed, or with `measure` the estimate. It must match the CSS's row height. |
7585
+ | `measure` | `boolean` | `false` | Measures the rendered rows: their heights replace `size`. |
7586
+ | `scroller` | `() => Element \| null \| undefined` | window | The element that scrolls, usually a `role="region"` box around the table. |
7587
+ | `head` | `JSX.Element` | | What comes before the body: `<caption>`, `<colgroup>`, `<thead>`. Give its rows `aria-rowindex` from 1. |
7588
+ | `headerRows` | `number` | `1` | How many rows `head` has: `aria-rowcount` is the items plus these, and the body's indexes follow. |
7589
+ | `colSpan` | `number` | `1` | The columns a spacer row spans. |
7590
+ | `empty` | `JSX.Element` | | A row shown in the body while there are no items. |
7591
+ | `overscan` | `number` | `4` | Rows rendered past each edge of the viewport. |
7592
+ | `initial` | `number` | `0` | The rows rendered before mount: the server's HTML. |
7593
+ | `rowProps` | `(item: T, index: number) => tr props` | | Attributes of each row: data attributes, a title. |
7594
+ | `virtualizer` | `(virtualizer: Virtualizer) => void` | | Receives the [virtualizer](virtualizer.md), for `scrollToIndex()`. |
7595
+
7596
+ Every other `<table>` prop passes through: `class`, `aria-label`, `id`.
7597
+
7598
+ ## Example
7599
+
7600
+ ```tsx check
7601
+ import { VirtualTable } from "@lumiose/ui";
7602
+
7603
+ const RELEASES = Array.from({ length: 2000 }, (_, i) => ({
7604
+ id: `r${i + 1}`,
7605
+ version: `1.${Math.floor(i / 10)}.${i % 10}`,
7606
+ downloads: (i * 7919) % 10007,
7607
+ }));
7608
+
7609
+ export function ReleaseTable() {
7610
+ let box: HTMLDivElement | undefined;
7611
+ return (
7612
+ <div
7613
+ ref={(el) => (box = el)}
7614
+ role="region"
7615
+ aria-label="Releases"
7616
+ tabindex={0}
7617
+ style={{ "inline-size": "100%", "max-block-size": "20rem", overflow: "auto" }}
7618
+ >
7619
+ <VirtualTable
7620
+ style={{ "border-collapse": "collapse" }}
7621
+ items={RELEASES}
7622
+ itemKey={(release) => release.id}
7623
+ size={32}
7624
+ scroller={() => box}
7625
+ initial={15}
7626
+ colSpan={2}
7627
+ head={
7628
+ <thead>
7629
+ <tr aria-rowindex={1}>
7630
+ <th scope="col">Version</th>
7631
+ <th scope="col">Downloads</th>
7632
+ </tr>
7633
+ </thead>
7634
+ }
7635
+ rowProps={() => ({ style: "block-size: 32px" })}
7636
+ empty={
7637
+ <tr>
7638
+ <td colspan={2}>No releases yet.</td>
7639
+ </tr>
7640
+ }
7641
+ >
7642
+ {(row) => (
7643
+ <>
7644
+ <td>{row.item.version}</td>
7645
+ <td>{row.item.downloads}</td>
7646
+ </>
7647
+ )}
7648
+ </VirtualTable>
7649
+ </div>
7650
+ );
7651
+ }
7652
+ ```
7653
+
7654
+ ## Styling
7655
+
7656
+ Unstyled. Rows stay in the table's flow, so the table lays out its columns as usual; set the rows'
7657
+ height in CSS to match `size` (`block-size` on the rows, or a custom property that `size` reads),
7658
+ and collapse the borders (`border-collapse: collapse`), or the default `border-spacing` puts 2px
7659
+ between rows that `size` does not count.
7660
+ The spacer rows (`[data-spacer]`) have no padding or border. The rows are the component's `<tr>`s:
7661
+ reach them from the table's class with `:deep()`, as in `.releases :deep(tr[data-row])`. The cells
7662
+ you render are yours to style; a sticky `<thead>` works as in any scroller.
7663
+
7664
+ ## Keyboard and focus
7665
+
7666
+ Focus the scroller (`tabindex="0"`) and the arrow keys, Page Up and Down, Home and End scroll it; the
7667
+ rows follow. Rows are keyed by the record, so a link focused in a row keeps focus through a sort or
7668
+ a refetch while it stays in the window. The table has no `keepFocused` (decision-84): a row outside the window
7669
+ cannot sit between the spacers.
7670
+
7671
+ ## Without JavaScript
7672
+
7673
+ The server renders the head, the first `initial` rows and a spacer for the rest, so the scroller has
7674
+ its full height. See [Virtualizer](virtualizer.md#server-rendering) for tables that must be complete
7675
+ in the HTML.
7676
+
7677
+ ## Browser support
7678
+
7679
+ ResizeObserver: every supported browser.
7680
+
7681
+ ## Related
7682
+
7683
+ - [Virtualizer](virtualizer.md): the primitive the table runs on.
7684
+ - [Virtual List](virtual-list.md) and [Virtual Grid](virtual-grid.md).
7685
+