@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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +93 -2
  3. package/dist/cli.js +1477 -0
  4. package/dist/index.d.ts +631 -0
  5. package/dist/index.js +5559 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/registry/Accordion.tsx +140 -0
  8. package/dist/registry/Alert.tsx +92 -0
  9. package/dist/registry/AuthorCard.tsx +67 -0
  10. package/dist/registry/Avatar.tsx +116 -0
  11. package/dist/registry/Badge.tsx +58 -0
  12. package/dist/registry/Banner.tsx +89 -0
  13. package/dist/registry/Breadcrumb.tsx +60 -0
  14. package/dist/registry/Button.tsx +372 -0
  15. package/dist/registry/ButtonGroup.tsx +82 -0
  16. package/dist/registry/Card.tsx +241 -0
  17. package/dist/registry/CardShell.tsx +78 -0
  18. package/dist/registry/Carousel.tsx +292 -0
  19. package/dist/registry/Checkbox.tsx +99 -0
  20. package/dist/registry/CloseButton.tsx +68 -0
  21. package/dist/registry/CodeBlock.tsx +202 -0
  22. package/dist/registry/CommandPalette.tsx +349 -0
  23. package/dist/registry/DataList.tsx +49 -0
  24. package/dist/registry/Drawer.tsx +164 -0
  25. package/dist/registry/DropdownMenu.tsx +257 -0
  26. package/dist/registry/EmptyState.tsx +55 -0
  27. package/dist/registry/Input.tsx +97 -0
  28. package/dist/registry/Kbd.tsx +24 -0
  29. package/dist/registry/Modal.tsx +128 -0
  30. package/dist/registry/Pagination.tsx +163 -0
  31. package/dist/registry/Popover.tsx +121 -0
  32. package/dist/registry/Progress.tsx +66 -0
  33. package/dist/registry/QuoteCard.tsx +93 -0
  34. package/dist/registry/Radio.tsx +202 -0
  35. package/dist/registry/ReactionBar.tsx +101 -0
  36. package/dist/registry/ReadingProgress.tsx +51 -0
  37. package/dist/registry/ReadingTime.tsx +35 -0
  38. package/dist/registry/Select.tsx +290 -0
  39. package/dist/registry/ShareButtons.tsx +134 -0
  40. package/dist/registry/Sidebar.tsx +182 -0
  41. package/dist/registry/Skeleton.tsx +83 -0
  42. package/dist/registry/SpotlightCard.tsx +106 -0
  43. package/dist/registry/Stat.tsx +82 -0
  44. package/dist/registry/StatCard.tsx +256 -0
  45. package/dist/registry/Steps.tsx +109 -0
  46. package/dist/registry/Table.tsx +121 -0
  47. package/dist/registry/TableOfContents.tsx +116 -0
  48. package/dist/registry/Tabs.tsx +136 -0
  49. package/dist/registry/TagInput.tsx +191 -0
  50. package/dist/registry/Textarea.tsx +84 -0
  51. package/dist/registry/Timeline.tsx +102 -0
  52. package/dist/registry/Toast.tsx +385 -0
  53. package/dist/registry/Toggle.tsx +99 -0
  54. package/dist/registry/Tooltip.tsx +212 -0
  55. package/dist/registry/styles.css +401 -0
  56. package/dist/registry/useModalBehavior.ts +77 -0
  57. package/dist/styles.css +401 -0
  58. 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
+ }