@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.
- 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 +8 -2
- 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/number-field/index.d.ts +18 -2
- package/dist/number-field/index.d.ts.map +1 -1
- package/dist/number-field/index.js +65 -51
- 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,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. |
|