@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,140 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback, useId, ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
interface AccordionItem {
|
|
6
|
+
key: string;
|
|
7
|
+
title: string;
|
|
8
|
+
content: ReactNode;
|
|
9
|
+
defaultOpen?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface AccordionProps {
|
|
13
|
+
items: AccordionItem[];
|
|
14
|
+
allowMultiple?: boolean;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function Accordion({ items, allowMultiple = false, className }: AccordionProps) {
|
|
19
|
+
const [openKeys, setOpenKeys] = useState<Set<string>>(() => {
|
|
20
|
+
const defaults = new Set<string>();
|
|
21
|
+
for (const item of items) {
|
|
22
|
+
if (item.defaultOpen) {
|
|
23
|
+
defaults.add(item.key);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return defaults;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const toggle = useCallback(
|
|
30
|
+
(key: string) => {
|
|
31
|
+
setOpenKeys((prev) => {
|
|
32
|
+
const next = new Set(prev);
|
|
33
|
+
if (next.has(key)) {
|
|
34
|
+
next.delete(key);
|
|
35
|
+
} else {
|
|
36
|
+
if (allowMultiple) {
|
|
37
|
+
next.add(key);
|
|
38
|
+
} else {
|
|
39
|
+
next.clear();
|
|
40
|
+
next.add(key);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return next;
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
[allowMultiple]
|
|
47
|
+
);
|
|
48
|
+
const uid = useId();
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
className={["rounded-[18px] p-1.5", className].filter(Boolean).join(" ")}
|
|
53
|
+
style={{ background: "var(--panel-bezel)", boxShadow: "var(--panel-shadow)" }}
|
|
54
|
+
>
|
|
55
|
+
<div
|
|
56
|
+
className="bg-white rounded-[13px] overflow-hidden p-1"
|
|
57
|
+
style={{ boxShadow: "var(--panel-face-shadow)" }}
|
|
58
|
+
>
|
|
59
|
+
{items.map((item, index) => {
|
|
60
|
+
const isOpen = openKeys.has(item.key);
|
|
61
|
+
const isLast = index === items.length - 1;
|
|
62
|
+
const headerId = `${uid}-${item.key}-header`;
|
|
63
|
+
const panelId = `${uid}-${item.key}-panel`;
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div
|
|
67
|
+
key={item.key}
|
|
68
|
+
className={[
|
|
69
|
+
"relative",
|
|
70
|
+
// Hairline between rows, inset so it doesn't touch the rounded corners
|
|
71
|
+
!isLast &&
|
|
72
|
+
"after:absolute after:left-3 after:right-3 after:bottom-0 after:h-px after:bg-neutral-100",
|
|
73
|
+
]
|
|
74
|
+
.filter(Boolean)
|
|
75
|
+
.join(" ")}
|
|
76
|
+
>
|
|
77
|
+
<h3>
|
|
78
|
+
<button
|
|
79
|
+
type="button"
|
|
80
|
+
id={headerId}
|
|
81
|
+
aria-expanded={isOpen}
|
|
82
|
+
aria-controls={panelId}
|
|
83
|
+
onClick={() => toggle(item.key)}
|
|
84
|
+
className={[
|
|
85
|
+
"group/acc w-full flex items-center justify-between gap-3 py-3 px-3 text-left text-[13.5px] font-medium cursor-pointer rounded-[10px]",
|
|
86
|
+
"transition-colors duration-150",
|
|
87
|
+
isOpen ? "text-neutral-900" : "text-neutral-700 hover:text-neutral-900 hover:bg-neutral-50",
|
|
88
|
+
].join(" ")}
|
|
89
|
+
>
|
|
90
|
+
<span>{item.title}</span>
|
|
91
|
+
{/* Plus that turns into a minus: says "more inside", not "navigate" */}
|
|
92
|
+
<span
|
|
93
|
+
aria-hidden
|
|
94
|
+
className="relative shrink-0 w-6 h-6 rounded-[7px] flex items-center justify-center text-neutral-500 group-hover/acc:text-neutral-900 transition-colors"
|
|
95
|
+
style={{ background: "var(--face-light)", boxShadow: "var(--face-light-shadow)" }}
|
|
96
|
+
>
|
|
97
|
+
<span className="absolute w-2.5 h-[1.5px] rounded-full bg-current" />
|
|
98
|
+
<span
|
|
99
|
+
className="absolute w-2.5 h-[1.5px] rounded-full bg-current"
|
|
100
|
+
style={{
|
|
101
|
+
transform: isOpen ? "rotate(0deg)" : "rotate(90deg)",
|
|
102
|
+
transition: "transform 300ms var(--ease-spring)",
|
|
103
|
+
}}
|
|
104
|
+
/>
|
|
105
|
+
</span>
|
|
106
|
+
</button>
|
|
107
|
+
</h3>
|
|
108
|
+
|
|
109
|
+
<div
|
|
110
|
+
id={panelId}
|
|
111
|
+
role="region"
|
|
112
|
+
aria-labelledby={headerId}
|
|
113
|
+
// Closed panels are removed from the tab order and the a11y tree
|
|
114
|
+
inert={!isOpen}
|
|
115
|
+
style={{
|
|
116
|
+
display: "grid",
|
|
117
|
+
gridTemplateRows: isOpen ? "1fr" : "0fr",
|
|
118
|
+
transition: "grid-template-rows 320ms var(--ease-out-soft)",
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
121
|
+
<div className="overflow-hidden">
|
|
122
|
+
<div
|
|
123
|
+
className="px-3 pb-4 pt-0.5 text-[13px] text-neutral-600 leading-relaxed"
|
|
124
|
+
style={{
|
|
125
|
+
opacity: isOpen ? 1 : 0,
|
|
126
|
+
transform: isOpen ? "translateY(0)" : "translateY(-4px)",
|
|
127
|
+
transition: "opacity 240ms ease-out, transform 320ms var(--ease-out-soft)",
|
|
128
|
+
}}
|
|
129
|
+
>
|
|
130
|
+
{item.content}
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
})}
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { CloseButton } from "./CloseButton";
|
|
3
|
+
|
|
4
|
+
type AlertVariant = "default" | "success" | "warning" | "error";
|
|
5
|
+
|
|
6
|
+
interface AlertProps {
|
|
7
|
+
variant?: AlertVariant;
|
|
8
|
+
title: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
onDismiss?: () => void;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/* Tiles share Toast's lit-from-above gradients, so an alert and the toast
|
|
15
|
+
it came from look like the same object. */
|
|
16
|
+
const variantConfig: Record<AlertVariant, {
|
|
17
|
+
tile: string;
|
|
18
|
+
icon: React.ReactNode;
|
|
19
|
+
}> = {
|
|
20
|
+
default: {
|
|
21
|
+
tile: "var(--face-dark)",
|
|
22
|
+
icon: (
|
|
23
|
+
<svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.75" viewBox="0 0 24 24">
|
|
24
|
+
<circle cx="12" cy="12" r="10" />
|
|
25
|
+
<path strokeLinecap="round" d="M12 8v4m0 4h.01" />
|
|
26
|
+
</svg>
|
|
27
|
+
),
|
|
28
|
+
},
|
|
29
|
+
success: {
|
|
30
|
+
tile: "linear-gradient(180deg, #10b981 0%, #059669 100%)",
|
|
31
|
+
icon: (
|
|
32
|
+
<svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
33
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
34
|
+
</svg>
|
|
35
|
+
),
|
|
36
|
+
},
|
|
37
|
+
warning: {
|
|
38
|
+
tile: "linear-gradient(180deg, #fbbf24 0%, #f59e0b 100%)",
|
|
39
|
+
icon: (
|
|
40
|
+
<svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.75" viewBox="0 0 24 24">
|
|
41
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
|
|
42
|
+
</svg>
|
|
43
|
+
),
|
|
44
|
+
},
|
|
45
|
+
error: {
|
|
46
|
+
tile: "linear-gradient(180deg, #f87171 0%, #dc2626 100%)",
|
|
47
|
+
icon: (
|
|
48
|
+
<svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
49
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
50
|
+
</svg>
|
|
51
|
+
),
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export function Alert({ variant = "default", title, description, onDismiss, className = "" }: AlertProps) {
|
|
56
|
+
const { tile, icon } = variantConfig[variant];
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div
|
|
60
|
+
// Errors and warnings interrupt; everything else waits its turn
|
|
61
|
+
role={variant === "error" || variant === "warning" ? "alert" : "status"}
|
|
62
|
+
className={["rounded-[18px] p-1.5", className].join(" ")}
|
|
63
|
+
style={{ background: "var(--panel-bezel)", boxShadow: "var(--panel-shadow)" }}
|
|
64
|
+
>
|
|
65
|
+
<div
|
|
66
|
+
className="bg-white rounded-[13px] pl-3 pr-2 py-3 flex items-start gap-3"
|
|
67
|
+
style={{ boxShadow: "var(--panel-face-shadow)" }}
|
|
68
|
+
>
|
|
69
|
+
<div
|
|
70
|
+
aria-hidden
|
|
71
|
+
className="shrink-0 w-7 h-7 rounded-[8px] flex items-center justify-center text-white"
|
|
72
|
+
style={{
|
|
73
|
+
background: tile,
|
|
74
|
+
boxShadow:
|
|
75
|
+
"0 0 3px rgba(0,0,0,0.212), 0 1px 2px rgba(0,0,0,0.15), inset 0 2px 3px -2px rgba(255,255,255,0.188)",
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
{icon}
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div className="flex-1 min-w-0 pt-[5px]">
|
|
82
|
+
<p className="text-[13px] font-semibold text-neutral-900 leading-tight tracking-[-0.01em]">{title}</p>
|
|
83
|
+
{description && (
|
|
84
|
+
<p className="text-[12.5px] text-neutral-500 mt-1 leading-relaxed">{description}</p>
|
|
85
|
+
)}
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
{onDismiss && <CloseButton size="sm" label="Dismiss" onClick={onDismiss} />}
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Avatar } from "./Avatar";
|
|
3
|
+
import { Button } from "./Button";
|
|
4
|
+
|
|
5
|
+
interface AuthorCardProps {
|
|
6
|
+
name: string;
|
|
7
|
+
bio?: string;
|
|
8
|
+
avatarSrc?: string;
|
|
9
|
+
onFollow?: () => void;
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function AuthorCard({
|
|
14
|
+
name,
|
|
15
|
+
bio,
|
|
16
|
+
avatarSrc,
|
|
17
|
+
onFollow,
|
|
18
|
+
className = "",
|
|
19
|
+
}: AuthorCardProps) {
|
|
20
|
+
return (
|
|
21
|
+
<div
|
|
22
|
+
className={[
|
|
23
|
+
"rim relative rounded-[22px] p-1.5",
|
|
24
|
+
className,
|
|
25
|
+
]
|
|
26
|
+
.filter(Boolean)
|
|
27
|
+
.join(" ")}
|
|
28
|
+
style={{ background: "var(--panel-bezel)", boxShadow: "var(--card-light-shadow)" }}
|
|
29
|
+
>
|
|
30
|
+
<div className="relative bg-white rounded-[17px] overflow-hidden" style={{ boxShadow: "var(--panel-face-shadow)" }}>
|
|
31
|
+
{/* Cover: a sunken well, so the avatar visibly sits on top of it */}
|
|
32
|
+
<div
|
|
33
|
+
aria-hidden
|
|
34
|
+
className="h-20 relative overflow-hidden"
|
|
35
|
+
style={{
|
|
36
|
+
background:
|
|
37
|
+
"radial-gradient(130% 90% at 25% 0%, rgba(255,255,255,0.7), transparent 60%), linear-gradient(180deg, #ececeb 0%, #f4f4f3 100%)",
|
|
38
|
+
boxShadow: "inset 0 1px 3px rgba(17,17,17,0.06), inset 0 -2px 3px -2px rgba(17,17,17,0.05)",
|
|
39
|
+
}}
|
|
40
|
+
/>
|
|
41
|
+
<div className="px-6 pb-6">
|
|
42
|
+
<div className="-mt-8 mb-4">
|
|
43
|
+
<Avatar
|
|
44
|
+
src={avatarSrc}
|
|
45
|
+
name={name}
|
|
46
|
+
size="lg"
|
|
47
|
+
className="ring-[3.5px] ring-white shadow-[0_3px_12px_-2px_rgba(0,0,0,0.15),0_0_0_1px_rgba(0,0,0,0.05)]"
|
|
48
|
+
/>
|
|
49
|
+
</div>
|
|
50
|
+
<div className="flex items-start justify-between gap-4">
|
|
51
|
+
<div className="flex-1 min-w-0">
|
|
52
|
+
<p className="text-[15px] font-semibold text-neutral-900 leading-snug truncate tracking-[-0.01em]">{name}</p>
|
|
53
|
+
{bio && (
|
|
54
|
+
<p className="text-[13px] text-neutral-500 leading-relaxed mt-1 line-clamp-2">{bio}</p>
|
|
55
|
+
)}
|
|
56
|
+
</div>
|
|
57
|
+
{onFollow && (
|
|
58
|
+
<Button variant="primary" size="sm" onClick={onFollow} className="shrink-0">
|
|
59
|
+
follow
|
|
60
|
+
</Button>
|
|
61
|
+
)}
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
type AvatarSize = "sm" | "md" | "lg";
|
|
4
|
+
|
|
5
|
+
interface AvatarProps {
|
|
6
|
+
src?: string;
|
|
7
|
+
alt?: string;
|
|
8
|
+
name?: string;
|
|
9
|
+
size?: AvatarSize;
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const sizeStyles: Record<AvatarSize, string> = {
|
|
14
|
+
sm: "w-7 h-7 text-[10px]",
|
|
15
|
+
md: "w-9 h-9 text-[12px]",
|
|
16
|
+
lg: "w-12 h-12 text-[14px]",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function getInitials(name: string): string {
|
|
20
|
+
return name
|
|
21
|
+
.split(" ")
|
|
22
|
+
.map((w) => w[0])
|
|
23
|
+
.slice(0, 2)
|
|
24
|
+
.join("")
|
|
25
|
+
.toLowerCase();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function Avatar({
|
|
29
|
+
src,
|
|
30
|
+
alt = "",
|
|
31
|
+
name,
|
|
32
|
+
size = "md",
|
|
33
|
+
className = "",
|
|
34
|
+
}: AvatarProps) {
|
|
35
|
+
const initials = name ? getInitials(name) : "?";
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div
|
|
39
|
+
className={[
|
|
40
|
+
"relative shrink-0 rounded-full overflow-hidden flex items-center justify-center font-semibold text-neutral-600 select-none",
|
|
41
|
+
sizeStyles[size],
|
|
42
|
+
className,
|
|
43
|
+
]
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
.join(" ")}
|
|
46
|
+
style={{ background: "var(--face-light)" }}
|
|
47
|
+
>
|
|
48
|
+
{src ? (
|
|
49
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
50
|
+
<img src={src} alt={alt || name || ""} loading="lazy" className="w-full h-full object-cover" />
|
|
51
|
+
) : (
|
|
52
|
+
<span role="img" aria-label={name ?? "Unknown"}>{initials}</span>
|
|
53
|
+
)}
|
|
54
|
+
<span
|
|
55
|
+
aria-hidden
|
|
56
|
+
className="pointer-events-none absolute inset-0 rounded-full"
|
|
57
|
+
style={{
|
|
58
|
+
boxShadow: "inset 0 2px 3px -2px rgba(255,255,255,0.45), inset 0 0 2px rgba(17,17,17,0.12), inset 0 -2px 3px -2px rgba(17,17,17,0.04)",
|
|
59
|
+
}}
|
|
60
|
+
/>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface AvatarGroupProps {
|
|
66
|
+
children: React.ReactNode;
|
|
67
|
+
max?: number;
|
|
68
|
+
size?: AvatarSize;
|
|
69
|
+
className?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const overlapStyles: Record<AvatarSize, string> = {
|
|
73
|
+
sm: "-ml-2",
|
|
74
|
+
md: "-ml-2.5",
|
|
75
|
+
lg: "-ml-3",
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export function AvatarGroup({
|
|
79
|
+
children,
|
|
80
|
+
max = 5,
|
|
81
|
+
size = "md",
|
|
82
|
+
className = "",
|
|
83
|
+
}: AvatarGroupProps) {
|
|
84
|
+
const items = React.Children.toArray(children);
|
|
85
|
+
const visible = items.slice(0, max);
|
|
86
|
+
const overflow = items.length - max;
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div className={["flex items-center", className].join(" ")}>
|
|
90
|
+
{visible.map((child, i) => (
|
|
91
|
+
<div
|
|
92
|
+
key={i}
|
|
93
|
+
className={[
|
|
94
|
+
"ring-2 ring-white rounded-full",
|
|
95
|
+
i > 0 ? overlapStyles[size] : "",
|
|
96
|
+
].join(" ")}
|
|
97
|
+
>
|
|
98
|
+
{child}
|
|
99
|
+
</div>
|
|
100
|
+
))}
|
|
101
|
+
{overflow > 0 && (
|
|
102
|
+
<div
|
|
103
|
+
className={[
|
|
104
|
+
"ring-2 ring-white rounded-full flex items-center justify-center font-semibold text-neutral-600 select-none",
|
|
105
|
+
overlapStyles[size],
|
|
106
|
+
sizeStyles[size],
|
|
107
|
+
].join(" ")}
|
|
108
|
+
style={{ background: "var(--well)", boxShadow: "var(--well-shadow)" }}
|
|
109
|
+
aria-label={`${overflow} more`}
|
|
110
|
+
>
|
|
111
|
+
+{overflow}
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
type BadgeVariant = "default" | "muted" | "accent";
|
|
4
|
+
|
|
5
|
+
interface BadgeProps {
|
|
6
|
+
variant?: BadgeVariant;
|
|
7
|
+
children: React.ReactNode;
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const variantStyles: Record<BadgeVariant, string> = {
|
|
12
|
+
default: "text-white",
|
|
13
|
+
muted: "text-neutral-600",
|
|
14
|
+
accent: "text-violet-700",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/* Badges are small raised faces, lit from the same top edge as buttons. */
|
|
18
|
+
const variantSurfaces: Record<BadgeVariant, React.CSSProperties> = {
|
|
19
|
+
default: {
|
|
20
|
+
background: "var(--face-dark)",
|
|
21
|
+
boxShadow:
|
|
22
|
+
"0 0 3px rgba(0,0,0,0.35), 0 1px 2px rgba(0,0,0,0.2), inset 0 2px 3px -2px rgba(255,255,255,0.105)",
|
|
23
|
+
textShadow: "0 -1px 0 rgba(0,0,0,0.4)",
|
|
24
|
+
},
|
|
25
|
+
muted: {
|
|
26
|
+
background: "linear-gradient(180deg, #ffffff 0%, #f1f1f1 100%)",
|
|
27
|
+
boxShadow:
|
|
28
|
+
"0 0 3px rgba(17,17,17,0.119), 0 1px 1.5px rgba(17,17,17,0.07), inset 0 2px 3px -2px rgba(255,255,255,1)",
|
|
29
|
+
textShadow: "0 1px 0 rgba(255,255,255,0.9)",
|
|
30
|
+
},
|
|
31
|
+
accent: {
|
|
32
|
+
background: "linear-gradient(180deg, #f5f0ff 0%, #ebe1ff 100%)",
|
|
33
|
+
boxShadow:
|
|
34
|
+
"0 0 3px rgba(124,58,237,0.255), 0 1px 1.5px rgba(124,58,237,0.12), inset 0 2px 3px -2px rgba(255,255,255,0.637)",
|
|
35
|
+
textShadow: "0 1px 0 rgba(255,255,255,0.7)",
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function Badge({
|
|
40
|
+
variant = "muted",
|
|
41
|
+
children,
|
|
42
|
+
className = "",
|
|
43
|
+
}: BadgeProps) {
|
|
44
|
+
return (
|
|
45
|
+
<span
|
|
46
|
+
className={[
|
|
47
|
+
"inline-flex items-center px-2 py-0.5 rounded-md text-[11px] font-medium tracking-wide",
|
|
48
|
+
variantStyles[variant],
|
|
49
|
+
className,
|
|
50
|
+
]
|
|
51
|
+
.filter(Boolean)
|
|
52
|
+
.join(" ")}
|
|
53
|
+
style={variantSurfaces[variant]}
|
|
54
|
+
>
|
|
55
|
+
{children}
|
|
56
|
+
</span>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ReactNode } from "react";
|
|
4
|
+
import { CloseButton } from "./CloseButton";
|
|
5
|
+
|
|
6
|
+
type BannerVariant = "default" | "accent" | "warning";
|
|
7
|
+
|
|
8
|
+
/* A banner is a dark bezel holding a dark face — the same build as the
|
|
9
|
+
primary button, stretched to full width. */
|
|
10
|
+
const variantStyles: Record<
|
|
11
|
+
BannerVariant,
|
|
12
|
+
{ bezel: string; bezelShadow: string; face: string; faceShadow: string; text: string; tone: "light" | "dark" }
|
|
13
|
+
> = {
|
|
14
|
+
default: {
|
|
15
|
+
bezel: "var(--bezel-dark)",
|
|
16
|
+
bezelShadow: "var(--bezel-dark-shadow)",
|
|
17
|
+
face: "var(--face-dark)",
|
|
18
|
+
faceShadow: "var(--face-dark-shadow)",
|
|
19
|
+
text: "text-white",
|
|
20
|
+
tone: "dark",
|
|
21
|
+
},
|
|
22
|
+
accent: {
|
|
23
|
+
bezel: "linear-gradient(180deg, #4c2a99 0%, #2e1065 100%)",
|
|
24
|
+
bezelShadow:
|
|
25
|
+
"0 0 0 0.5px rgba(46,16,101,0.36), 0 4px 12px -4px rgba(76,29,149,0.45), inset 0 2px 3px -2px rgba(255,255,255,0.09)",
|
|
26
|
+
face: "linear-gradient(180deg, #7c3aed 0%, #6d28d9 100%)",
|
|
27
|
+
faceShadow:
|
|
28
|
+
"0 0 3px rgba(46,16,101,0.35), 0 1px 2px rgba(46,16,101,0.4), inset 0 2px 3px -2px rgba(255,255,255,0.165)",
|
|
29
|
+
text: "text-white",
|
|
30
|
+
tone: "dark",
|
|
31
|
+
},
|
|
32
|
+
warning: {
|
|
33
|
+
bezel: "linear-gradient(180deg, #fde68a 0%, #f5c84b 100%)",
|
|
34
|
+
bezelShadow:
|
|
35
|
+
"0 0 0 0.5px rgba(180,83,9,0.15), 0 4px 12px -4px rgba(217,119,6,0.3), inset 0 2px 3px -2px rgba(255,255,255,0.45)",
|
|
36
|
+
face: "linear-gradient(180deg, #fffbeb 0%, #fef3c7 100%)",
|
|
37
|
+
faceShadow:
|
|
38
|
+
"0 0 3px rgba(180,83,9,0.255), 0 1px 2px rgba(180,83,9,0.12), inset 0 2px 3px -2px rgba(255,255,255,1)",
|
|
39
|
+
text: "text-amber-900",
|
|
40
|
+
tone: "light",
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function Banner({
|
|
45
|
+
variant = "default",
|
|
46
|
+
children,
|
|
47
|
+
icon,
|
|
48
|
+
dismissible = false,
|
|
49
|
+
onDismiss,
|
|
50
|
+
action,
|
|
51
|
+
className = "",
|
|
52
|
+
}: {
|
|
53
|
+
variant?: BannerVariant;
|
|
54
|
+
children: ReactNode;
|
|
55
|
+
icon?: ReactNode;
|
|
56
|
+
dismissible?: boolean;
|
|
57
|
+
onDismiss?: () => void;
|
|
58
|
+
action?: ReactNode;
|
|
59
|
+
className?: string;
|
|
60
|
+
}) {
|
|
61
|
+
const s = variantStyles[variant];
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div
|
|
65
|
+
role="region"
|
|
66
|
+
aria-label="Announcement"
|
|
67
|
+
className={`w-full rounded-[18px] p-1 ${className}`}
|
|
68
|
+
style={{ background: s.bezel, boxShadow: s.bezelShadow }}
|
|
69
|
+
>
|
|
70
|
+
<div
|
|
71
|
+
className={`rounded-[14px] pl-4 pr-2 py-2.5 flex items-center gap-3 ${s.text}`}
|
|
72
|
+
style={{ background: s.face, boxShadow: s.faceShadow }}
|
|
73
|
+
>
|
|
74
|
+
{icon && <span aria-hidden className="flex-shrink-0 opacity-80">{icon}</span>}
|
|
75
|
+
|
|
76
|
+
{/* Left-aligned so it reads in one sweep with the icon beside it */}
|
|
77
|
+
<span className="text-[13px] font-medium flex-1 min-w-0 leading-relaxed">
|
|
78
|
+
{children}
|
|
79
|
+
</span>
|
|
80
|
+
|
|
81
|
+
{action && <span className="flex-shrink-0">{action}</span>}
|
|
82
|
+
|
|
83
|
+
{dismissible && (
|
|
84
|
+
<CloseButton size="sm" tone={s.tone} label="Dismiss announcement" onClick={onDismiss} />
|
|
85
|
+
)}
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
interface BreadcrumbItem {
|
|
4
|
+
label: string;
|
|
5
|
+
href?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface BreadcrumbProps {
|
|
9
|
+
items: BreadcrumbItem[];
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function Breadcrumb({ items, className = "" }: BreadcrumbProps) {
|
|
14
|
+
return (
|
|
15
|
+
<nav aria-label="Breadcrumb" className={className}>
|
|
16
|
+
<ol className="flex items-center gap-1 flex-wrap">
|
|
17
|
+
{items.map((item, i) => {
|
|
18
|
+
const isLast = i === items.length - 1;
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<li key={i} className="flex items-center gap-1 min-w-0">
|
|
22
|
+
{i > 0 && (
|
|
23
|
+
<svg
|
|
24
|
+
aria-hidden
|
|
25
|
+
width="12"
|
|
26
|
+
height="12"
|
|
27
|
+
fill="none"
|
|
28
|
+
stroke="currentColor"
|
|
29
|
+
strokeWidth="2"
|
|
30
|
+
viewBox="0 0 24 24"
|
|
31
|
+
className="text-neutral-300 shrink-0"
|
|
32
|
+
>
|
|
33
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
34
|
+
</svg>
|
|
35
|
+
)}
|
|
36
|
+
{isLast || !item.href ? (
|
|
37
|
+
<span
|
|
38
|
+
aria-current={isLast ? "page" : undefined}
|
|
39
|
+
className={[
|
|
40
|
+
"text-[12.5px] px-1.5 py-0.5 truncate",
|
|
41
|
+
isLast ? "font-medium text-neutral-900" : "text-neutral-500",
|
|
42
|
+
].join(" ")}
|
|
43
|
+
>
|
|
44
|
+
{item.label}
|
|
45
|
+
</span>
|
|
46
|
+
) : (
|
|
47
|
+
<a
|
|
48
|
+
href={item.href}
|
|
49
|
+
className="text-[12.5px] text-neutral-500 hover:text-neutral-900 px-1.5 py-0.5 rounded-[6px] hover:bg-neutral-900/[0.05] transition-colors truncate"
|
|
50
|
+
>
|
|
51
|
+
{item.label}
|
|
52
|
+
</a>
|
|
53
|
+
)}
|
|
54
|
+
</li>
|
|
55
|
+
);
|
|
56
|
+
})}
|
|
57
|
+
</ol>
|
|
58
|
+
</nav>
|
|
59
|
+
);
|
|
60
|
+
}
|