panelui-web 0.6.0 → 0.8.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.
@@ -0,0 +1,327 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ /**
4
+ * AlertDialog — a question that has to be answered before anything else on
5
+ * the page can be done: delete this, discard that, sign out everywhere.
6
+ *
7
+ * ```tsx
8
+ * <AlertDialog>
9
+ * <AlertDialog.Trigger render={<Button variant="destructive" />}>Delete project</AlertDialog.Trigger>
10
+ * <AlertDialog.Content tone="destructive">
11
+ * <AlertDialog.Title>Delete Orchard?</AlertDialog.Title>
12
+ * <AlertDialog.Description>Its 14 boards and their history go with it.</AlertDialog.Description>
13
+ * <AlertDialog.Footer>
14
+ * <AlertDialog.Cancel />
15
+ * <AlertDialog.Action onClick={remove}>Delete project</AlertDialog.Action>
16
+ * </AlertDialog.Footer>
17
+ * </AlertDialog.Content>
18
+ * </AlertDialog>
19
+ * ```
20
+ *
21
+ * ## A press outside is Cancel
22
+ *
23
+ * A press anywhere outside the dialog, Escape and `AlertDialog.Cancel` all
24
+ * close it without doing anything; an action closes it once it has done its
25
+ * work. `dismissible={false}` takes the outside press away, for a question
26
+ * that should not be waved off by a stray click. While an action is running,
27
+ * nothing closes it.
28
+ *
29
+ * ## The question, then the answers
30
+ *
31
+ * It is drawn as a panel in a darker band, the way Feedback is: the panel
32
+ * holds the question, and `AlertDialog.Footer` moves out of it into the band
33
+ * underneath, where the answers sit side by side at equal widths. Neither
34
+ * answer is made to look like the default by being bigger.
35
+ *
36
+ * ## Actions that take time, or nerve
37
+ *
38
+ * An action whose `onClick` returns a promise shows a spinner and keeps the
39
+ * dialog open, and unclosable, until the promise settles: closed if it
40
+ * resolved, still open if it failed, so the failure can be shown in it.
41
+ * `hold` makes an action one that must be pressed and held while a fill
42
+ * runs across it, for a step that cannot be undone.
43
+ */
44
+ // The dialog primitive rather than the alert-dialog one, which refuses every
45
+ // press outside; the role is still `alertdialog`, set on the popup.
46
+ import { Dialog as Primitive } from '@base-ui/react/dialog';
47
+ import { Children, createContext, isValidElement, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, } from 'react';
48
+ import { tv } from 'tailwind-variants';
49
+ import { Button } from '../button/index.js';
50
+ const alertDialogVariants = tv({
51
+ slots: {
52
+ backdrop: 'fixed inset-0 z-50 bg-black/40 transition-opacity duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none',
53
+ viewport: 'fixed inset-0 z-50 grid place-items-center overflow-y-auto p-4',
54
+ /*
55
+ * The band is the popover surface with black mixed in, so it is the
56
+ * darker of the two in a light theme and a dark one alike. 38 and 30 are
57
+ * one corner: the panel's radius is the band's less the 8px between them.
58
+ */
59
+ shell: 'relative flex w-full flex-col rounded-[38px] bg-[color-mix(in_oklab,var(--popover),black_5%)] p-2 text-popover-foreground shadow-xl outline-none ring-1 ring-foreground/[0.06] dark:bg-[color-mix(in_oklab,var(--popover),black_14%)]',
60
+ popup: 'transition-[opacity,scale,translate] duration-200 ease-[cubic-bezier(0.2,0,0,1)] data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:translate-y-2 data-starting-style:scale-95 data-starting-style:opacity-0 motion-reduce:transition-none',
61
+ panel: 'flex flex-col gap-2 rounded-[30px] bg-popover p-5 shadow-xs',
62
+ media: [
63
+ 'mb-2 flex size-12 shrink-0 items-center justify-center rounded-full',
64
+ 'bg-[color-mix(in_oklab,var(--pu-tone)_14%,transparent)] text-[var(--pu-tone)] [&_svg:not([class*=size-])]:size-6',
65
+ ],
66
+ title: 'text-[19px] font-semibold leading-6 tracking-[-0.01em]',
67
+ description: 'text-[15px] leading-[22px] text-muted-foreground',
68
+ // Held in from the band's edge further than the panel is, so the row of
69
+ // answers does not read as a third edge of the dialog.
70
+ footer: 'flex items-center gap-3 px-[18px] pb-2.5 pt-4',
71
+ action: 'relative isolate h-11 min-w-0 flex-1 overflow-hidden rounded-full px-4 text-[15px]',
72
+ // Drawn under the label and over the button's own fill, from the start edge.
73
+ fill: 'pointer-events-none absolute inset-0 -z-10 origin-left scale-x-0 bg-[color-mix(in_oklab,currentColor_24%,transparent)] rtl:origin-right',
74
+ },
75
+ variants: {
76
+ palette: {
77
+ neutral: { shell: '[--pu-tone:var(--muted-foreground)]' },
78
+ info: { shell: '[--pu-tone:var(--info)]' },
79
+ success: { shell: '[--pu-tone:var(--success)]' },
80
+ warning: { shell: '[--pu-tone:var(--warning)]' },
81
+ destructive: { shell: '[--pu-tone:var(--destructive)]' },
82
+ },
83
+ size: {
84
+ default: { shell: 'max-w-md' },
85
+ // Narrow, so the question is centred over the two answers.
86
+ sm: { shell: 'max-w-xs', panel: 'items-center text-center' },
87
+ },
88
+ blur: {
89
+ true: { backdrop: 'bg-black/20 backdrop-blur-md' },
90
+ },
91
+ /** How much an answer asks to be pressed: filled, or a tint of its colour. */
92
+ weight: {
93
+ solid: {},
94
+ soft: {},
95
+ },
96
+ actionTone: {
97
+ primary: {},
98
+ destructive: {},
99
+ neutral: {},
100
+ },
101
+ },
102
+ compoundVariants: [
103
+ {
104
+ weight: 'soft',
105
+ actionTone: 'primary',
106
+ className: { action: 'bg-foreground/10 text-foreground hover:not-disabled:bg-foreground/15 hover:not-disabled:text-foreground' },
107
+ },
108
+ {
109
+ weight: 'soft',
110
+ actionTone: 'neutral',
111
+ className: {
112
+ action: 'bg-foreground/10 text-muted-foreground hover:not-disabled:bg-foreground/15 hover:not-disabled:text-foreground',
113
+ },
114
+ },
115
+ {
116
+ weight: 'soft',
117
+ actionTone: 'destructive',
118
+ className: {
119
+ action: 'bg-[color-mix(in_oklab,var(--destructive)_12%,transparent)] text-destructive hover:not-disabled:bg-[color-mix(in_oklab,var(--destructive)_18%,transparent)] hover:not-disabled:text-destructive',
120
+ },
121
+ },
122
+ ],
123
+ defaultVariants: { palette: 'neutral', size: 'default' },
124
+ });
125
+ const AlertDialogContext = createContext(null);
126
+ function useAlertDialog(part) {
127
+ const context = useContext(AlertDialogContext);
128
+ if (!context)
129
+ throw new Error(`${part} must be used inside an <AlertDialog>.`);
130
+ return context;
131
+ }
132
+ function AlertDialogRoot({ children, open, defaultOpen = false, onOpenChange, dismissible = true, }) {
133
+ const [internalOpen, setInternalOpen] = useState(defaultOpen);
134
+ const resolvedOpen = open ?? internalOpen;
135
+ const [busy, setBusy] = useState(false);
136
+ const setOpen = useCallback((next) => {
137
+ if (open === undefined)
138
+ setInternalOpen(next);
139
+ onOpenChange?.(next);
140
+ }, [open, onOpenChange]);
141
+ const context = useMemo(() => ({ open: resolvedOpen, setOpen, busy, setBusy, tone: 'neutral', inline: false }), [resolvedOpen, setOpen, busy]);
142
+ return (_jsx(AlertDialogContext.Provider, { value: context, children: _jsx(Primitive.Root, { open: resolvedOpen, disablePointerDismissal: !dismissible, onOpenChange: (next) => {
143
+ // While an action runs, the answer is already being acted on.
144
+ if (!next && busy)
145
+ return;
146
+ setOpen(next);
147
+ }, children: children }) }));
148
+ }
149
+ AlertDialogRoot.displayName = 'AlertDialog';
150
+ /** What opens the dialog. A plain button unless `render` supplies one. */
151
+ function AlertDialogTrigger({ render, children, ...props }) {
152
+ return (_jsx(Primitive.Trigger, { render: render, ...props, children: children }));
153
+ }
154
+ AlertDialogTrigger.displayName = 'AlertDialog.Trigger';
155
+ const isFooter = (child) => isValidElement(child) && child.type === AlertDialogFooter;
156
+ /** The dialog: the panel with the question, in a band that holds the answers. */
157
+ function AlertDialogContent({ tone = 'neutral', size = 'default', blur = false, inline = false, className, children, ...props }) {
158
+ const dialog = useAlertDialog('AlertDialog.Content');
159
+ const slots = alertDialogVariants({ palette: tone, size, blur });
160
+ const context = useMemo(() => ({ ...dialog, tone, inline }), [dialog, tone, inline]);
161
+ // The footer leaves the panel for the band under it.
162
+ const all = Children.toArray(children);
163
+ const footer = all.filter(isFooter);
164
+ const inside = (_jsxs(AlertDialogContext.Provider, { value: context, children: [_jsx("div", { "data-slot": "alert-dialog-panel", className: slots.panel(), children: all.filter((child) => !isFooter(child)) }), footer] }));
165
+ if (inline) {
166
+ return (_jsx("div", { "data-slot": "alert-dialog-content", "data-tone": tone, className: slots.shell({ className }), ...props, children: inside }));
167
+ }
168
+ return (_jsxs(Primitive.Portal, { children: [_jsx(Primitive.Backdrop, { className: slots.backdrop() }), _jsx(Primitive.Viewport, { className: slots.viewport(), children: _jsx(Primitive.Popup, { role: "alertdialog", "data-slot": "alert-dialog-content", "data-tone": tone, className: slots.shell({ className: [slots.popup(), className] }), ...props, children: inside }) })] }));
169
+ }
170
+ AlertDialogContent.displayName = 'AlertDialog.Content';
171
+ /** A glyph in a disc of the dialog's tone, above the title. */
172
+ function AlertDialogMedia({ className, ...props }) {
173
+ return _jsx("div", { "data-slot": "alert-dialog-media", "aria-hidden": "true", className: alertDialogVariants().media({ className }), ...props });
174
+ }
175
+ AlertDialogMedia.displayName = 'AlertDialog.Media';
176
+ /** The question. It is what the dialog is announced as. */
177
+ function AlertDialogTitle({ className, ...props }) {
178
+ const { inline } = useAlertDialog('AlertDialog.Title');
179
+ const slots = alertDialogVariants();
180
+ if (inline)
181
+ return _jsx("h2", { "data-slot": "alert-dialog-title", className: slots.title({ className }), ...props });
182
+ return _jsx(Primitive.Title, { "data-slot": "alert-dialog-title", className: slots.title({ className }), ...props });
183
+ }
184
+ AlertDialogTitle.displayName = 'AlertDialog.Title';
185
+ /** What follows from each answer, read out after the title. */
186
+ function AlertDialogDescription({ className, ...props }) {
187
+ const { inline } = useAlertDialog('AlertDialog.Description');
188
+ const slots = alertDialogVariants();
189
+ if (inline)
190
+ return _jsx("p", { "data-slot": "alert-dialog-description", className: slots.description({ className }), ...props });
191
+ return _jsx(Primitive.Description, { "data-slot": "alert-dialog-description", className: slots.description({ className }), ...props });
192
+ }
193
+ AlertDialogDescription.displayName = 'AlertDialog.Description';
194
+ /**
195
+ * The answers, side by side at equal widths in the band under the panel.
196
+ * Keep it a direct child of `AlertDialog.Content`, so the band can find it.
197
+ */
198
+ function AlertDialogFooter({ className, ...props }) {
199
+ return _jsx("div", { "data-slot": "alert-dialog-footer", className: alertDialogVariants().footer({ className }), ...props });
200
+ }
201
+ AlertDialogFooter.displayName = 'AlertDialog.Footer';
202
+ /** Closes the dialog without doing anything. It reads "Cancel" unless given children. */
203
+ function AlertDialogCancel({ className, children = 'Cancel', disabled, ...props }) {
204
+ const dialog = useAlertDialog('AlertDialog.Cancel');
205
+ const slots = alertDialogVariants({ weight: 'soft', actionTone: 'neutral' });
206
+ const button = (_jsx(Button, { variant: "ghost", "data-slot": "alert-dialog-cancel", disabled: disabled || dialog.busy, className: slots.action({ className }), ...props }));
207
+ if (dialog.inline)
208
+ return _jsx(Button, { ...button.props, children: children });
209
+ return _jsx(Primitive.Close, { render: button, children: children });
210
+ }
211
+ AlertDialogCancel.displayName = 'AlertDialog.Cancel';
212
+ /** Holding starts on a press and ends on a release; a click on its own does nothing. */
213
+ function useHold(duration, onDone) {
214
+ const [holding, setHolding] = useState(false);
215
+ const timer = useRef(undefined);
216
+ // When the last press or key went down, so a click that no press preceded,
217
+ // as a screen reader sends, can be told apart.
218
+ const pressedAt = useRef(0);
219
+ const done = useRef(onDone);
220
+ done.current = onDone;
221
+ const stop = useCallback(() => {
222
+ window.clearTimeout(timer.current);
223
+ setHolding(false);
224
+ }, []);
225
+ const start = useCallback(() => {
226
+ if (duration === undefined)
227
+ return;
228
+ pressedAt.current = Date.now();
229
+ window.clearTimeout(timer.current);
230
+ setHolding(true);
231
+ timer.current = window.setTimeout(() => {
232
+ setHolding(false);
233
+ done.current();
234
+ }, duration);
235
+ }, [duration]);
236
+ useEffect(() => () => window.clearTimeout(timer.current), []);
237
+ return {
238
+ holding,
239
+ pressed: () => Date.now() - pressedAt.current < (duration ?? 0) + 1000,
240
+ handlers: duration === undefined
241
+ ? {}
242
+ : {
243
+ onPointerDown: (event) => {
244
+ if (event.button === 0)
245
+ start();
246
+ },
247
+ onPointerUp: stop,
248
+ onPointerLeave: stop,
249
+ onPointerCancel: stop,
250
+ onKeyDown: (event) => {
251
+ if ((event.key === ' ' || event.key === 'Enter') && !event.repeat) {
252
+ // Enter would click at once; the hold decides instead.
253
+ event.preventDefault();
254
+ start();
255
+ }
256
+ },
257
+ onKeyUp: (event) => {
258
+ if (event.key === ' ' || event.key === 'Enter') {
259
+ event.preventDefault();
260
+ stop();
261
+ }
262
+ },
263
+ onContextMenu: (event) => event.preventDefault(),
264
+ },
265
+ };
266
+ }
267
+ /** Does what the dialog asks about, then closes it. */
268
+ function AlertDialogAction({ onClick, tone, variant = 'solid', hold, loading, disabled, className, children, ...props }) {
269
+ const dialog = useAlertDialog('AlertDialog.Action');
270
+ const resolvedTone = tone ?? (dialog.tone === 'destructive' ? 'destructive' : 'primary');
271
+ const slots = alertDialogVariants({ weight: variant, actionTone: resolvedTone });
272
+ const [pending, setPending] = useState(false);
273
+ const hintId = useId();
274
+ const run = (event) => {
275
+ const result = onClick?.(event);
276
+ if (event.defaultPrevented)
277
+ return;
278
+ if (result && typeof result.then === 'function') {
279
+ setPending(true);
280
+ dialog.setBusy(true);
281
+ result.then(() => {
282
+ setPending(false);
283
+ dialog.setBusy(false);
284
+ dialog.setOpen(false);
285
+ }, () => {
286
+ setPending(false);
287
+ dialog.setBusy(false);
288
+ });
289
+ return;
290
+ }
291
+ dialog.setOpen(false);
292
+ };
293
+ // A hold that completes acts as a click on the button would.
294
+ const button = useRef(null);
295
+ const confirmed = useRef(false);
296
+ const { holding, pressed, handlers } = useHold(hold, () => {
297
+ confirmed.current = true;
298
+ button.current?.click();
299
+ });
300
+ return (_jsxs(Button, { ref: button, "data-slot": "alert-dialog-action", "data-holding": holding ? '' : undefined, variant: variant === 'solid' ? (resolvedTone === 'destructive' ? 'destructive' : 'default') : 'ghost', loading: loading || pending, disabled: disabled || (dialog.busy && !pending), "aria-describedby": hold !== undefined ? hintId : undefined, className: slots.action({ className }), ...handlers, onClick: (event) => {
301
+ if (hold !== undefined) {
302
+ const wasHeld = confirmed.current;
303
+ confirmed.current = false;
304
+ // A click no press or key preceded comes from assistive technology,
305
+ // which cannot hold; it confirms. Any other click must be a hold.
306
+ if (!wasHeld && pressed())
307
+ return;
308
+ }
309
+ run(event);
310
+ }, ...props, children: [hold !== undefined ? (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", "data-slot": "alert-dialog-action-fill", className: slots.fill({ className: holding ? 'scale-x-100' : undefined }), style: {
311
+ transitionProperty: 'scale, transform',
312
+ transitionDuration: holding ? `${hold}ms` : '250ms',
313
+ transitionTimingFunction: holding ? 'linear' : 'cubic-bezier(0.2,0,0,1)',
314
+ } }), _jsx("span", { id: hintId, className: "sr-only", children: "Press and hold to confirm" })] })) : null, children] }));
315
+ }
316
+ AlertDialogAction.displayName = 'AlertDialog.Action';
317
+ export const AlertDialog = Object.assign(AlertDialogRoot, {
318
+ Trigger: AlertDialogTrigger,
319
+ Content: AlertDialogContent,
320
+ Media: AlertDialogMedia,
321
+ Title: AlertDialogTitle,
322
+ Description: AlertDialogDescription,
323
+ Footer: AlertDialogFooter,
324
+ Cancel: AlertDialogCancel,
325
+ Action: AlertDialogAction,
326
+ });
327
+ export { AlertDialogTrigger, AlertDialogContent, AlertDialogMedia, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction, };
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Canvas — a surface for other components to sit on: a field of dots, a
3
+ * grid or crosses, with a light that can follow the pointer across it.
4
+ *
5
+ * ```tsx
6
+ * <Canvas className="h-96">
7
+ * <Canvas.Pattern variant="dots" />
8
+ * <Canvas.Spotlight />
9
+ * <Canvas.Content>…</Canvas.Content>
10
+ * </Canvas>
11
+ * ```
12
+ *
13
+ * ## Layers on one surface
14
+ *
15
+ * A canvas is a stack of layers. `Canvas.Pattern` draws marks across the
16
+ * whole surface, several patterns stack, `Canvas.Spotlight` lights them near
17
+ * the pointer, and `Canvas.Content` is what sits on top. Every layer is
18
+ * drawn from the same `viewport`, so a layer added later, such as ink drawn
19
+ * with the pointer, lines up with the pattern without measuring it.
20
+ *
21
+ * ## It moves with what is on it
22
+ *
23
+ * `viewport` takes the `{ x, y, zoom }` a Flow reports from
24
+ * `onViewportChange`, so the pattern pans and scales with the graph on top
25
+ * of it instead of staying behind as the graph slides over it.
26
+ *
27
+ * ## The light follows the hand
28
+ *
29
+ * The spotlight is a second copy of the pattern in the foreground colour,
30
+ * shown through a round window centred on the pointer that fades from the
31
+ * middle to its edge. It follows the pointer without easing, from events on
32
+ * the canvas itself, so it works over whatever is in the content. It is
33
+ * written straight to the element, once a frame, without rendering, and it
34
+ * fades out when the pointer leaves. On a touch screen it shows while a
35
+ * finger is down.
36
+ */
37
+ import { type ComponentProps } from 'react';
38
+ /** Where the canvas is looking: the same shape a Flow reports. */
39
+ export interface CanvasViewport {
40
+ x: number;
41
+ y: number;
42
+ zoom: number;
43
+ }
44
+ /** The marks a pattern draws. */
45
+ export type CanvasPatternVariant = 'dots' | 'grid' | 'cross';
46
+ /** How a pattern fades out: not at all, towards every edge, or towards the bottom. */
47
+ export type CanvasFade = 'none' | 'edges' | 'bottom';
48
+ export interface CanvasProps extends ComponentProps<'div'> {
49
+ /**
50
+ * Where the canvas is looking, as `{ x, y, zoom }`. Pass what a Flow reports
51
+ * from `onViewportChange`, and the pattern pans and scales with the graph.
52
+ * Default `{ x: 0, y: 0, zoom: 1 }`.
53
+ */
54
+ viewport?: CanvasViewport;
55
+ }
56
+ declare function CanvasRoot({ viewport, className, children, ...props }: CanvasProps): import("react").JSX.Element;
57
+ declare namespace CanvasRoot {
58
+ var displayName: string;
59
+ }
60
+ export interface CanvasPatternProps extends Omit<ComponentProps<'svg'>, 'children'> {
61
+ /** Dots, a grid of lines, or small crosses. Default `dots`. */
62
+ variant?: CanvasPatternVariant;
63
+ /** The distance from one mark to the next, in pixels at a zoom of 1. Default `12`, or `24` for a grid. */
64
+ gap?: number;
65
+ /** How big a mark is: a dot's radius, a line's width, half a cross's arm. Default `0.75`, `1` or `3`. */
66
+ size?: number;
67
+ /** Draws every this-many-th line or mark stronger, as on graph paper. */
68
+ major?: number;
69
+ /** Fades the pattern out towards every edge or towards the bottom. Default `none`. */
70
+ fade?: CanvasFade;
71
+ /**
72
+ * How strongly the marks show at rest, as a share of the foreground
73
+ * colour. Default `28%` for dots and crosses, `10%` for a grid.
74
+ */
75
+ strength?: string;
76
+ }
77
+ /** A pattern across the whole canvas, which pans and scales with its viewport. */
78
+ declare function CanvasPattern({ variant, gap, size, major, fade, strength, className, style, ...props }: CanvasPatternProps): import("react").JSX.Element;
79
+ declare namespace CanvasPattern {
80
+ var displayName: string;
81
+ }
82
+ export interface CanvasSpotlightProps extends Omit<ComponentProps<'div'>, 'children'> {
83
+ /** How far the light reaches from its centre, in pixels. Default `100`. */
84
+ radius?: number;
85
+ /**
86
+ * Holds the light at a point, in pixels from the canvas's top left corner,
87
+ * instead of following the pointer.
88
+ */
89
+ position?: {
90
+ x: number;
91
+ y: number;
92
+ };
93
+ }
94
+ /**
95
+ * Lights the patterns near the pointer: the marks inside the circle brighten
96
+ * towards the foreground colour, most at the centre.
97
+ */
98
+ declare function CanvasSpotlight({ radius, position, className, style, ...props }: CanvasSpotlightProps): import("react").JSX.Element;
99
+ declare namespace CanvasSpotlight {
100
+ var displayName: string;
101
+ }
102
+ export interface CanvasContentProps extends ComponentProps<'div'> {
103
+ }
104
+ /** What sits on the canvas, above its patterns and its light. */
105
+ declare function CanvasContent({ className, ...props }: CanvasContentProps): import("react").JSX.Element;
106
+ declare namespace CanvasContent {
107
+ var displayName: string;
108
+ }
109
+ export declare const Canvas: typeof CanvasRoot & {
110
+ Pattern: typeof CanvasPattern;
111
+ Spotlight: typeof CanvasSpotlight;
112
+ Content: typeof CanvasContent;
113
+ };
114
+ export { CanvasPattern, CanvasSpotlight, CanvasContent };
@@ -0,0 +1,210 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ /**
4
+ * Canvas — a surface for other components to sit on: a field of dots, a
5
+ * grid or crosses, with a light that can follow the pointer across it.
6
+ *
7
+ * ```tsx
8
+ * <Canvas className="h-96">
9
+ * <Canvas.Pattern variant="dots" />
10
+ * <Canvas.Spotlight />
11
+ * <Canvas.Content>…</Canvas.Content>
12
+ * </Canvas>
13
+ * ```
14
+ *
15
+ * ## Layers on one surface
16
+ *
17
+ * A canvas is a stack of layers. `Canvas.Pattern` draws marks across the
18
+ * whole surface, several patterns stack, `Canvas.Spotlight` lights them near
19
+ * the pointer, and `Canvas.Content` is what sits on top. Every layer is
20
+ * drawn from the same `viewport`, so a layer added later, such as ink drawn
21
+ * with the pointer, lines up with the pattern without measuring it.
22
+ *
23
+ * ## It moves with what is on it
24
+ *
25
+ * `viewport` takes the `{ x, y, zoom }` a Flow reports from
26
+ * `onViewportChange`, so the pattern pans and scales with the graph on top
27
+ * of it instead of staying behind as the graph slides over it.
28
+ *
29
+ * ## The light follows the hand
30
+ *
31
+ * The spotlight is a second copy of the pattern in the foreground colour,
32
+ * shown through a round window centred on the pointer that fades from the
33
+ * middle to its edge. It follows the pointer without easing, from events on
34
+ * the canvas itself, so it works over whatever is in the content. It is
35
+ * written straight to the element, once a frame, without rendering, and it
36
+ * fades out when the pointer leaves. On a touch screen it shows while a
37
+ * finger is down.
38
+ */
39
+ import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, } from 'react';
40
+ import { tv } from 'tailwind-variants';
41
+ const canvasVariants = tv({
42
+ slots: {
43
+ root: 'relative isolate overflow-hidden bg-background text-foreground',
44
+ layer: 'pointer-events-none absolute inset-0 size-full',
45
+ // The resting marks: the foreground at a fraction of its strength, so the
46
+ // field reads as a texture and not as content.
47
+ pattern: 'text-foreground',
48
+ spotlight: 'text-foreground opacity-0 transition-opacity duration-200 data-on:opacity-100 motion-reduce:transition-none',
49
+ content: 'relative z-10 size-full',
50
+ },
51
+ });
52
+ const CanvasContext = createContext(null);
53
+ function useCanvas(part) {
54
+ const canvas = useContext(CanvasContext);
55
+ if (!canvas)
56
+ throw new Error(`${part} must be used inside a <Canvas>.`);
57
+ return canvas;
58
+ }
59
+ const ORIGIN = { x: 0, y: 0, zoom: 1 };
60
+ const FADES = {
61
+ none: undefined,
62
+ edges: 'radial-gradient(ellipse 75% 70% at 50% 50%, #000 35%, transparent 100%)',
63
+ bottom: 'linear-gradient(to bottom, #000 40%, transparent 95%)',
64
+ };
65
+ function CanvasRoot({ viewport = ORIGIN, className, children, ...props }) {
66
+ const [root, setRoot] = useState(null);
67
+ const [patterns, setPatterns] = useState([]);
68
+ const register = useCallback((key, spec) => {
69
+ setPatterns((current) => [...current.filter((pattern) => pattern.key !== key), { key, ...spec }]);
70
+ return () => setPatterns((current) => current.filter((pattern) => pattern.key !== key));
71
+ }, []);
72
+ const context = useMemo(() => ({ viewport, root, patterns, register }), [viewport, root, patterns, register]);
73
+ return (_jsx(CanvasContext.Provider, { value: context, children: _jsx("div", { ref: setRoot, "data-slot": "canvas", className: canvasVariants().root({ className }), ...props, children: children }) }));
74
+ }
75
+ CanvasRoot.displayName = 'Canvas';
76
+ /** Draws one pattern's marks into an SVG, from the viewport. */
77
+ function Marks({ id, spec, viewport }) {
78
+ const { variant, gap, size, major } = spec;
79
+ const transform = `translate(${viewport.x.toFixed(2)} ${viewport.y.toFixed(2)}) scale(${viewport.zoom.toFixed(4)})`;
80
+ const half = gap / 2;
81
+ const mark = (scale, at) => {
82
+ if (variant === 'dots')
83
+ return _jsx("circle", { cx: at, cy: at, r: size * scale, fill: "currentColor" });
84
+ if (variant === 'cross') {
85
+ const arm = size * scale;
86
+ return (_jsx("path", { d: `M${at - arm} ${at}h${arm * 2}M${at} ${at - arm}v${arm * 2}`, stroke: "currentColor", strokeWidth: scale > 1 ? 1.25 : 1, strokeLinecap: "round", fill: "none" }));
87
+ }
88
+ return null;
89
+ };
90
+ // Every variant marks the same lattice points, half a gap in from each
91
+ // tile's corner, so stacked patterns line up: a cross lands on a grid's
92
+ // crossing, and a major line on a minor one.
93
+ const tile = major ? gap * major : gap;
94
+ return (_jsxs(_Fragment, { children: [_jsxs("defs", { children: [_jsx("pattern", { id: `${id}-minor`, width: gap, height: gap, patternUnits: "userSpaceOnUse", patternTransform: transform, children: variant === 'grid' ? (_jsx("path", { d: `M${half} 0V${gap}M0 ${half}H${gap}`, fill: "none", stroke: "currentColor", strokeWidth: size, opacity: major ? 0.6 : 1 })) : (mark(1, half)) }), major ? (_jsx("pattern", { id: `${id}-major`, width: tile, height: tile, patternUnits: "userSpaceOnUse", patternTransform: transform, children: variant === 'grid' ? (_jsx("path", { d: `M${half} 0V${tile}M0 ${half}H${tile}`, fill: "none", stroke: "currentColor", strokeWidth: size * 1.5 })) : (mark(variant === 'dots' ? 1.8 : 1.6, half)) })) : null] }), _jsx("rect", { width: "100%", height: "100%", fill: `url(#${id}-minor)` }), major ? _jsx("rect", { width: "100%", height: "100%", fill: `url(#${id}-major)` }) : null] }));
95
+ }
96
+ /** A pattern across the whole canvas, which pans and scales with its viewport. */
97
+ function CanvasPattern({ variant = 'dots', gap, size, major, fade = 'none', strength, className, style, ...props }) {
98
+ const canvas = useCanvas('Canvas.Pattern');
99
+ const id = `canvas${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
100
+ const spec = useMemo(() => ({
101
+ variant,
102
+ gap: gap ?? (variant === 'grid' ? 24 : 12),
103
+ size: size ?? (variant === 'dots' ? 0.75 : variant === 'grid' ? 1 : 3),
104
+ major,
105
+ fade,
106
+ }), [variant, gap, size, major, fade]);
107
+ const { register } = canvas;
108
+ // Known to the spotlight, which draws a lit copy of every pattern.
109
+ useLayoutEffect(() => register(id, spec), [register, id, spec]);
110
+ const mask = FADES[fade];
111
+ return (_jsx("svg", { "aria-hidden": "true", "data-slot": "canvas-pattern", className: canvasVariants().layer({ className: [canvasVariants().pattern(), className] }), style: {
112
+ opacity: strength ?? (variant === 'grid' ? '10%' : '28%'),
113
+ maskImage: mask,
114
+ WebkitMaskImage: mask,
115
+ ...style,
116
+ }, ...props, children: _jsx(Marks, { id: id, spec: spec, viewport: canvas.viewport }) }));
117
+ }
118
+ CanvasPattern.displayName = 'Canvas.Pattern';
119
+ /**
120
+ * Lights the patterns near the pointer: the marks inside the circle brighten
121
+ * towards the foreground colour, most at the centre.
122
+ */
123
+ function CanvasSpotlight({ radius = 100, position, className, style, ...props }) {
124
+ const canvas = useCanvas('Canvas.Spotlight');
125
+ const light = useRef(null);
126
+ const prefix = `spot${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
127
+ // Follows the pointer from events on the whole canvas, so content on top
128
+ // does not stop it. Written to the element once a frame, without rendering.
129
+ useEffect(() => {
130
+ const root = canvas.root;
131
+ const node = light.current;
132
+ if (!root || !node || position)
133
+ return;
134
+ let frame = 0;
135
+ let x = 0;
136
+ let y = 0;
137
+ let down = false;
138
+ const write = () => {
139
+ frame = 0;
140
+ node.style.setProperty('--canvas-x', `${x}px`);
141
+ node.style.setProperty('--canvas-y', `${y}px`);
142
+ };
143
+ const place = (event) => {
144
+ const box = root.getBoundingClientRect();
145
+ x = event.clientX - box.left;
146
+ y = event.clientY - box.top;
147
+ if (!frame)
148
+ frame = requestAnimationFrame(write);
149
+ };
150
+ const show = (on) => node.toggleAttribute('data-on', on);
151
+ const move = (event) => {
152
+ place(event);
153
+ if (event.pointerType !== 'touch' || down)
154
+ show(true);
155
+ };
156
+ const press = (event) => {
157
+ down = true;
158
+ place(event);
159
+ show(true);
160
+ };
161
+ const release = (event) => {
162
+ down = false;
163
+ if (event.pointerType === 'touch')
164
+ show(false);
165
+ };
166
+ const leave = () => show(false);
167
+ root.addEventListener('pointermove', move);
168
+ root.addEventListener('pointerdown', press);
169
+ root.addEventListener('pointerup', release);
170
+ root.addEventListener('pointercancel', release);
171
+ root.addEventListener('pointerleave', leave);
172
+ return () => {
173
+ cancelAnimationFrame(frame);
174
+ root.removeEventListener('pointermove', move);
175
+ root.removeEventListener('pointerdown', press);
176
+ root.removeEventListener('pointerup', release);
177
+ root.removeEventListener('pointercancel', release);
178
+ root.removeEventListener('pointerleave', leave);
179
+ };
180
+ }, [canvas.root, position]);
181
+ // A round window on the lit copy: opaque at the pointer, clear at the radius.
182
+ const circle = `radial-gradient(circle var(--canvas-r) at var(--canvas-x, -999px) var(--canvas-y, -999px), #000, transparent)`;
183
+ return (_jsx("div", { ref: light, "aria-hidden": "true", "data-slot": "canvas-spotlight", "data-on": position ? '' : undefined, className: canvasVariants().layer({ className: [canvasVariants().spotlight(), className] }), style: {
184
+ '--canvas-r': `${radius}px`,
185
+ ...(position ? { '--canvas-x': `${position.x}px`, '--canvas-y': `${position.y}px` } : null),
186
+ ...style,
187
+ }, ...props, children: canvas.patterns.map((pattern) => {
188
+ const fade = FADES[pattern.fade];
189
+ // The pattern's own fade still applies inside the light.
190
+ const mask = fade ? `${circle}, ${fade}` : circle;
191
+ return (_jsx("svg", { className: "absolute inset-0 size-full", style: {
192
+ maskImage: mask,
193
+ WebkitMaskImage: mask,
194
+ maskComposite: fade ? 'intersect' : undefined,
195
+ WebkitMaskComposite: fade ? 'source-in' : undefined,
196
+ }, children: _jsx(Marks, { id: `${prefix}${pattern.key}`, spec: pattern, viewport: canvas.viewport }) }, pattern.key));
197
+ }) }));
198
+ }
199
+ CanvasSpotlight.displayName = 'Canvas.Spotlight';
200
+ /** What sits on the canvas, above its patterns and its light. */
201
+ function CanvasContent({ className, ...props }) {
202
+ return _jsx("div", { "data-slot": "canvas-content", className: canvasVariants().content({ className }), ...props });
203
+ }
204
+ CanvasContent.displayName = 'Canvas.Content';
205
+ export const Canvas = Object.assign(CanvasRoot, {
206
+ Pattern: CanvasPattern,
207
+ Spotlight: CanvasSpotlight,
208
+ Content: CanvasContent,
209
+ });
210
+ export { CanvasPattern, CanvasSpotlight, CanvasContent };