@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,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
|
+
}
|