@rinzai/zen 0.13.0 → 0.15.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.
Files changed (172) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/README.md +16 -1
  3. package/dist/carousel/index.d.ts.map +1 -1
  4. package/dist/carousel/index.js +5 -5
  5. package/dist/docs/README.md +205 -0
  6. package/dist/docs/accordion.md +122 -0
  7. package/dist/docs/alert.md +69 -0
  8. package/dist/docs/animated-number.md +105 -0
  9. package/dist/docs/avatar.md +91 -0
  10. package/dist/docs/backdrop.md +94 -0
  11. package/dist/docs/badge.md +37 -0
  12. package/dist/docs/breadcrumbs.md +44 -0
  13. package/dist/docs/button-group.md +76 -0
  14. package/dist/docs/button.md +131 -0
  15. package/dist/docs/calendar-heatmap.md +73 -0
  16. package/dist/docs/calendar.md +101 -0
  17. package/dist/docs/canvas.md +584 -0
  18. package/dist/docs/card.md +180 -0
  19. package/dist/docs/carousel.md +162 -0
  20. package/dist/docs/chart.md +325 -0
  21. package/dist/docs/checkbox.md +88 -0
  22. package/dist/docs/chip.md +52 -0
  23. package/dist/docs/code-block.md +116 -0
  24. package/dist/docs/code-input.md +52 -0
  25. package/dist/docs/collapse.md +46 -0
  26. package/dist/docs/color-picker.md +145 -0
  27. package/dist/docs/combobox.md +208 -0
  28. package/dist/docs/command-palette.md +65 -0
  29. package/dist/docs/date-picker.md +167 -0
  30. package/dist/docs/dialog.md +271 -0
  31. package/dist/docs/disclosure.md +88 -0
  32. package/dist/docs/editable-cell.md +90 -0
  33. package/dist/docs/empty-state.md +46 -0
  34. package/dist/docs/field.md +82 -0
  35. package/dist/docs/file-drop.md +44 -0
  36. package/dist/docs/filter-bar.md +66 -0
  37. package/dist/docs/header.md +51 -0
  38. package/dist/docs/hover-card.md +110 -0
  39. package/dist/docs/icon-picker.md +65 -0
  40. package/dist/docs/input-group.md +89 -0
  41. package/dist/docs/input.md +33 -0
  42. package/dist/docs/inset.md +57 -0
  43. package/dist/docs/kbd.md +34 -0
  44. package/dist/docs/keypad.md +51 -0
  45. package/dist/docs/load-more.md +66 -0
  46. package/dist/docs/locale-provider.md +99 -0
  47. package/dist/docs/marquee.md +86 -0
  48. package/dist/docs/menu.md +403 -0
  49. package/dist/docs/meter.md +102 -0
  50. package/dist/docs/money-input.md +169 -0
  51. package/dist/docs/month-picker.md +80 -0
  52. package/dist/docs/navigation-menu.md +157 -0
  53. package/dist/docs/number-field.md +129 -0
  54. package/dist/docs/page-header.md +44 -0
  55. package/dist/docs/page-transition.md +93 -0
  56. package/dist/docs/pagination.md +42 -0
  57. package/dist/docs/pills.md +85 -0
  58. package/dist/docs/player-bar.md +158 -0
  59. package/dist/docs/popover.md +200 -0
  60. package/dist/docs/progress-ring.md +70 -0
  61. package/dist/docs/radio-cards.md +82 -0
  62. package/dist/docs/radio-group.md +86 -0
  63. package/dist/docs/range-slider.md +64 -0
  64. package/dist/docs/repeat-picker.md +44 -0
  65. package/dist/docs/resizable.md +107 -0
  66. package/dist/docs/seek-bar.md +68 -0
  67. package/dist/docs/segmented.md +89 -0
  68. package/dist/docs/select.md +148 -0
  69. package/dist/docs/separator.md +76 -0
  70. package/dist/docs/side-nav.md +75 -0
  71. package/dist/docs/skeleton.md +89 -0
  72. package/dist/docs/slider.md +87 -0
  73. package/dist/docs/sortable.md +182 -0
  74. package/dist/docs/sparkline.md +56 -0
  75. package/dist/docs/spinner.md +69 -0
  76. package/dist/docs/status-pill.md +81 -0
  77. package/dist/docs/stepper.md +59 -0
  78. package/dist/docs/tab-bar.md +78 -0
  79. package/dist/docs/table-of-contents.md +43 -0
  80. package/dist/docs/table.md +714 -0
  81. package/dist/docs/tabs.md +140 -0
  82. package/dist/docs/tag-input.md +45 -0
  83. package/dist/docs/textarea.md +25 -0
  84. package/dist/docs/theme-toggle.md +85 -0
  85. package/dist/docs/theming.md +193 -0
  86. package/dist/docs/time-field.md +71 -0
  87. package/dist/docs/timeline.md +50 -0
  88. package/dist/docs/toast.md +53 -0
  89. package/dist/docs/toggle-group.md +83 -0
  90. package/dist/docs/toggle.md +38 -0
  91. package/dist/docs/toolbar.md +97 -0
  92. package/dist/docs/tooltip.md +60 -0
  93. package/dist/docs/tree.md +295 -0
  94. package/dist/docs/trend.md +50 -0
  95. package/dist/icons/arrow-path.d.ts +3 -0
  96. package/dist/icons/arrow-path.d.ts.map +1 -0
  97. package/dist/icons/arrows-right-left.d.ts +3 -0
  98. package/dist/icons/arrows-right-left.d.ts.map +1 -0
  99. package/dist/icons/backward.d.ts +3 -0
  100. package/dist/icons/backward.d.ts.map +1 -0
  101. package/dist/icons/forward.d.ts +3 -0
  102. package/dist/icons/forward.d.ts.map +1 -0
  103. package/dist/icons/heart-solid.d.ts +3 -0
  104. package/dist/icons/heart-solid.d.ts.map +1 -0
  105. package/dist/icons/heart.d.ts +3 -0
  106. package/dist/icons/heart.d.ts.map +1 -0
  107. package/dist/icons/musical-note.d.ts +3 -0
  108. package/dist/icons/musical-note.d.ts.map +1 -0
  109. package/dist/icons/pause.d.ts +3 -0
  110. package/dist/icons/pause.d.ts.map +1 -0
  111. package/dist/icons/play.d.ts +3 -0
  112. package/dist/icons/play.d.ts.map +1 -0
  113. package/dist/icons/queue-list.d.ts +3 -0
  114. package/dist/icons/queue-list.d.ts.map +1 -0
  115. package/dist/icons/speaker-wave.d.ts +3 -0
  116. package/dist/icons/speaker-wave.d.ts.map +1 -0
  117. package/dist/icons/speaker-x-mark.d.ts +3 -0
  118. package/dist/icons/speaker-x-mark.d.ts.map +1 -0
  119. package/dist/index.d.ts +8 -2
  120. package/dist/index.d.ts.map +1 -1
  121. package/dist/lib.js +80 -74
  122. package/dist/marquee/index.d.ts +15 -0
  123. package/dist/marquee/index.d.ts.map +1 -0
  124. package/dist/marquee/index.js +90 -0
  125. package/dist/menu/index.d.ts.map +1 -1
  126. package/dist/menu/index.js +7 -6
  127. package/dist/number-field/index.d.ts +18 -2
  128. package/dist/number-field/index.d.ts.map +1 -1
  129. package/dist/number-field/index.js +65 -51
  130. package/dist/player-bar/index.d.ts +11 -0
  131. package/dist/player-bar/index.d.ts.map +1 -0
  132. package/dist/player-bar/index.js +17 -0
  133. package/dist/radio-cards/index.d.ts +2 -1
  134. package/dist/radio-cards/index.d.ts.map +1 -1
  135. package/dist/radio-cards/index.js +3 -3
  136. package/dist/seek-bar/index.d.ts +24 -0
  137. package/dist/seek-bar/index.d.ts.map +1 -0
  138. package/dist/seek-bar/index.js +120 -0
  139. package/dist/segmented/index.d.ts +3 -2
  140. package/dist/segmented/index.d.ts.map +1 -1
  141. package/dist/segmented/index.js +19 -18
  142. package/dist/slider/index.d.ts +2 -1
  143. package/dist/slider/index.d.ts.map +1 -1
  144. package/dist/tab-bar/index.d.ts +4 -2
  145. package/dist/tab-bar/index.d.ts.map +1 -1
  146. package/dist/tab-bar/index.js +24 -19
  147. package/dist/textarea/index.d.ts +6 -2
  148. package/dist/textarea/index.d.ts.map +1 -1
  149. package/dist/textarea/index.js +2 -1
  150. package/dist/theme.css +40 -0
  151. package/dist/utils/duration.d.ts +3 -0
  152. package/dist/utils/duration.d.ts.map +1 -0
  153. package/dist/utils/duration.js +7 -0
  154. package/dist/utils/messages.d.ts +2 -0
  155. package/dist/utils/messages.d.ts.map +1 -1
  156. package/dist/utils/messages.js +3 -1
  157. package/dist/utils/slot.d.ts +5 -2
  158. package/dist/utils/slot.d.ts.map +1 -1
  159. package/dist/utils/slot.js +16 -11
  160. package/dist/utils/useAnchoredPopup.d.ts.map +1 -1
  161. package/dist/utils/useAnchoredPopup.js +3 -2
  162. package/dist/utils/useHeightVar.d.ts +7 -0
  163. package/dist/utils/useHeightVar.d.ts.map +1 -0
  164. package/dist/utils/useHeightVar.js +17 -0
  165. package/dist/utils/useMediaPlayer.d.ts +38 -0
  166. package/dist/utils/useMediaPlayer.d.ts.map +1 -0
  167. package/dist/utils/useMediaPlayer.js +91 -0
  168. package/dist/utils/useMediaSession.d.ts +24 -0
  169. package/dist/utils/useMediaSession.d.ts.map +1 -0
  170. package/dist/utils/useMediaSession.js +76 -0
  171. package/dist/zen.css +1 -1
  172. package/package.json +4 -2
@@ -0,0 +1,714 @@
1
+ # Table
2
+
3
+ Composable tables: a glass panel that scrolls, sticky headers, first column and totals, sortable columns, rows reordered by drag and drop (into groups and under other rows), and virtual rows for long lists.
4
+
5
+ ```tsx
6
+ import { TableContainer, Table, TableHead, TableCell, TableFooter, TableFooterCell, TableSpacerRow, TableSelectCell, TableSelectHead, SelectionBar, TableSwipeRow, TableSwipeAction } from '@rinzai/zen';
7
+ ```
8
+
9
+ ## Examples
10
+
11
+ ### Default
12
+
13
+ ```tsx
14
+ import { Badge, Table, TableBody, TableCell, TableContainer, TableHead, TableHeader, TableRow } from '@rinzai/zen';
15
+
16
+ const ENTRIES = [
17
+ { label: 'Rent', category: 'Housing', amount: -1850, recurring: true },
18
+ { label: 'Paycheck', category: 'Salary', amount: 8450, recurring: true },
19
+ { label: 'Weekly shop', category: 'Groceries', amount: -164, recurring: false },
20
+ ];
21
+
22
+ export default function Default() {
23
+ return (
24
+ <TableContainer className="w-full max-w-xl">
25
+ <Table>
26
+ <TableHeader>
27
+ <TableRow>
28
+ <TableHead>Entry</TableHead>
29
+ <TableHead>Category</TableHead>
30
+ <TableHead numeric>Amount</TableHead>
31
+ </TableRow>
32
+ </TableHeader>
33
+ <TableBody className="rise-list">
34
+ {ENTRIES.map((e) => (
35
+ <TableRow key={e.label}>
36
+ <TableCell className="font-medium">
37
+ {e.label} {e.recurring && <Badge variant="outline">Monthly</Badge>}
38
+ </TableCell>
39
+ <TableCell className="text-muted-foreground">{e.category}</TableCell>
40
+ <TableCell numeric className={e.amount > 0 ? 'text-primary' : undefined}>
41
+ {e.amount > 0 ? '+' : '−'}${Math.abs(e.amount).toLocaleString('en-US')}
42
+ </TableCell>
43
+ </TableRow>
44
+ ))}
45
+ </TableBody>
46
+ </Table>
47
+ </TableContainer>
48
+ );
49
+ }
50
+ ```
51
+
52
+ ### Sortable
53
+
54
+ ```tsx
55
+ import {
56
+ Table,
57
+ TableBody,
58
+ TableCell,
59
+ TableContainer,
60
+ TableEmpty,
61
+ TableHead,
62
+ TableHeader,
63
+ TableRow,
64
+ useSort,
65
+ } from '@rinzai/zen';
66
+
67
+ interface Payment {
68
+ month: string;
69
+ principal: number;
70
+ interest: number;
71
+ balance: number;
72
+ }
73
+
74
+ // An amortization schedule: 240 000 over 12 months at 9%.
75
+ const SCHEDULE: Payment[] = (() => {
76
+ let balance = 240000;
77
+ const rate = 0.09 / 12;
78
+ const emi = (balance * rate) / (1 - (1 + rate) ** -12);
79
+ return Array.from({ length: 12 }, (_, i) => {
80
+ const interest = balance * rate;
81
+ const principal = emi - interest;
82
+ balance -= principal;
83
+ return {
84
+ month: new Date(2026, 9 + i).toLocaleString('en-US', { month: 'short', year: 'numeric' }),
85
+ principal,
86
+ interest,
87
+ balance: Math.max(0, balance),
88
+ };
89
+ });
90
+ })();
91
+
92
+ const COMPARE = {
93
+ principal: (a: Payment, b: Payment) => a.principal - b.principal,
94
+ interest: (a: Payment, b: Payment) => a.interest - b.interest,
95
+ balance: (a: Payment, b: Payment) => a.balance - b.balance,
96
+ };
97
+
98
+ const money = (n: number) => n.toLocaleString('en-US', { maximumFractionDigits: 0 });
99
+
100
+ /** Click a heading to sort by it, again to reverse, a third time to clear. The header stays put as the rows scroll. */
101
+ export default function Sortable() {
102
+ const { rows, headProps } = useSort(SCHEDULE, COMPARE);
103
+ return (
104
+ <TableContainer label="Repayment schedule" className="max-h-80 w-full max-w-xl">
105
+ <Table>
106
+ <TableHeader>
107
+ <TableRow>
108
+ <TableHead>Month</TableHead>
109
+ <TableHead numeric {...headProps('principal')}>
110
+ Principal
111
+ </TableHead>
112
+ <TableHead numeric {...headProps('interest')}>
113
+ Interest
114
+ </TableHead>
115
+ <TableHead numeric {...headProps('balance')}>
116
+ Balance
117
+ </TableHead>
118
+ </TableRow>
119
+ </TableHeader>
120
+ <TableBody>
121
+ {rows.length === 0 && <TableEmpty colSpan={4} />}
122
+ {rows.map((p) => (
123
+ <TableRow key={p.month}>
124
+ <TableCell>{p.month}</TableCell>
125
+ <TableCell numeric>{money(p.principal)}</TableCell>
126
+ <TableCell numeric className="text-muted-foreground">
127
+ {money(p.interest)}
128
+ </TableCell>
129
+ <TableCell numeric>{money(p.balance)}</TableCell>
130
+ </TableRow>
131
+ ))}
132
+ </TableBody>
133
+ </Table>
134
+ </TableContainer>
135
+ );
136
+ }
137
+ ```
138
+
139
+ ### Reorder
140
+
141
+ ```tsx
142
+ import {
143
+ flattenTree,
144
+ SortableHandle,
145
+ SortableItem,
146
+ type SortableNode,
147
+ SortableTree,
148
+ Table,
149
+ TableBody,
150
+ TableCell,
151
+ TableContainer,
152
+ TableHead,
153
+ TableHeader,
154
+ TableRow,
155
+ } from '@rinzai/zen';
156
+ import { useState } from 'react';
157
+ // Icons from Heroicons (https://heroicons.com): ChevronRightMicro (chevron-right).
158
+
159
+ const NAMES: Record<string, string> = {
160
+ home: 'Home',
161
+ rent: 'Rent',
162
+ power: 'Electricity',
163
+ internet: 'Internet',
164
+ food: 'Food',
165
+ groceries: 'Groceries',
166
+ dining: 'Eating out',
167
+ coffee: 'Coffee',
168
+ takeaway: 'Takeaway',
169
+ };
170
+
171
+ const AMOUNTS: Record<string, number[]> = {
172
+ rent: [1800, 1800, 1800, 1800],
173
+ power: [92, 88, 75, 70],
174
+ internet: [60, 60, 60, 60],
175
+ groceries: [420, 395, 410, 430],
176
+ dining: [180, 150, 210, 160],
177
+ coffee: [42, 38, 45, 40],
178
+ takeaway: [64, 50, 80, 55],
179
+ };
180
+
181
+ const MONTHS = ['Jul', 'Aug', 'Sep', 'Oct'];
182
+ const CATEGORIES = new Set(['home', 'food']);
183
+
184
+ /**
185
+ * Rows reordered by drag and drop (SortableTree, with asChild on the table and
186
+ * its rows). Drag a row by its grip within its category, into the other one,
187
+ * into an open sub-item (Eating out), or under another row (drag sideways a
188
+ * level in or out); or Tab to a grip and use Space and the arrow keys.
189
+ * Categories stay at the top level and can be reordered too. The first column
190
+ * stays in view as it scrolls sideways.
191
+ */
192
+ export default function Reorder() {
193
+ const [tree, setTree] = useState<SortableNode[]>([
194
+ { id: 'home', children: [{ id: 'rent' }, { id: 'power' }, { id: 'internet' }] },
195
+ {
196
+ id: 'food',
197
+ children: [{ id: 'groceries' }, { id: 'dining', children: [{ id: 'coffee' }, { id: 'takeaway' }] }],
198
+ },
199
+ ]);
200
+ const [closed, setClosed] = useState(new Set<string>());
201
+ const toggle = (id: string) =>
202
+ setClosed((now) => {
203
+ const next = new Set(now);
204
+ if (!next.delete(id)) next.add(id);
205
+ return next;
206
+ });
207
+ const childrenOf = (id: string, nodes = tree): SortableNode[] | undefined => {
208
+ for (const node of nodes) {
209
+ if (node.id === id) return node.children;
210
+ const found = node.children && childrenOf(id, node.children);
211
+ if (found) return found;
212
+ }
213
+ };
214
+ return (
215
+ <TableContainer label="Planner" className="w-full max-w-xl">
216
+ <SortableTree
217
+ asChild
218
+ value={tree}
219
+ onValueChange={setTree}
220
+ accepts={(id, parent) => (CATEGORIES.has(id) ? parent === null : parent !== null)}
221
+ >
222
+ <Table className="min-w-[36rem]">
223
+ <TableHeader>
224
+ <TableRow>
225
+ <TableHead sticky="start">Item</TableHead>
226
+ {MONTHS.map((m) => (
227
+ <TableHead key={m} numeric>
228
+ {m}
229
+ </TableHead>
230
+ ))}
231
+ </TableRow>
232
+ </TableHeader>
233
+ <TableBody>
234
+ {flattenTree(tree, (id) => !closed.has(id)).map(({ id, depth }) => {
235
+ const category = CATEGORIES.has(id);
236
+ const kids = childrenOf(id);
237
+ return (
238
+ <SortableItem key={id} id={id} label={NAMES[id]} asChild>
239
+ <TableRow>
240
+ <TableCell sticky="start" className="py-1.5">
241
+ <span
242
+ className="flex items-center gap-1"
243
+ style={{ paddingInlineStart: Math.max(0, depth - 1) * 24 }}
244
+ >
245
+ <SortableHandle id={id} label={NAMES[id]} />
246
+ {!category && kids?.length ? (
247
+ <button
248
+ type="button"
249
+ aria-expanded={!closed.has(id)}
250
+ aria-label={NAMES[id]}
251
+ onClick={() => toggle(id)}
252
+ className="text-muted-foreground hover:text-foreground -ms-1 grid size-6 cursor-pointer place-items-center rounded-md"
253
+ >
254
+ <ChevronRightMicro
255
+ className={`size-4 transition-transform rtl:-scale-x-100 ${closed.has(id) ? '' : 'rotate-90'}`}
256
+ />
257
+ </button>
258
+ ) : null}
259
+ <span
260
+ className={
261
+ category
262
+ ? 'text-muted-foreground text-xs font-medium tracking-wide uppercase'
263
+ : ''
264
+ }
265
+ >
266
+ {NAMES[id]}
267
+ </span>
268
+ </span>
269
+ </TableCell>
270
+ {MONTHS.map((m, i) => (
271
+ <TableCell key={m} numeric className="py-1.5">
272
+ {AMOUNTS[id] ? `$${AMOUNTS[id][i]}` : ''}
273
+ </TableCell>
274
+ ))}
275
+ </TableRow>
276
+ </SortableItem>
277
+ );
278
+ })}
279
+ </TableBody>
280
+ </Table>
281
+ </SortableTree>
282
+ </TableContainer>
283
+ );
284
+ }
285
+ ```
286
+
287
+ ### Selectable
288
+
289
+ ```tsx
290
+ import {
291
+ Button,
292
+ SelectionBar,
293
+ Table,
294
+ TableBody,
295
+ TableCell,
296
+ TableContainer,
297
+ TableHead,
298
+ TableHeader,
299
+ TableRow,
300
+ TableSelectCell,
301
+ TableSelectHead,
302
+ useSelection,
303
+ } from '@rinzai/zen';
304
+ import { useState } from 'react';
305
+
306
+ const ENTRIES = [
307
+ { id: 1, label: 'Weekly shop', amount: '−$165' },
308
+ { id: 2, label: 'Farmers market', amount: '−$42' },
309
+ { id: 3, label: 'Train pass', amount: '−$92' },
310
+ { id: 4, label: 'Dinner with Sam', amount: '−$78' },
311
+ { id: 5, label: 'Birthday lunch', amount: '−$92' },
312
+ ];
313
+
314
+ /** Pick rows (shift-click for a run of them), then act on them all from the bar. */
315
+ export default function Selectable() {
316
+ const [entries, setEntries] = useState(ENTRIES);
317
+ const selection = useSelection(entries.map((e) => e.id));
318
+ return (
319
+ <>
320
+ <TableContainer label="Entries" className="w-full max-w-lg">
321
+ <Table>
322
+ <TableHeader>
323
+ <TableRow>
324
+ <TableSelectHead {...selection.allProps()} />
325
+ <TableHead>Entry</TableHead>
326
+ <TableHead numeric>Amount</TableHead>
327
+ </TableRow>
328
+ </TableHeader>
329
+ <TableBody>
330
+ {entries.map((e) => (
331
+ <TableRow key={e.id} selected={selection.selected.has(e.id)}>
332
+ <TableSelectCell label={`Select ${e.label}`} {...selection.rowProps(e.id)} />
333
+ <TableCell>{e.label}</TableCell>
334
+ <TableCell numeric>{e.amount}</TableCell>
335
+ </TableRow>
336
+ ))}
337
+ </TableBody>
338
+ </Table>
339
+ </TableContainer>
340
+ <SelectionBar count={selection.selected.size} onClear={selection.clear}>
341
+ <Button
342
+ size="sm"
343
+ tone="negative"
344
+ onClick={() => setEntries((es) => es.filter((e) => !selection.selected.has(e.id)))}
345
+ >
346
+ Delete
347
+ </Button>
348
+ {entries.length < ENTRIES.length && (
349
+ <Button size="sm" variant="ghost" onClick={() => setEntries(ENTRIES)}>
350
+ Restore all
351
+ </Button>
352
+ )}
353
+ </SelectionBar>
354
+ </>
355
+ );
356
+ }
357
+ ```
358
+
359
+ ### Swipe
360
+
361
+ ```tsx
362
+ import {
363
+ Button,
364
+ Table,
365
+ TableBody,
366
+ TableCell,
367
+ TableContainer,
368
+ TableHead,
369
+ TableHeader,
370
+ TableRow,
371
+ TableSwipeAction,
372
+ TableSwipeRow,
373
+ } from '@rinzai/zen';
374
+ import { useState } from 'react';
375
+
376
+ const ENTRIES = [
377
+ { id: 1, label: 'Weekly shop', amount: '−$165' },
378
+ { id: 2, label: 'Farmers market', amount: '−$42' },
379
+ { id: 3, label: 'Train pass', amount: '−$92' },
380
+ { id: 4, label: 'Dinner with Sam', amount: '−$78' },
381
+ ];
382
+
383
+ /** On a phone, swipe a row left for its actions; with a keyboard, tab to them. */
384
+ export default function Swipe() {
385
+ const [entries, setEntries] = useState(ENTRIES);
386
+ return (
387
+ <div className="flex w-full max-w-lg flex-col gap-3">
388
+ <TableContainer label="Entries">
389
+ <Table>
390
+ <TableHeader>
391
+ <TableRow>
392
+ <TableHead>Entry</TableHead>
393
+ <TableHead numeric>Amount</TableHead>
394
+ </TableRow>
395
+ </TableHeader>
396
+ <TableBody>
397
+ {entries.map((e) => (
398
+ <TableSwipeRow
399
+ key={e.id}
400
+ actions={
401
+ <TableSwipeAction
402
+ tone="negative"
403
+ aria-label={`Delete ${e.label}`}
404
+ onClick={() => setEntries((es) => es.filter((x) => x.id !== e.id))}
405
+ >
406
+ Delete
407
+ </TableSwipeAction>
408
+ }
409
+ >
410
+ <TableCell>{e.label}</TableCell>
411
+ <TableCell numeric>{e.amount}</TableCell>
412
+ </TableSwipeRow>
413
+ ))}
414
+ </TableBody>
415
+ </Table>
416
+ </TableContainer>
417
+ {entries.length < ENTRIES.length && (
418
+ <Button variant="ghost" className="self-start" onClick={() => setEntries(ENTRIES)}>
419
+ Bring them back
420
+ </Button>
421
+ )}
422
+ </div>
423
+ );
424
+ }
425
+ ```
426
+
427
+ ### Detail
428
+
429
+ ```tsx
430
+ import {
431
+ Button,
432
+ Card,
433
+ CardDescription,
434
+ CardHeader,
435
+ CardTitle,
436
+ Table,
437
+ TableBody,
438
+ TableCell,
439
+ TableContainer,
440
+ TableHead,
441
+ TableHeader,
442
+ TableRow,
443
+ } from '@rinzai/zen';
444
+ import { startTransition, useState, ViewTransition } from 'react';
445
+
446
+ const ENTRIES = [
447
+ {
448
+ id: 'shop',
449
+ label: 'Weekly shop',
450
+ amount: '−$165.20',
451
+ date: 'Oct 4',
452
+ category: 'Groceries',
453
+ note: 'Market and bakery.',
454
+ },
455
+ {
456
+ id: 'train',
457
+ label: 'Train pass',
458
+ amount: '−$92.00',
459
+ date: 'Oct 2',
460
+ category: 'Transport',
461
+ note: 'Monthly pass.',
462
+ },
463
+ { id: 'pay', label: 'Salary', amount: '+$4,200.00', date: 'Oct 1', category: 'Income', note: 'October.' },
464
+ ];
465
+
466
+ /** Open an entry and its row grows into its details (a view transition, with share="zen-morph"). */
467
+ export default function Detail() {
468
+ const [open, setOpen] = useState<string | null>(null);
469
+ const go = (id: string | null) => startTransition(() => setOpen(id));
470
+ const entry = ENTRIES.find((e) => e.id === open);
471
+
472
+ if (entry)
473
+ return (
474
+ <ViewTransition name={`entry-${entry.id}`} share="zen-morph">
475
+ <Card className="w-full max-w-lg">
476
+ <CardHeader>
477
+ <CardTitle>{entry.label}</CardTitle>
478
+ <Button size="sm" variant="ghost" onClick={() => go(null)}>
479
+ Back
480
+ </Button>
481
+ </CardHeader>
482
+ <p className="text-3xl font-semibold tabular-nums">{entry.amount}</p>
483
+ <CardDescription className="mt-1">
484
+ {entry.date} · {entry.category}
485
+ </CardDescription>
486
+ <p className="mt-4 text-sm">{entry.note}</p>
487
+ </Card>
488
+ </ViewTransition>
489
+ );
490
+
491
+ return (
492
+ <TableContainer label="Entries" className="w-full max-w-lg">
493
+ <Table>
494
+ <TableHeader>
495
+ <TableRow>
496
+ <TableHead>Entry</TableHead>
497
+ <TableHead>Date</TableHead>
498
+ <TableHead numeric>Amount</TableHead>
499
+ </TableRow>
500
+ </TableHeader>
501
+ <TableBody className="rise-list">
502
+ {ENTRIES.map((e) => (
503
+ <ViewTransition key={e.id} name={`entry-${e.id}`} share="zen-morph">
504
+ <TableRow onClick={() => go(e.id)} className="cursor-pointer">
505
+ <TableCell>
506
+ <button
507
+ type="button"
508
+ onClick={() => go(e.id)}
509
+ className="text-left outline-hidden focus-visible:underline"
510
+ >
511
+ {e.label}
512
+ </button>
513
+ </TableCell>
514
+ <TableCell className="text-muted-foreground">{e.date}</TableCell>
515
+ <TableCell numeric>{e.amount}</TableCell>
516
+ </TableRow>
517
+ </ViewTransition>
518
+ ))}
519
+ </TableBody>
520
+ </Table>
521
+ </TableContainer>
522
+ );
523
+ }
524
+ ```
525
+
526
+ ### Virtual
527
+
528
+ ```tsx
529
+ import {
530
+ formatMoney,
531
+ Table,
532
+ TableBody,
533
+ TableCell,
534
+ TableContainer,
535
+ TableHead,
536
+ TableHeader,
537
+ TableRow,
538
+ TableSpacerRow,
539
+ useVirtualList,
540
+ } from '@rinzai/zen';
541
+ import { useRef } from 'react';
542
+
543
+ const ROW_HEIGHT = 40;
544
+ const PAYEES = ['Grocer', 'Metro', 'Landlord', 'Cafe', 'Pharmacy', 'Bookshop', 'Cinema', 'Utilities'];
545
+
546
+ // 10,000 transactions, generated.
547
+ const ROWS = Array.from({ length: 10000 }, (_, i) => ({
548
+ id: i + 1,
549
+ date: new Date(2026, 0, 1 + Math.floor(i / 28)).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
550
+ payee: PAYEES[(i * 7) % PAYEES.length],
551
+ amount: ((i * 7919) % 20000) + 150,
552
+ }));
553
+
554
+ /** 10,000 rows, but only the ones in view (plus a few either side) are rendered: useVirtualList with spacer rows. */
555
+ export default function Virtual() {
556
+ const scrollRef = useRef<HTMLDivElement>(null);
557
+ const rows = useVirtualList({ count: ROWS.length, itemHeight: ROW_HEIGHT, scrollRef, overscan: 8 });
558
+ return (
559
+ <TableContainer ref={scrollRef} label="Transactions" className="h-96 w-full max-w-xl">
560
+ <Table>
561
+ <TableHeader>
562
+ <TableRow>
563
+ <TableHead numeric className="w-20">
564
+ #
565
+ </TableHead>
566
+ <TableHead>Date</TableHead>
567
+ <TableHead>Payee</TableHead>
568
+ <TableHead numeric>Amount</TableHead>
569
+ </TableRow>
570
+ </TableHeader>
571
+ <TableBody>
572
+ <TableSpacerRow height={rows.paddingTop} colSpan={4} />
573
+ {rows.items.map(({ index }) => {
574
+ const r = ROWS[index];
575
+ return (
576
+ <TableRow key={r.id}>
577
+ <TableCell numeric className="text-muted-foreground h-10 py-0">
578
+ {r.id}
579
+ </TableCell>
580
+ <TableCell className="h-10 py-0">{r.date}</TableCell>
581
+ <TableCell className="h-10 py-0">{r.payee}</TableCell>
582
+ <TableCell numeric className="h-10 py-0">
583
+ {formatMoney(r.amount, 'USD', 'en-US')}
584
+ </TableCell>
585
+ </TableRow>
586
+ );
587
+ })}
588
+ <TableSpacerRow height={rows.paddingBottom} colSpan={4} />
589
+ </TableBody>
590
+ </Table>
591
+ </TableContainer>
592
+ );
593
+ }
594
+ ```
595
+
596
+ ## API reference
597
+
598
+ ### TableContainer
599
+
600
+ The panel a table scrolls in: glass with an edge that catches the pointer
601
+ light, like a Card. Give it a max height (className) to scroll vertically;
602
+ it scrolls sideways when the table is wider. `ref` and scroll events are the
603
+ scrolling element's. With `label`, it's a focusable region, so keyboard users
604
+ can scroll it too.
605
+
606
+ | Prop | Type | Default | Description |
607
+ | --- | --- | --- | --- |
608
+ | `label` | `string` | | Accessible name; makes the scroll area a focusable region. |
609
+
610
+ ### Table
611
+
612
+ The <table>. Put it in a TableContainer for the panel and scrolling.
613
+
614
+ No props of its own; it accepts the props of the element it renders.
615
+
616
+ ### TableHead
617
+
618
+ Column heading: small spaced caps (TableHeader keeps the row at the top). With `onSort`, it's a
619
+ button that shows the direction and announces it (aria-sort).
620
+
621
+ | Prop | Type | Default | Description |
622
+ | --- | --- | --- | --- |
623
+ | `numeric` | `boolean` | | Aligned to the end, for numbers. |
624
+ | `onSort` | `(() => void)` | | Makes the heading a sort button. |
625
+ | `sortDirection` | `SortDirection` | | |
626
+ | `sticky` | `"start"` | | Stay in view when the table scrolls sideways. |
627
+
628
+ ### TableCell
629
+
630
+ A cell: hairline below, lit with its row on hover. `sticky="start"` keeps it in view when scrolling sideways.
631
+
632
+ | Prop | Type | Default | Description |
633
+ | --- | --- | --- | --- |
634
+ | `numeric` | `boolean` | | Aligned to the end, tabular digits. |
635
+ | `sticky` | `"start"` | | Stay in view when the table scrolls sideways. |
636
+
637
+ ### TableFooter
638
+
639
+ Rows that stay at the bottom while the body scrolls (totals, balances).
640
+
641
+ No props of its own; it accepts the props of the element it renders.
642
+
643
+ ### TableFooterCell
644
+
645
+ A footer cell: sits on the card, above a hairline. Use inside TableFooter.
646
+
647
+ | Prop | Type | Default | Description |
648
+ | --- | --- | --- | --- |
649
+ | `numeric` | `boolean` | | Aligned to the end, tabular digits. |
650
+ | `sticky` | `"start"` | | Stay in view when the table scrolls sideways. |
651
+
652
+ ### TableSpacerRow
653
+
654
+ An empty row of a given height, standing in for rows scrolled out of view (see useVirtualList).
655
+
656
+ | Prop | Type | Default | Description |
657
+ | --- | --- | --- | --- |
658
+ | `colSpan` (required) | `number` | | |
659
+ | `height` (required) | `number` | | |
660
+
661
+ ### TableSelectCell
662
+
663
+ A row's checkbox cell; spread `rowProps(key)` from useSelection into it.
664
+
665
+ | Prop | Type | Default | Description |
666
+ | --- | --- | --- | --- |
667
+ | `checked` (required) | `boolean` | | |
668
+ | `label` (required) | `string` | | The checkbox's name, e.g. "Select Rent". |
669
+ | `onSelect` (required) | `(on: boolean, range: boolean) => void` | | With `range` when shift was held. |
670
+
671
+ ### TableSelectHead
672
+
673
+ The header's checkbox, for every row at once; spread `allProps()` from useSelection into it.
674
+
675
+ | Prop | Type | Default | Description |
676
+ | --- | --- | --- | --- |
677
+ | `checked` (required) | `boolean` | | |
678
+ | `indeterminate` (required) | `boolean` | | |
679
+ | `onSelect` (required) | `(on: boolean) => void` | | |
680
+ | `label` | `string` | | |
681
+
682
+ ### SelectionBar
683
+
684
+ What to do with the selected rows: their count, your actions, and a × to
685
+ clear the selection. Floats at the bottom of the screen while any are
686
+ selected (move it with className).
687
+
688
+ | Prop | Type | Default | Description |
689
+ | --- | --- | --- | --- |
690
+ | `count` (required) | `number` | | |
691
+ | `onClear` (required) | `() => void` | | |
692
+ | `children` | `ReactNode` | | The actions, e.g. buttons to delete or re-tag them. |
693
+ | `className` | `string` | | |
694
+ | `clearLabel` | `string` | | |
695
+ | `label` | `((count: number) => string)` | | The count as text (default "3 selected"). |
696
+
697
+ ### TableSwipeRow
698
+
699
+ A row whose actions sit behind it at its end: swipe it left to reveal them
700
+ (right, in right-to-left layouts), back (or tap the row) to put them away. They also come out when focused, so the
701
+ keyboard reaches them. Use it in a TableContainer.
702
+
703
+ | Prop | Type | Default | Description |
704
+ | --- | --- | --- | --- |
705
+ | `actions` (required) | `ReactNode` | | What's behind the row: TableSwipeActions, e.g. Edit and Delete. |
706
+ | `selected` | `boolean` | | |
707
+
708
+ ### TableSwipeAction
709
+
710
+ One of a swipe row's actions: fills the row's height, flush with its end.
711
+
712
+ | Prop | Type | Default | Description |
713
+ | --- | --- | --- | --- |
714
+ | `tone` | `"default" \| "negative"` | `default` | |