panelui-web 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +43 -2
  2. package/dist/components/bar-picker/index.d.ts +61 -0
  3. package/dist/components/bar-picker/index.js +139 -0
  4. package/dist/components/card/index.d.ts +107 -0
  5. package/dist/components/card/index.js +88 -0
  6. package/dist/components/command-dock/index.d.ts +118 -0
  7. package/dist/components/command-dock/index.js +239 -0
  8. package/dist/components/day-grid/index.d.ts +87 -0
  9. package/dist/components/day-grid/index.js +251 -0
  10. package/dist/components/feedback/index.d.ts +135 -0
  11. package/dist/components/feedback/index.js +283 -0
  12. package/dist/components/flow/flow-paths.d.ts +94 -0
  13. package/dist/components/flow/flow-paths.js +270 -0
  14. package/dist/components/flow/index.d.ts +235 -0
  15. package/dist/components/flow/index.js +597 -0
  16. package/dist/components/frame/index.d.ts +189 -0
  17. package/dist/components/frame/index.js +181 -0
  18. package/dist/components/kpi/index.d.ts +211 -0
  19. package/dist/components/kpi/index.js +243 -0
  20. package/dist/components/lane-board/index.d.ts +125 -0
  21. package/dist/components/lane-board/index.js +691 -0
  22. package/dist/components/planet/index.d.ts +132 -0
  23. package/dist/components/planet/index.js +653 -0
  24. package/dist/components/planet/land.d.ts +8 -0
  25. package/dist/components/planet/land.js +122 -0
  26. package/dist/components/range-meter/index.d.ts +70 -0
  27. package/dist/components/range-meter/index.js +170 -0
  28. package/dist/components/rolling-number/index.d.ts +46 -0
  29. package/dist/components/rolling-number/index.js +81 -0
  30. package/dist/components/sheet/index.d.ts +148 -0
  31. package/dist/components/sheet/index.js +330 -0
  32. package/dist/components/tilt-card/index.d.ts +60 -0
  33. package/dist/components/tilt-card/index.js +114 -0
  34. package/dist/index.d.ts +15 -0
  35. package/dist/index.js +15 -0
  36. package/dist/lib/cn.d.ts +3 -0
  37. package/dist/lib/cn.js +6 -0
  38. package/package.json +72 -4
@@ -0,0 +1,239 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ /**
4
+ * CommandDock — a small button that opens into a place to ask for something,
5
+ * with the answer arriving under it a row at a time.
6
+ *
7
+ * ```tsx
8
+ * <CommandDock onSubmit={ask}>
9
+ * <CommandDock.Trigger>Ask</CommandDock.Trigger>
10
+ * <CommandDock.Panel>
11
+ * <CommandDock.Input placeholder="What do you want to find?" />
12
+ * <CommandDock.Results>
13
+ * {rows.map((row) => <CommandDock.Row key={row.id}>{row.text}</CommandDock.Row>)}
14
+ * </CommandDock.Results>
15
+ * </CommandDock.Panel>
16
+ * </CommandDock>
17
+ * ```
18
+ *
19
+ * ## One surface that opens
20
+ *
21
+ * The button and the panel are the same surface. Closed, it is a pill as
22
+ * wide as its label. Opened, it widens, the label fades into a field in the
23
+ * same place, and it grows downwards as rows arrive. Closing runs the same
24
+ * steps backwards: the rows fold up, then it narrows. Nothing appears from
25
+ * elsewhere, so it is clear what was pressed and where the answer belongs.
26
+ *
27
+ * ## It runs nothing
28
+ *
29
+ * The dock does not know where answers come from. `onSubmit` is handed what
30
+ * was typed, and the rows are whatever you render. A row that mounts slides
31
+ * in after the one before it, so rows added one by one read as an answer
32
+ * being written, whether they come from a search, a model or a list you
33
+ * already had.
34
+ *
35
+ * ## Closing
36
+ *
37
+ * Escape closes it and gives focus back to where it was, and so does a press
38
+ * anywhere outside it.
39
+ */
40
+ import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, } from 'react';
41
+ import { tv } from 'tailwind-variants';
42
+ const commandDockVariants = tv({
43
+ slots: {
44
+ root: 'relative flex w-full justify-center',
45
+ /*
46
+ * The widths are both real lengths, so the change between them can be
47
+ * animated; `auto` cannot. The easing has no overshoot: a surface this
48
+ * size that bounces as it closes reads as a mistake.
49
+ */
50
+ surface: 'overflow-hidden border bg-card text-card-foreground shadow-sm transition-[width,border-radius,box-shadow] duration-[400ms] ease-[cubic-bezier(0.2,0,0,1)] motion-reduce:transition-none',
51
+ // The label keeps its row whether or not it can be seen, so the surface
52
+ // is never shorter than one row and never jumps as the two trade places.
53
+ trigger: 'flex h-11 w-full cursor-pointer items-center justify-center gap-2 whitespace-nowrap px-4 text-sm font-medium outline-none transition-[opacity,background-color] duration-200 hover:bg-accent focus-visible:bg-accent [&_svg]:size-4 [&_svg]:shrink-0',
54
+ // Pulled up over the label's row: the field sits exactly where the label
55
+ // was, and the two fade through each other.
56
+ panel: '-mt-11 flex flex-col',
57
+ form: 'flex h-11 items-center gap-2 pe-1.5 ps-4 transition-opacity duration-200 motion-reduce:transition-none',
58
+ input: 'h-full min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground',
59
+ submit: 'inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full bg-primary text-primary-foreground outline-none transition-[opacity,scale] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card active:scale-95 disabled:cursor-default disabled:opacity-30 [&_svg]:size-4',
60
+ // Two rows of a grid, the second going from no height to its own, which
61
+ // is how the panel grows to whatever the rows come to.
62
+ results: 'grid transition-[grid-template-rows] duration-300 ease-out motion-reduce:transition-none',
63
+ resultsClip: 'min-h-0 overflow-hidden',
64
+ resultsInner: 'flex flex-col gap-0.5 border-t p-1.5',
65
+ row: 'flex items-center gap-3 rounded-xl px-2.5 py-2 text-sm transition-[opacity,translate] duration-300 ease-out starting:translate-y-1.5 starting:opacity-0 motion-reduce:transition-none [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground',
66
+ status: 'flex items-center gap-2 px-2.5 py-2 text-sm text-muted-foreground',
67
+ dots: 'flex gap-1',
68
+ dot: 'size-1 animate-pulse rounded-full bg-muted-foreground',
69
+ },
70
+ variants: {
71
+ /*
72
+ * Opening and closing are the same steps in opposite orders. Opening, the
73
+ * surface widens and the rows follow it down. Closing, the rows fold up
74
+ * first and the surface narrows after them, so what is seen is one thing
75
+ * putting itself away and not two things changing at once.
76
+ */
77
+ open: {
78
+ true: {
79
+ // One length, not a width and a limit on it: a change of width is
80
+ // animated from the width as written, and `100%` with a `max-width`
81
+ // would start the closing from the full width of whatever it is in.
82
+ surface: 'w-[min(100%,28rem)] rounded-3xl shadow-lg',
83
+ trigger: 'pointer-events-none opacity-0',
84
+ form: 'opacity-100 delay-150',
85
+ },
86
+ false: {
87
+ surface: 'w-[var(--pu-closed)] rounded-[1.375rem] delay-200',
88
+ trigger: 'opacity-100 delay-300',
89
+ form: 'pointer-events-none opacity-0',
90
+ },
91
+ },
92
+ filled: {
93
+ true: { results: 'grid-rows-[1fr] delay-200' },
94
+ false: { results: 'grid-rows-[0fr]' },
95
+ },
96
+ interactive: {
97
+ true: { row: 'cursor-pointer hover:bg-accent focus-visible:bg-accent focus-visible:outline-none' },
98
+ },
99
+ },
100
+ });
101
+ const CommandDockContext = createContext(null);
102
+ function useDock(part) {
103
+ const dock = useContext(CommandDockContext);
104
+ if (!dock)
105
+ throw new Error(`${part} must be used inside a <CommandDock>.`);
106
+ return dock;
107
+ }
108
+ function CommandDockRoot({ open: controlledOpen, defaultOpen = false, onOpenChange, value: controlledValue, onValueChange, onSubmit, busy = false, closedWidth = '9rem', className, style, children, ...props }) {
109
+ const [internalOpen, setInternalOpen] = useState(defaultOpen);
110
+ const [internalValue, setInternalValue] = useState('');
111
+ const open = controlledOpen ?? internalOpen;
112
+ const value = controlledValue ?? internalValue;
113
+ const root = useRef(null);
114
+ const panelId = useId();
115
+ const slots = commandDockVariants({ open });
116
+ const setOpen = useCallback((next) => {
117
+ if (controlledOpen === undefined)
118
+ setInternalOpen(next);
119
+ onOpenChange?.(next);
120
+ }, [controlledOpen, onOpenChange]);
121
+ const setValue = useCallback((next) => {
122
+ if (controlledValue === undefined)
123
+ setInternalValue(next);
124
+ onValueChange?.(next);
125
+ }, [controlledValue, onValueChange]);
126
+ const submit = useCallback(() => {
127
+ const text = value.trim();
128
+ if (text && !busy)
129
+ onSubmit?.(text);
130
+ }, [value, busy, onSubmit]);
131
+ // A press outside closes it. Escape does too, and puts focus back on the
132
+ // surface so the next key goes somewhere sensible.
133
+ useEffect(() => {
134
+ if (!open)
135
+ return;
136
+ const onPointerDown = (event) => {
137
+ if (!root.current?.contains(event.target))
138
+ setOpen(false);
139
+ };
140
+ const onKeyDown = (event) => {
141
+ if (event.key !== 'Escape' || !root.current?.contains(document.activeElement))
142
+ return;
143
+ setOpen(false);
144
+ requestAnimationFrame(() => root.current?.querySelector('[data-dock-trigger]')?.focus());
145
+ };
146
+ document.addEventListener('pointerdown', onPointerDown);
147
+ document.addEventListener('keydown', onKeyDown);
148
+ return () => {
149
+ document.removeEventListener('pointerdown', onPointerDown);
150
+ document.removeEventListener('keydown', onKeyDown);
151
+ };
152
+ }, [open, setOpen]);
153
+ const context = useMemo(() => ({ open, setOpen, value, setValue, submit, busy, panelId }), [open, setOpen, value, setValue, submit, busy, panelId]);
154
+ return (_jsx(CommandDockContext.Provider, { value: context, children: _jsx("div", { ref: root, className: slots.root({ className }), ...props, children: _jsx("div", { "data-open": open ? '' : undefined, className: slots.surface(), style: { ['--pu-closed']: closedWidth, ...style }, children: children }) }) }));
155
+ }
156
+ CommandDockRoot.displayName = 'CommandDock';
157
+ /** Keeps an element out of the tab order and the accessibility tree while it cannot be seen. */
158
+ function useInert(inert) {
159
+ const ref = useRef(null);
160
+ // Before anything else runs: the field asks for focus as the dock opens,
161
+ // and an element inside something still inert cannot take it.
162
+ useLayoutEffect(() => {
163
+ // Set on the element, not passed as a prop: how a boolean `inert` prop is
164
+ // written to the page differs between React versions.
165
+ ref.current?.toggleAttribute('inert', inert);
166
+ }, [inert]);
167
+ return ref;
168
+ }
169
+ /** The closed form: a button that opens the dock. */
170
+ function CommandDockTrigger({ className, onClick, ...props }) {
171
+ const dock = useDock('CommandDock.Trigger');
172
+ const ref = useInert(dock.open);
173
+ return (_jsx("button", { ref: ref, type: "button", "data-dock-trigger": "", "aria-expanded": dock.open, "aria-controls": dock.panelId, className: commandDockVariants({ open: dock.open }).trigger({ className }), onClick: (event) => {
174
+ onClick?.(event);
175
+ if (!event.defaultPrevented)
176
+ dock.setOpen(true);
177
+ }, ...props }));
178
+ }
179
+ CommandDockTrigger.displayName = 'CommandDock.Trigger';
180
+ /** The open form: the field, and the rows under it. */
181
+ function CommandDockPanel({ className, ...props }) {
182
+ const dock = useDock('CommandDock.Panel');
183
+ // Kept in the page while closed, so it can be seen folding away. Inert, so
184
+ // nothing in it can be reached while it cannot be seen.
185
+ const ref = useInert(!dock.open);
186
+ return (_jsx("div", { ref: ref, id: dock.panelId, "data-open": dock.open ? '' : undefined, className: commandDockVariants().panel({ className }), ...props }));
187
+ }
188
+ CommandDockPanel.displayName = 'CommandDock.Panel';
189
+ /** The field, with a button to send what is in it. Enter sends too. */
190
+ function CommandDockInput({ className, submitLabel = 'Send', 'aria-label': ariaLabel = 'Ask', ...props }) {
191
+ const dock = useDock('CommandDock.Input');
192
+ const slots = commandDockVariants({ open: dock.open });
193
+ const field = useRef(null);
194
+ // It is opened in order to type in it. Asked for without scrolling: the
195
+ // surface is still on its way to its size, and the page should not chase it.
196
+ useEffect(() => {
197
+ if (dock.open)
198
+ field.current?.focus({ preventScroll: true });
199
+ }, [dock.open]);
200
+ const send = (event) => {
201
+ event.preventDefault();
202
+ dock.submit();
203
+ };
204
+ return (_jsxs("form", { role: "search", className: slots.form(), onSubmit: send, children: [_jsx("input", { ref: field, type: "text", "aria-label": ariaLabel, value: dock.value, onChange: (event) => dock.setValue(event.target.value), className: slots.input({ className }), ...props }), _jsx("button", { type: "submit", "aria-label": submitLabel, disabled: dock.busy || dock.value.trim() === '', className: slots.submit(), children: _jsx("svg", { "aria-hidden": "true", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.25", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: "M12 19V5M5 12l7-7 7 7" }) }) })] }));
205
+ }
206
+ CommandDockInput.displayName = 'CommandDock.Input';
207
+ /** Where the rows go. It takes no room until there is something in it. */
208
+ function CommandDockResults({ className, label = 'Results', children, ...props }) {
209
+ const dock = useDock('CommandDock.Results');
210
+ const rows = Array.isArray(children) ? children.flat().filter(Boolean).length : children ? 1 : 0;
211
+ // Closed, it folds away whatever it holds.
212
+ const filled = dock.open && (rows > 0 || dock.busy);
213
+ const slots = commandDockVariants({ filled });
214
+ return (_jsx("div", { className: slots.results(), children: _jsx("div", { className: slots.resultsClip(), children: _jsx("div", { role: "list", "aria-label": label, "aria-live": "polite", className: slots.resultsInner({ className }), ...props, children: children }) }) }));
215
+ }
216
+ CommandDockResults.displayName = 'CommandDock.Results';
217
+ /** One row of the answer. It slides in when it is added. */
218
+ function CommandDockRow({ className, onSelect, children, ...props }) {
219
+ const slots = commandDockVariants({ interactive: onSelect !== undefined });
220
+ return (_jsx("div", { role: "listitem", ...props, children: onSelect ? (_jsx("button", { type: "button", onClick: onSelect, className: slots.row({ className: ['w-full text-start', className] }), children: children })) : (_jsx("div", { className: slots.row({ className }), children: children })) }));
221
+ }
222
+ CommandDockRow.displayName = 'CommandDock.Row';
223
+ /** Shown under the rows while `busy`: three dots, and words if you give it some. */
224
+ function CommandDockStatus({ className, children = 'Working', ...props }) {
225
+ const slots = commandDockVariants();
226
+ return (_jsxs("div", { role: "status", className: slots.status({ className }), ...props, children: [_jsxs("span", { "aria-hidden": "true", className: slots.dots(), children: [_jsx("span", { className: slots.dot() }), _jsx("span", { className: slots.dot({ className: '[animation-delay:150ms]' }) }), _jsx("span", { className: slots.dot({ className: '[animation-delay:300ms]' }) })] }), children] }));
227
+ }
228
+ CommandDockStatus.displayName = 'CommandDock.Status';
229
+ export const CommandDock = Object.assign(CommandDockRoot, {
230
+ Trigger: CommandDockTrigger,
231
+ Panel: CommandDockPanel,
232
+ Input: CommandDockInput,
233
+ Results: CommandDockResults,
234
+ Row: CommandDockRow,
235
+ Status: CommandDockStatus,
236
+ });
237
+ /* By name as well, for a Server Component, which cannot dot into a client
238
+ module. */
239
+ export { CommandDockTrigger, CommandDockPanel, CommandDockInput, CommandDockResults, CommandDockRow, CommandDockStatus, };
@@ -0,0 +1,87 @@
1
+ /**
2
+ * DayGrid — a run of days as a field of dots, each sized by how much happened
3
+ * on it.
4
+ *
5
+ * ```tsx
6
+ * <DayGrid cells={days} columns={7} />
7
+ * <DayGrid.Legend />
8
+ * ```
9
+ *
10
+ * ## Size carries the amount, not only shade
11
+ *
12
+ * A day is level 0 to 4, and its dot grows with the level: a speck for a day
13
+ * with nothing on it, a full disc for the busiest. The period reads the way a
14
+ * halftone print does, heavy where a lot happened and thin where little did,
15
+ * and it still reads for someone who cannot tell two shades of one colour
16
+ * apart.
17
+ *
18
+ * ## The pointer is a lens
19
+ *
20
+ * Dots swell as the pointer comes near and settle as it leaves, so the day
21
+ * under the pointer is the largest thing on the grid and its neighbours lean
22
+ * towards it. The day's `label` is shown in a tag above it. The lens changes
23
+ * nothing about what the grid says; it is there to make one day easy to
24
+ * single out among forty.
25
+ *
26
+ * ## Days you can ask about
27
+ *
28
+ * Pass `onValueChange` and each day becomes a button. The grid then behaves as
29
+ * one control to the keyboard: a single Tab stop, with the arrow keys moving
30
+ * between days, so forty days are not forty presses of Tab.
31
+ */
32
+ import { type ComponentProps, type ReactNode } from 'react';
33
+ /** How much happened on a day: 0 is nothing, 4 is the most. */
34
+ export type DayLevel = 0 | 1 | 2 | 3 | 4;
35
+ /** Which of the theme's roles the dots are drawn in. */
36
+ export type DayGridColor = 'primary' | 'info' | 'success' | 'warning' | 'destructive';
37
+ export interface DayCell {
38
+ level: DayLevel;
39
+ /** What the day is, for the tag and a screen reader: "14 May, 6 commits". */
40
+ label?: string;
41
+ }
42
+ export interface DayGridProps extends Omit<ComponentProps<'div'>, 'children' | 'onChange' | 'color'> {
43
+ /** The days, in order. They fill the grid row by row. */
44
+ cells: DayCell[];
45
+ /** Cells per row. Seven makes each row a week. */
46
+ columns?: number;
47
+ /**
48
+ * Cells per column. Giving this fills the grid column by column, and
49
+ * `columns` is ignored. Seven makes each column a week, which lays a long
50
+ * period out wide and short.
51
+ */
52
+ rows?: number;
53
+ /** Which of the theme's roles the dots are drawn in. */
54
+ color?: DayGridColor;
55
+ /** The index of the selected day, when the caller holds it. */
56
+ value?: number | null;
57
+ /**
58
+ * Called with the index of a day when it is pressed. Passing this makes the
59
+ * days buttons; without it the grid is a picture.
60
+ */
61
+ onValueChange?: (index: number) => void;
62
+ /** Set to `false` for dots that hold still under the pointer. */
63
+ lens?: boolean;
64
+ /** What a screen reader calls the grid. */
65
+ label?: string;
66
+ }
67
+ declare function DayGridRoot({ cells, columns, rows: perColumn, color, value, onValueChange, lens, label, className, style, onPointerMove, onPointerLeave, ...props }: DayGridProps): import("react").JSX.Element;
68
+ declare namespace DayGridRoot {
69
+ var displayName: string;
70
+ }
71
+ export interface DayGridLegendProps extends Omit<ComponentProps<'div'>, 'children' | 'color'> {
72
+ /** The same role the grid is drawn in. */
73
+ color?: DayGridColor;
74
+ /** The word at the quiet end of the scale. */
75
+ less?: ReactNode;
76
+ /** The word at the busy end of the scale. */
77
+ more?: ReactNode;
78
+ }
79
+ /** The five sizes, quietest to busiest, with a word at each end. */
80
+ declare function DayGridLegend({ color, less, more, className, ...props }: DayGridLegendProps): import("react").JSX.Element;
81
+ declare namespace DayGridLegend {
82
+ var displayName: string;
83
+ }
84
+ export declare const DayGrid: typeof DayGridRoot & {
85
+ Legend: typeof DayGridLegend;
86
+ };
87
+ export { DayGridLegend };
@@ -0,0 +1,251 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ /**
4
+ * DayGrid — a run of days as a field of dots, each sized by how much happened
5
+ * on it.
6
+ *
7
+ * ```tsx
8
+ * <DayGrid cells={days} columns={7} />
9
+ * <DayGrid.Legend />
10
+ * ```
11
+ *
12
+ * ## Size carries the amount, not only shade
13
+ *
14
+ * A day is level 0 to 4, and its dot grows with the level: a speck for a day
15
+ * with nothing on it, a full disc for the busiest. The period reads the way a
16
+ * halftone print does, heavy where a lot happened and thin where little did,
17
+ * and it still reads for someone who cannot tell two shades of one colour
18
+ * apart.
19
+ *
20
+ * ## The pointer is a lens
21
+ *
22
+ * Dots swell as the pointer comes near and settle as it leaves, so the day
23
+ * under the pointer is the largest thing on the grid and its neighbours lean
24
+ * towards it. The day's `label` is shown in a tag above it. The lens changes
25
+ * nothing about what the grid says; it is there to make one day easy to
26
+ * single out among forty.
27
+ *
28
+ * ## Days you can ask about
29
+ *
30
+ * Pass `onValueChange` and each day becomes a button. The grid then behaves as
31
+ * one control to the keyboard: a single Tab stop, with the arrow keys moving
32
+ * between days, so forty days are not forty presses of Tab.
33
+ */
34
+ import { useEffect, useMemo, useRef, useState, } from 'react';
35
+ import { tv } from 'tailwind-variants';
36
+ const dayGridVariants = tv({
37
+ slots: {
38
+ root: 'relative grid w-full gap-1',
39
+ // The cell is the target and keeps its size. Only the dot inside it grows.
40
+ cell: 'relative flex aspect-square w-full items-center justify-center rounded-full outline-none',
41
+ /*
42
+ * One scale made of three parts: how big the level is, how near the
43
+ * pointer is, and whether the grid has arrived yet. Kept as variables so
44
+ * each can change without a class changing.
45
+ */
46
+ dot: 'block size-full rounded-full [scale:calc(var(--pu-size)*(1+var(--pu-lens,0)*0.5)*var(--pu-in,1))] transition-[scale,box-shadow] duration-200 ease-[cubic-bezier(0.34,1.56,0.64,1)] motion-reduce:transition-none',
47
+ tag: 'pointer-events-none absolute z-20 whitespace-nowrap rounded-lg bg-foreground px-2 py-1 text-xs font-medium text-background shadow-lg',
48
+ legend: 'flex items-center gap-1.5 text-xs text-muted-foreground',
49
+ swatch: 'flex size-4 items-center justify-center',
50
+ },
51
+ variants: {
52
+ /*
53
+ * The colour is written to one variable and every dot reads it. The legend
54
+ * is a sibling of the grid, not a child, so it takes the same variant.
55
+ */
56
+ color: {
57
+ primary: { root: '[--pu-color:var(--primary)]', legend: '[--pu-color:var(--primary)]' },
58
+ info: { root: '[--pu-color:var(--info)]', legend: '[--pu-color:var(--info)]' },
59
+ success: { root: '[--pu-color:var(--success)]', legend: '[--pu-color:var(--success)]' },
60
+ warning: { root: '[--pu-color:var(--warning)]', legend: '[--pu-color:var(--warning)]' },
61
+ destructive: {
62
+ root: '[--pu-color:var(--destructive)]',
63
+ legend: '[--pu-color:var(--destructive)]',
64
+ },
65
+ },
66
+ level: {
67
+ 0: { dot: 'bg-muted-foreground/30 [--pu-size:0.2]' },
68
+ 1: { dot: 'bg-[color-mix(in_oklab,var(--pu-color)_45%,transparent)] [--pu-size:0.42]' },
69
+ 2: { dot: 'bg-[color-mix(in_oklab,var(--pu-color)_65%,transparent)] [--pu-size:0.6]' },
70
+ 3: { dot: 'bg-[color-mix(in_oklab,var(--pu-color)_85%,transparent)] [--pu-size:0.8]' },
71
+ // The busiest days carry a little light of their own.
72
+ 4: {
73
+ dot: 'bg-[var(--pu-color)] shadow-[0_0_14px_color-mix(in_oklab,var(--pu-color)_45%,transparent)] [--pu-size:1]',
74
+ },
75
+ },
76
+ interactive: {
77
+ true: {
78
+ cell: 'cursor-pointer focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
79
+ },
80
+ },
81
+ selected: {
82
+ true: { cell: 'ring-2 ring-foreground ring-offset-2 ring-offset-background' },
83
+ },
84
+ },
85
+ });
86
+ const LEVELS = [0, 1, 2, 3, 4];
87
+ /** How far the lens reaches, in cells. */
88
+ const LENS_REACH = 2.4;
89
+ /** Milliseconds between one diagonal of dots arriving and the next. */
90
+ const STAGGER = 22;
91
+ function DayGridRoot({ cells, columns = 7, rows: perColumn, color = 'primary', value = null, onValueChange, lens = true, label = 'Activity', className, style, onPointerMove, onPointerLeave, ...props }) {
92
+ const slots = dayGridVariants({ color });
93
+ const root = useRef(null);
94
+ const nodes = useRef([]);
95
+ const frame = useRef(0);
96
+ const interactive = onValueChange !== undefined;
97
+ // The day whose tag is showing: the one under the pointer, or with focus.
98
+ const [pointed, setPointed] = useState(null);
99
+ // The dots start at nothing and arrive along the diagonals.
100
+ const [arrived, setArrived] = useState('no');
101
+ // Which way the days run: along each row, or down each column.
102
+ const byColumn = perColumn !== undefined;
103
+ const across = byColumn ? Math.ceil(cells.length / Math.max(1, perColumn)) : columns;
104
+ const rows = byColumn ? Math.max(1, perColumn) : Math.ceil(cells.length / columns);
105
+ useEffect(() => {
106
+ const start = requestAnimationFrame(() => setArrived('arriving'));
107
+ // Once the last diagonal is in, the delays come off. Left on, they would
108
+ // hold every dot back from the lens by its own share of the stagger.
109
+ const settle = setTimeout(() => setArrived('yes'), (rows + across) * STAGGER + 450);
110
+ return () => {
111
+ cancelAnimationFrame(start);
112
+ clearTimeout(settle);
113
+ };
114
+ }, [rows, across]);
115
+ useEffect(() => () => cancelAnimationFrame(frame.current), []);
116
+ const summary = useMemo(() => {
117
+ const active = cells.filter((cell) => cell.level > 0).length;
118
+ return `${label}: something on ${active} of ${cells.length} days.`;
119
+ }, [cells, label]);
120
+ const moveLens = (event) => {
121
+ onPointerMove?.(event);
122
+ // A finger covers what a lens would show, and has no hover to end it.
123
+ if (event.pointerType === 'touch')
124
+ return;
125
+ const still = !lens || window.matchMedia('(prefers-reduced-motion: reduce)').matches;
126
+ const { clientX, clientY } = event;
127
+ cancelAnimationFrame(frame.current);
128
+ frame.current = requestAnimationFrame(() => {
129
+ let nearest = null;
130
+ let best = Infinity;
131
+ nodes.current.forEach((node, index) => {
132
+ if (!node)
133
+ return;
134
+ const box = node.getBoundingClientRect();
135
+ const distance = Math.hypot(clientX - (box.left + box.width / 2), clientY - (box.top + box.height / 2));
136
+ if (!still) {
137
+ const near = Math.max(0, 1 - distance / (box.width * LENS_REACH));
138
+ // Eased, so the swell has a soft edge and a round top.
139
+ node.style.setProperty('--pu-lens', (near * near * (3 - 2 * near)).toFixed(3));
140
+ }
141
+ if (distance < best && distance < box.width * 0.75) {
142
+ best = distance;
143
+ nearest = index;
144
+ }
145
+ });
146
+ setPointed(nearest);
147
+ });
148
+ };
149
+ const leave = (event) => {
150
+ onPointerLeave?.(event);
151
+ cancelAnimationFrame(frame.current);
152
+ for (const node of nodes.current)
153
+ node?.style.removeProperty('--pu-lens');
154
+ setPointed(null);
155
+ };
156
+ const layout = byColumn
157
+ ? {
158
+ gridTemplateRows: `repeat(${rows}, auto)`,
159
+ gridAutoFlow: 'column',
160
+ gridAutoColumns: 'minmax(0, 1fr)',
161
+ ...style,
162
+ }
163
+ : { gridTemplateColumns: `repeat(${across}, minmax(0, 1fr))`, ...style };
164
+ /** Where a dot is in its entrance, as the styles that put it there. */
165
+ const dotStyle = (index) => {
166
+ if (arrived === 'yes')
167
+ return undefined;
168
+ const diagonal = byColumn
169
+ ? Math.floor(index / rows) + (index % rows)
170
+ : Math.floor(index / across) + (index % across);
171
+ return {
172
+ ['--pu-in']: arrived === 'no' ? 0 : 1,
173
+ transitionDelay: `${diagonal * STAGGER}ms`,
174
+ transitionDuration: '420ms',
175
+ };
176
+ };
177
+ const tag = (() => {
178
+ if (pointed === null)
179
+ return null;
180
+ const text = cells[pointed]?.label;
181
+ const node = nodes.current[pointed];
182
+ const box = root.current;
183
+ if (!text || !node || !box)
184
+ return null;
185
+ const centre = node.offsetLeft + node.offsetWidth / 2;
186
+ // Hung from whichever side keeps it inside the grid.
187
+ const share = centre / box.offsetWidth;
188
+ const shift = share < 0.25 ? '-12%' : share > 0.75 ? '-88%' : '-50%';
189
+ return (_jsx("span", { "aria-hidden": "true", className: slots.tag(), style: { left: centre, top: node.offsetTop - 6, translate: `${shift} -100%` }, children: text }));
190
+ })();
191
+ if (!interactive) {
192
+ return (_jsxs("div", { ref: root, role: "img", "aria-label": summary, className: slots.root({ className }), style: layout, onPointerMove: moveLens, onPointerLeave: leave, ...props, children: [cells.map((cell, index) => {
193
+ const part = dayGridVariants({ level: cell.level });
194
+ return (_jsx("span", { ref: (node) => {
195
+ nodes.current[index] = node;
196
+ }, className: part.cell(), children: _jsx("span", { className: part.dot(), style: dotStyle(index) }) }, index));
197
+ }), tag] }));
198
+ }
199
+ // One Tab stop for the grid: the selected day, or the first.
200
+ const focusable = value !== null && value >= 0 && value < cells.length ? value : 0;
201
+ const onKeyDown = (event, index) => {
202
+ const rtl = getComputedStyle(event.currentTarget).direction === 'rtl';
203
+ const moves = {
204
+ // One day along a row, or one whole column when the days run downwards.
205
+ ArrowLeft: (rtl ? 1 : -1) * (byColumn ? rows : 1),
206
+ ArrowRight: (rtl ? -1 : 1) * (byColumn ? rows : 1),
207
+ ArrowUp: byColumn ? -1 : -across,
208
+ ArrowDown: byColumn ? 1 : across,
209
+ };
210
+ let next;
211
+ if (event.key in moves)
212
+ next = index + (moves[event.key] ?? 0);
213
+ else if (event.key === 'Home')
214
+ next = 0;
215
+ else if (event.key === 'End')
216
+ next = cells.length - 1;
217
+ if (next === undefined || next < 0 || next >= cells.length)
218
+ return;
219
+ event.preventDefault();
220
+ nodes.current[next]?.focus();
221
+ onValueChange(next);
222
+ };
223
+ return (_jsxs("div", { ref: root, role: "group", "aria-label": summary, className: slots.root({ className }), style: layout, onPointerMove: moveLens, onPointerLeave: leave, ...props, children: [cells.map((cell, index) => {
224
+ const part = dayGridVariants({
225
+ level: cell.level,
226
+ interactive: true,
227
+ selected: value === index,
228
+ });
229
+ return (_jsx("button", { ref: (node) => {
230
+ nodes.current[index] = node;
231
+ }, type: "button", "aria-label": cell.label ?? `Day ${index + 1}, level ${cell.level} of 4`, "aria-pressed": value === index, tabIndex: index === focusable ? 0 : -1, onClick: () => onValueChange(index), onKeyDown: (event) => onKeyDown(event, index), onFocus: (event) => {
232
+ // Only for focus that came from the keyboard: a click focuses
233
+ // too, and the pointer is already showing that day's tag.
234
+ if (event.currentTarget.matches(':focus-visible'))
235
+ setPointed(index);
236
+ }, onBlur: () => setPointed((current) => (current === index ? null : current)), className: part.cell(), children: _jsx("span", { className: part.dot(), style: dotStyle(index) }) }, index));
237
+ }), tag] }));
238
+ }
239
+ DayGridRoot.displayName = 'DayGrid';
240
+ /** The five sizes, quietest to busiest, with a word at each end. */
241
+ function DayGridLegend({ color = 'primary', less = 'Less', more = 'More', className, ...props }) {
242
+ const slots = dayGridVariants({ color });
243
+ return (_jsxs("div", { className: slots.legend({ className }), ...props, children: [_jsx("span", { children: less }), LEVELS.map((level) => (_jsx("span", { "aria-hidden": "true", className: slots.swatch(), children: _jsx("span", { className: dayGridVariants({ level }).dot({ className: 'shadow-none' }) }) }, level))), _jsx("span", { children: more })] }));
244
+ }
245
+ DayGridLegend.displayName = 'DayGrid.Legend';
246
+ export const DayGrid = Object.assign(DayGridRoot, {
247
+ Legend: DayGridLegend,
248
+ });
249
+ /* By name as well, for a Server Component, which cannot dot into a client
250
+ module. */
251
+ export { DayGridLegend };