@blurrow/ui 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.
- package/LICENSE +21 -0
- package/README.md +93 -2
- package/dist/cli.js +1477 -0
- package/dist/index.d.ts +631 -0
- package/dist/index.js +5559 -0
- package/dist/index.js.map +1 -0
- package/dist/registry/Accordion.tsx +140 -0
- package/dist/registry/Alert.tsx +92 -0
- package/dist/registry/AuthorCard.tsx +67 -0
- package/dist/registry/Avatar.tsx +116 -0
- package/dist/registry/Badge.tsx +58 -0
- package/dist/registry/Banner.tsx +89 -0
- package/dist/registry/Breadcrumb.tsx +60 -0
- package/dist/registry/Button.tsx +372 -0
- package/dist/registry/ButtonGroup.tsx +82 -0
- package/dist/registry/Card.tsx +241 -0
- package/dist/registry/CardShell.tsx +78 -0
- package/dist/registry/Carousel.tsx +292 -0
- package/dist/registry/Checkbox.tsx +99 -0
- package/dist/registry/CloseButton.tsx +68 -0
- package/dist/registry/CodeBlock.tsx +202 -0
- package/dist/registry/CommandPalette.tsx +349 -0
- package/dist/registry/DataList.tsx +49 -0
- package/dist/registry/Drawer.tsx +164 -0
- package/dist/registry/DropdownMenu.tsx +257 -0
- package/dist/registry/EmptyState.tsx +55 -0
- package/dist/registry/Input.tsx +97 -0
- package/dist/registry/Kbd.tsx +24 -0
- package/dist/registry/Modal.tsx +128 -0
- package/dist/registry/Pagination.tsx +163 -0
- package/dist/registry/Popover.tsx +121 -0
- package/dist/registry/Progress.tsx +66 -0
- package/dist/registry/QuoteCard.tsx +93 -0
- package/dist/registry/Radio.tsx +202 -0
- package/dist/registry/ReactionBar.tsx +101 -0
- package/dist/registry/ReadingProgress.tsx +51 -0
- package/dist/registry/ReadingTime.tsx +35 -0
- package/dist/registry/Select.tsx +290 -0
- package/dist/registry/ShareButtons.tsx +134 -0
- package/dist/registry/Sidebar.tsx +182 -0
- package/dist/registry/Skeleton.tsx +83 -0
- package/dist/registry/SpotlightCard.tsx +106 -0
- package/dist/registry/Stat.tsx +82 -0
- package/dist/registry/StatCard.tsx +256 -0
- package/dist/registry/Steps.tsx +109 -0
- package/dist/registry/Table.tsx +121 -0
- package/dist/registry/TableOfContents.tsx +116 -0
- package/dist/registry/Tabs.tsx +136 -0
- package/dist/registry/TagInput.tsx +191 -0
- package/dist/registry/Textarea.tsx +84 -0
- package/dist/registry/Timeline.tsx +102 -0
- package/dist/registry/Toast.tsx +385 -0
- package/dist/registry/Toggle.tsx +99 -0
- package/dist/registry/Tooltip.tsx +212 -0
- package/dist/registry/styles.css +401 -0
- package/dist/registry/useModalBehavior.ts +77 -0
- package/dist/styles.css +401 -0
- package/package.json +56 -4
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, {
|
|
4
|
+
useState,
|
|
5
|
+
useEffect,
|
|
6
|
+
useCallback,
|
|
7
|
+
createContext,
|
|
8
|
+
useContext,
|
|
9
|
+
useRef,
|
|
10
|
+
useLayoutEffect,
|
|
11
|
+
} from "react";
|
|
12
|
+
import { CloseButton } from "./CloseButton";
|
|
13
|
+
|
|
14
|
+
type ToastVariant = "default" | "success" | "warning" | "error";
|
|
15
|
+
|
|
16
|
+
interface ToastItem {
|
|
17
|
+
id: string;
|
|
18
|
+
title: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
variant: ToastVariant;
|
|
21
|
+
duration: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface ToastContextValue {
|
|
25
|
+
toast: (opts: {
|
|
26
|
+
title: string;
|
|
27
|
+
description?: string;
|
|
28
|
+
variant?: ToastVariant;
|
|
29
|
+
duration?: number;
|
|
30
|
+
}) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const ToastContext = createContext<ToastContextValue>({ toast: () => {} });
|
|
34
|
+
|
|
35
|
+
export function useToast() {
|
|
36
|
+
return useContext(ToastContext);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const variantConfig: Record<
|
|
40
|
+
ToastVariant,
|
|
41
|
+
{ tile: string; icon: React.ReactNode }
|
|
42
|
+
> = {
|
|
43
|
+
default: {
|
|
44
|
+
tile: "var(--face-dark)",
|
|
45
|
+
icon: (
|
|
46
|
+
<svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.75" viewBox="0 0 24 24">
|
|
47
|
+
<circle cx="12" cy="12" r="10" />
|
|
48
|
+
<path strokeLinecap="round" d="M12 8v4m0 4h.01" />
|
|
49
|
+
</svg>
|
|
50
|
+
),
|
|
51
|
+
},
|
|
52
|
+
success: {
|
|
53
|
+
tile: "linear-gradient(180deg, #10b981 0%, #059669 100%)",
|
|
54
|
+
icon: (
|
|
55
|
+
<svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.75" viewBox="0 0 24 24">
|
|
56
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
57
|
+
</svg>
|
|
58
|
+
),
|
|
59
|
+
},
|
|
60
|
+
warning: {
|
|
61
|
+
tile: "linear-gradient(180deg, #fbbf24 0%, #f59e0b 100%)",
|
|
62
|
+
icon: (
|
|
63
|
+
<svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.75" viewBox="0 0 24 24">
|
|
64
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
|
|
65
|
+
</svg>
|
|
66
|
+
),
|
|
67
|
+
},
|
|
68
|
+
error: {
|
|
69
|
+
tile: "linear-gradient(180deg, #f87171 0%, #dc2626 100%)",
|
|
70
|
+
icon: (
|
|
71
|
+
<svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.75" viewBox="0 0 24 24">
|
|
72
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
73
|
+
</svg>
|
|
74
|
+
),
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const TOAST_WIDTH = "min(360px, calc(100vw - 24px))";
|
|
79
|
+
const VISIBLE_STACK = 3;
|
|
80
|
+
const STACK_OFFSET = 10;
|
|
81
|
+
const STACK_SCALE_STEP = 0.05;
|
|
82
|
+
const GAP = 10;
|
|
83
|
+
const ENTER_MS = 320;
|
|
84
|
+
const EXIT_MS = 220;
|
|
85
|
+
const SHIFT_MS = 420;
|
|
86
|
+
|
|
87
|
+
interface ToastCardProps {
|
|
88
|
+
item: ToastItem;
|
|
89
|
+
index: number;
|
|
90
|
+
total: number;
|
|
91
|
+
expanded: boolean;
|
|
92
|
+
expandedOffset: number;
|
|
93
|
+
exiting: boolean;
|
|
94
|
+
entering: boolean;
|
|
95
|
+
paused: boolean;
|
|
96
|
+
onDismiss: (id: string) => void;
|
|
97
|
+
onMeasure: (id: string, height: number) => void;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function ToastCard({
|
|
101
|
+
item,
|
|
102
|
+
index,
|
|
103
|
+
total,
|
|
104
|
+
expanded,
|
|
105
|
+
expandedOffset,
|
|
106
|
+
exiting,
|
|
107
|
+
entering,
|
|
108
|
+
paused,
|
|
109
|
+
onDismiss,
|
|
110
|
+
onMeasure,
|
|
111
|
+
}: ToastCardProps) {
|
|
112
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
113
|
+
const innerRef = useRef<HTMLDivElement>(null);
|
|
114
|
+
const remainingRef = useRef(item.duration);
|
|
115
|
+
const startRef = useRef<number | null>(null);
|
|
116
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
117
|
+
|
|
118
|
+
useLayoutEffect(() => {
|
|
119
|
+
if (!innerRef.current) return;
|
|
120
|
+
const ro = new ResizeObserver(() => {
|
|
121
|
+
if (innerRef.current) onMeasure(item.id, innerRef.current.offsetHeight);
|
|
122
|
+
});
|
|
123
|
+
ro.observe(innerRef.current);
|
|
124
|
+
onMeasure(item.id, innerRef.current.offsetHeight);
|
|
125
|
+
return () => ro.disconnect();
|
|
126
|
+
}, [item.id, onMeasure]);
|
|
127
|
+
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (exiting) {
|
|
130
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (paused) {
|
|
134
|
+
if (timerRef.current) {
|
|
135
|
+
clearTimeout(timerRef.current);
|
|
136
|
+
timerRef.current = null;
|
|
137
|
+
if (startRef.current !== null) {
|
|
138
|
+
remainingRef.current = Math.max(
|
|
139
|
+
0,
|
|
140
|
+
remainingRef.current - (Date.now() - startRef.current),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
startRef.current = Date.now();
|
|
147
|
+
timerRef.current = setTimeout(() => {
|
|
148
|
+
onDismiss(item.id);
|
|
149
|
+
}, remainingRef.current);
|
|
150
|
+
return () => {
|
|
151
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
152
|
+
};
|
|
153
|
+
}, [paused, exiting, item.id, onDismiss]);
|
|
154
|
+
|
|
155
|
+
const { tile, icon } = variantConfig[item.variant];
|
|
156
|
+
|
|
157
|
+
const hidden = !expanded && index >= VISIBLE_STACK;
|
|
158
|
+
|
|
159
|
+
const collapsedTransform = `translateX(-50%) translateY(${
|
|
160
|
+
index * STACK_OFFSET
|
|
161
|
+
}px) scale(${1 - index * STACK_SCALE_STEP})`;
|
|
162
|
+
|
|
163
|
+
const expandedTransform = `translateX(-50%) translateY(${expandedOffset}px) scale(1)`;
|
|
164
|
+
|
|
165
|
+
let transform = expanded ? expandedTransform : collapsedTransform;
|
|
166
|
+
let opacity = hidden ? 0 : 1;
|
|
167
|
+
|
|
168
|
+
if (entering) {
|
|
169
|
+
transform = `translateX(-50%) translateY(-14px) scale(0.96)`;
|
|
170
|
+
opacity = 0;
|
|
171
|
+
}
|
|
172
|
+
if (exiting) {
|
|
173
|
+
transform = expanded
|
|
174
|
+
? `translateX(-50%) translateY(${expandedOffset - 12}px) scale(0.96)`
|
|
175
|
+
: `translateX(-50%) translateY(${
|
|
176
|
+
index * STACK_OFFSET - 12
|
|
177
|
+
}px) scale(${1 - index * STACK_SCALE_STEP - 0.02})`;
|
|
178
|
+
opacity = 0;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<div
|
|
183
|
+
ref={ref}
|
|
184
|
+
className="absolute top-0 left-1/2 pointer-events-auto"
|
|
185
|
+
style={{
|
|
186
|
+
width: TOAST_WIDTH,
|
|
187
|
+
transform,
|
|
188
|
+
opacity,
|
|
189
|
+
zIndex: total - index,
|
|
190
|
+
transformOrigin: "top center",
|
|
191
|
+
transition: `transform ${SHIFT_MS}ms cubic-bezier(0.32,0.72,0,1), opacity ${
|
|
192
|
+
exiting ? EXIT_MS : ENTER_MS
|
|
193
|
+
}ms ease`,
|
|
194
|
+
}}
|
|
195
|
+
>
|
|
196
|
+
<div
|
|
197
|
+
ref={innerRef}
|
|
198
|
+
className="rounded-[18px] p-1.5"
|
|
199
|
+
style={{ background: "var(--panel-bezel)", boxShadow: "var(--overlay-shadow)" }}
|
|
200
|
+
>
|
|
201
|
+
<div
|
|
202
|
+
className="bg-white rounded-[13px] pl-3 pr-2 py-3 flex items-start gap-3"
|
|
203
|
+
style={{ boxShadow: "var(--panel-face-shadow)" }}
|
|
204
|
+
>
|
|
205
|
+
<div
|
|
206
|
+
aria-hidden
|
|
207
|
+
className="shrink-0 w-7 h-7 rounded-[8px] flex items-center justify-center text-white"
|
|
208
|
+
style={{
|
|
209
|
+
background: tile,
|
|
210
|
+
boxShadow:
|
|
211
|
+
"0 0 3px rgba(0,0,0,0.212), 0 1px 2px rgba(0,0,0,0.15), inset 0 2px 3px -2px rgba(255,255,255,0.188)",
|
|
212
|
+
}}
|
|
213
|
+
>
|
|
214
|
+
{icon}
|
|
215
|
+
</div>
|
|
216
|
+
<div className="flex-1 min-w-0 pt-[5px]">
|
|
217
|
+
<p className="text-[13px] font-semibold text-neutral-900 leading-tight tracking-[-0.01em]">
|
|
218
|
+
{item.title}
|
|
219
|
+
</p>
|
|
220
|
+
{item.description && (
|
|
221
|
+
<p className="text-[12.5px] text-neutral-500 mt-1 leading-relaxed">
|
|
222
|
+
{item.description}
|
|
223
|
+
</p>
|
|
224
|
+
)}
|
|
225
|
+
</div>
|
|
226
|
+
<CloseButton size="sm" label="Dismiss notification" onClick={() => onDismiss(item.id)} />
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
|
234
|
+
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
|
235
|
+
const [expanded, setExpanded] = useState(false);
|
|
236
|
+
const [heights, setHeights] = useState<Record<string, number>>({});
|
|
237
|
+
const [exiting, setExiting] = useState<Set<string>>(new Set());
|
|
238
|
+
const [entering, setEntering] = useState<Set<string>>(new Set());
|
|
239
|
+
const counter = useRef(0);
|
|
240
|
+
|
|
241
|
+
const reportHeight = useCallback((id: string, h: number) => {
|
|
242
|
+
setHeights((prev) => (prev[id] === h ? prev : { ...prev, [id]: h }));
|
|
243
|
+
}, []);
|
|
244
|
+
|
|
245
|
+
const dismiss = useCallback((id: string) => {
|
|
246
|
+
setExiting((prev) => {
|
|
247
|
+
if (prev.has(id)) return prev;
|
|
248
|
+
const next = new Set(prev);
|
|
249
|
+
next.add(id);
|
|
250
|
+
return next;
|
|
251
|
+
});
|
|
252
|
+
setTimeout(() => {
|
|
253
|
+
setToasts((prev) => prev.filter((t) => t.id !== id));
|
|
254
|
+
setExiting((prev) => {
|
|
255
|
+
const next = new Set(prev);
|
|
256
|
+
next.delete(id);
|
|
257
|
+
return next;
|
|
258
|
+
});
|
|
259
|
+
setHeights((prev) => {
|
|
260
|
+
const next = { ...prev };
|
|
261
|
+
delete next[id];
|
|
262
|
+
return next;
|
|
263
|
+
});
|
|
264
|
+
}, EXIT_MS);
|
|
265
|
+
}, []);
|
|
266
|
+
|
|
267
|
+
const toast = useCallback(
|
|
268
|
+
(opts: {
|
|
269
|
+
title: string;
|
|
270
|
+
description?: string;
|
|
271
|
+
variant?: ToastVariant;
|
|
272
|
+
duration?: number;
|
|
273
|
+
}) => {
|
|
274
|
+
const id = `toast-${++counter.current}`;
|
|
275
|
+
const item: ToastItem = {
|
|
276
|
+
id,
|
|
277
|
+
title: opts.title,
|
|
278
|
+
description: opts.description,
|
|
279
|
+
variant: opts.variant ?? "default",
|
|
280
|
+
duration: opts.duration ?? 4000,
|
|
281
|
+
};
|
|
282
|
+
setToasts((prev) => [...prev, item]);
|
|
283
|
+
setEntering((prev) => {
|
|
284
|
+
const next = new Set(prev);
|
|
285
|
+
next.add(id);
|
|
286
|
+
return next;
|
|
287
|
+
});
|
|
288
|
+
requestAnimationFrame(() => {
|
|
289
|
+
requestAnimationFrame(() => {
|
|
290
|
+
setEntering((prev) => {
|
|
291
|
+
const next = new Set(prev);
|
|
292
|
+
next.delete(id);
|
|
293
|
+
return next;
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
},
|
|
298
|
+
[],
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
const list = [...toasts].reverse();
|
|
302
|
+
|
|
303
|
+
const expandedOffsets: number[] = [];
|
|
304
|
+
let cum = 0;
|
|
305
|
+
for (let i = 0; i < list.length; i++) {
|
|
306
|
+
expandedOffsets.push(cum);
|
|
307
|
+
cum += (heights[list[i].id] ?? 72) + GAP;
|
|
308
|
+
}
|
|
309
|
+
const expandedHeight = Math.max(0, cum - GAP);
|
|
310
|
+
|
|
311
|
+
const topH = heights[list[0]?.id] ?? 72;
|
|
312
|
+
const visibleCount = Math.min(list.length, VISIBLE_STACK);
|
|
313
|
+
const collapsedHeight =
|
|
314
|
+
list.length === 0
|
|
315
|
+
? 0
|
|
316
|
+
: topH + Math.max(0, visibleCount - 1) * STACK_OFFSET;
|
|
317
|
+
|
|
318
|
+
const containerHeight = expanded ? expandedHeight : collapsedHeight;
|
|
319
|
+
|
|
320
|
+
const showCount = list.length - VISIBLE_STACK;
|
|
321
|
+
|
|
322
|
+
return (
|
|
323
|
+
<ToastContext.Provider value={{ toast }}>
|
|
324
|
+
{children}
|
|
325
|
+
{/* Hover or keyboard focus fans the stack out and pauses the timers */}
|
|
326
|
+
<section
|
|
327
|
+
aria-label="Notifications"
|
|
328
|
+
aria-live="polite"
|
|
329
|
+
className="fixed top-[72px] left-1/2 z-[95] pointer-events-none"
|
|
330
|
+
style={{
|
|
331
|
+
width: TOAST_WIDTH,
|
|
332
|
+
transform: "translateX(-50%)",
|
|
333
|
+
}}
|
|
334
|
+
onMouseEnter={() => setExpanded(true)}
|
|
335
|
+
onMouseLeave={() => setExpanded(false)}
|
|
336
|
+
onFocus={() => setExpanded(true)}
|
|
337
|
+
onBlur={(e) => {
|
|
338
|
+
if (!e.currentTarget.contains(e.relatedTarget as Node)) setExpanded(false);
|
|
339
|
+
}}
|
|
340
|
+
>
|
|
341
|
+
<div
|
|
342
|
+
className={list.length > 0 ? "relative pointer-events-auto" : "relative"}
|
|
343
|
+
style={{
|
|
344
|
+
width: TOAST_WIDTH,
|
|
345
|
+
height: containerHeight,
|
|
346
|
+
transition: `height ${SHIFT_MS}ms cubic-bezier(0.32,0.72,0,1)`,
|
|
347
|
+
}}
|
|
348
|
+
>
|
|
349
|
+
{list.map((t, i) => (
|
|
350
|
+
<ToastCard
|
|
351
|
+
key={t.id}
|
|
352
|
+
item={t}
|
|
353
|
+
index={i}
|
|
354
|
+
total={list.length}
|
|
355
|
+
expanded={expanded}
|
|
356
|
+
expandedOffset={expandedOffsets[i]}
|
|
357
|
+
exiting={exiting.has(t.id)}
|
|
358
|
+
entering={entering.has(t.id)}
|
|
359
|
+
paused={expanded}
|
|
360
|
+
onDismiss={dismiss}
|
|
361
|
+
onMeasure={reportHeight}
|
|
362
|
+
/>
|
|
363
|
+
))}
|
|
364
|
+
|
|
365
|
+
{!expanded && showCount > 0 && (
|
|
366
|
+
<div
|
|
367
|
+
className="absolute left-1/2 -translate-x-1/2 pointer-events-none"
|
|
368
|
+
style={{
|
|
369
|
+
top: collapsedHeight + 6,
|
|
370
|
+
transition: `opacity ${ENTER_MS}ms ease`,
|
|
371
|
+
}}
|
|
372
|
+
>
|
|
373
|
+
<span
|
|
374
|
+
className="text-[10.5px] font-medium text-neutral-500 px-2 py-0.5 rounded-full"
|
|
375
|
+
style={{ background: "var(--face-light)", boxShadow: "var(--face-light-shadow)" }}
|
|
376
|
+
>
|
|
377
|
+
+{showCount} more
|
|
378
|
+
</span>
|
|
379
|
+
</div>
|
|
380
|
+
)}
|
|
381
|
+
</div>
|
|
382
|
+
</section>
|
|
383
|
+
</ToastContext.Provider>
|
|
384
|
+
);
|
|
385
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useId, useState } from "react";
|
|
4
|
+
|
|
5
|
+
interface ToggleProps {
|
|
6
|
+
checked?: boolean;
|
|
7
|
+
defaultChecked?: boolean;
|
|
8
|
+
onChange?: (checked: boolean) => void;
|
|
9
|
+
label?: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function Toggle({
|
|
15
|
+
checked,
|
|
16
|
+
defaultChecked = false,
|
|
17
|
+
onChange,
|
|
18
|
+
label,
|
|
19
|
+
description,
|
|
20
|
+
disabled = false,
|
|
21
|
+
}: ToggleProps) {
|
|
22
|
+
const [internal, setInternal] = useState(defaultChecked);
|
|
23
|
+
const [pressed, setPressed] = useState(false);
|
|
24
|
+
const uid = useId();
|
|
25
|
+
const isOn = checked !== undefined ? checked : internal;
|
|
26
|
+
|
|
27
|
+
const toggle = () => {
|
|
28
|
+
if (disabled) return;
|
|
29
|
+
const next = !isOn;
|
|
30
|
+
setInternal(next);
|
|
31
|
+
onChange?.(next);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div
|
|
36
|
+
className={[
|
|
37
|
+
"flex items-center justify-between gap-4 select-none",
|
|
38
|
+
disabled ? "opacity-35 pointer-events-none" : "cursor-pointer",
|
|
39
|
+
].join(" ")}
|
|
40
|
+
onClick={toggle}
|
|
41
|
+
>
|
|
42
|
+
{(label || description) && (
|
|
43
|
+
<div className="flex flex-col gap-0.5 min-w-0">
|
|
44
|
+
{label && (
|
|
45
|
+
<span id={`${uid}-label`} className="text-[13px] font-medium text-neutral-900 leading-snug">
|
|
46
|
+
{label}
|
|
47
|
+
</span>
|
|
48
|
+
)}
|
|
49
|
+
{description && (
|
|
50
|
+
<span id={`${uid}-desc`} className="text-[12px] text-neutral-500 leading-relaxed">
|
|
51
|
+
{description}
|
|
52
|
+
</span>
|
|
53
|
+
)}
|
|
54
|
+
</div>
|
|
55
|
+
)}
|
|
56
|
+
|
|
57
|
+
{/* Clicks bubble to the row, so the switch itself has no onClick */}
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
role="switch"
|
|
61
|
+
aria-checked={isOn}
|
|
62
|
+
aria-labelledby={label ? `${uid}-label` : undefined}
|
|
63
|
+
aria-describedby={description ? `${uid}-desc` : undefined}
|
|
64
|
+
disabled={disabled}
|
|
65
|
+
onPointerDown={() => setPressed(true)}
|
|
66
|
+
onPointerUp={() => setPressed(false)}
|
|
67
|
+
onPointerLeave={() => setPressed(false)}
|
|
68
|
+
className={[
|
|
69
|
+
"relative shrink-0 w-[46px] h-[26px] rounded-[13px] cursor-pointer",
|
|
70
|
+
"transition-[background-color,box-shadow] duration-200 ease-out",
|
|
71
|
+
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900",
|
|
72
|
+
isOn ? "bg-neutral-900" : "bg-neutral-200/70",
|
|
73
|
+
].join(" ")}
|
|
74
|
+
style={{
|
|
75
|
+
/* The track is a recessed channel; the knob is a raised face inside it */
|
|
76
|
+
boxShadow: isOn
|
|
77
|
+
? "inset 0 1px 3px rgba(0,0,0,0.5), inset 0 0 2px rgba(0,0,0,0.3), 0 1px 2px -1px rgba(255,255,255,0.48)"
|
|
78
|
+
: "inset 0 1px 2px rgba(17,17,17,0.12), inset 0 0 2px rgba(17,17,17,0.12), 0 1px 2px -1px rgba(255,255,255,0.64)",
|
|
79
|
+
}}
|
|
80
|
+
>
|
|
81
|
+
<span
|
|
82
|
+
aria-hidden
|
|
83
|
+
className="absolute top-1/2 h-5 rounded-[10px]"
|
|
84
|
+
style={{
|
|
85
|
+
left: 3,
|
|
86
|
+
/* The knob stretches while held, like a finger dragging it */
|
|
87
|
+
width: pressed ? 24 : 20,
|
|
88
|
+
background: "var(--face-light)",
|
|
89
|
+
boxShadow:
|
|
90
|
+
"0 0 3px rgba(17,17,17,0.119), 0 1px 2px rgba(0,0,0,0.14), 0 3px 6px -1px rgba(0,0,0,0.12), inset 0 2px 3px -2px rgba(255,255,255,1), inset 0 -2px 3px -2px rgba(0,0,0,0.04)",
|
|
91
|
+
transform: `translateX(${isOn ? (pressed ? 16 : 20) : 0}px) translateY(-50%)`,
|
|
92
|
+
transition:
|
|
93
|
+
"transform 260ms var(--ease-spring), width 200ms var(--ease-out-soft)",
|
|
94
|
+
}}
|
|
95
|
+
/>
|
|
96
|
+
</button>
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useRef, useCallback, useEffect, useId } from "react";
|
|
4
|
+
|
|
5
|
+
type TooltipPosition = "top" | "bottom" | "left" | "right";
|
|
6
|
+
|
|
7
|
+
interface TooltipProps {
|
|
8
|
+
content: string;
|
|
9
|
+
position?: TooltipPosition;
|
|
10
|
+
children: React.ReactNode;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const origin: Record<TooltipPosition, string> = {
|
|
15
|
+
top: "origin-bottom",
|
|
16
|
+
bottom: "origin-top",
|
|
17
|
+
left: "origin-right",
|
|
18
|
+
right: "origin-left",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const enterTranslate: Record<TooltipPosition, string> = {
|
|
22
|
+
top: "translate-y-1",
|
|
23
|
+
bottom: "-translate-y-1",
|
|
24
|
+
left: "translate-x-1",
|
|
25
|
+
right: "-translate-x-1",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function Tooltip({
|
|
29
|
+
content,
|
|
30
|
+
position = "top",
|
|
31
|
+
children,
|
|
32
|
+
className = "",
|
|
33
|
+
}: TooltipProps) {
|
|
34
|
+
const [open, setOpen] = useState(false);
|
|
35
|
+
const [visible, setVisible] = useState(false);
|
|
36
|
+
const [mounted, setMounted] = useState(false);
|
|
37
|
+
const [coords, setCoords] = useState({ top: 0, left: 0 });
|
|
38
|
+
const triggerRef = useRef<HTMLDivElement>(null);
|
|
39
|
+
const tooltipRef = useRef<HTMLDivElement>(null);
|
|
40
|
+
const enterTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
41
|
+
const exitTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
42
|
+
const autoHideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
43
|
+
const touchActiveRef = useRef(false);
|
|
44
|
+
const uid = useId();
|
|
45
|
+
|
|
46
|
+
const hide = useCallback(() => {
|
|
47
|
+
if (enterTimer.current) clearTimeout(enterTimer.current);
|
|
48
|
+
if (autoHideTimer.current) clearTimeout(autoHideTimer.current);
|
|
49
|
+
setVisible(false);
|
|
50
|
+
exitTimer.current = setTimeout(() => {
|
|
51
|
+
setMounted(false);
|
|
52
|
+
setOpen(false);
|
|
53
|
+
}, 150);
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
const show = useCallback((fromTouch = false) => {
|
|
57
|
+
if (exitTimer.current) clearTimeout(exitTimer.current);
|
|
58
|
+
if (autoHideTimer.current) clearTimeout(autoHideTimer.current);
|
|
59
|
+
enterTimer.current = setTimeout(() => setOpen(true), fromTouch ? 0 : 80);
|
|
60
|
+
if (fromTouch) {
|
|
61
|
+
autoHideTimer.current = setTimeout(() => {
|
|
62
|
+
touchActiveRef.current = false;
|
|
63
|
+
hide();
|
|
64
|
+
}, 2400);
|
|
65
|
+
}
|
|
66
|
+
}, [hide]);
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (open) {
|
|
70
|
+
setMounted(true);
|
|
71
|
+
requestAnimationFrame(() => requestAnimationFrame(() => setVisible(true)));
|
|
72
|
+
}
|
|
73
|
+
}, [open]);
|
|
74
|
+
|
|
75
|
+
const measure = useCallback(() => {
|
|
76
|
+
if (!triggerRef.current || !tooltipRef.current) return;
|
|
77
|
+
const tr = triggerRef.current.getBoundingClientRect();
|
|
78
|
+
const tp = tooltipRef.current.getBoundingClientRect();
|
|
79
|
+
const gap = 7;
|
|
80
|
+
|
|
81
|
+
let top = 0;
|
|
82
|
+
let left = 0;
|
|
83
|
+
|
|
84
|
+
switch (position) {
|
|
85
|
+
case "top":
|
|
86
|
+
top = -(tp.height + gap);
|
|
87
|
+
left = (tr.width - tp.width) / 2;
|
|
88
|
+
break;
|
|
89
|
+
case "bottom":
|
|
90
|
+
top = tr.height + gap;
|
|
91
|
+
left = (tr.width - tp.width) / 2;
|
|
92
|
+
break;
|
|
93
|
+
case "left":
|
|
94
|
+
top = (tr.height - tp.height) / 2;
|
|
95
|
+
left = -(tp.width + gap);
|
|
96
|
+
break;
|
|
97
|
+
case "right":
|
|
98
|
+
top = (tr.height - tp.height) / 2;
|
|
99
|
+
left = tr.width + gap;
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const viewportPadding = 8;
|
|
104
|
+
const absLeft = triggerRef.current.getBoundingClientRect().left + left;
|
|
105
|
+
const overflowRight = absLeft + tp.width - (window.innerWidth - viewportPadding);
|
|
106
|
+
const overflowLeft = viewportPadding - absLeft;
|
|
107
|
+
if (overflowRight > 0) left -= overflowRight;
|
|
108
|
+
if (overflowLeft > 0) left += overflowLeft;
|
|
109
|
+
|
|
110
|
+
setCoords({ top, left });
|
|
111
|
+
}, [position]);
|
|
112
|
+
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (mounted) measure();
|
|
115
|
+
}, [mounted, measure]);
|
|
116
|
+
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (!open) return;
|
|
119
|
+
const onDocPointer = (e: PointerEvent) => {
|
|
120
|
+
if (!triggerRef.current?.contains(e.target as Node)) {
|
|
121
|
+
touchActiveRef.current = false;
|
|
122
|
+
hide();
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
document.addEventListener("pointerdown", onDocPointer);
|
|
126
|
+
return () => document.removeEventListener("pointerdown", onDocPointer);
|
|
127
|
+
}, [open, hide]);
|
|
128
|
+
|
|
129
|
+
// Escape dismisses without moving focus (WCAG 1.4.13)
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
if (!open) return;
|
|
132
|
+
const onKey = (e: KeyboardEvent) => {
|
|
133
|
+
if (e.key === "Escape") hide();
|
|
134
|
+
};
|
|
135
|
+
document.addEventListener("keydown", onKey);
|
|
136
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
137
|
+
}, [open, hide]);
|
|
138
|
+
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
return () => {
|
|
141
|
+
if (enterTimer.current) clearTimeout(enterTimer.current);
|
|
142
|
+
if (exitTimer.current) clearTimeout(exitTimer.current);
|
|
143
|
+
if (autoHideTimer.current) clearTimeout(autoHideTimer.current);
|
|
144
|
+
};
|
|
145
|
+
}, []);
|
|
146
|
+
|
|
147
|
+
const handlePointerEnter = (e: React.PointerEvent) => {
|
|
148
|
+
if (e.pointerType === "touch") return;
|
|
149
|
+
show(false);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const handlePointerLeave = (e: React.PointerEvent) => {
|
|
153
|
+
if (e.pointerType === "touch") return;
|
|
154
|
+
if (touchActiveRef.current) return;
|
|
155
|
+
hide();
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const handlePointerDown = (e: React.PointerEvent) => {
|
|
159
|
+
if (e.pointerType !== "touch") return;
|
|
160
|
+
touchActiveRef.current = true;
|
|
161
|
+
if (open) {
|
|
162
|
+
hide();
|
|
163
|
+
} else {
|
|
164
|
+
show(true);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<div
|
|
170
|
+
ref={triggerRef}
|
|
171
|
+
className={["relative inline-flex", className].join(" ")}
|
|
172
|
+
onPointerEnter={handlePointerEnter}
|
|
173
|
+
onPointerLeave={handlePointerLeave}
|
|
174
|
+
onPointerDown={handlePointerDown}
|
|
175
|
+
onFocus={() => show(false)}
|
|
176
|
+
onBlur={() => {
|
|
177
|
+
if (!touchActiveRef.current) hide();
|
|
178
|
+
}}
|
|
179
|
+
>
|
|
180
|
+
{React.isValidElement(children)
|
|
181
|
+
? React.cloneElement(children as React.ReactElement<Record<string, unknown>>, {
|
|
182
|
+
"aria-describedby": mounted ? `${uid}-tip` : undefined,
|
|
183
|
+
})
|
|
184
|
+
: children}
|
|
185
|
+
{mounted && (
|
|
186
|
+
<div
|
|
187
|
+
ref={tooltipRef}
|
|
188
|
+
id={`${uid}-tip`}
|
|
189
|
+
role="tooltip"
|
|
190
|
+
className={[
|
|
191
|
+
"absolute z-50 px-2.5 py-1.5 rounded-[9px] text-white text-[11.5px] font-medium whitespace-nowrap pointer-events-none",
|
|
192
|
+
"transition-[opacity,transform,scale,translate] duration-150 ease-[var(--ease-spring)]",
|
|
193
|
+
origin[position],
|
|
194
|
+
visible
|
|
195
|
+
? "opacity-100 scale-100 translate-x-0 translate-y-0"
|
|
196
|
+
: ["opacity-0 scale-95", enterTranslate[position]].join(" "),
|
|
197
|
+
].join(" ")}
|
|
198
|
+
style={{
|
|
199
|
+
top: coords.top,
|
|
200
|
+
left: coords.left,
|
|
201
|
+
background: "var(--face-dark)",
|
|
202
|
+
boxShadow:
|
|
203
|
+
"0 0 3px rgba(0,0,0,0.35), 0 2px 6px rgba(0,0,0,0.22), 0 8px 24px -4px rgba(0,0,0,0.3), inset 0 2px 3px -2px rgba(255,255,255,0.105)",
|
|
204
|
+
textShadow: "0 -1px 0 rgba(0,0,0,0.4)",
|
|
205
|
+
}}
|
|
206
|
+
>
|
|
207
|
+
{content}
|
|
208
|
+
</div>
|
|
209
|
+
)}
|
|
210
|
+
</div>
|
|
211
|
+
);
|
|
212
|
+
}
|