@blurrow/ui 0.1.0 → 0.2.1
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 +60 -20
- package/dist/Accordion.d.ts +17 -0
- package/dist/Accordion.js +138 -0
- package/dist/Accordion.js.map +1 -0
- package/dist/Alert.d.ts +13 -0
- package/dist/Alert.js +69 -0
- package/dist/Alert.js.map +1 -0
- package/dist/AuthorCard.d.ts +12 -0
- package/dist/AuthorCard.js +57 -0
- package/dist/AuthorCard.js.map +1 -0
- package/dist/Avatar.d.ts +21 -0
- package/dist/Avatar.js +96 -0
- package/dist/Avatar.js.map +1 -0
- package/dist/Badge.d.ts +12 -0
- package/dist/Badge.js +46 -0
- package/dist/Badge.js.map +1 -0
- package/dist/Banner.d.ts +15 -0
- package/dist/Banner.js +68 -0
- package/dist/Banner.js.map +1 -0
- package/dist/Breadcrumb.d.ts +13 -0
- package/dist/Breadcrumb.js +45 -0
- package/dist/Breadcrumb.js.map +1 -0
- package/dist/Button.d.ts +19 -0
- package/dist/Button.js +282 -0
- package/dist/Button.js.map +1 -0
- package/dist/ButtonGroup.d.ts +19 -0
- package/dist/ButtonGroup.js +62 -0
- package/dist/ButtonGroup.js.map +1 -0
- package/dist/Card.d.ts +40 -0
- package/dist/Card.js +175 -0
- package/dist/Card.js.map +1 -0
- package/dist/CardShell.d.ts +18 -0
- package/dist/CardShell.js +58 -0
- package/dist/CardShell.js.map +1 -0
- package/dist/Carousel.d.ts +22 -0
- package/dist/Carousel.js +247 -0
- package/dist/Carousel.js.map +1 -0
- package/dist/Checkbox.d.ts +15 -0
- package/dist/Checkbox.js +84 -0
- package/dist/Checkbox.js.map +1 -0
- package/dist/CloseButton.d.ts +12 -0
- package/dist/CloseButton.js +63 -0
- package/dist/CloseButton.js.map +1 -0
- package/dist/CodeBlock.d.ts +12 -0
- package/dist/CodeBlock.js +221 -0
- package/dist/CodeBlock.js.map +1 -0
- package/dist/ColorPicker.d.ts +24 -0
- package/dist/ColorPicker.js +366 -0
- package/dist/ColorPicker.js.map +1 -0
- package/dist/Combobox.d.ts +34 -0
- package/dist/Combobox.js +255 -0
- package/dist/Combobox.js.map +1 -0
- package/dist/CommandPalette.d.ts +25 -0
- package/dist/CommandPalette.js +304 -0
- package/dist/CommandPalette.js.map +1 -0
- package/dist/ContextMenu.d.ts +16 -0
- package/dist/ContextMenu.js +209 -0
- package/dist/ContextMenu.js.map +1 -0
- package/dist/DataList.d.ts +15 -0
- package/dist/DataList.js +42 -0
- package/dist/DataList.js.map +1 -0
- package/dist/DatePicker.d.ts +65 -0
- package/dist/DatePicker.js +391 -0
- package/dist/DatePicker.js.map +1 -0
- package/dist/DateRangePicker.d.ts +34 -0
- package/dist/DateRangePicker.js +180 -0
- package/dist/DateRangePicker.js.map +1 -0
- package/dist/Drawer.d.ts +17 -0
- package/dist/Drawer.js +132 -0
- package/dist/Drawer.js.map +1 -0
- package/dist/DropdownMenu.d.ts +23 -0
- package/dist/DropdownMenu.js +185 -0
- package/dist/DropdownMenu.js.map +1 -0
- package/dist/EmptyState.d.ts +13 -0
- package/dist/EmptyState.js +55 -0
- package/dist/EmptyState.js.map +1 -0
- package/dist/FieldMessage.d.ts +26 -0
- package/dist/FieldMessage.js +95 -0
- package/dist/FieldMessage.js.map +1 -0
- package/dist/FileUpload.d.ts +32 -0
- package/dist/FileUpload.js +253 -0
- package/dist/FileUpload.js.map +1 -0
- package/dist/Floating.d.ts +32 -0
- package/dist/Floating.js +95 -0
- package/dist/Floating.js.map +1 -0
- package/dist/HoverCard.d.ts +18 -0
- package/dist/HoverCard.js +90 -0
- package/dist/HoverCard.js.map +1 -0
- package/dist/Input.d.ts +17 -0
- package/dist/Input.js +96 -0
- package/dist/Input.js.map +1 -0
- package/dist/Kbd.d.ts +9 -0
- package/dist/Kbd.js +24 -0
- package/dist/Kbd.js.map +1 -0
- package/dist/Modal.d.ts +15 -0
- package/dist/Modal.js +109 -0
- package/dist/Modal.js.map +1 -0
- package/dist/NumberInput.d.ts +23 -0
- package/dist/NumberInput.js +162 -0
- package/dist/NumberInput.js.map +1 -0
- package/dist/OtpInput.d.ts +26 -0
- package/dist/OtpInput.js +173 -0
- package/dist/OtpInput.js.map +1 -0
- package/dist/Pagination.d.ts +11 -0
- package/dist/Pagination.js +148 -0
- package/dist/Pagination.js.map +1 -0
- package/dist/Popover.d.ts +13 -0
- package/dist/Popover.js +98 -0
- package/dist/Popover.js.map +1 -0
- package/dist/Progress.d.ts +12 -0
- package/dist/Progress.js +61 -0
- package/dist/Progress.js.map +1 -0
- package/dist/QuoteCard.d.ts +13 -0
- package/dist/QuoteCard.js +92 -0
- package/dist/QuoteCard.js.map +1 -0
- package/dist/Radio.d.ts +19 -0
- package/dist/Radio.js +161 -0
- package/dist/Radio.js.map +1 -0
- package/dist/RadioCards.d.ts +27 -0
- package/dist/RadioCards.js +205 -0
- package/dist/RadioCards.js.map +1 -0
- package/dist/ReactionBar.d.ts +15 -0
- package/dist/ReactionBar.js +92 -0
- package/dist/ReactionBar.js.map +1 -0
- package/dist/ReadingProgress.d.ts +8 -0
- package/dist/ReadingProgress.js +51 -0
- package/dist/ReadingProgress.js.map +1 -0
- package/dist/ReadingTime.d.ts +9 -0
- package/dist/ReadingTime.js +39 -0
- package/dist/ReadingTime.js.map +1 -0
- package/dist/Select.d.ts +20 -0
- package/dist/Select.js +244 -0
- package/dist/Select.js.map +1 -0
- package/dist/Separator.d.ts +14 -0
- package/dist/Separator.js +33 -0
- package/dist/Separator.js.map +1 -0
- package/dist/ShareButtons.d.ts +10 -0
- package/dist/ShareButtons.js +107 -0
- package/dist/ShareButtons.js.map +1 -0
- package/dist/Sidebar.d.ts +30 -0
- package/dist/Sidebar.js +140 -0
- package/dist/Sidebar.js.map +1 -0
- package/dist/Skeleton.d.ts +15 -0
- package/dist/Skeleton.js +67 -0
- package/dist/Skeleton.js.map +1 -0
- package/dist/Slider.d.ts +27 -0
- package/dist/Slider.js +182 -0
- package/dist/Slider.js.map +1 -0
- package/dist/Spinner.d.ts +12 -0
- package/dist/Spinner.js +24 -0
- package/dist/Spinner.js.map +1 -0
- package/dist/SpotlightCard.d.ts +17 -0
- package/dist/SpotlightCard.js +87 -0
- package/dist/SpotlightCard.js.map +1 -0
- package/dist/Stat.d.ts +19 -0
- package/dist/Stat.js +71 -0
- package/dist/Stat.js.map +1 -0
- package/dist/StatCard.d.ts +24 -0
- package/dist/StatCard.js +212 -0
- package/dist/StatCard.js.map +1 -0
- package/dist/Steps.d.ts +14 -0
- package/dist/Steps.js +94 -0
- package/dist/Steps.js.map +1 -0
- package/dist/Table.d.ts +21 -0
- package/dist/Table.js +93 -0
- package/dist/Table.js.map +1 -0
- package/dist/TableOfContents.d.ts +17 -0
- package/dist/TableOfContents.js +92 -0
- package/dist/TableOfContents.js.map +1 -0
- package/dist/Tabs.d.ts +17 -0
- package/dist/Tabs.js +123 -0
- package/dist/Tabs.js.map +1 -0
- package/dist/TagInput.d.ts +16 -0
- package/dist/TagInput.js +169 -0
- package/dist/TagInput.js.map +1 -0
- package/dist/Textarea.d.ts +13 -0
- package/dist/Textarea.js +74 -0
- package/dist/Textarea.js.map +1 -0
- package/dist/TimePicker.d.ts +28 -0
- package/dist/TimePicker.js +264 -0
- package/dist/TimePicker.js.map +1 -0
- package/dist/Timeline.d.ts +19 -0
- package/dist/Timeline.js +76 -0
- package/dist/Timeline.js.map +1 -0
- package/dist/Toast.d.ts +18 -0
- package/dist/Toast.js +318 -0
- package/dist/Toast.js.map +1 -0
- package/dist/Toggle.d.ts +13 -0
- package/dist/Toggle.js +84 -0
- package/dist/Toggle.js.map +1 -0
- package/dist/Tooltip.d.ts +13 -0
- package/dist/Tooltip.js +138 -0
- package/dist/Tooltip.js.map +1 -0
- package/dist/cli.js +1644 -0
- package/dist/index.d.ts +68 -631
- package/dist/index.js +67 -5558
- package/dist/index.js.map +1 -1
- package/dist/registry/Accordion.tsx +140 -0
- package/dist/registry/Alert.tsx +95 -0
- package/dist/registry/AuthorCard.tsx +67 -0
- package/dist/registry/Avatar.tsx +116 -0
- package/dist/registry/Badge.tsx +55 -0
- package/dist/registry/Banner.tsx +86 -0
- package/dist/registry/Breadcrumb.tsx +60 -0
- package/dist/registry/Button.tsx +361 -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 +296 -0
- package/dist/registry/Checkbox.tsx +104 -0
- package/dist/registry/CloseButton.tsx +68 -0
- package/dist/registry/CodeBlock.tsx +202 -0
- package/dist/registry/ColorPicker.tsx +404 -0
- package/dist/registry/Combobox.tsx +318 -0
- package/dist/registry/CommandPalette.tsx +356 -0
- package/dist/registry/ContextMenu.tsx +241 -0
- package/dist/registry/DataList.tsx +49 -0
- package/dist/registry/DatePicker.tsx +494 -0
- package/dist/registry/DateRangePicker.tsx +232 -0
- package/dist/registry/Drawer.tsx +166 -0
- package/dist/registry/DropdownMenu.tsx +219 -0
- package/dist/registry/EmptyState.tsx +55 -0
- package/dist/registry/FieldMessage.tsx +123 -0
- package/dist/registry/FileUpload.tsx +318 -0
- package/dist/registry/Floating.tsx +134 -0
- package/dist/registry/HoverCard.tsx +113 -0
- package/dist/registry/Input.tsx +108 -0
- package/dist/registry/Kbd.tsx +23 -0
- package/dist/registry/Modal.tsx +130 -0
- package/dist/registry/NumberInput.tsx +183 -0
- package/dist/registry/OtpInput.tsx +219 -0
- package/dist/registry/Pagination.tsx +160 -0
- package/dist/registry/Popover.tsx +116 -0
- package/dist/registry/Progress.tsx +66 -0
- package/dist/registry/QuoteCard.tsx +93 -0
- package/dist/registry/Radio.tsx +205 -0
- package/dist/registry/RadioCards.tsx +247 -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 +265 -0
- package/dist/registry/Separator.tsx +52 -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/Slider.tsx +229 -0
- package/dist/registry/Spinner.tsx +32 -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 +123 -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/TimePicker.tsx +315 -0
- package/dist/registry/Timeline.tsx +102 -0
- package/dist/registry/Toast.tsx +388 -0
- package/dist/registry/Toggle.tsx +96 -0
- package/dist/registry/Tooltip.tsx +160 -0
- package/dist/registry/styles.css +1207 -0
- package/dist/registry/useFloatingPanel.ts +112 -0
- package/dist/registry/useModalBehavior.ts +77 -0
- package/dist/styles.css +868 -62
- package/dist/useFloatingPanel.d.ts +24 -0
- package/dist/useFloatingPanel.js +89 -0
- package/dist/useFloatingPanel.js.map +1 -0
- package/dist/useModalBehavior.d.ts +15 -0
- package/dist/useModalBehavior.js +57 -0
- package/dist/useModalBehavior.js.map +1 -0
- package/package.json +23 -6
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
type SpinnerSize = "sm" | "md" | "lg";
|
|
4
|
+
|
|
5
|
+
interface SpinnerProps {
|
|
6
|
+
size?: SpinnerSize | number;
|
|
7
|
+
/** Read out to screen readers; the spinner is silent without it */
|
|
8
|
+
label?: string;
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const SIZES: Record<SpinnerSize, number> = { sm: 14, md: 18, lg: 24 };
|
|
13
|
+
|
|
14
|
+
/* A short arc chasing round a faint ring. It takes the text colour, so
|
|
15
|
+
inside a primary button it's light and on the page it's ink. */
|
|
16
|
+
export function Spinner({ size = "md", label, className = "" }: SpinnerProps) {
|
|
17
|
+
const px = typeof size === "number" ? size : SIZES[size];
|
|
18
|
+
return (
|
|
19
|
+
<span
|
|
20
|
+
role={label ? "status" : undefined}
|
|
21
|
+
aria-label={label}
|
|
22
|
+
aria-hidden={label ? undefined : true}
|
|
23
|
+
className={["inline-flex shrink-0", className].join(" ")}
|
|
24
|
+
style={{ width: px, height: px }}
|
|
25
|
+
>
|
|
26
|
+
<svg viewBox="0 0 24 24" width={px} height={px} fill="none" className="animate-spin">
|
|
27
|
+
<circle cx="12" cy="12" r="9.5" stroke="currentColor" strokeWidth="2.5" opacity="0.18" />
|
|
28
|
+
<path d="M21.5 12a9.5 9.5 0 0 0-9.5-9.5" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
|
29
|
+
</svg>
|
|
30
|
+
</span>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -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-ink-soft",
|
|
56
|
+
].join(" ")}
|
|
57
|
+
style={{
|
|
58
|
+
background: dark ? "var(--tile-dark)" : "var(--tile)",
|
|
59
|
+
boxShadow: dark ? "var(--tile-dark-shadow)" : "var(--tile-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-ink-muted",
|
|
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-ink",
|
|
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-ink-body",
|
|
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-ink leading-none tabular-nums"
|
|
47
|
+
style={{ letterSpacing: "-0.03em" }}
|
|
48
|
+
>
|
|
49
|
+
{prefix}{displayed}{suffix}
|
|
50
|
+
</span>
|
|
51
|
+
{trend && (
|
|
52
|
+
<span className="text-[12px] text-ink-muted font-medium">{trend}</span>
|
|
53
|
+
)}
|
|
54
|
+
</div>
|
|
55
|
+
<span className="text-[12px] text-ink-muted">{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-line-strong 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
|
+
}
|
|
@@ -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 ? "var(--spark-dark)" : "var(--spark)";
|
|
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%, black 18%)",
|
|
90
|
+
WebkitMaskImage: "linear-gradient(90deg, transparent 0%, black 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%" style={{ stopColor: stroke }} stopOpacity={dark ? 0.2 : 0.08} />
|
|
97
|
+
<stop offset="100%" style={{ stopColor: stroke }} stopOpacity="0" />
|
|
98
|
+
</linearGradient>
|
|
99
|
+
</defs>
|
|
100
|
+
<path d={area} fill={`url(#${gradId})`} />
|
|
101
|
+
<path
|
|
102
|
+
d={line}
|
|
103
|
+
fill="none"
|
|
104
|
+
style={{ 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 var(--spark-dark-halo)" : "0 0 0 3px var(--spark-halo)",
|
|
120
|
+
}}
|
|
121
|
+
/>
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const deltaTone: Record<DeltaDirection, { light: string; dark: string; label: string }> = {
|
|
127
|
+
up: { light: "text-success", dark: "text-emerald-400", label: "up" },
|
|
128
|
+
down: { light: "text-danger", dark: "text-red-400", label: "down" },
|
|
129
|
+
neutral: { light: "text-ink-body", 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(--tile)",
|
|
175
|
+
boxShadow: dark ? "var(--tile-dark-shadow)" : "var(--tile-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-ink-body",
|
|
196
|
+
].join(" ")}
|
|
197
|
+
style={{
|
|
198
|
+
background: dark ? "var(--tile-dark)" : "var(--tile)",
|
|
199
|
+
boxShadow: dark ? "var(--tile-dark-shadow)" : "var(--tile-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-ink-label",
|
|
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-ink",
|
|
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-ink-faint",
|
|
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-ink-muted"].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-inverse)",
|
|
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-ink-inverse" : "text-ink-faint",
|
|
77
|
+
].join(" ")}
|
|
78
|
+
style={{
|
|
79
|
+
background: reached ? "var(--face-inverse)" : "var(--field)",
|
|
80
|
+
boxShadow: isCurrent
|
|
81
|
+
? "var(--face-inverse-shadow), var(--halo)"
|
|
82
|
+
: reached
|
|
83
|
+
? "var(--face-inverse-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-ink" : "text-ink-muted",
|
|
96
|
+
].join(" ")}
|
|
97
|
+
>
|
|
98
|
+
{step.label}
|
|
99
|
+
</span>
|
|
100
|
+
{step.description && (
|
|
101
|
+
<span className="text-[12px] text-ink-faint leading-snug">{step.description}</span>
|
|
102
|
+
)}
|
|
103
|
+
</span>
|
|
104
|
+
</li>
|
|
105
|
+
);
|
|
106
|
+
})}
|
|
107
|
+
</ol>
|
|
108
|
+
);
|
|
109
|
+
}
|