@blurrow/ui 0.1.0 → 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 (53) hide show
  1. package/README.md +28 -0
  2. package/dist/cli.js +1477 -0
  3. package/dist/registry/Accordion.tsx +140 -0
  4. package/dist/registry/Alert.tsx +92 -0
  5. package/dist/registry/AuthorCard.tsx +67 -0
  6. package/dist/registry/Avatar.tsx +116 -0
  7. package/dist/registry/Badge.tsx +58 -0
  8. package/dist/registry/Banner.tsx +89 -0
  9. package/dist/registry/Breadcrumb.tsx +60 -0
  10. package/dist/registry/Button.tsx +372 -0
  11. package/dist/registry/ButtonGroup.tsx +82 -0
  12. package/dist/registry/Card.tsx +241 -0
  13. package/dist/registry/CardShell.tsx +78 -0
  14. package/dist/registry/Carousel.tsx +292 -0
  15. package/dist/registry/Checkbox.tsx +99 -0
  16. package/dist/registry/CloseButton.tsx +68 -0
  17. package/dist/registry/CodeBlock.tsx +202 -0
  18. package/dist/registry/CommandPalette.tsx +349 -0
  19. package/dist/registry/DataList.tsx +49 -0
  20. package/dist/registry/Drawer.tsx +164 -0
  21. package/dist/registry/DropdownMenu.tsx +257 -0
  22. package/dist/registry/EmptyState.tsx +55 -0
  23. package/dist/registry/Input.tsx +97 -0
  24. package/dist/registry/Kbd.tsx +24 -0
  25. package/dist/registry/Modal.tsx +128 -0
  26. package/dist/registry/Pagination.tsx +163 -0
  27. package/dist/registry/Popover.tsx +121 -0
  28. package/dist/registry/Progress.tsx +66 -0
  29. package/dist/registry/QuoteCard.tsx +93 -0
  30. package/dist/registry/Radio.tsx +202 -0
  31. package/dist/registry/ReactionBar.tsx +101 -0
  32. package/dist/registry/ReadingProgress.tsx +51 -0
  33. package/dist/registry/ReadingTime.tsx +35 -0
  34. package/dist/registry/Select.tsx +290 -0
  35. package/dist/registry/ShareButtons.tsx +134 -0
  36. package/dist/registry/Sidebar.tsx +182 -0
  37. package/dist/registry/Skeleton.tsx +83 -0
  38. package/dist/registry/SpotlightCard.tsx +106 -0
  39. package/dist/registry/Stat.tsx +82 -0
  40. package/dist/registry/StatCard.tsx +256 -0
  41. package/dist/registry/Steps.tsx +109 -0
  42. package/dist/registry/Table.tsx +121 -0
  43. package/dist/registry/TableOfContents.tsx +116 -0
  44. package/dist/registry/Tabs.tsx +136 -0
  45. package/dist/registry/TagInput.tsx +191 -0
  46. package/dist/registry/Textarea.tsx +84 -0
  47. package/dist/registry/Timeline.tsx +102 -0
  48. package/dist/registry/Toast.tsx +385 -0
  49. package/dist/registry/Toggle.tsx +99 -0
  50. package/dist/registry/Tooltip.tsx +212 -0
  51. package/dist/registry/styles.css +401 -0
  52. package/dist/registry/useModalBehavior.ts +77 -0
  53. package/package.json +23 -6
@@ -0,0 +1,256 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useId, useRef, useState } from "react";
4
+ import { CardShell } from "./CardShell";
5
+
6
+ type StatTheme = "light" | "dark";
7
+ type DeltaDirection = "up" | "down" | "neutral";
8
+
9
+ interface StatCardProps {
10
+ label: string;
11
+ value: number;
12
+ prefix?: string;
13
+ suffix?: string;
14
+ decimals?: number;
15
+ delta?: { value: string; direction: DeltaDirection };
16
+ hint?: string;
17
+ sparkline?: number[];
18
+ icon?: React.ReactNode;
19
+ theme?: StatTheme;
20
+ className?: string;
21
+ }
22
+
23
+ function useAnimatedNumber(target: number, duration: number) {
24
+ const [v, setV] = useState(0);
25
+ const prevRef = useRef(0);
26
+
27
+ useEffect(() => {
28
+ const from = prevRef.current;
29
+ const delta = target - from;
30
+ if (delta === 0) return;
31
+ // Reduced motion: show the number, skip the count
32
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
33
+ prevRef.current = target;
34
+ const id = requestAnimationFrame(() => setV(target));
35
+ return () => cancelAnimationFrame(id);
36
+ }
37
+ const start = performance.now();
38
+ let raf = 0;
39
+ const tick = (now: number) => {
40
+ const t = Math.min(1, (now - start) / duration);
41
+ const eased = 1 - Math.pow(1 - t, 3);
42
+ setV(from + delta * eased);
43
+ if (t < 1) {
44
+ raf = requestAnimationFrame(tick);
45
+ } else {
46
+ prevRef.current = target;
47
+ }
48
+ };
49
+ raf = requestAnimationFrame(tick);
50
+ return () => cancelAnimationFrame(raf);
51
+ }, [target, duration]);
52
+
53
+ return v;
54
+ }
55
+
56
+ function Sparkline({ data, dark }: { data: number[]; dark?: boolean }) {
57
+ const uid = useId();
58
+ if (data.length < 2) return null;
59
+ const W = 120;
60
+ const H = 44;
61
+ const P = 6;
62
+ const max = Math.max(...data);
63
+ const min = Math.min(...data);
64
+ const range = max - min || 1;
65
+ const step = (W - P * 2) / (data.length - 1);
66
+
67
+ const pts = data.map((d, i) => {
68
+ const x = P + i * step;
69
+ const y = H - P - ((d - min) / range) * (H - P * 2);
70
+ return [x, y] as const;
71
+ });
72
+
73
+ const line = pts
74
+ .map(([x, y], i) => `${i === 0 ? "M" : "L"}${x.toFixed(1)},${y.toFixed(1)}`)
75
+ .join(" ");
76
+ // The fill runs off both edges of the face so it reads as part of the surface
77
+ const area = `M0,${pts[0][1].toFixed(1)} ${line.replace(/^M/, "L")} L${W},${pts[pts.length - 1][1].toFixed(1)} L${W},${H} L0,${H} Z`;
78
+
79
+ const stroke = dark ? "#ffffff" : "#171717";
80
+ const gradId = `spark-fade-${uid.replace(/:/g, "")}`;
81
+ const [lx, ly] = pts[pts.length - 1];
82
+
83
+ return (
84
+ <div
85
+ aria-hidden
86
+ className="relative h-14"
87
+ // Fades in from the left so the line arrives rather than starts at a hard edge
88
+ style={{
89
+ maskImage: "linear-gradient(90deg, transparent 0%, #000 18%)",
90
+ WebkitMaskImage: "linear-gradient(90deg, transparent 0%, #000 18%)",
91
+ }}
92
+ >
93
+ <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="absolute inset-0 w-full h-full overflow-visible">
94
+ <defs>
95
+ <linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
96
+ <stop offset="0%" stopColor={stroke} stopOpacity={dark ? 0.2 : 0.08} />
97
+ <stop offset="100%" stopColor={stroke} stopOpacity="0" />
98
+ </linearGradient>
99
+ </defs>
100
+ <path d={area} fill={`url(#${gradId})`} />
101
+ <path
102
+ d={line}
103
+ fill="none"
104
+ stroke={stroke}
105
+ strokeOpacity={dark ? 0.85 : 0.9}
106
+ strokeWidth={1.5}
107
+ strokeLinecap="round"
108
+ strokeLinejoin="round"
109
+ vectorEffect="non-scaling-stroke"
110
+ />
111
+ </svg>
112
+ {/* The latest value, as a dot — drawn in HTML so it stays round when the SVG stretches */}
113
+ <span
114
+ className="absolute w-[7px] h-[7px] rounded-full -translate-x-1/2 -translate-y-1/2"
115
+ style={{
116
+ left: `${(lx / W) * 100}%`,
117
+ top: `${(ly / H) * 100}%`,
118
+ background: stroke,
119
+ boxShadow: dark ? "0 0 0 3px rgba(255,255,255,0.14)" : "0 0 0 3px rgba(17,17,17,0.08)",
120
+ }}
121
+ />
122
+ </div>
123
+ );
124
+ }
125
+
126
+ const deltaTone: Record<DeltaDirection, { light: string; dark: string; label: string }> = {
127
+ up: { light: "text-emerald-700", dark: "text-emerald-400", label: "up" },
128
+ down: { light: "text-red-600", dark: "text-red-400", label: "down" },
129
+ neutral: { light: "text-neutral-600", dark: "text-neutral-300", label: "unchanged" },
130
+ };
131
+
132
+ function DeltaArrow({ direction }: { direction: DeltaDirection }) {
133
+ const d =
134
+ direction === "up" ? "M12 19V5m0 0l-6 6m6-6l6 6" : direction === "down" ? "M12 5v14m0 0l-6-6m6 6l6-6" : "M5 12h14m0 0l-6-6m6 6l-6 6";
135
+ return (
136
+ <svg aria-hidden width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
137
+ <path strokeLinecap="round" strokeLinejoin="round" d={d} />
138
+ </svg>
139
+ );
140
+ }
141
+
142
+ /* Built on CardShell; the trend line bleeds off the bottom of the face
143
+ like a chart on a screen. */
144
+ export function StatCard({
145
+ label,
146
+ value,
147
+ prefix = "",
148
+ suffix = "",
149
+ decimals = 0,
150
+ delta,
151
+ hint,
152
+ sparkline,
153
+ icon,
154
+ theme = "light",
155
+ className = "",
156
+ }: StatCardProps) {
157
+ const animated = useAnimatedNumber(value, 900);
158
+ const dark = theme === "dark";
159
+
160
+ const formatter = new Intl.NumberFormat("en-US", {
161
+ minimumFractionDigits: decimals,
162
+ maximumFractionDigits: decimals,
163
+ });
164
+ const finalText = `${prefix}${formatter.format(value)}${suffix}`;
165
+
166
+ const badge = delta && (
167
+ <span
168
+ aria-label={`${deltaTone[delta.direction].label} ${delta.value}`}
169
+ className={[
170
+ "inline-flex items-center gap-1 h-6 px-2 rounded-[7px] text-[12px] font-medium tabular-nums shrink-0",
171
+ dark ? deltaTone[delta.direction].dark : deltaTone[delta.direction].light,
172
+ ].join(" ")}
173
+ style={{
174
+ background: dark ? "var(--tile-dark)" : "var(--face-light)",
175
+ boxShadow: dark ? "var(--tile-dark-shadow)" : "var(--tile-light-shadow)",
176
+ }}
177
+ >
178
+ <DeltaArrow direction={delta.direction} />
179
+ {delta.value}
180
+ </span>
181
+ );
182
+
183
+ /* Label and change share the top line — what it is, and which way it's
184
+ going. The number carries the card; the hint qualifies it in a
185
+ quieter voice right underneath. */
186
+ return (
187
+ <CardShell theme={theme} className={className}>
188
+ <div className="px-6 pt-5 flex items-center justify-between gap-3 min-h-11">
189
+ <div className="flex items-center gap-2.5 min-w-0">
190
+ {icon && (
191
+ <div
192
+ aria-hidden
193
+ className={[
194
+ "w-7 h-7 rounded-[9px] flex items-center justify-center shrink-0",
195
+ dark ? "text-neutral-100" : "text-neutral-600",
196
+ ].join(" ")}
197
+ style={{
198
+ background: dark ? "var(--tile-dark)" : "var(--face-light)",
199
+ boxShadow: dark ? "var(--tile-dark-shadow)" : "var(--tile-light-shadow)",
200
+ }}
201
+ >
202
+ {icon}
203
+ </div>
204
+ )}
205
+ <p
206
+ className={[
207
+ "text-[14px] font-medium tracking-[-0.01em] truncate",
208
+ dark ? "text-neutral-300" : "text-neutral-700",
209
+ ].join(" ")}
210
+ >
211
+ {label}
212
+ </p>
213
+ </div>
214
+ {badge}
215
+ </div>
216
+
217
+ {/* Screen readers get the final value once, not every frame of the count */}
218
+ <p className="sr-only">{finalText}</p>
219
+ <p
220
+ aria-hidden
221
+ className={[
222
+ "px-6 mt-5 text-[44px] font-semibold tabular-nums tracking-[-0.045em] leading-none",
223
+ dark ? "text-white" : "text-neutral-900",
224
+ ].join(" ")}
225
+ >
226
+ {prefix}
227
+ {formatter.format(animated)}
228
+ {/* Units are context, not the headline — set them smaller and quieter */}
229
+ {suffix && (
230
+ <span
231
+ className={[
232
+ "ml-1 text-[17px] font-medium tracking-[-0.01em]",
233
+ dark ? "text-neutral-500" : "text-neutral-400",
234
+ ].join(" ")}
235
+ >
236
+ {suffix.trim()}
237
+ </span>
238
+ )}
239
+ </p>
240
+ {hint && (
241
+ <p className={["px-6 mt-2.5 text-[13px] truncate", dark ? "text-neutral-500" : "text-neutral-500"].join(" ")}>
242
+ {hint}
243
+ </p>
244
+ )}
245
+
246
+ {/* The trend line bleeds to the face's edges, like a chart on a screen */}
247
+ {sparkline && sparkline.length > 1 ? (
248
+ <div className="mt-auto pt-6">
249
+ <Sparkline data={sparkline} dark={dark} />
250
+ </div>
251
+ ) : (
252
+ <div className="pb-6" />
253
+ )}
254
+ </CardShell>
255
+ );
256
+ }
@@ -0,0 +1,109 @@
1
+ import React from "react";
2
+
3
+ interface StepItem {
4
+ label: string;
5
+ description?: string;
6
+ }
7
+
8
+ interface StepsProps {
9
+ steps: StepItem[];
10
+ current: number;
11
+ className?: string;
12
+ }
13
+
14
+ function CheckIcon() {
15
+ return (
16
+ <svg
17
+ width="13"
18
+ height="13"
19
+ viewBox="0 0 24 24"
20
+ fill="none"
21
+ stroke="currentColor"
22
+ strokeWidth="2.75"
23
+ strokeLinecap="round"
24
+ strokeLinejoin="round"
25
+ >
26
+ <polyline points="20 6 9 17 4 12" />
27
+ </svg>
28
+ );
29
+ }
30
+
31
+ /* Upcoming steps are sunken (not yet filled in), done and current steps
32
+ are dark raised faces; the current one also wears a soft halo. Each step owns an equal column, so labels get
33
+ real width instead of being squeezed under a 32px circle. */
34
+ export function Steps({ steps, current, className = "" }: StepsProps) {
35
+ return (
36
+ <ol className={["flex items-start", className].filter(Boolean).join(" ")}>
37
+ {steps.map((step, i) => {
38
+ const isCompleted = i < current;
39
+ const isCurrent = i === current;
40
+ const reached = isCompleted || isCurrent;
41
+ const isLast = i === steps.length - 1;
42
+
43
+ return (
44
+ <li
45
+ key={i}
46
+ aria-current={isCurrent ? "step" : undefined}
47
+ className="relative flex-1 min-w-0 flex flex-col items-center text-center"
48
+ >
49
+ {/* One groove per gap, running between the two circles' edges
50
+ (columns are equal, so the next centre is exactly 100% away) */}
51
+ {!isLast && (
52
+ <span
53
+ aria-hidden
54
+ className="absolute top-[15px] h-1 -translate-y-1/2 rounded-full overflow-hidden"
55
+ style={{
56
+ left: "calc(50% + 25px)",
57
+ width: "calc(100% - 50px)",
58
+ background: "var(--well)",
59
+ boxShadow: "var(--well-shadow)",
60
+ }}
61
+ >
62
+ <span
63
+ className="block h-full rounded-full origin-left transition-transform duration-500 ease-[var(--ease-out-soft)]"
64
+ style={{
65
+ background: "var(--face-dark)",
66
+ transform: isCompleted ? "scaleX(1)" : "scaleX(0)",
67
+ }}
68
+ />
69
+ </span>
70
+ )}
71
+
72
+ <span
73
+ className={[
74
+ "relative w-[30px] h-[30px] rounded-full flex items-center justify-center text-[12.5px] font-semibold tabular-nums shrink-0",
75
+ "transition-[box-shadow,color] duration-300",
76
+ reached ? "text-white" : "text-neutral-400",
77
+ ].join(" ")}
78
+ style={{
79
+ background: reached ? "var(--face-dark)" : "var(--field)",
80
+ boxShadow: isCurrent
81
+ ? "var(--face-dark-shadow), 0 0 0 4px rgba(17,17,17,0.06), 0 0 0 5px rgba(17,17,17,0.03)"
82
+ : reached
83
+ ? "var(--face-dark-shadow)"
84
+ : "var(--field-shadow)",
85
+ }}
86
+ >
87
+ {isCompleted ? <CheckIcon /> : i + 1}
88
+ <span className="sr-only">{isCompleted ? " (completed)" : ""}</span>
89
+ </span>
90
+
91
+ <span className="mt-3 px-1.5 flex flex-col gap-0.5">
92
+ <span
93
+ className={[
94
+ "text-[13px] font-medium leading-tight transition-colors duration-200",
95
+ reached ? "text-neutral-900" : "text-neutral-500",
96
+ ].join(" ")}
97
+ >
98
+ {step.label}
99
+ </span>
100
+ {step.description && (
101
+ <span className="text-[12px] text-neutral-400 leading-snug">{step.description}</span>
102
+ )}
103
+ </span>
104
+ </li>
105
+ );
106
+ })}
107
+ </ol>
108
+ );
109
+ }
@@ -0,0 +1,121 @@
1
+ import React from "react";
2
+
3
+ interface Column<T> {
4
+ key: keyof T | string;
5
+ label: string;
6
+ align?: "left" | "right" | "center";
7
+ render?: (value: unknown, row: T) => React.ReactNode;
8
+ width?: string;
9
+ }
10
+
11
+ interface TableProps<T extends Record<string, unknown>> {
12
+ columns: Column<T>[];
13
+ rows: T[];
14
+ title?: string;
15
+ description?: string;
16
+ className?: string;
17
+ onRowClick?: (row: T) => void;
18
+ }
19
+
20
+ const alignClass = (align?: "left" | "right" | "center") =>
21
+ align === "right" ? "text-right" : align === "center" ? "text-center" : "text-left";
22
+
23
+ /* Title and column headers live in the bezel; rows sit on the raised face. */
24
+ export function Table<T extends Record<string, unknown>>({
25
+ columns,
26
+ rows,
27
+ title,
28
+ description,
29
+ className = "",
30
+ onRowClick,
31
+ }: TableProps<T>) {
32
+ return (
33
+ <div
34
+ className={["w-full rounded-[18px] p-1.5", title || description ? "pt-0" : "", className].join(" ")}
35
+ style={{ background: "var(--panel-bezel)", boxShadow: "var(--panel-shadow)" }}
36
+ >
37
+ {(title || description) && (
38
+ <div className="px-3.5 pt-3.5 pb-3">
39
+ {title && <p className="text-[13px] font-semibold text-neutral-900 tracking-[-0.01em]">{title}</p>}
40
+ {description && <p className="text-[12px] text-neutral-500 mt-0.5">{description}</p>}
41
+ </div>
42
+ )}
43
+
44
+ <div
45
+ className="bg-white rounded-[13px] overflow-x-auto scroll-thin"
46
+ style={{ boxShadow: "var(--panel-face-shadow)" }}
47
+ >
48
+ <table className="w-full text-left border-collapse">
49
+ {title && <caption className="sr-only">{title}</caption>}
50
+ <thead>
51
+ <tr className="border-b border-neutral-100">
52
+ {columns.map((col) => (
53
+ <th
54
+ key={String(col.key)}
55
+ scope="col"
56
+ style={col.width ? { width: col.width } : undefined}
57
+ className={[
58
+ "px-4 pt-3 pb-2.5 text-[12px] font-medium text-neutral-500 lowercase whitespace-nowrap",
59
+ alignClass(col.align),
60
+ ].join(" ")}
61
+ >
62
+ {col.label}
63
+ </th>
64
+ ))}
65
+ </tr>
66
+ </thead>
67
+ <tbody>
68
+ {rows.map((row, ri) => (
69
+ <tr
70
+ key={ri}
71
+ onClick={() => onRowClick?.(row)}
72
+ // Clickable rows are reachable and operable from the keyboard too
73
+ tabIndex={onRowClick ? 0 : undefined}
74
+ onKeyDown={
75
+ onRowClick
76
+ ? (e) => {
77
+ if (e.key === "Enter" || e.key === " ") {
78
+ e.preventDefault();
79
+ onRowClick(row);
80
+ }
81
+ }
82
+ : undefined
83
+ }
84
+ className={[
85
+ "border-b border-neutral-100 last:border-0 transition-colors duration-100",
86
+ onRowClick
87
+ ? "cursor-pointer hover:bg-neutral-50 focus-visible:bg-neutral-50 outline-none focus-visible:shadow-[inset_2px_0_0_#171717]"
88
+ : "",
89
+ ].join(" ")}
90
+ >
91
+ {columns.map((col, ci) => {
92
+ const val = row[col.key as keyof T] as unknown;
93
+ return (
94
+ <td
95
+ key={String(col.key)}
96
+ className={[
97
+ "px-4 py-3 text-[13px]",
98
+ ci === 0 ? "font-medium text-neutral-900" : "text-neutral-600 tabular-nums",
99
+ alignClass(col.align),
100
+ ].join(" ")}
101
+ >
102
+ {col.render ? col.render(val, row) : String(val ?? "")}
103
+ </td>
104
+ );
105
+ })}
106
+ </tr>
107
+ ))}
108
+ {rows.length === 0 && (
109
+ <tr>
110
+ <td colSpan={columns.length} className="px-5 py-12 text-center">
111
+ <p className="text-[13px] font-medium text-neutral-700">nothing here yet</p>
112
+ <p className="text-[12px] text-neutral-500 mt-0.5">rows will show up here once there are some.</p>
113
+ </td>
114
+ </tr>
115
+ )}
116
+ </tbody>
117
+ </table>
118
+ </div>
119
+ </div>
120
+ );
121
+ }
@@ -0,0 +1,116 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
4
+
5
+ export interface TocItem {
6
+ key: string;
7
+ label: string;
8
+ level: 1 | 2 | 3;
9
+ }
10
+
11
+ interface TableOfContentsProps {
12
+ items: TocItem[];
13
+ activeKey?: string;
14
+ defaultActiveKey?: string;
15
+ onSelect?: (key: string) => void;
16
+ className?: string;
17
+ }
18
+
19
+ const indent: Record<1 | 2 | 3, number> = { 1: 16, 2: 28, 3: 40 };
20
+
21
+ /* A hairline track runs down the left edge; a slim dark marker glides
22
+ along it to the active heading. Nothing else moves — the active label
23
+ just darkens. */
24
+ export function TableOfContents({
25
+ items,
26
+ activeKey,
27
+ defaultActiveKey,
28
+ onSelect,
29
+ className = "",
30
+ }: TableOfContentsProps) {
31
+ const [internal, setInternal] = useState(defaultActiveKey ?? items[0]?.key ?? "");
32
+ const current = activeKey !== undefined ? activeKey : internal;
33
+ const refs = useRef<Record<string, HTMLButtonElement | null>>({});
34
+ const [marker, setMarker] = useState<{ top: number; height: number } | null>(null);
35
+ const [ready, setReady] = useState(false);
36
+
37
+ const measure = () => {
38
+ const el = refs.current[current];
39
+ setMarker(el ? { top: el.offsetTop, height: el.offsetHeight } : null);
40
+ };
41
+
42
+ useLayoutEffect(() => {
43
+ measure();
44
+ // eslint-disable-next-line react-hooks/exhaustive-deps
45
+ }, [current]);
46
+
47
+ useEffect(() => {
48
+ // Only animate moves after the first placement
49
+ const id = requestAnimationFrame(() => setReady(true));
50
+ return () => cancelAnimationFrame(id);
51
+ }, []);
52
+
53
+ const handleSelect = (key: string) => {
54
+ setInternal(key);
55
+ onSelect?.(key);
56
+ };
57
+
58
+ const slide = ready ? "top 360ms var(--ease-out-soft), height 360ms var(--ease-out-soft)" : "none";
59
+
60
+ return (
61
+ <nav
62
+ aria-label="On this page"
63
+ className={["rounded-[18px] p-1.5", className].filter(Boolean).join(" ")}
64
+ style={{ background: "var(--panel-bezel)", boxShadow: "var(--panel-shadow)" }}
65
+ >
66
+ <div
67
+ className="bg-white rounded-[13px] px-5 pt-4 pb-4"
68
+ style={{ boxShadow: "var(--panel-face-shadow)" }}
69
+ >
70
+ <p aria-hidden className="text-[12px] font-medium text-neutral-400 mb-3">
71
+ on this page
72
+ </p>
73
+
74
+ <div className="relative">
75
+ <span aria-hidden className="absolute left-0 top-0 bottom-0 w-px bg-neutral-900/[0.08]" />
76
+ {marker && (
77
+ <span
78
+ aria-hidden
79
+ className="absolute -left-[0.5px] w-[2px] rounded-full bg-neutral-900"
80
+ style={{ top: marker.top + 6, height: marker.height - 12, transition: slide }}
81
+ />
82
+ )}
83
+
84
+ {items.map((item) => {
85
+ const isActive = current === item.key;
86
+ return (
87
+ <button
88
+ key={item.key}
89
+ ref={(el) => {
90
+ refs.current[item.key] = el;
91
+ }}
92
+ type="button"
93
+ onClick={() => handleSelect(item.key)}
94
+ aria-current={isActive ? "location" : undefined}
95
+ className={[
96
+ // Weight stays fixed so the active item doesn't reflow its line
97
+ "relative block w-full text-left py-1.5 text-[13px] leading-5 rounded-[6px] cursor-pointer",
98
+ "transition-colors duration-200 outline-none",
99
+ "focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-neutral-900",
100
+ isActive
101
+ ? "text-neutral-900 font-medium"
102
+ : item.level === 1
103
+ ? "text-neutral-600 hover:text-neutral-900"
104
+ : "text-neutral-400 hover:text-neutral-700",
105
+ ].join(" ")}
106
+ style={{ paddingLeft: indent[item.level] }}
107
+ >
108
+ {item.label}
109
+ </button>
110
+ );
111
+ })}
112
+ </div>
113
+ </div>
114
+ </nav>
115
+ );
116
+ }