@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.
- package/README.md +28 -0
- package/dist/cli.js +1477 -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/package.json +23 -6
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useCallback } from "react";
|
|
4
|
+
import { ButtonGroup } from "./ButtonGroup";
|
|
5
|
+
|
|
6
|
+
interface ShareButtonsProps {
|
|
7
|
+
url?: string;
|
|
8
|
+
title?: string;
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
function LinkIcon() {
|
|
14
|
+
return (
|
|
15
|
+
<svg
|
|
16
|
+
width="14"
|
|
17
|
+
height="14"
|
|
18
|
+
viewBox="0 0 24 24"
|
|
19
|
+
fill="none"
|
|
20
|
+
stroke="currentColor"
|
|
21
|
+
strokeWidth="1.75"
|
|
22
|
+
strokeLinecap="round"
|
|
23
|
+
strokeLinejoin="round"
|
|
24
|
+
>
|
|
25
|
+
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
|
26
|
+
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
|
27
|
+
</svg>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function CheckIcon() {
|
|
32
|
+
return (
|
|
33
|
+
<svg
|
|
34
|
+
width="14"
|
|
35
|
+
height="14"
|
|
36
|
+
viewBox="0 0 24 24"
|
|
37
|
+
fill="none"
|
|
38
|
+
stroke="currentColor"
|
|
39
|
+
strokeWidth="2"
|
|
40
|
+
strokeLinecap="round"
|
|
41
|
+
strokeLinejoin="round"
|
|
42
|
+
>
|
|
43
|
+
<polyline points="20 6 9 17 4 12" />
|
|
44
|
+
</svg>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function XIcon() {
|
|
49
|
+
return (
|
|
50
|
+
<svg width="13" height="13" fill="currentColor" viewBox="0 0 24 24">
|
|
51
|
+
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
|
52
|
+
</svg>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function LinkedInIcon() {
|
|
57
|
+
return (
|
|
58
|
+
<svg width="13" height="13" fill="currentColor" viewBox="0 0 24 24">
|
|
59
|
+
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.063 2.063 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
|
|
60
|
+
</svg>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function ShareButtons({
|
|
65
|
+
url,
|
|
66
|
+
title,
|
|
67
|
+
className = "",
|
|
68
|
+
}: ShareButtonsProps) {
|
|
69
|
+
const [copied, setCopied] = useState(false);
|
|
70
|
+
|
|
71
|
+
const shareUrl = url ?? (typeof window !== "undefined" ? window.location.href : "");
|
|
72
|
+
const shareTitle = title ?? "";
|
|
73
|
+
|
|
74
|
+
const handleCopy = useCallback(async () => {
|
|
75
|
+
try {
|
|
76
|
+
await navigator.clipboard.writeText(shareUrl);
|
|
77
|
+
setCopied(true);
|
|
78
|
+
setTimeout(() => setCopied(false), 2000);
|
|
79
|
+
} catch {
|
|
80
|
+
// Fallback silently
|
|
81
|
+
}
|
|
82
|
+
}, [shareUrl]);
|
|
83
|
+
|
|
84
|
+
const handleTwitter = useCallback(() => {
|
|
85
|
+
const params = new URLSearchParams({
|
|
86
|
+
text: shareTitle,
|
|
87
|
+
url: shareUrl,
|
|
88
|
+
});
|
|
89
|
+
window.open(
|
|
90
|
+
`https://x.com/intent/tweet?${params.toString()}`,
|
|
91
|
+
"_blank",
|
|
92
|
+
"noopener,noreferrer"
|
|
93
|
+
);
|
|
94
|
+
}, [shareUrl, shareTitle]);
|
|
95
|
+
|
|
96
|
+
const handleLinkedIn = useCallback(() => {
|
|
97
|
+
const params = new URLSearchParams({
|
|
98
|
+
url: shareUrl,
|
|
99
|
+
});
|
|
100
|
+
window.open(
|
|
101
|
+
`https://www.linkedin.com/sharing/share-offsite/?${params.toString()}`,
|
|
102
|
+
"_blank",
|
|
103
|
+
"noopener,noreferrer"
|
|
104
|
+
);
|
|
105
|
+
}, [shareUrl]);
|
|
106
|
+
|
|
107
|
+
const keys = [
|
|
108
|
+
{
|
|
109
|
+
label: copied ? "Link copied" : "Copy link",
|
|
110
|
+
onClick: handleCopy as () => void,
|
|
111
|
+
content: (
|
|
112
|
+
<>
|
|
113
|
+
<span key={String(copied)} className={["animate-checkbox-in", copied ? "text-emerald-600" : ""].join(" ")}>
|
|
114
|
+
{copied ? <CheckIcon /> : <LinkIcon />}
|
|
115
|
+
</span>
|
|
116
|
+
<span className="w-[62px] text-left">{copied ? "copied" : "copy link"}</span>
|
|
117
|
+
</>
|
|
118
|
+
),
|
|
119
|
+
wide: true,
|
|
120
|
+
},
|
|
121
|
+
{ label: "Share on X", onClick: handleTwitter, content: <XIcon />, wide: false },
|
|
122
|
+
{ label: "Share on LinkedIn", onClick: handleLinkedIn, content: <LinkedInIcon />, wide: false },
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<div className={className}>
|
|
127
|
+
<p className="text-[12px] font-medium text-neutral-500 lowercase mb-3">
|
|
128
|
+
Share
|
|
129
|
+
</p>
|
|
130
|
+
<ButtonGroup label="Share" items={keys} />
|
|
131
|
+
<span aria-live="polite" className="sr-only">{copied ? "Link copied to clipboard" : ""}</span>
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useRef, useEffect, useLayoutEffect } from "react";
|
|
4
|
+
|
|
5
|
+
interface SidebarItem {
|
|
6
|
+
key: string;
|
|
7
|
+
label: string;
|
|
8
|
+
icon?: React.ReactNode;
|
|
9
|
+
badge?: string | number;
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SidebarSection {
|
|
14
|
+
title?: string;
|
|
15
|
+
items: SidebarItem[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface SidebarHeader {
|
|
19
|
+
logo?: React.ReactNode;
|
|
20
|
+
title: string;
|
|
21
|
+
subtitle?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface SidebarProps {
|
|
25
|
+
sections: SidebarSection[];
|
|
26
|
+
header?: SidebarHeader;
|
|
27
|
+
defaultActive?: string;
|
|
28
|
+
active?: string;
|
|
29
|
+
onSelect?: (key: string) => void;
|
|
30
|
+
className?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function Sidebar({
|
|
34
|
+
sections,
|
|
35
|
+
header,
|
|
36
|
+
defaultActive = "",
|
|
37
|
+
active,
|
|
38
|
+
onSelect,
|
|
39
|
+
className = "",
|
|
40
|
+
}: SidebarProps) {
|
|
41
|
+
const [internal, setInternal] = useState(defaultActive);
|
|
42
|
+
const current = active !== undefined ? active : internal;
|
|
43
|
+
const [pill, setPill] = useState({ top: 0, height: 0, ready: false });
|
|
44
|
+
const navRef = useRef<HTMLElement>(null);
|
|
45
|
+
const btnRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
|
46
|
+
|
|
47
|
+
const measurePill = (key: string) => {
|
|
48
|
+
const btn = btnRefs.current[key];
|
|
49
|
+
const nav = navRef.current;
|
|
50
|
+
if (!btn || !nav) return;
|
|
51
|
+
const nr = nav.getBoundingClientRect();
|
|
52
|
+
const br = btn.getBoundingClientRect();
|
|
53
|
+
setPill({ top: br.top - nr.top, height: br.height, ready: true });
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
useLayoutEffect(() => { measurePill(current); }, []);
|
|
57
|
+
useEffect(() => { measurePill(current); }, [current]);
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
const ro = new ResizeObserver(() => measurePill(current));
|
|
61
|
+
if (navRef.current) ro.observe(navRef.current);
|
|
62
|
+
return () => ro.disconnect();
|
|
63
|
+
}, [current]);
|
|
64
|
+
|
|
65
|
+
const handleSelect = (key: string, disabled?: boolean) => {
|
|
66
|
+
if (disabled) return;
|
|
67
|
+
setInternal(key);
|
|
68
|
+
onSelect?.(key);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div
|
|
73
|
+
className={["rounded-[20px] p-1.5", className].join(" ")}
|
|
74
|
+
style={{ background: "var(--panel-bezel)", boxShadow: "var(--panel-shadow)" }}
|
|
75
|
+
>
|
|
76
|
+
<div className="bg-white rounded-[15px] overflow-hidden" style={{ boxShadow: "var(--panel-face-shadow)" }}>
|
|
77
|
+
{header && (
|
|
78
|
+
<div className="px-3 py-3 border-b border-neutral-100 flex items-center gap-2.5">
|
|
79
|
+
{header.logo && (
|
|
80
|
+
<div
|
|
81
|
+
className="w-7 h-7 rounded-[8px] flex items-center justify-center shrink-0 text-white"
|
|
82
|
+
style={{ background: "var(--face-dark)", boxShadow: "var(--face-dark-shadow)" }}
|
|
83
|
+
>
|
|
84
|
+
{header.logo}
|
|
85
|
+
</div>
|
|
86
|
+
)}
|
|
87
|
+
<div className="flex-1 min-w-0">
|
|
88
|
+
<p className="text-[13px] font-semibold text-neutral-900 leading-none truncate">{header.title}</p>
|
|
89
|
+
{header.subtitle && (
|
|
90
|
+
<p className="text-[11px] text-neutral-400 mt-0.5 leading-none truncate">{header.subtitle}</p>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
<button
|
|
94
|
+
type="button"
|
|
95
|
+
aria-label="Switch workspace"
|
|
96
|
+
className="shrink-0 w-6 h-6 flex items-center justify-center text-neutral-400 hover:text-neutral-900 transition-colors cursor-pointer rounded-[7px] hover:bg-neutral-100"
|
|
97
|
+
>
|
|
98
|
+
<svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
99
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M8 9l4-4 4 4M8 15l4 4 4-4" />
|
|
100
|
+
</svg>
|
|
101
|
+
</button>
|
|
102
|
+
</div>
|
|
103
|
+
)}
|
|
104
|
+
|
|
105
|
+
<nav ref={navRef} aria-label={header?.title ?? "Sidebar"} className="relative p-2 flex flex-col gap-4">
|
|
106
|
+
{pill.ready && (
|
|
107
|
+
<span
|
|
108
|
+
aria-hidden
|
|
109
|
+
className="absolute left-2 right-2 rounded-[11px] pointer-events-none z-0"
|
|
110
|
+
style={{
|
|
111
|
+
top: pill.top,
|
|
112
|
+
height: pill.height,
|
|
113
|
+
background: "var(--face-dark)",
|
|
114
|
+
boxShadow: "var(--face-dark-shadow)",
|
|
115
|
+
transition: "top 300ms var(--ease-spring), height 300ms var(--ease-spring)",
|
|
116
|
+
}}
|
|
117
|
+
/>
|
|
118
|
+
)}
|
|
119
|
+
|
|
120
|
+
{sections.map((section, si) => (
|
|
121
|
+
<div key={si} className="flex flex-col gap-0.5">
|
|
122
|
+
{section.title && (
|
|
123
|
+
<p className="relative z-10 px-2.5 pt-1 pb-1.5 text-[11.5px] font-medium text-neutral-400 lowercase">
|
|
124
|
+
{section.title}
|
|
125
|
+
</p>
|
|
126
|
+
)}
|
|
127
|
+
{section.items.map((item) => {
|
|
128
|
+
const isActive = current === item.key;
|
|
129
|
+
return (
|
|
130
|
+
<button
|
|
131
|
+
key={item.key}
|
|
132
|
+
ref={(el) => { btnRefs.current[item.key] = el; }}
|
|
133
|
+
type="button"
|
|
134
|
+
onClick={() => handleSelect(item.key, item.disabled)}
|
|
135
|
+
disabled={item.disabled}
|
|
136
|
+
aria-current={isActive ? "page" : undefined}
|
|
137
|
+
className={[
|
|
138
|
+
"relative z-10 w-full flex items-center gap-2.5 px-2.5 py-2 rounded-[11px] text-[13px] font-medium text-left",
|
|
139
|
+
"transition-colors duration-200 cursor-pointer",
|
|
140
|
+
"disabled:opacity-35 disabled:pointer-events-none",
|
|
141
|
+
isActive
|
|
142
|
+
? "text-white"
|
|
143
|
+
: "text-neutral-600 hover:text-neutral-900 hover:bg-neutral-100/80",
|
|
144
|
+
].join(" ")}
|
|
145
|
+
>
|
|
146
|
+
{item.icon && (
|
|
147
|
+
<span
|
|
148
|
+
className={[
|
|
149
|
+
"shrink-0 w-[22px] h-[22px] rounded-md flex items-center justify-center transition-colors duration-200",
|
|
150
|
+
isActive
|
|
151
|
+
? "bg-white/12 shadow-[inset_0_1px_0_rgba(255,255,255,0.12)]"
|
|
152
|
+
: "text-neutral-500 [background:var(--face-light)] [box-shadow:var(--face-light-shadow)]",
|
|
153
|
+
].join(" ")}
|
|
154
|
+
>
|
|
155
|
+
{item.icon}
|
|
156
|
+
</span>
|
|
157
|
+
)}
|
|
158
|
+
|
|
159
|
+
<span className="flex-1 truncate">{item.label}</span>
|
|
160
|
+
|
|
161
|
+
{item.badge !== undefined && (
|
|
162
|
+
<span
|
|
163
|
+
className={[
|
|
164
|
+
"shrink-0 text-[11px] font-medium px-1.5 py-0.5 rounded-md tabular-nums leading-none transition-colors duration-200",
|
|
165
|
+
isActive
|
|
166
|
+
? "bg-white/20 text-white/80"
|
|
167
|
+
: "bg-neutral-100 text-neutral-500",
|
|
168
|
+
].join(" ")}
|
|
169
|
+
>
|
|
170
|
+
{item.badge}
|
|
171
|
+
</span>
|
|
172
|
+
)}
|
|
173
|
+
</button>
|
|
174
|
+
);
|
|
175
|
+
})}
|
|
176
|
+
</div>
|
|
177
|
+
))}
|
|
178
|
+
</nav>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
type SkeletonVariant = "text" | "circle" | "rect" | "card";
|
|
4
|
+
|
|
5
|
+
interface SkeletonProps {
|
|
6
|
+
variant?: SkeletonVariant;
|
|
7
|
+
width?: string | number;
|
|
8
|
+
height?: string | number;
|
|
9
|
+
lines?: number;
|
|
10
|
+
className?: string;
|
|
11
|
+
/** What is loading, for screen readers */
|
|
12
|
+
label?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/* Placeholders are empty sunken wells — the shape of what will fill them —
|
|
16
|
+
with a light sweep travelling across, rather than a blink. */
|
|
17
|
+
const base = "skeleton-sheen relative overflow-hidden rounded-[8px]";
|
|
18
|
+
const well: React.CSSProperties = { background: "var(--well)", boxShadow: "var(--well-shadow)" };
|
|
19
|
+
|
|
20
|
+
export function Skeleton({
|
|
21
|
+
variant = "text",
|
|
22
|
+
width,
|
|
23
|
+
height,
|
|
24
|
+
lines = 1,
|
|
25
|
+
className = "",
|
|
26
|
+
label = "Loading",
|
|
27
|
+
}: SkeletonProps) {
|
|
28
|
+
const a11y = { role: "status", "aria-busy": true, "aria-label": label } as const;
|
|
29
|
+
|
|
30
|
+
if (variant === "circle") {
|
|
31
|
+
return (
|
|
32
|
+
<div
|
|
33
|
+
{...a11y}
|
|
34
|
+
className={[base, "rounded-full", className].join(" ")}
|
|
35
|
+
style={{ ...well, width: width ?? 40, height: height ?? 40 }}
|
|
36
|
+
/>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (variant === "card") {
|
|
41
|
+
return (
|
|
42
|
+
<div
|
|
43
|
+
{...a11y}
|
|
44
|
+
className={["rounded-[20px] p-1.5", className].join(" ")}
|
|
45
|
+
style={{ background: "var(--panel-bezel)", boxShadow: "var(--panel-shadow)" }}
|
|
46
|
+
>
|
|
47
|
+
<div className="bg-white rounded-[15px] p-1.5" style={{ boxShadow: "var(--panel-face-shadow)" }}>
|
|
48
|
+
<div className={[base, "w-full rounded-[11px]"].join(" ")} style={{ ...well, height: height ?? 160 }} />
|
|
49
|
+
<div className="px-3.5 pt-4 pb-3.5 flex flex-col gap-2.5">
|
|
50
|
+
<div className={[base, "h-3.5 w-3/5"].join(" ")} style={well} />
|
|
51
|
+
<div className={[base, "h-2.5 w-full"].join(" ")} style={well} />
|
|
52
|
+
<div className={[base, "h-2.5 w-4/5"].join(" ")} style={well} />
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (variant === "rect") {
|
|
60
|
+
return (
|
|
61
|
+
<div
|
|
62
|
+
{...a11y}
|
|
63
|
+
className={[base, "rounded-[12px]", className].join(" ")}
|
|
64
|
+
style={{ ...well, width: width ?? "100%", height: height ?? 80 }}
|
|
65
|
+
/>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div {...a11y} className={["flex flex-col gap-2", className].join(" ")}>
|
|
71
|
+
{Array.from({ length: lines }).map((_, i) => (
|
|
72
|
+
<div
|
|
73
|
+
key={i}
|
|
74
|
+
className={[base, "h-2.5 rounded-full"].join(" ")}
|
|
75
|
+
style={{
|
|
76
|
+
...well,
|
|
77
|
+
width: width ?? (i === lines - 1 && lines > 1 ? "60%" : "100%"),
|
|
78
|
+
}}
|
|
79
|
+
/>
|
|
80
|
+
))}
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { CardShell } from "./CardShell";
|
|
3
|
+
|
|
4
|
+
interface SpotlightCardProps {
|
|
5
|
+
title: string;
|
|
6
|
+
subtitle?: string;
|
|
7
|
+
eyebrow?: string;
|
|
8
|
+
icon?: React.ReactNode;
|
|
9
|
+
actions?: React.ReactNode;
|
|
10
|
+
children?: React.ReactNode;
|
|
11
|
+
theme?: "light" | "dark";
|
|
12
|
+
size?: "sm" | "md" | "lg";
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const paddingBySize: Record<NonNullable<SpotlightCardProps["size"]>, string> = {
|
|
17
|
+
// Feature cards pad 32px whatever their size; only the type scales
|
|
18
|
+
sm: "p-6",
|
|
19
|
+
md: "p-8",
|
|
20
|
+
lg: "p-8",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const titleSizeBySize: Record<
|
|
24
|
+
NonNullable<SpotlightCardProps["size"]>,
|
|
25
|
+
string
|
|
26
|
+
> = {
|
|
27
|
+
sm: "text-[20px]",
|
|
28
|
+
md: "text-[26px]",
|
|
29
|
+
lg: "text-[34px]",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function SpotlightCard({
|
|
33
|
+
title,
|
|
34
|
+
subtitle,
|
|
35
|
+
eyebrow,
|
|
36
|
+
icon,
|
|
37
|
+
actions,
|
|
38
|
+
children,
|
|
39
|
+
theme = "dark",
|
|
40
|
+
size = "md",
|
|
41
|
+
className = "",
|
|
42
|
+
}: SpotlightCardProps) {
|
|
43
|
+
const dark = theme === "dark";
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
/* Built on CardShell: icon at the top of the face, words and actions
|
|
47
|
+
settled at the bottom — the same rhythm as Card and QuoteCard. */
|
|
48
|
+
<CardShell as="article" theme={theme} className={className}>
|
|
49
|
+
{/* Headline sits low on the face, so tall cards breathe above it, not below */}
|
|
50
|
+
<div className={["relative flex-1 flex flex-col gap-3", paddingBySize[size]].join(" ")}>
|
|
51
|
+
{icon && (
|
|
52
|
+
<div
|
|
53
|
+
className={[
|
|
54
|
+
"w-9 h-9 rounded-[11px] flex items-center justify-center mb-auto",
|
|
55
|
+
dark ? "text-white" : "text-neutral-800",
|
|
56
|
+
].join(" ")}
|
|
57
|
+
style={{
|
|
58
|
+
background: dark ? "var(--tile-dark)" : "var(--face-light)",
|
|
59
|
+
boxShadow: dark ? "var(--tile-dark-shadow)" : "var(--tile-light-shadow)",
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
{icon}
|
|
63
|
+
</div>
|
|
64
|
+
)}
|
|
65
|
+
|
|
66
|
+
{eyebrow && (
|
|
67
|
+
<p
|
|
68
|
+
className={[
|
|
69
|
+
// Without an icon above to push it down, the copy still sits low
|
|
70
|
+
icon ? "pt-8" : "mt-auto",
|
|
71
|
+
"text-[14px] font-medium tracking-[-0.01em]",
|
|
72
|
+
dark ? "text-neutral-400" : "text-neutral-500",
|
|
73
|
+
].join(" ")}
|
|
74
|
+
>
|
|
75
|
+
{eyebrow}
|
|
76
|
+
</p>
|
|
77
|
+
)}
|
|
78
|
+
|
|
79
|
+
<h3
|
|
80
|
+
className={[
|
|
81
|
+
titleSizeBySize[size],
|
|
82
|
+
!icon && !eyebrow ? "mt-auto" : "",
|
|
83
|
+
"font-medium leading-[1.12] tracking-[-0.03em] text-balance",
|
|
84
|
+
dark ? "text-white" : "text-neutral-900",
|
|
85
|
+
].join(" ")}
|
|
86
|
+
>
|
|
87
|
+
{title}
|
|
88
|
+
</h3>
|
|
89
|
+
|
|
90
|
+
{subtitle && (
|
|
91
|
+
<p
|
|
92
|
+
className={[
|
|
93
|
+
"text-[15px] leading-[1.6] max-w-lg text-pretty",
|
|
94
|
+
dark ? "text-neutral-400" : "text-neutral-600",
|
|
95
|
+
].join(" ")}
|
|
96
|
+
>
|
|
97
|
+
{subtitle}
|
|
98
|
+
</p>
|
|
99
|
+
)}
|
|
100
|
+
|
|
101
|
+
{children}
|
|
102
|
+
{actions && <div className="pt-4 flex items-center gap-2">{actions}</div>}
|
|
103
|
+
</div>
|
|
104
|
+
</CardShell>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
4
|
+
|
|
5
|
+
interface StatProps {
|
|
6
|
+
value: number | string;
|
|
7
|
+
label: string;
|
|
8
|
+
prefix?: string;
|
|
9
|
+
suffix?: string;
|
|
10
|
+
trend?: string;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function useCountUp(target: number, duration = 800) {
|
|
15
|
+
const [count, setCount] = useState(0);
|
|
16
|
+
const raf = useRef<number | null>(null);
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
|
20
|
+
raf.current = requestAnimationFrame(() => setCount(target));
|
|
21
|
+
return () => { if (raf.current) cancelAnimationFrame(raf.current); };
|
|
22
|
+
}
|
|
23
|
+
let start: number | null = null;
|
|
24
|
+
const step = (now: number) => {
|
|
25
|
+
if (!start) start = now;
|
|
26
|
+
const p = Math.min((now - start) / duration, 1);
|
|
27
|
+
setCount(Math.round((1 - Math.pow(1 - p, 3)) * target));
|
|
28
|
+
if (p < 1) raf.current = requestAnimationFrame(step);
|
|
29
|
+
};
|
|
30
|
+
raf.current = requestAnimationFrame(step);
|
|
31
|
+
return () => { if (raf.current) cancelAnimationFrame(raf.current); };
|
|
32
|
+
}, [target, duration]);
|
|
33
|
+
|
|
34
|
+
return count;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function Stat({ value, label, prefix = "", suffix = "", trend, className = "" }: StatProps) {
|
|
38
|
+
const isNum = typeof value === "number";
|
|
39
|
+
const counted = useCountUp(isNum ? (value as number) : 0);
|
|
40
|
+
const displayed = isNum ? counted : value;
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className={["flex flex-col gap-0.5", className].join(" ")}>
|
|
44
|
+
<div className="flex items-baseline gap-1.5">
|
|
45
|
+
<span
|
|
46
|
+
className="text-[32px] font-semibold text-neutral-900 leading-none tabular-nums"
|
|
47
|
+
style={{ letterSpacing: "-0.03em" }}
|
|
48
|
+
>
|
|
49
|
+
{prefix}{displayed}{suffix}
|
|
50
|
+
</span>
|
|
51
|
+
{trend && (
|
|
52
|
+
<span className="text-[12px] text-neutral-500 font-medium">{trend}</span>
|
|
53
|
+
)}
|
|
54
|
+
</div>
|
|
55
|
+
<span className="text-[12px] text-neutral-500">{label}</span>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface StatRowProps {
|
|
61
|
+
children: React.ReactNode;
|
|
62
|
+
className?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function StatRow({ children, className = "" }: StatRowProps) {
|
|
66
|
+
const arr = React.Children.toArray(children);
|
|
67
|
+
return (
|
|
68
|
+
<div className={["grid grid-cols-2 gap-6 sm:flex sm:divide-x sm:divide-neutral-200 sm:gap-0", className].join(" ")}>
|
|
69
|
+
{arr.map((child, i) => (
|
|
70
|
+
<div
|
|
71
|
+
key={i}
|
|
72
|
+
className={[
|
|
73
|
+
"min-w-0",
|
|
74
|
+
i === 0 ? "sm:pr-6" : i === arr.length - 1 ? "sm:pl-6" : "sm:px-6",
|
|
75
|
+
].join(" ")}
|
|
76
|
+
>
|
|
77
|
+
{child}
|
|
78
|
+
</div>
|
|
79
|
+
))}
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|