@lumiose/ui 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/accordion/accordion.tsx +3 -2
- package/dist/collapsible/collapsible.tsx +2 -1
- package/dist/collapsible/panel.ts +3 -1
- package/dist/combobox/combobox.tsx +2 -1
- package/dist/combobox/context.ts +2 -1
- package/dist/context-menu/context-menu.tsx +2 -1
- package/dist/dialog/dialog.tsx +2 -1
- package/dist/drawer/drawer.tsx +2 -1
- package/dist/field/field.tsx +2 -1
- package/dist/index.ts +17 -0
- package/dist/menu/menu.tsx +4 -3
- package/dist/meter/meter.tsx +2 -1
- package/dist/navigation-menu/navigation-menu.tsx +2 -1
- package/dist/not-inside.ts +13 -0
- package/dist/number-field/number-field.tsx +2 -1
- package/dist/otp-field/otp-field.tsx +2 -1
- package/dist/popover/hover.ts +2 -1
- package/dist/popover/popover.tsx +12 -8
- package/dist/preview-card/preview-card.tsx +2 -1
- package/dist/progress/progress.tsx +2 -1
- package/dist/radio-group/radio-group.tsx +2 -1
- package/dist/select/select.tsx +10 -4
- package/dist/slider/slider.tsx +2 -1
- package/dist/tabs/tabs.tsx +2 -1
- package/dist/toast/toast.tsx +2 -1
- package/dist/toolbar/toolbar.tsx +2 -1
- package/dist/tooltip/tooltip.tsx +2 -1
- package/dist/virtualizer/virtual.tsx +398 -0
- package/dist/virtualizer/virtualizer.ts +354 -0
- package/docs/README.md +11 -1
- package/docs/llms-full.txt +728 -1
- 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 +6 -5
package/docs/llms-full.txt
CHANGED
|
@@ -42,7 +42,13 @@ Anatomy, API, Example, Styling, Keyboard and focus, Without JavaScript, Browser
|
|
|
42
42
|
runtime.
|
|
43
43
|
- **Parts.** Each component is a set of parts named as in Base UI (`Dialog.Root`,
|
|
44
44
|
`Dialog.Trigger`, `Dialog.Popup`, …). Every part that renders an element takes that element's
|
|
45
|
-
props (`class`, `style`, `aria-*`, events) and passes them through.
|
|
45
|
+
props (`class`, `style`, `aria-*`, events) and passes them through. A part finds its `Root`
|
|
46
|
+
through context, so a `Root` and its parts render on the same side of an island boundary, even
|
|
47
|
+
where the JSX nests them. A part the page passes to an island as a slot (children or a JSX prop)
|
|
48
|
+
renders in the page, outside the `Root` the island renders, and the server throws "Combobox
|
|
49
|
+
parts must be inside <Combobox.Root>"; an island inside a `Root` the page renders does not see
|
|
50
|
+
that `Root` either. Pass the data as props and render the `Root` and its parts inside the
|
|
51
|
+
island ([Islands](https://itsjavi.com/lumiose/docs/concepts/islands/)).
|
|
46
52
|
- **Signals.** A component's state is a prop and a callback: `open` with `onOpenChange`, `checked`
|
|
47
53
|
with `onCheckedChange`, `value` with `onValueChange`. Pass a signal's value and write it back in
|
|
48
54
|
the callback to keep both in sync, or leave both out and the element keeps its own state. Custom
|
|
@@ -112,6 +118,10 @@ custom properties, named as in shadcn/ui, are listed in [Theming](theming.md#the
|
|
|
112
118
|
| [Avatar](avatar.md) | Available: an image over its fallback, `<lu-avatar>` follows its loading status |
|
|
113
119
|
| [Menubar](menubar.md) | Available: Menus in a `role="menubar"`, `<lu-menubar>` moves between them |
|
|
114
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 |
|
|
115
125
|
|
|
116
126
|
## Ids
|
|
117
127
|
|
|
@@ -390,6 +400,315 @@ Each component's page has an example stylesheet to start from.
|
|
|
390
400
|
- [Installation](installation.md): adding the package and importing its stylesheets.
|
|
391
401
|
- [Styles](https://itsjavi.com/lumiose/docs/concepts/styles/): scoped CSS and global stylesheets in Lumiose.
|
|
392
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
|
+
|
|
393
712
|
<!-- source: accordion.md -->
|
|
394
713
|
|
|
395
714
|
# Accordion
|
|
@@ -6956,3 +7275,411 @@ in browsers without CSS anchor positioning.
|
|
|
6956
7275
|
- [UI components](README.md): the approach, styling, the default theme and the other components.
|
|
6957
7276
|
- [Popover](popover.md): a popup with interactive content, opened by a click.
|
|
6958
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
|
+
|