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.
- package/dist/components/alert/index.d.ts +138 -0
- package/dist/components/alert/index.js +279 -0
- package/dist/components/alert-dialog/index.d.ts +128 -0
- package/dist/components/alert-dialog/index.js +327 -0
- package/dist/components/canvas/index.d.ts +114 -0
- package/dist/components/canvas/index.js +210 -0
- package/dist/components/dialog/index.d.ts +162 -0
- package/dist/components/dialog/index.js +299 -0
- package/dist/components/prompt/index.d.ts +34 -8
- package/dist/components/prompt/index.js +43 -13
- package/dist/components/sheet/index.d.ts +47 -1
- package/dist/components/sheet/index.js +251 -2
- package/dist/components/soundwave/index.d.ts +195 -0
- package/dist/components/soundwave/index.js +732 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +6 -1
- package/package.json +1 -1
|
@@ -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 };
|