@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,101 @@
1
+ # Calendar
2
+
3
+ A month grid to pick a date or a range, fully keyboard-driven, with limits, blocked days and a year view to jump far.
4
+
5
+ ```tsx
6
+ import { Calendar } from '@rinzai/zen';
7
+ ```
8
+
9
+ ## Examples
10
+
11
+ ### Default
12
+
13
+ ```tsx
14
+ import { Calendar, formatDate } from '@rinzai/zen';
15
+ import { useState } from 'react';
16
+
17
+ /** Click a day, or use the keyboard: arrows, Home/End, Page Up/Down (Shift for years). The title jumps to any month and year. */
18
+ export default function Default() {
19
+ const [date, setDate] = useState<string | null>('2026-09-30');
20
+ return (
21
+ <div className="flex flex-col items-center gap-3">
22
+ <Calendar value={date} onValueChange={setDate} locale="en-US" />
23
+ <p className="text-muted-foreground mt-0! text-xs">
24
+ {date ? formatDate(date, 'en-US', 'full') : 'No date'}
25
+ </p>
26
+ </div>
27
+ );
28
+ }
29
+ ```
30
+
31
+ ### Range
32
+
33
+ ```tsx
34
+ import { Calendar, type DateRange, daysBetween, formatDateRange } from '@rinzai/zen';
35
+ import { useState } from 'react';
36
+
37
+ /** Two months side by side: pick a start, then an end. The days between are previewed as you point. */
38
+ export default function Range() {
39
+ const [range, setRange] = useState<DateRange | null>({ start: '2026-10-05', end: '2026-10-12' });
40
+ return (
41
+ <div className="flex flex-col items-center gap-3">
42
+ <Calendar mode="range" months={2} value={range} onValueChange={setRange} locale="en-GB" />
43
+ <p className="text-muted-foreground mt-0! text-xs">
44
+ {range?.end
45
+ ? `${formatDateRange(range.start, range.end, 'en-GB')} · ${daysBetween(range.start, range.end) + 1} days`
46
+ : 'Pick the last day'}
47
+ </p>
48
+ </div>
49
+ );
50
+ }
51
+ ```
52
+
53
+ ### Limits
54
+
55
+ ```tsx
56
+ import { addDays, Calendar, dayOfWeek, today } from '@rinzai/zen';
57
+ import { useState } from 'react';
58
+
59
+ /** Only the next 60 days, and no weekends: min, max and isDisabled. Weeks start on Monday here, whatever the locale. */
60
+ export default function Limits() {
61
+ const start = today();
62
+ const [date, setDate] = useState<string | null>(null);
63
+ return (
64
+ <Calendar
65
+ value={date}
66
+ onValueChange={setDate}
67
+ locale="en-US"
68
+ weekStartsOn={1}
69
+ min={start}
70
+ max={addDays(start, 60)}
71
+ isDisabled={(d) => dayOfWeek(d) === 0 || dayOfWeek(d) === 6}
72
+ />
73
+ );
74
+ }
75
+ ```
76
+
77
+ ## API reference
78
+
79
+ ### Calendar
80
+
81
+ A month grid to pick a date, or a range of dates, fully keyboard-driven:
82
+ arrows move by day and week, Home and End to the week's ends, Page Up and
83
+ Down by month (with Shift, by year). The title opens a year and month grid
84
+ to jump far. Dates are "YYYY-MM-DD" strings, so they never shift a day with
85
+ time zones. Inline on a page, or in a popup with DatePicker.
86
+
87
+ | Prop | Type | Default | Description |
88
+ | --- | --- | --- | --- |
89
+ | `autoFocus` | `boolean` | | Focus the chosen date (or today) when shown, e.g. in a popup. |
90
+ | `className` | `string` | | |
91
+ | `defaultMonth` | `string` | | Month shown first ("YYYY-MM"). Defaults to the chosen date's, or this month. |
92
+ | `defaultValue` | `DateRange \| string \| null` | | The first date, when the calendar keeps its own. The first range, when the calendar keeps its own. |
93
+ | `isDisabled` | `((date: string) => boolean)` | | Dates that can't be picked, e.g. weekends or booked days. |
94
+ | `locale` | `string` | | Decides month and weekday names, and the first day of the week, e.g. "en-IN". Defaults to the LocaleProvider's. |
95
+ | `max` | `string` | | Latest date that can be picked. |
96
+ | `min` | `string` | | Earliest date that can be picked ("YYYY-MM-DD"). |
97
+ | `mode` | `"range" \| "single"` | | Pick a start and then an end; the days between are highlighted, previewed while pointing. |
98
+ | `months` | `number` | | Months shown side by side (e.g. 2 for ranges). |
99
+ | `onValueChange` | `((date: string) => void) \| ((range: DateRange) => void)` | | |
100
+ | `value` | `DateRange \| string \| null` | | "YYYY-MM-DD", or null for none, when you keep it. The range when you keep it; `end` is null while only the start is picked. |
101
+ | `weekStartsOn` | `number` | | 0 for Sunday … 6 for Saturday. Defaults to the locale's. |
@@ -0,0 +1,584 @@
1
+ # Canvas
2
+
3
+ A fixed-size surface where items sit anywhere, like the cards of a stream overlay: drag, resize and select several, by pointer, touch or keyboard, with snapping, a grid, undo and a fit-to-width scale.
4
+
5
+ ```tsx
6
+ import { Canvas, CanvasItem } from '@rinzai/zen';
7
+ ```
8
+
9
+ ## Examples
10
+
11
+ ### Designer
12
+
13
+ ```tsx
14
+ import {
15
+ Button,
16
+ Canvas,
17
+ CanvasItem,
18
+ type CanvasItemRect,
19
+ ColorPicker,
20
+ InputGroup,
21
+ InputGroupAddon,
22
+ InputGroupInput,
23
+ Kbd,
24
+ SortableHandle,
25
+ SortableItem,
26
+ SortableList,
27
+ cx,
28
+ } from '@rinzai/zen';
29
+ import { KeyboardEvent, useRef, useState } from 'react';
30
+
31
+ interface Layer extends CanvasItemRect {
32
+ kind: 'text' | 'bar' | 'image';
33
+ name: string;
34
+ color: string;
35
+ }
36
+
37
+ const W = 640;
38
+ const H = 360;
39
+
40
+ const START: Layer[] = [
41
+ { id: 'scene', kind: 'image', name: 'Backdrop', color: '#1b1630', x: 0, y: 0, w: W, h: H, locked: true },
42
+ { id: 'cam', kind: 'image', name: 'Camera', color: '#7c5cff', x: 404, y: 20, w: 216, h: 122 },
43
+ { id: 'title', kind: 'text', name: 'Title', color: '#ffffff', x: 24, y: 252, w: 300, h: 40 },
44
+ { id: 'goal', kind: 'bar', name: 'Sub goal', color: '#2bb3a3', x: 24, y: 304, w: 300, h: 16 },
45
+ ];
46
+
47
+ const NEW: Record<Layer['kind'], Omit<Layer, 'id' | 'x' | 'y'>> = {
48
+ text: { kind: 'text', name: 'Text', color: '#ffffff', w: 200, h: 32 },
49
+ bar: { kind: 'bar', name: 'Bar', color: '#f0a83a', w: 220, h: 14 },
50
+ image: { kind: 'image', name: 'Image', color: '#ef5a7a', w: 120, h: 120 },
51
+ };
52
+
53
+ /**
54
+ * A small overlay designer: a 640×360 scene that fits its width, a layers list whose order is the
55
+ * stacking (drag to reorder), and an inspector for the selected card. `onItemsChangeEnd` gives each change
56
+ * with the items before it, which is all Undo needs. Try `⌘Z`, `⇧⌘Z` and `Delete`.
57
+ */
58
+ export default function Designer() {
59
+ const [items, setItems] = useState(START);
60
+ const [selected, setSelected] = useState<string[]>(['title']);
61
+ const [past, setPast] = useState<Layer[][]>([]);
62
+ const [future, setFuture] = useState<Layer[][]>([]);
63
+ const editing = useRef<Layer[] | null>(null);
64
+ const count = useRef(0);
65
+
66
+ const remember = (before: Layer[]) => {
67
+ setPast((p) => [...p, before]);
68
+ setFuture([]);
69
+ };
70
+ const commit = (next: Layer[]) => {
71
+ remember(items);
72
+ setItems(next);
73
+ };
74
+ // Live edits (a colour being dragged) become one step of history when they end.
75
+ const live = (next: Layer[]) => {
76
+ editing.current ??= items;
77
+ setItems(next);
78
+ };
79
+ const settle = () => {
80
+ if (editing.current) remember(editing.current);
81
+ editing.current = null;
82
+ };
83
+ const undo = () => {
84
+ if (!past.length) return;
85
+ setFuture((f) => [items, ...f]);
86
+ setItems(past[past.length - 1]);
87
+ setPast((p) => p.slice(0, -1));
88
+ };
89
+ const redo = () => {
90
+ if (!future.length) return;
91
+ setPast((p) => [...p, items]);
92
+ setItems(future[0]);
93
+ setFuture((f) => f.slice(1));
94
+ };
95
+ const add = (kind: Layer['kind']) => {
96
+ const id = `${kind}-${++count.current}`;
97
+ const base = NEW[kind];
98
+ commit([
99
+ ...items,
100
+ { ...base, id, name: `${base.name} ${count.current}`, x: (W - base.w) / 2, y: (H - base.h) / 2 },
101
+ ]);
102
+ setSelected([id]);
103
+ };
104
+ const remove = () => {
105
+ const gone = new Set(selected.filter((id) => !items.find((i) => i.id === id)?.locked));
106
+ if (!gone.size) return;
107
+ commit(items.filter((i) => !gone.has(i.id)));
108
+ setSelected([]);
109
+ };
110
+ const onKeyDown = (e: KeyboardEvent) => {
111
+ const mod = e.metaKey || e.ctrlKey;
112
+ if (mod && e.key.toLowerCase() === 'z') {
113
+ e.preventDefault();
114
+ if (e.shiftKey) redo();
115
+ else undo();
116
+ } else if ((e.key === 'Delete' || e.key === 'Backspace') && (e.target as Element).closest('[data-canvas-id]')) {
117
+ e.preventDefault();
118
+ remove();
119
+ }
120
+ };
121
+
122
+ // Top of the stack first; the locked backdrop stays at the bottom.
123
+ const layers = items.filter((i) => !i.locked).reverse();
124
+ const one = selected.length === 1 ? items.find((i) => i.id === selected[0]) : undefined;
125
+ const update = (patch: Partial<Layer>) =>
126
+ one && commit(items.map((i) => (i.id === one.id ? { ...i, ...patch } : i)));
127
+ const field = (key: 'x' | 'y' | 'w' | 'h', max: number) => (
128
+ <InputGroup className="h-9">
129
+ <InputGroupAddon className="w-3 uppercase">{key}</InputGroupAddon>
130
+ <InputGroupInput
131
+ type="number"
132
+ aria-label={{ x: 'X', y: 'Y', w: 'Width', h: 'Height' }[key]}
133
+ value={one?.[key] ?? ''}
134
+ disabled={!one || one.locked}
135
+ min={0}
136
+ max={max}
137
+ onChange={(e) => {
138
+ const v = Math.round(Number(e.target.value));
139
+ if (one && Number.isFinite(v))
140
+ update({ [key]: Math.min(max, Math.max(key === 'w' || key === 'h' ? 8 : 0, v)) });
141
+ }}
142
+ className="tabular-nums"
143
+ />
144
+ </InputGroup>
145
+ );
146
+
147
+ return (
148
+ <div className="flex w-full max-w-3xl flex-col gap-4" onKeyDown={onKeyDown}>
149
+ <div className="flex flex-wrap items-center gap-2">
150
+ <Button size="sm" variant="secondary" onClick={() => add('text')}>
151
+ Text
152
+ </Button>
153
+ <Button size="sm" variant="secondary" onClick={() => add('bar')}>
154
+ Bar
155
+ </Button>
156
+ <Button size="sm" variant="secondary" onClick={() => add('image')}>
157
+ Image
158
+ </Button>
159
+ <span className="flex-1" />
160
+ <Button size="sm" variant="ghost" onClick={undo} disabled={!past.length}>
161
+ Undo
162
+ </Button>
163
+ <Button size="sm" variant="ghost" onClick={redo} disabled={!future.length}>
164
+ Redo
165
+ </Button>
166
+ <Button size="sm" variant="ghost" tone="negative" onClick={remove} disabled={!selected.length}>
167
+ Delete
168
+ </Button>
169
+ </div>
170
+
171
+ <Canvas
172
+ aria-label="Scene"
173
+ width={W}
174
+ height={H}
175
+ scale="fit"
176
+ snap
177
+ items={items}
178
+ onItemsChange={setItems}
179
+ onItemsChangeEnd={(_, before) => remember([...before])}
180
+ selected={selected}
181
+ onSelectedChange={setSelected}
182
+ >
183
+ {items.map((layer) => (
184
+ <CanvasItem key={layer.id} id={layer.id} label={layer.name} className="flex items-center">
185
+ {layer.kind === 'text' && (
186
+ <span
187
+ className="truncate font-semibold tracking-tight"
188
+ style={{ color: layer.color, fontSize: layer.h * 0.72, lineHeight: 1 }}
189
+ >
190
+ Live in 5 minutes
191
+ </span>
192
+ )}
193
+ {layer.kind === 'bar' && (
194
+ <div className="size-full overflow-hidden rounded-full bg-white/10">
195
+ <div className="h-full w-3/5 rounded-full" style={{ background: layer.color }} />
196
+ </div>
197
+ )}
198
+ {layer.kind === 'image' && (
199
+ <div
200
+ className={cx('size-full', !layer.locked && 'rounded-xl')}
201
+ style={{
202
+ background: layer.locked
203
+ ? `radial-gradient(ellipse at 20% 0%, ${layer.color}, #0b0a12 70%)`
204
+ : `radial-gradient(circle at 30% 25%, color-mix(in oklch, ${layer.color} 70%, white), ${layer.color})`,
205
+ }}
206
+ />
207
+ )}
208
+ </CanvasItem>
209
+ ))}
210
+ </Canvas>
211
+
212
+ <div className="grid gap-4 sm:grid-cols-2">
213
+ <section className="flex flex-col gap-2">
214
+ <h4 className="text-muted-foreground m-0! text-xs font-medium tracking-widest uppercase">Layers</h4>
215
+ <SortableList
216
+ aria-label="Layers"
217
+ value={layers.map((i) => i.id)}
218
+ onValueChange={(order) =>
219
+ commit([
220
+ ...items.filter((i) => i.locked),
221
+ ...[...order].reverse().map((id) => items.find((i) => i.id === id)!),
222
+ ])
223
+ }
224
+ >
225
+ {layers.map((layer) => (
226
+ <SortableItem
227
+ key={layer.id}
228
+ id={layer.id}
229
+ className={cx(
230
+ 'flex items-center gap-1 rounded-lg pr-2',
231
+ selected.includes(layer.id) && 'bg-primary/10',
232
+ )}
233
+ >
234
+ <SortableHandle id={layer.id} label={layer.name} />
235
+ <button
236
+ type="button"
237
+ onClick={() => setSelected([layer.id])}
238
+ className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 py-1.5 text-left text-sm outline-hidden"
239
+ >
240
+ <span
241
+ aria-hidden
242
+ className="size-3 shrink-0 rounded-sm"
243
+ style={{ background: layer.color }}
244
+ />
245
+ <span className="truncate">{layer.name}</span>
246
+ </button>
247
+ </SortableItem>
248
+ ))}
249
+ </SortableList>
250
+ {items
251
+ .filter((i) => i.locked)
252
+ .map((layer) => (
253
+ <p
254
+ key={layer.id}
255
+ className="text-muted-foreground mt-0! flex items-center gap-2 py-1.5 pl-9 text-sm"
256
+ >
257
+ <span
258
+ aria-hidden
259
+ className="size-3 shrink-0 rounded-sm"
260
+ style={{ background: layer.color }}
261
+ />
262
+ {layer.name}
263
+ <span className="ml-auto pr-2 text-xs">Locked</span>
264
+ </p>
265
+ ))}
266
+ </section>
267
+ <section className="flex flex-col gap-2">
268
+ <h4 className="text-muted-foreground m-0! text-xs font-medium tracking-widest uppercase">
269
+ {one ? one.name : selected.length ? `${selected.length} selected` : 'Nothing selected'}
270
+ </h4>
271
+ <div className="grid grid-cols-2 gap-2">
272
+ {field('x', W)}
273
+ {field('y', H)}
274
+ {field('w', W)}
275
+ {field('h', H)}
276
+ </div>
277
+ <ColorPicker
278
+ aria-label="Colour"
279
+ value={one?.color ?? '#000000'}
280
+ disabled={!one}
281
+ onValueChange={(color) =>
282
+ one && live(items.map((i) => (i.id === one.id ? { ...i, color } : i)))
283
+ }
284
+ onValueCommit={settle}
285
+ />
286
+ <p className="text-muted-foreground mt-0! text-xs">
287
+ <Kbd>Shift</Kbd> keeps a card's shape as you resize; draw a box to select several.
288
+ </p>
289
+ </section>
290
+ </div>
291
+ </div>
292
+ );
293
+ }
294
+ ```
295
+
296
+ ### Dashboard
297
+
298
+ ```tsx
299
+ import { Canvas, CanvasItem, type CanvasItemRect, Meter, Sparkline, Stat, StatusPill } from '@rinzai/zen';
300
+ import { ReactNode, useState } from 'react';
301
+
302
+ const WIDGETS: Record<string, { label: string; render: () => ReactNode }> = {
303
+ spent: {
304
+ label: 'Spent',
305
+ render: () => (
306
+ <Stat label="Spent" value="$2,742" tone="negative" className="size-full">
307
+ <Sparkline
308
+ values={[40, 52, 48, 61, 58, 66, 72, 69, 80]}
309
+ tone="negative"
310
+ area
311
+ className="mt-2 h-auto min-h-0 w-full flex-1"
312
+ />
313
+ </Stat>
314
+ ),
315
+ },
316
+ left: {
317
+ label: 'Left to spend',
318
+ render: () => <Stat label="Left to spend" value="$1,258" tone="positive" className="size-full" />,
319
+ },
320
+ budgets: {
321
+ label: 'Budgets',
322
+ render: () => (
323
+ <div className="glass glow-edge flex size-full flex-col gap-4 rounded-xl p-3.5 md:p-4">
324
+ <span className="text-muted-foreground text-2xs tracking-widest uppercase sm:text-xs">Budgets</span>
325
+ <Meter label="Groceries" value={207} max={600} detail="$207 / $600" />
326
+ <Meter label="Dining out" value={170} max={150} detail="$170 / $150" />
327
+ <Meter label="Transport" value={64} max={120} detail="$64 / $120" />
328
+ <Meter label="Bills" value={310} max={340} detail="$310 / $340" />
329
+ </div>
330
+ ),
331
+ },
332
+ balance: {
333
+ label: 'Balance',
334
+ render: () => (
335
+ <Stat label="Balance" value="$18,400" className="size-full">
336
+ <Sparkline
337
+ values={[12, 13, 13.5, 14, 15.2, 15, 16.4, 17.1, 18.4]}
338
+ area
339
+ className="mt-2 h-auto min-h-0 w-full flex-1"
340
+ />
341
+ </Stat>
342
+ ),
343
+ },
344
+ };
345
+
346
+ /**
347
+ * A dashboard you lay out yourself: widgets on a 20-unit `grid`, lining up with each other as you drag
348
+ * (`snap`), with `minSize` so none gets too small. Widgets are any content; here Stat, Meter and
349
+ * Sparkline. `onItemsChangeEnd` is the moment to save the layout.
350
+ */
351
+ export default function Dashboard() {
352
+ const [items, setItems] = useState<CanvasItemRect[]>([
353
+ { id: 'spent', x: 0, y: 0, w: 220, h: 140 },
354
+ { id: 'left', x: 240, y: 0, w: 220, h: 140 },
355
+ { id: 'budgets', x: 480, y: 0, w: 240, h: 300 },
356
+ { id: 'balance', x: 0, y: 160, w: 460, h: 140 },
357
+ ]);
358
+ const [selected, setSelected] = useState<string[]>([]);
359
+ const [saves, setSaves] = useState(0);
360
+ return (
361
+ <div className="flex w-full max-w-3xl flex-col gap-3">
362
+ <Canvas
363
+ aria-label="Dashboard"
364
+ width={720}
365
+ height={300}
366
+ scale="fit"
367
+ snap
368
+ grid={20}
369
+ minSize={100}
370
+ items={items}
371
+ onItemsChange={setItems}
372
+ onItemsChangeEnd={() => setSaves((n) => n + 1)}
373
+ selected={selected}
374
+ onSelectedChange={setSelected}
375
+ className="bg-transparent shadow-none"
376
+ >
377
+ {items.map((item) => (
378
+ <CanvasItem key={item.id} id={item.id} label={WIDGETS[item.id].label} className="rounded-xl">
379
+ {WIDGETS[item.id].render()}
380
+ </CanvasItem>
381
+ ))}
382
+ </Canvas>
383
+ <div className="flex justify-end">
384
+ <StatusPill tone={saves ? 'positive' : 'default'} quiet={!saves}>
385
+ {saves ? `Layout saved (${saves})` : 'Drag a widget, or a handle to resize it'}
386
+ </StatusPill>
387
+ </div>
388
+ </div>
389
+ );
390
+ }
391
+ ```
392
+
393
+ ### Notes
394
+
395
+ ```tsx
396
+ import { Button, Canvas, CanvasItem, type CanvasItemRect, Textarea } from '@rinzai/zen';
397
+ import { useRef, useState } from 'react';
398
+
399
+ interface Note extends CanvasItemRect {
400
+ text: string;
401
+ color: string;
402
+ }
403
+
404
+ const COLORS = ['var(--chart-4)', 'var(--chart-3)', 'var(--chart-5)', 'var(--chart-1)'];
405
+
406
+ /**
407
+ * Sticky notes with a field in each: typing, selecting text and the × button work as usual, because
408
+ * presses on fields and buttons inside an item go to them. Drag a note by its edge or top strip.
409
+ */
410
+ export default function Notes() {
411
+ const [notes, setNotes] = useState<Note[]>([
412
+ { id: 'n1', x: 24, y: 24, w: 170, h: 150, color: COLORS[0], text: 'Rent goes out on the 1st' },
413
+ { id: 'n2', x: 220, y: 60, w: 170, h: 150, color: COLORS[1], text: 'Move $400 to the holiday fund' },
414
+ { id: 'n3', x: 420, y: 30, w: 170, h: 150, color: COLORS[2], text: 'Cancel the old phone plan' },
415
+ ]);
416
+ const [selected, setSelected] = useState<string[]>([]);
417
+ const count = useRef(notes.length);
418
+ const edit = (id: string, patch: Partial<Note>) =>
419
+ setNotes((all) => all.map((n) => (n.id === id ? { ...n, ...patch } : n)));
420
+
421
+ return (
422
+ <div className="flex w-full max-w-3xl flex-col gap-3">
423
+ <Canvas
424
+ aria-label="Notes"
425
+ width={620}
426
+ height={260}
427
+ scale="fit"
428
+ minSize={120}
429
+ items={notes}
430
+ onItemsChange={setNotes}
431
+ selected={selected}
432
+ onSelectedChange={setSelected}
433
+ >
434
+ {notes.map((note, i) => (
435
+ <CanvasItem
436
+ key={note.id}
437
+ id={note.id}
438
+ label={`Note ${i + 1}`}
439
+ style={{ background: `color-mix(in oklch, ${note.color} 22%, oklch(var(--card)))` }}
440
+ className="flex flex-col overflow-hidden rounded-xl shadow-[0_10px_30px_-12px_oklch(0_0_0/0.6)]"
441
+ >
442
+ <div className="flex h-7 shrink-0 items-center justify-between pr-1 pl-3">
443
+ <span aria-hidden className="h-1 w-8 rounded-full" style={{ background: note.color }} />
444
+ <button
445
+ type="button"
446
+ aria-label={`Remove note ${i + 1}`}
447
+ onClick={() => setNotes((all) => all.filter((n) => n.id !== note.id))}
448
+ className="text-muted-foreground hover:text-foreground grid size-6 cursor-pointer place-items-center rounded-md"
449
+ >
450
+ ×
451
+ </button>
452
+ </div>
453
+ <Textarea
454
+ aria-label={`Note ${i + 1}`}
455
+ value={note.text}
456
+ onChange={(e) => edit(note.id, { text: e.target.value })}
457
+ className="min-h-0 flex-1 resize-none rounded-none border-0 bg-transparent px-3 pt-0 shadow-none"
458
+ />
459
+ </CanvasItem>
460
+ ))}
461
+ </Canvas>
462
+ <div>
463
+ <Button
464
+ size="sm"
465
+ variant="secondary"
466
+ onClick={() => {
467
+ const id = `n${++count.current}`;
468
+ setNotes((all) => [
469
+ ...all,
470
+ {
471
+ id,
472
+ x: 225,
473
+ y: 55,
474
+ w: 170,
475
+ h: 150,
476
+ color: COLORS[count.current % COLORS.length],
477
+ text: '',
478
+ },
479
+ ]);
480
+ setSelected([id]);
481
+ }}
482
+ >
483
+ Add a note
484
+ </Button>
485
+ </div>
486
+ </div>
487
+ );
488
+ }
489
+ ```
490
+
491
+ ### Snapping
492
+
493
+ ```tsx
494
+ import { Canvas, CanvasItem, type CanvasItemRect, Segmented, SegmentedItem } from '@rinzai/zen';
495
+ import { useState } from 'react';
496
+
497
+ type Mode = 'off' | 'guides' | 'grid';
498
+
499
+ /** Off: items go where you drop them. Guides (`snap`): edges and centres line up with the surface and each other. Grid (`grid`, here with `snap`): they keep to a 20-unit grid, its dots showing while you drag. */
500
+ export default function Snapping() {
501
+ const [mode, setMode] = useState<Mode>('guides');
502
+ const [items, setItems] = useState<CanvasItemRect[]>([
503
+ { id: 'a', x: 20, y: 20, w: 120, h: 70 },
504
+ { id: 'b', x: 210, y: 70, w: 100, h: 100 },
505
+ { id: 'c', x: 330, y: 30, w: 100, h: 40 },
506
+ ]);
507
+ const [selected, setSelected] = useState<string[]>([]);
508
+ return (
509
+ <div className="flex flex-col items-start gap-3">
510
+ <Segmented label="Snapping" value={mode} onValueChange={setMode}>
511
+ <SegmentedItem value="off">Off</SegmentedItem>
512
+ <SegmentedItem value="guides">Guides</SegmentedItem>
513
+ <SegmentedItem value="grid">Grid</SegmentedItem>
514
+ </Segmented>
515
+ <Canvas
516
+ aria-label="Layout"
517
+ width={460}
518
+ height={220}
519
+ snap={mode !== 'off'}
520
+ grid={mode === 'grid' ? 20 : undefined}
521
+ items={items}
522
+ onItemsChange={setItems}
523
+ selected={selected}
524
+ onSelectedChange={setSelected}
525
+ >
526
+ {items.map((item, n) => (
527
+ <CanvasItem
528
+ key={item.id}
529
+ id={item.id}
530
+ label={`Card ${n + 1}`}
531
+ className="glass text-muted-foreground grid place-items-center rounded-lg text-sm tabular-nums"
532
+ >
533
+ {item.x}, {item.y}
534
+ </CanvasItem>
535
+ ))}
536
+ </Canvas>
537
+ </div>
538
+ );
539
+ }
540
+ ```
541
+
542
+ ## API reference
543
+
544
+ ### Canvas
545
+
546
+ A fixed-size surface where items sit anywhere, such as the cards of a
547
+ stream overlay or a slide. Drag an item to move it, and the handles of the
548
+ selected one to resize it (Shift keeps its shape); Escape mid-drag puts it
549
+ back. Shift- or Cmd-click, or drag a box on empty space, to select several
550
+ and move them together. From the keyboard, Tab reaches each item, the arrow
551
+ keys move the selection (Shift by 10), Alt and the arrow keys resize, Cmd or
552
+ Ctrl+A selects all, and Escape deselects; each change is announced.
553
+
554
+ Items stay on the surface; with `snap` they line up with its edges and
555
+ centre and with each other, and with `grid` they keep to a grid. `scale`
556
+ shows the design larger or smaller, or `"fit"` fills the width it's given.
557
+ `onItemsChange` gets the items with every step of a change; `onItemsChangeEnd`, once
558
+ it's made, with the items before it, for undo or saving.
559
+
560
+ | Prop | Type | Default | Description |
561
+ | --- | --- | --- | --- |
562
+ | `height` (required) | `number` | | |
563
+ | `items` (required) | `readonly T[]` | | Where each item is (other fields are kept); render a CanvasItem for each. |
564
+ | `onItemsChange` (required) | `(items: T[]) => void` | | The items, with every step of a move or resize. |
565
+ | `width` (required) | `number` | | The surface's size, in your design's units (px at scale 1). |
566
+ | `grid` | `number` | | Keep positions and sizes to multiples of this (design units) when no line is in reach. |
567
+ | `minSize` | `number` | `8` | Smallest width and height an item can be resized to. |
568
+ | `onItemsChangeEnd` | `((items: T[], before: readonly T[]) => void)` | | The items once a change is made (a drag let go, a key pressed), and as they were before: for undo or saving. |
569
+ | `onSelectedChange` | `((ids: string[]) => void)` | | |
570
+ | `scale` | `"fit" \| number` | `1` | How large to show it: 2 shows a 400×120 design 800×240; "fit" fills the width it's given. Positions stay in design units. |
571
+ | `selected` | `readonly string[]` | `[]` | The selected items' ids. |
572
+ | `snap` | `boolean \| number` | `false` | Line items up with the surface and each other: true, or how close counts, in screen px (true is 6). |
573
+
574
+ ### CanvasItem
575
+
576
+ One item on a Canvas, placed where the canvas's `items` say for its `id`;
577
+ later items sit on top of earlier ones. `label` names it ("Title") in what's
578
+ announced. Its content is yours: fields and buttons in it work as usual,
579
+ and pressing them doesn't drag it (nor anything with data-canvas-no-drag).
580
+
581
+ | Prop | Type | Default | Description |
582
+ | --- | --- | --- | --- |
583
+ | `id` (required) | `string` | | |
584
+ | `label` (required) | `string` | | The item's name, e.g. "Title", for what's announced. |