@rinzai/zen 0.14.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.
- package/CHANGELOG.md +138 -0
- package/README.md +16 -1
- package/dist/carousel/index.d.ts.map +1 -1
- package/dist/carousel/index.js +5 -5
- package/dist/docs/README.md +205 -0
- package/dist/docs/accordion.md +122 -0
- package/dist/docs/alert.md +69 -0
- package/dist/docs/animated-number.md +105 -0
- package/dist/docs/avatar.md +91 -0
- package/dist/docs/backdrop.md +94 -0
- package/dist/docs/badge.md +37 -0
- package/dist/docs/breadcrumbs.md +44 -0
- package/dist/docs/button-group.md +76 -0
- package/dist/docs/button.md +131 -0
- package/dist/docs/calendar-heatmap.md +73 -0
- package/dist/docs/calendar.md +101 -0
- package/dist/docs/canvas.md +584 -0
- package/dist/docs/card.md +180 -0
- package/dist/docs/carousel.md +162 -0
- package/dist/docs/chart.md +325 -0
- package/dist/docs/checkbox.md +88 -0
- package/dist/docs/chip.md +52 -0
- package/dist/docs/code-block.md +116 -0
- package/dist/docs/code-input.md +52 -0
- package/dist/docs/collapse.md +46 -0
- package/dist/docs/color-picker.md +145 -0
- package/dist/docs/combobox.md +208 -0
- package/dist/docs/command-palette.md +65 -0
- package/dist/docs/date-picker.md +167 -0
- package/dist/docs/dialog.md +271 -0
- package/dist/docs/disclosure.md +88 -0
- package/dist/docs/editable-cell.md +90 -0
- package/dist/docs/empty-state.md +46 -0
- package/dist/docs/field.md +82 -0
- package/dist/docs/file-drop.md +44 -0
- package/dist/docs/filter-bar.md +66 -0
- package/dist/docs/header.md +51 -0
- package/dist/docs/hover-card.md +110 -0
- package/dist/docs/icon-picker.md +65 -0
- package/dist/docs/input-group.md +89 -0
- package/dist/docs/input.md +33 -0
- package/dist/docs/inset.md +57 -0
- package/dist/docs/kbd.md +34 -0
- package/dist/docs/keypad.md +51 -0
- package/dist/docs/load-more.md +66 -0
- package/dist/docs/locale-provider.md +99 -0
- package/dist/docs/marquee.md +86 -0
- package/dist/docs/menu.md +403 -0
- package/dist/docs/meter.md +102 -0
- package/dist/docs/money-input.md +169 -0
- package/dist/docs/month-picker.md +80 -0
- package/dist/docs/navigation-menu.md +157 -0
- package/dist/docs/number-field.md +129 -0
- package/dist/docs/page-header.md +44 -0
- package/dist/docs/page-transition.md +93 -0
- package/dist/docs/pagination.md +42 -0
- package/dist/docs/pills.md +85 -0
- package/dist/docs/player-bar.md +158 -0
- package/dist/docs/popover.md +200 -0
- package/dist/docs/progress-ring.md +70 -0
- package/dist/docs/radio-cards.md +82 -0
- package/dist/docs/radio-group.md +86 -0
- package/dist/docs/range-slider.md +64 -0
- package/dist/docs/repeat-picker.md +44 -0
- package/dist/docs/resizable.md +107 -0
- package/dist/docs/seek-bar.md +68 -0
- package/dist/docs/segmented.md +89 -0
- package/dist/docs/select.md +148 -0
- package/dist/docs/separator.md +76 -0
- package/dist/docs/side-nav.md +75 -0
- package/dist/docs/skeleton.md +89 -0
- package/dist/docs/slider.md +87 -0
- package/dist/docs/sortable.md +182 -0
- package/dist/docs/sparkline.md +56 -0
- package/dist/docs/spinner.md +69 -0
- package/dist/docs/status-pill.md +81 -0
- package/dist/docs/stepper.md +59 -0
- package/dist/docs/tab-bar.md +78 -0
- package/dist/docs/table-of-contents.md +43 -0
- package/dist/docs/table.md +714 -0
- package/dist/docs/tabs.md +140 -0
- package/dist/docs/tag-input.md +45 -0
- package/dist/docs/textarea.md +25 -0
- package/dist/docs/theme-toggle.md +85 -0
- package/dist/docs/theming.md +193 -0
- package/dist/docs/time-field.md +71 -0
- package/dist/docs/timeline.md +50 -0
- package/dist/docs/toast.md +53 -0
- package/dist/docs/toggle-group.md +83 -0
- package/dist/docs/toggle.md +38 -0
- package/dist/docs/toolbar.md +97 -0
- package/dist/docs/tooltip.md +60 -0
- package/dist/docs/tree.md +295 -0
- package/dist/docs/trend.md +50 -0
- package/dist/icons/arrow-path.d.ts +3 -0
- package/dist/icons/arrow-path.d.ts.map +1 -0
- package/dist/icons/arrows-right-left.d.ts +3 -0
- package/dist/icons/arrows-right-left.d.ts.map +1 -0
- package/dist/icons/backward.d.ts +3 -0
- package/dist/icons/backward.d.ts.map +1 -0
- package/dist/icons/forward.d.ts +3 -0
- package/dist/icons/forward.d.ts.map +1 -0
- package/dist/icons/heart-solid.d.ts +3 -0
- package/dist/icons/heart-solid.d.ts.map +1 -0
- package/dist/icons/heart.d.ts +3 -0
- package/dist/icons/heart.d.ts.map +1 -0
- package/dist/icons/musical-note.d.ts +3 -0
- package/dist/icons/musical-note.d.ts.map +1 -0
- package/dist/icons/pause.d.ts +3 -0
- package/dist/icons/pause.d.ts.map +1 -0
- package/dist/icons/play.d.ts +3 -0
- package/dist/icons/play.d.ts.map +1 -0
- package/dist/icons/queue-list.d.ts +3 -0
- package/dist/icons/queue-list.d.ts.map +1 -0
- package/dist/icons/speaker-wave.d.ts +3 -0
- package/dist/icons/speaker-wave.d.ts.map +1 -0
- package/dist/icons/speaker-x-mark.d.ts +3 -0
- package/dist/icons/speaker-x-mark.d.ts.map +1 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/lib.js +80 -74
- package/dist/marquee/index.d.ts +15 -0
- package/dist/marquee/index.d.ts.map +1 -0
- package/dist/marquee/index.js +90 -0
- package/dist/menu/index.d.ts.map +1 -1
- package/dist/menu/index.js +7 -6
- package/dist/player-bar/index.d.ts +11 -0
- package/dist/player-bar/index.d.ts.map +1 -0
- package/dist/player-bar/index.js +17 -0
- package/dist/radio-cards/index.d.ts +2 -1
- package/dist/radio-cards/index.d.ts.map +1 -1
- package/dist/radio-cards/index.js +3 -3
- package/dist/seek-bar/index.d.ts +24 -0
- package/dist/seek-bar/index.d.ts.map +1 -0
- package/dist/seek-bar/index.js +120 -0
- package/dist/segmented/index.d.ts +3 -2
- package/dist/segmented/index.d.ts.map +1 -1
- package/dist/segmented/index.js +19 -18
- package/dist/slider/index.d.ts +2 -1
- package/dist/slider/index.d.ts.map +1 -1
- package/dist/tab-bar/index.d.ts +4 -2
- package/dist/tab-bar/index.d.ts.map +1 -1
- package/dist/tab-bar/index.js +24 -19
- package/dist/textarea/index.d.ts +6 -2
- package/dist/textarea/index.d.ts.map +1 -1
- package/dist/textarea/index.js +2 -1
- package/dist/theme.css +40 -0
- package/dist/utils/duration.d.ts +3 -0
- package/dist/utils/duration.d.ts.map +1 -0
- package/dist/utils/duration.js +7 -0
- package/dist/utils/messages.d.ts +2 -0
- package/dist/utils/messages.d.ts.map +1 -1
- package/dist/utils/messages.js +3 -1
- package/dist/utils/slot.d.ts +5 -2
- package/dist/utils/slot.d.ts.map +1 -1
- package/dist/utils/slot.js +16 -11
- package/dist/utils/useAnchoredPopup.d.ts.map +1 -1
- package/dist/utils/useAnchoredPopup.js +3 -2
- package/dist/utils/useHeightVar.d.ts +7 -0
- package/dist/utils/useHeightVar.d.ts.map +1 -0
- package/dist/utils/useHeightVar.js +17 -0
- package/dist/utils/useMediaPlayer.d.ts +38 -0
- package/dist/utils/useMediaPlayer.d.ts.map +1 -0
- package/dist/utils/useMediaPlayer.js +91 -0
- package/dist/utils/useMediaSession.d.ts +24 -0
- package/dist/utils/useMediaSession.d.ts.map +1 -0
- package/dist/utils/useMediaSession.js +76 -0
- package/dist/zen.css +1 -1
- 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` | |
|