@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/docs/llms.txt CHANGED
@@ -12,6 +12,7 @@ props, events and styling hooks. The Lumiose docs: `node_modules/lumiose/docs/ll
12
12
  - [UI components](README.md): `@lumiose/ui`: accessible, unstyled UI primitives built on native elements (`<dialog>`, popover, invoker commands, CSS anchor positioning), server-rendered without hydration. Style them with your own CSS or the optional default theme.
13
13
  - [Installation](installation.md): Add `@lumiose/ui` to a Lumiose app and import its stylesheets, and the default theme if you want its look. The components then render in server HTML, without an island, and a page loads only the small scripts of the components it renders.
14
14
  - [Theming](theming.md): `@lumiose/ui` is unstyled: its stylesheets hold only what the components need to work. The optional default theme gives them a look, light and dark, through `--lu-` custom properties you can change; or style the parts with your own CSS.
15
+ - [Virtualizer](virtualizer.md): 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 Grid or Virtual Table, which run on it.
15
16
 
16
17
  ## Components
17
18
 
@@ -51,3 +52,6 @@ props, events and styling hooks. The Lumiose docs: `node_modules/lumiose/docs/ll
51
52
  - [Toggle Group](toggle-group.md): A set of Toggles with one value, after Base UI's ToggleGroup: text alignment (one pressed at a time) or text style (several). `<lu-toggle-group role="group">` presses its toggles and gives the group one tab stop that the arrow keys, Home and End move, following the orientation and the direction, on server pages and in islands. `bind:value` keeps a signal in sync.
52
53
  - [Toolbar](toolbar.md): A set of controls with one tab stop, after Base UI's Toolbar and the APG toolbar pattern: `<lu-toolbar role="toolbar">` moves focus between its items with the arrow keys, Home and End, following the orientation and the text direction. Its items are native: buttons that stay focusable when disabled, links, inputs, and the toggles of a Toggle Group inside it. In the server HTML every item is a tab stop, so each is reachable without JavaScript; a 1.2 kB script makes them one tab stop and moves it.
53
54
  - [Tooltip](tooltip.md): A short tip next to its trigger, on hover or keyboard focus: a native `popover="hint"` with `role="tooltip"`, placed with CSS anchor positioning. The trigger is described by the tip in server HTML, so screen readers announce it without hovering; a 2.1 kB script opens and closes it as Base UI's tooltip does. `bind:open` (or `open` and `onOpenChange`) keeps a signal in sync in islands.
55
+ - [Virtual Grid](virtual-grid.md): 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. 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.
56
+ - [Virtual List](virtual-list.md): A long list that renders only the rows near the viewport, in islands: a `<ul>` whose rows are `<li>`s placed by createVirtualizer, 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.
57
+ - [Virtual Table](virtual-table.md): 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. 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.
@@ -0,0 +1,136 @@
1
+ # Virtual Grid
2
+
3
+ > 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.
4
+
5
+ ```ts
6
+ import { VirtualGrid } from "@lumiose/ui"; // or "@lumiose/ui/virtualizer"
7
+ ```
8
+
9
+ ## Anatomy
10
+
11
+ ```tsx
12
+ <VirtualGrid items={cards()} itemKey={(card) => card.id} minItemWidth={192} gap={16} size={160}>
13
+ {(cell) => <Card card={cell.item} />}
14
+ </VirtualGrid>
15
+ ```
16
+
17
+ | Part | Renders |
18
+ | ------------- | ---------------------------------------------------------------------------------------------------------- |
19
+ | `VirtualGrid` | `<ul>`, `total()` tall once mounted; an `<li data-row aria-posinset aria-setsize>` per cell, a column wide |
20
+
21
+ ## API
22
+
23
+ ### VirtualGrid
24
+
25
+ | Prop | Type | Default | |
26
+ | -------------- | --------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------- |
27
+ | `items` | `readonly T[]` | | The items. |
28
+ | `itemKey` | `(item: T) => string \| number` | | The item's identity: a cell stays while its key does. |
29
+ | `children` | `(cell: VirtualItem<T>) => JSX.Element` | | A cell's content, rendered once per cell; `cell.item` and `cell.index` are getters. |
30
+ | `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()`. |
31
+ | `measure` | `boolean` | `false` | Measures the cells: each row is as tall as its tallest. |
32
+ | `minItemWidth` | `number` | | As many columns of at least this many px as the container's width fits. |
33
+ | `minColumns` | `number` | `1` | The fewest columns with `minItemWidth`, before mount too. |
34
+ | `columns` | `number` | `1` | A fixed number of columns, without `minItemWidth`. |
35
+ | `gap` | `number` | `0` | The px between rows. |
36
+ | `columnGap` | `number` | `gap` | The px between columns. |
37
+ | `overscan` | `number` | `4` | Rows rendered past each edge of the viewport. |
38
+ | `scroller` | `() => Element \| null \| undefined` | window | The element that scrolls. |
39
+ | `initial` | `number` | `0` | The cells rendered before mount (in whole rows of `minColumns`): the server's HTML. |
40
+ | `keepFocused` | `boolean` | `true` | Keeps the row of the focused cell rendered while the grid scrolls away from it. |
41
+ | `itemProps` | `(item: T, index: number) => li props` | | Attributes of each `<li>`. Not `style`, which places the cell. |
42
+ | `virtualizer` | `(virtualizer: Virtualizer) => void` | | Receives the [virtualizer](virtualizer.md): `scrollToIndex()`, `itemWidth()`, `columns()`. |
43
+
44
+ Every other `<ul>` prop passes through. A `style` lays out the server's cells before mount (a
45
+ CSS grid); once mounted, a `style` object stays under the grid's own position and height.
46
+
47
+ ## Example
48
+
49
+ 1,200 entries in a scroll box. The server renders the first 48 in a CSS grid of the same columns
50
+ (the `style`, kept until the grid mounts), and the island places the same cells:
51
+
52
+ ```tsx check
53
+ import { VirtualGrid } from "@lumiose/ui";
54
+
55
+ const ENTRIES = Array.from({ length: 1200 }, (_, i) => ({ id: `e${i + 1}`, label: `#${i + 1}` }));
56
+
57
+ export function EntryGrid() {
58
+ let box: HTMLDivElement | undefined;
59
+ return (
60
+ <div
61
+ ref={(el) => (box = el)}
62
+ role="region"
63
+ aria-label="Entries"
64
+ tabindex={0}
65
+ style={{ "inline-size": "100%", "max-block-size": "18rem", overflow: "auto" }}
66
+ >
67
+ <VirtualGrid
68
+ aria-label="Entries"
69
+ style={{
70
+ margin: "0",
71
+ padding: "0",
72
+ "list-style": "none",
73
+ display: "grid",
74
+ "grid-template-columns": "repeat(auto-fill, minmax(96px, 1fr))",
75
+ gap: "8px",
76
+ }}
77
+ items={ENTRIES}
78
+ itemKey={(entry) => entry.id}
79
+ minItemWidth={96}
80
+ gap={8}
81
+ size={64}
82
+ initial={48}
83
+ scroller={() => box}
84
+ >
85
+ {(cell) => (
86
+ <button type="button" style={{ "inline-size": "100%", "block-size": "64px" }}>
87
+ {cell.item.label}
88
+ </button>
89
+ )}
90
+ </VirtualGrid>
91
+ </div>
92
+ );
93
+ }
94
+ ```
95
+
96
+ The same server layout in CSS, for a grid whose columns change at breakpoints:
97
+
98
+ ```css
99
+ .entries:not([data-mounted]) {
100
+ display: grid;
101
+ grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
102
+ gap: 8px;
103
+ }
104
+ ```
105
+
106
+ ## Styling
107
+
108
+ Unstyled. Before mount the cells are in flow: lay them out with a CSS grid, in the grid's `style`
109
+ or under `:not([data-mounted])`, with the columns and gaps the grid computes (`repeat(auto-fill,
110
+ minmax(<minItemWidth>px, 1fr))` and `gap`), and the cells take the same places once mounted.
111
+ Mounted, the `<ul>` gets `data-mounted`, `position: relative` and its total height, and each `<li>`
112
+ `position: absolute`, its column as `left`, the column's width, its row as a `translateY`, and with
113
+ a fixed `size` the row's height. Give the content `block-size: 100%` to fill a fixed cell. The cells
114
+ are the component's elements: reach them from the grid's class with `:deep()`
115
+ (`.entries :deep(li)`); `[data-row]` is each cell's row.
116
+
117
+ ## Keyboard and focus
118
+
119
+ Cells are in DOM order, row by row, so Tab moves through them and the window follows. The row of
120
+ the focused cell stays rendered while the grid scrolls away (`keepFocused`, on by default, decision-84). Cells are keyed by
121
+ the record, so focus and state stay with their record through a filter, a sort or a refetch.
122
+
123
+ ## Without JavaScript
124
+
125
+ The server renders the first `initial` cells, which your CSS lays out. See
126
+ [Virtualizer](virtualizer.md#server-rendering) for grids that must be complete in the HTML.
127
+
128
+ ## Browser support
129
+
130
+ ResizeObserver (for `measure` and the width): every supported browser. Cells are placed left to
131
+ right.
132
+
133
+ ## Related
134
+
135
+ - [Virtualizer](virtualizer.md): the primitive the grid runs on.
136
+ - [Virtual List](virtual-list.md) and [Virtual Table](virtual-table.md).
@@ -0,0 +1,121 @@
1
+ # Virtual List
2
+
3
+ > 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.
4
+
5
+ ```ts
6
+ import { VirtualList } from "@lumiose/ui"; // or "@lumiose/ui/virtualizer"
7
+ ```
8
+
9
+ ## Anatomy
10
+
11
+ ```tsx
12
+ <VirtualList items={people()} itemKey={(person) => person.id} size={40}>
13
+ {(row) => <a href={`/people/${row.item.id}`}>{row.item.name}</a>}
14
+ </VirtualList>
15
+ ```
16
+
17
+ | Part | Renders |
18
+ | ------------- | ------------------------------------------------------------------------------------------------------------------- |
19
+ | `VirtualList` | `<ul>`, `total()` tall and `position: relative` once mounted; an `<li data-row aria-posinset aria-setsize>` per row |
20
+
21
+ ## API
22
+
23
+ ### VirtualList
24
+
25
+ | Prop | Type | Default | |
26
+ | ------------- | -------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------- |
27
+ | `items` | `readonly T[]` | | The items. |
28
+ | `itemKey` | `(item: T) => string \| number` | | The item's identity, such as its record's id: a row stays while its key does. |
29
+ | `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. |
30
+ | `size` | `number \| (row: number) => number` | | A row's height in px: fixed (the `<li>` gets it), or with `measure` the estimate. |
31
+ | `measure` | `boolean` | `false` | Measures the rendered rows (one ResizeObserver): their heights replace `size`. |
32
+ | `gap` | `number` | `0` | The px between rows. |
33
+ | `overscan` | `number` | `4` | Rows rendered past each edge of the viewport. |
34
+ | `scroller` | `() => Element \| null \| undefined` | window | The element that scrolls. |
35
+ | `initial` | `number` | `0` | The rows rendered before mount: the server's HTML. |
36
+ | `keepFocused` | `boolean` | `true` | Keeps the row that holds focus rendered while the list scrolls away from it. |
37
+ | `itemProps` | `(item: T, index: number) => li props` | | Attributes of each `<li>`: data attributes, a title. Not `style`, which places the row. |
38
+ | `virtualizer` | `(virtualizer: Virtualizer) => void` | | Receives the [virtualizer](virtualizer.md), for `scrollToIndex()` and `range()`. |
39
+
40
+ Every other `<ul>` prop passes through: `class`, `aria-label`, `aria-labelledby`, `id`. A `style`
41
+ object applies too, with the list's own properties (its padding, position and height) over it.
42
+
43
+ ## Example
44
+
45
+ A thousand people in a scroll box, the first 20 in the server's HTML:
46
+
47
+ ```tsx check
48
+ import { VirtualList, type Virtualizer } from "@lumiose/ui";
49
+
50
+ const PEOPLE = Array.from({ length: 1000 }, (_, i) => ({
51
+ id: `p${i + 1}`,
52
+ name: `Person ${i + 1}`,
53
+ }));
54
+
55
+ export function PeopleList() {
56
+ let box: HTMLDivElement | undefined;
57
+ let list: Virtualizer | undefined;
58
+ return (
59
+ <>
60
+ <button type="button" onClick={() => list?.scrollToIndex(499, { align: "start" })}>
61
+ Go to person 500
62
+ </button>
63
+ <div
64
+ ref={(el) => (box = el)}
65
+ role="region"
66
+ aria-label="People"
67
+ tabindex={0}
68
+ style={{ "inline-size": "100%", "max-block-size": "16rem", overflow: "auto" }}
69
+ >
70
+ <VirtualList
71
+ aria-label="People"
72
+ style={{ margin: "0", padding: "0", "list-style": "none" }}
73
+ items={PEOPLE}
74
+ itemKey={(person) => person.id}
75
+ size={40}
76
+ initial={20}
77
+ scroller={() => box}
78
+ virtualizer={(v) => (list = v)}
79
+ >
80
+ {(row) => (
81
+ <span>
82
+ {row.index + 1}. {row.item.name}
83
+ </span>
84
+ )}
85
+ </VirtualList>
86
+ </div>
87
+ </>
88
+ );
89
+ }
90
+ ```
91
+
92
+ ## Styling
93
+
94
+ Unstyled. Before mount the rows are in flow (each `size` tall, the rest of the list's height a
95
+ `padding-block-end`), so the server's list already has its full height. Once mounted the `<ul>`
96
+ gets `data-mounted`, `position: relative` and its total height, and each `<li>` `position:
97
+ absolute`, `inset: 0 0 auto` and a `transform`. Style with `class` on the list, `[data-mounted]`,
98
+ and the rows through the list's class: they are the component's elements, so a scoped selector
99
+ reaches them with `:deep()`, as in `.people :deep(li)`. The content you render is yours to style.
100
+
101
+ ## Keyboard and focus
102
+
103
+ The rows are in DOM order, so Tab moves through them, and the browser scrolls the next one into
104
+ view. A focused row stays rendered while the list scrolls away from it (`keepFocused`, on by default for placed rows, decision-84), so focus
105
+ never falls back to the page. Rows are keyed by the record: a row's focus, typed text or toggled
106
+ state stays with its record when items are added, removed, reordered or fetched again.
107
+
108
+ ## Without JavaScript
109
+
110
+ The server renders the first `initial` rows, in flow, with the list's full height reserved. See
111
+ [Virtualizer](virtualizer.md#server-rendering) for the takeover and for lists that must be
112
+ complete in the HTML.
113
+
114
+ ## Browser support
115
+
116
+ ResizeObserver (for `measure`): every supported browser.
117
+
118
+ ## Related
119
+
120
+ - [Virtualizer](virtualizer.md): the primitive the list runs on, for other layouts.
121
+ - [Virtual Grid](virtual-grid.md) and [Virtual Table](virtual-table.md).
@@ -0,0 +1,142 @@
1
+ # Virtual Table
2
+
3
+ > 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.
4
+
5
+ ```ts
6
+ import { VirtualTable } from "@lumiose/ui"; // or "@lumiose/ui/virtualizer"
7
+ ```
8
+
9
+ ## Anatomy
10
+
11
+ ```tsx
12
+ <VirtualTable
13
+ items={releases()}
14
+ itemKey={(release) => release.id}
15
+ size={32}
16
+ scroller={() => box}
17
+ head={
18
+ <thead>
19
+ <tr aria-rowindex={1}>
20
+ <th scope="col">Version</th>
21
+ </tr>
22
+ </thead>
23
+ }
24
+ >
25
+ {(row) => <td>{row.item.version}</td>}
26
+ </VirtualTable>
27
+ ```
28
+
29
+ | Part | Renders |
30
+ | -------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
31
+ | `VirtualTable` | `<table aria-rowcount>`, `head`, and a `<tbody>` of `<tr data-row aria-rowindex>` rows between `<tr aria-hidden data-spacer>` rows |
32
+
33
+ ## API
34
+
35
+ ### VirtualTable
36
+
37
+ | Prop | Type | Default | |
38
+ | ------------- | -------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------- |
39
+ | `items` | `readonly T[]` | | The items. |
40
+ | `itemKey` | `(item: T) => string \| number` | | The item's identity: a row stays while its key does. |
41
+ | `children` | `(row: VirtualItem<T>) => JSX.Element` | | A row's cells, rendered once per row; `row.item` and `row.index` are getters. |
42
+ | `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. |
43
+ | `measure` | `boolean` | `false` | Measures the rendered rows: their heights replace `size`. |
44
+ | `scroller` | `() => Element \| null \| undefined` | window | The element that scrolls, usually a `role="region"` box around the table. |
45
+ | `head` | `JSX.Element` | | What comes before the body: `<caption>`, `<colgroup>`, `<thead>`. Give its rows `aria-rowindex` from 1. |
46
+ | `headerRows` | `number` | `1` | How many rows `head` has: `aria-rowcount` is the items plus these, and the body's indexes follow. |
47
+ | `colSpan` | `number` | `1` | The columns a spacer row spans. |
48
+ | `empty` | `JSX.Element` | | A row shown in the body while there are no items. |
49
+ | `overscan` | `number` | `4` | Rows rendered past each edge of the viewport. |
50
+ | `initial` | `number` | `0` | The rows rendered before mount: the server's HTML. |
51
+ | `rowProps` | `(item: T, index: number) => tr props` | | Attributes of each row: data attributes, a title. |
52
+ | `virtualizer` | `(virtualizer: Virtualizer) => void` | | Receives the [virtualizer](virtualizer.md), for `scrollToIndex()`. |
53
+
54
+ Every other `<table>` prop passes through: `class`, `aria-label`, `id`.
55
+
56
+ ## Example
57
+
58
+ ```tsx check
59
+ import { VirtualTable } from "@lumiose/ui";
60
+
61
+ const RELEASES = Array.from({ length: 2000 }, (_, i) => ({
62
+ id: `r${i + 1}`,
63
+ version: `1.${Math.floor(i / 10)}.${i % 10}`,
64
+ downloads: (i * 7919) % 10007,
65
+ }));
66
+
67
+ export function ReleaseTable() {
68
+ let box: HTMLDivElement | undefined;
69
+ return (
70
+ <div
71
+ ref={(el) => (box = el)}
72
+ role="region"
73
+ aria-label="Releases"
74
+ tabindex={0}
75
+ style={{ "inline-size": "100%", "max-block-size": "20rem", overflow: "auto" }}
76
+ >
77
+ <VirtualTable
78
+ style={{ "border-collapse": "collapse" }}
79
+ items={RELEASES}
80
+ itemKey={(release) => release.id}
81
+ size={32}
82
+ scroller={() => box}
83
+ initial={15}
84
+ colSpan={2}
85
+ head={
86
+ <thead>
87
+ <tr aria-rowindex={1}>
88
+ <th scope="col">Version</th>
89
+ <th scope="col">Downloads</th>
90
+ </tr>
91
+ </thead>
92
+ }
93
+ rowProps={() => ({ style: "block-size: 32px" })}
94
+ empty={
95
+ <tr>
96
+ <td colspan={2}>No releases yet.</td>
97
+ </tr>
98
+ }
99
+ >
100
+ {(row) => (
101
+ <>
102
+ <td>{row.item.version}</td>
103
+ <td>{row.item.downloads}</td>
104
+ </>
105
+ )}
106
+ </VirtualTable>
107
+ </div>
108
+ );
109
+ }
110
+ ```
111
+
112
+ ## Styling
113
+
114
+ Unstyled. Rows stay in the table's flow, so the table lays out its columns as usual; set the rows'
115
+ height in CSS to match `size` (`block-size` on the rows, or a custom property that `size` reads),
116
+ and collapse the borders (`border-collapse: collapse`), or the default `border-spacing` puts 2px
117
+ between rows that `size` does not count.
118
+ The spacer rows (`[data-spacer]`) have no padding or border. The rows are the component's `<tr>`s:
119
+ reach them from the table's class with `:deep()`, as in `.releases :deep(tr[data-row])`. The cells
120
+ you render are yours to style; a sticky `<thead>` works as in any scroller.
121
+
122
+ ## Keyboard and focus
123
+
124
+ Focus the scroller (`tabindex="0"`) and the arrow keys, Page Up and Down, Home and End scroll it; the
125
+ rows follow. Rows are keyed by the record, so a link focused in a row keeps focus through a sort or
126
+ a refetch while it stays in the window. The table has no `keepFocused` (decision-84): a row outside the window
127
+ cannot sit between the spacers.
128
+
129
+ ## Without JavaScript
130
+
131
+ The server renders the head, the first `initial` rows and a spacer for the rest, so the scroller has
132
+ its full height. See [Virtualizer](virtualizer.md#server-rendering) for tables that must be complete
133
+ in the HTML.
134
+
135
+ ## Browser support
136
+
137
+ ResizeObserver: every supported browser.
138
+
139
+ ## Related
140
+
141
+ - [Virtualizer](virtualizer.md): the primitive the table runs on.
142
+ - [Virtual List](virtual-list.md) and [Virtual Grid](virtual-grid.md).