@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,372 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useRef, useCallback } from "react";
|
|
4
|
+
|
|
5
|
+
type ButtonVariant = "primary" | "secondary" | "ghost" | "outline";
|
|
6
|
+
type ButtonSize = "sm" | "md" | "lg" | "icon";
|
|
7
|
+
|
|
8
|
+
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
9
|
+
variant?: ButtonVariant;
|
|
10
|
+
size?: ButtonSize;
|
|
11
|
+
children: React.ReactNode;
|
|
12
|
+
fullWidth?: boolean;
|
|
13
|
+
/** Surface the button sits on. On dark cards, secondary/outline switch to a
|
|
14
|
+
dark face so they don't glare against the card. */
|
|
15
|
+
tone?: "light" | "dark";
|
|
16
|
+
/** Equal width and height, for icon-only layered buttons. */
|
|
17
|
+
square?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/* Layered buttons are a raised face seated in a machined bezel. The bezel
|
|
21
|
+
never moves; the face lifts on hover and sinks flush when pressed. */
|
|
22
|
+
const shellConfig: Record<
|
|
23
|
+
string,
|
|
24
|
+
{
|
|
25
|
+
bezel: string;
|
|
26
|
+
bezelShadow: string;
|
|
27
|
+
face: string;
|
|
28
|
+
faceShadow: string;
|
|
29
|
+
faceHoverShadow: string;
|
|
30
|
+
facePressedShadow: string;
|
|
31
|
+
text: string;
|
|
32
|
+
textShadow: string;
|
|
33
|
+
fill: string;
|
|
34
|
+
}
|
|
35
|
+
> = {
|
|
36
|
+
primary: {
|
|
37
|
+
bezel: "var(--bezel-dark)",
|
|
38
|
+
bezelShadow: "var(--bezel-dark-shadow)",
|
|
39
|
+
face: "var(--face-dark)",
|
|
40
|
+
faceShadow: "var(--face-dark-shadow)",
|
|
41
|
+
faceHoverShadow:
|
|
42
|
+
"0 0 3px rgba(0,0,0,0.35), 0 2px 3px rgba(0,0,0,0.45), 0 4px 8px -2px rgba(0,0,0,0.35), inset 0 2px 3px -2px rgba(255,255,255,0.135), inset 0 -2px 3px -2px rgba(0,0,0,0.25)",
|
|
43
|
+
facePressedShadow: "var(--face-dark-pressed)",
|
|
44
|
+
text: "text-white",
|
|
45
|
+
textShadow: "0 -1px 0 rgba(0,0,0,0.45)",
|
|
46
|
+
fill: "bg-white/[0.05]",
|
|
47
|
+
},
|
|
48
|
+
secondary: {
|
|
49
|
+
bezel: "var(--bezel-light)",
|
|
50
|
+
bezelShadow: "var(--bezel-light-shadow)",
|
|
51
|
+
face: "var(--face-light)",
|
|
52
|
+
faceShadow: "var(--face-light-shadow)",
|
|
53
|
+
faceHoverShadow:
|
|
54
|
+
"0 0 3px rgba(17,17,17,0.136), 0 2px 3px rgba(17,17,17,0.1), 0 4px 8px -2px rgba(17,17,17,0.08), inset 0 2px 3px -2px rgba(255,255,255,1), inset 0 -2px 3px -2px rgba(17,17,17,0.035)",
|
|
55
|
+
facePressedShadow: "var(--face-light-pressed)",
|
|
56
|
+
text: "text-neutral-800",
|
|
57
|
+
textShadow: "0 1px 0 rgba(255,255,255,0.9)",
|
|
58
|
+
fill: "bg-neutral-900/[0.025]",
|
|
59
|
+
},
|
|
60
|
+
outline: {
|
|
61
|
+
bezel: "linear-gradient(180deg, rgba(255,255,255,0.5) 0%, rgba(17,17,17,0.04) 100%)",
|
|
62
|
+
bezelShadow:
|
|
63
|
+
"0 0 0 0.5px rgba(17,17,17,0.048), inset 0 2px 3px -2px rgba(255,255,255,0.45)",
|
|
64
|
+
face: "var(--face-light)",
|
|
65
|
+
faceShadow:
|
|
66
|
+
"0 0 3px rgba(17,17,17,0.102), 0 1px 1px rgba(17,17,17,0.06), inset 0 2px 3px -2px rgba(255,255,255,1)",
|
|
67
|
+
faceHoverShadow:
|
|
68
|
+
"0 0 3px rgba(17,17,17,0.136), 0 1px 2px rgba(17,17,17,0.08), 0 3px 6px -2px rgba(17,17,17,0.06), inset 0 2px 3px -2px rgba(255,255,255,1)",
|
|
69
|
+
facePressedShadow: "var(--face-light-pressed)",
|
|
70
|
+
text: "text-neutral-700",
|
|
71
|
+
textShadow: "0 1px 0 rgba(255,255,255,0.9)",
|
|
72
|
+
fill: "bg-neutral-900/[0.025]",
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/* Secondary on a dark surface: a raised face a few steps lighter than the
|
|
77
|
+
card — present, but in the same family of tones. */
|
|
78
|
+
const darkSecondary = {
|
|
79
|
+
bezel: "linear-gradient(180deg, #2a2a2a 0%, #161616 100%)",
|
|
80
|
+
bezelShadow:
|
|
81
|
+
"0 0 0 0.5px rgba(0,0,0,0.3), 0 1px 2px rgba(0,0,0,0.35), inset 0 2px 3px -2px rgba(255,255,255,0.053)",
|
|
82
|
+
face: "linear-gradient(180deg, #353535 0%, #2a2a2a 55%, #242424 100%)",
|
|
83
|
+
faceShadow:
|
|
84
|
+
"0 0 3px rgba(0,0,0,0.35), 0 1px 1.5px rgba(0,0,0,0.4), inset 0 2px 3px -2px rgba(255,255,255,0.09), inset 0 -2px 3px -2px rgba(0,0,0,0.2)",
|
|
85
|
+
faceHoverShadow:
|
|
86
|
+
"0 0 3px rgba(0,0,0,0.35), 0 2px 3px rgba(0,0,0,0.45), 0 4px 8px -2px rgba(0,0,0,0.35), inset 0 2px 3px -2px rgba(255,255,255,0.12), inset 0 -2px 3px -2px rgba(0,0,0,0.2)",
|
|
87
|
+
facePressedShadow: "var(--face-dark-pressed)",
|
|
88
|
+
text: "text-neutral-100",
|
|
89
|
+
textShadow: "0 -1px 0 rgba(0,0,0,0.4)",
|
|
90
|
+
fill: "bg-white/[0.04]",
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/* Flat buttons (ghost, icon) are a single face without a bezel. */
|
|
94
|
+
const flatConfig: Record<
|
|
95
|
+
ButtonVariant,
|
|
96
|
+
{
|
|
97
|
+
bg: string;
|
|
98
|
+
background?: string;
|
|
99
|
+
fill: string;
|
|
100
|
+
shadow: string;
|
|
101
|
+
hoverShadow: string;
|
|
102
|
+
pressedShadow: string;
|
|
103
|
+
}
|
|
104
|
+
> = {
|
|
105
|
+
primary: {
|
|
106
|
+
bg: "text-white",
|
|
107
|
+
background: "var(--face-dark)",
|
|
108
|
+
fill: "bg-white/[0.08]",
|
|
109
|
+
shadow: "var(--face-dark-shadow)",
|
|
110
|
+
hoverShadow:
|
|
111
|
+
"0 0 3px rgba(0,0,0,0.35), 0 2px 4px rgba(0,0,0,0.4), 0 6px 14px -4px rgba(0,0,0,0.35), inset 0 2px 3px -2px rgba(255,255,255,0.135)",
|
|
112
|
+
pressedShadow: "var(--face-dark-pressed)",
|
|
113
|
+
},
|
|
114
|
+
secondary: {
|
|
115
|
+
bg: "text-neutral-800",
|
|
116
|
+
background: "var(--face-light)",
|
|
117
|
+
fill: "bg-neutral-900/[0.035]",
|
|
118
|
+
shadow: "var(--face-light-shadow)",
|
|
119
|
+
hoverShadow:
|
|
120
|
+
"0 0 3px rgba(17,17,17,0.136), 0 2px 4px rgba(17,17,17,0.08), 0 6px 14px -4px rgba(17,17,17,0.1), inset 0 2px 3px -2px rgba(255,255,255,1)",
|
|
121
|
+
pressedShadow: "var(--face-light-pressed)",
|
|
122
|
+
},
|
|
123
|
+
ghost: {
|
|
124
|
+
bg: "bg-transparent text-neutral-500 hover:text-neutral-800",
|
|
125
|
+
fill: "bg-neutral-900/[0.05]",
|
|
126
|
+
shadow: "none",
|
|
127
|
+
hoverShadow: "inset 0 0 2px rgba(17,17,17,0.09)",
|
|
128
|
+
pressedShadow: "inset 0 1px 2px rgba(17,17,17,0.08)",
|
|
129
|
+
},
|
|
130
|
+
outline: {
|
|
131
|
+
bg: "bg-transparent text-neutral-700",
|
|
132
|
+
fill: "bg-neutral-900/[0.035]",
|
|
133
|
+
shadow: "inset 0 0 2px rgba(17,17,17,0.15)",
|
|
134
|
+
hoverShadow:
|
|
135
|
+
"inset 0 0 2px rgba(17,17,17,0.24), 0 1px 2px rgba(17,17,17,0.05)",
|
|
136
|
+
pressedShadow:
|
|
137
|
+
"inset 0 0 2px rgba(17,17,17,0.24), inset 0 1px 2px rgba(17,17,17,0.06)",
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/* Bezel radius minus bezel padding = face radius, so the curves stay concentric. */
|
|
142
|
+
const shellRadius: Record<ButtonSize, string> = {
|
|
143
|
+
sm: "rounded-[12px] p-[3px]",
|
|
144
|
+
md: "rounded-[13px] p-[3px]",
|
|
145
|
+
lg: "rounded-[15px] p-[3.5px]",
|
|
146
|
+
icon: "",
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const faceRadius: Record<ButtonSize, string> = {
|
|
150
|
+
sm: "rounded-[9px]",
|
|
151
|
+
md: "rounded-[10px]",
|
|
152
|
+
lg: "rounded-[11.5px]",
|
|
153
|
+
icon: "",
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const innerPadding: Record<ButtonSize, string> = {
|
|
157
|
+
sm: "px-4 py-2 text-[13px]",
|
|
158
|
+
md: "px-5 py-2.5 text-sm",
|
|
159
|
+
lg: "px-7 py-3 text-[15px]",
|
|
160
|
+
icon: "w-7 h-7 p-0 text-[13px]",
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const squarePadding: Record<ButtonSize, string> = {
|
|
164
|
+
sm: "w-9 h-9 p-0 text-[13px]",
|
|
165
|
+
md: "w-10 h-10 p-0 text-sm",
|
|
166
|
+
lg: "w-12 h-12 p-0 text-[15px]",
|
|
167
|
+
icon: "",
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const flatSizeStyles: Record<ButtonSize, string> = {
|
|
171
|
+
sm: "px-4 py-2.5 text-[13px] rounded-[10px]",
|
|
172
|
+
md: "px-5 py-3 text-sm rounded-[10px]",
|
|
173
|
+
lg: "px-7 py-3.5 text-[15px] rounded-xl",
|
|
174
|
+
icon: "w-8 h-8 p-0 rounded-[10px] text-[13px]",
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
export function Button({
|
|
178
|
+
variant = "primary",
|
|
179
|
+
size = "md",
|
|
180
|
+
fullWidth = false,
|
|
181
|
+
tone = "light",
|
|
182
|
+
square = false,
|
|
183
|
+
className = "",
|
|
184
|
+
children,
|
|
185
|
+
disabled,
|
|
186
|
+
onClick,
|
|
187
|
+
onPointerDown,
|
|
188
|
+
onPointerUp,
|
|
189
|
+
onPointerLeave,
|
|
190
|
+
onPointerCancel,
|
|
191
|
+
onPointerEnter,
|
|
192
|
+
style,
|
|
193
|
+
...rest
|
|
194
|
+
}: ButtonProps) {
|
|
195
|
+
const [pressed, setPressed] = useState(false);
|
|
196
|
+
const [hovered, setHovered] = useState(false);
|
|
197
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
198
|
+
|
|
199
|
+
/* The press state is the button's own; handlers passed in run alongside
|
|
200
|
+
it rather than replacing it */
|
|
201
|
+
const handlePointerDown = useCallback(
|
|
202
|
+
(e: React.PointerEvent<HTMLButtonElement>) => {
|
|
203
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
204
|
+
setPressed(true);
|
|
205
|
+
onPointerDown?.(e);
|
|
206
|
+
},
|
|
207
|
+
[onPointerDown],
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const handlePointerUp = useCallback(
|
|
211
|
+
(e: React.PointerEvent<HTMLButtonElement>) => {
|
|
212
|
+
timerRef.current = setTimeout(() => setPressed(false), 150);
|
|
213
|
+
onPointerUp?.(e);
|
|
214
|
+
},
|
|
215
|
+
[onPointerUp],
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
const handlePointerCancel = useCallback(
|
|
219
|
+
(e: React.PointerEvent<HTMLButtonElement>) => {
|
|
220
|
+
timerRef.current = setTimeout(() => setPressed(false), 150);
|
|
221
|
+
onPointerCancel?.(e);
|
|
222
|
+
},
|
|
223
|
+
[onPointerCancel],
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const handlePointerLeave = useCallback(
|
|
227
|
+
(e: React.PointerEvent<HTMLButtonElement>) => {
|
|
228
|
+
setHovered(false);
|
|
229
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
230
|
+
timerRef.current = setTimeout(() => setPressed(false), 150);
|
|
231
|
+
onPointerLeave?.(e);
|
|
232
|
+
},
|
|
233
|
+
[onPointerLeave],
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const layered = variant !== "ghost" && size !== "icon";
|
|
237
|
+
|
|
238
|
+
const handlers = {
|
|
239
|
+
onClick,
|
|
240
|
+
onPointerDown: handlePointerDown,
|
|
241
|
+
onPointerUp: handlePointerUp,
|
|
242
|
+
onPointerLeave: handlePointerLeave,
|
|
243
|
+
onPointerCancel: handlePointerCancel,
|
|
244
|
+
onPointerEnter: (e: React.PointerEvent<HTMLButtonElement>) => {
|
|
245
|
+
setHovered(true);
|
|
246
|
+
onPointerEnter?.(e);
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
if (layered) {
|
|
251
|
+
const cfg =
|
|
252
|
+
tone === "dark" && (variant === "secondary" || variant === "outline")
|
|
253
|
+
? darkSecondary
|
|
254
|
+
: shellConfig[variant];
|
|
255
|
+
const faceShadow = pressed
|
|
256
|
+
? cfg.facePressedShadow
|
|
257
|
+
: hovered
|
|
258
|
+
? cfg.faceHoverShadow
|
|
259
|
+
: cfg.faceShadow;
|
|
260
|
+
|
|
261
|
+
return (
|
|
262
|
+
<button
|
|
263
|
+
data-btn
|
|
264
|
+
className={[
|
|
265
|
+
"group/btn relative inline-flex cursor-pointer select-none whitespace-nowrap",
|
|
266
|
+
shellRadius[size],
|
|
267
|
+
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900",
|
|
268
|
+
"disabled:opacity-40 disabled:pointer-events-none",
|
|
269
|
+
fullWidth ? "w-full" : "",
|
|
270
|
+
className,
|
|
271
|
+
]
|
|
272
|
+
.filter(Boolean)
|
|
273
|
+
.join(" ")}
|
|
274
|
+
style={{
|
|
275
|
+
background: cfg.bezel,
|
|
276
|
+
boxShadow: cfg.bezelShadow,
|
|
277
|
+
...style,
|
|
278
|
+
}}
|
|
279
|
+
disabled={disabled}
|
|
280
|
+
{...handlers}
|
|
281
|
+
{...rest}
|
|
282
|
+
>
|
|
283
|
+
<span
|
|
284
|
+
className={[
|
|
285
|
+
"btn-face relative overflow-hidden flex items-center justify-center gap-1.5 font-medium",
|
|
286
|
+
faceRadius[size],
|
|
287
|
+
cfg.text,
|
|
288
|
+
square ? squarePadding[size] : ["w-full", innerPadding[size]].join(" "),
|
|
289
|
+
].join(" ")}
|
|
290
|
+
style={{
|
|
291
|
+
background: cfg.face,
|
|
292
|
+
boxShadow: faceShadow,
|
|
293
|
+
textShadow: cfg.textShadow,
|
|
294
|
+
transform: pressed
|
|
295
|
+
? "translateY(0.5px) scale(0.985)"
|
|
296
|
+
: hovered
|
|
297
|
+
? "translateY(-0.5px)"
|
|
298
|
+
: "none",
|
|
299
|
+
transition:
|
|
300
|
+
"transform 240ms var(--ease-spring), box-shadow 200ms ease-out",
|
|
301
|
+
}}
|
|
302
|
+
>
|
|
303
|
+
<span
|
|
304
|
+
aria-hidden="true"
|
|
305
|
+
className={[
|
|
306
|
+
"pointer-events-none absolute inset-0 origin-bottom transition-transform duration-300 ease-out",
|
|
307
|
+
pressed
|
|
308
|
+
? "scale-y-100"
|
|
309
|
+
: "scale-y-0 group-hover/btn:scale-y-100",
|
|
310
|
+
cfg.fill,
|
|
311
|
+
].join(" ")}
|
|
312
|
+
/>
|
|
313
|
+
<span className="btn-icon-nudge relative z-10 inline-flex items-center gap-1.5">
|
|
314
|
+
{children}
|
|
315
|
+
</span>
|
|
316
|
+
</span>
|
|
317
|
+
</button>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const cfg = flatConfig[variant];
|
|
322
|
+
const currentShadow = pressed
|
|
323
|
+
? cfg.pressedShadow
|
|
324
|
+
: hovered
|
|
325
|
+
? cfg.hoverShadow
|
|
326
|
+
: cfg.shadow;
|
|
327
|
+
|
|
328
|
+
return (
|
|
329
|
+
<button
|
|
330
|
+
data-btn
|
|
331
|
+
className={[
|
|
332
|
+
"btn-face group/btn relative overflow-hidden inline-flex items-center justify-center gap-1.5 font-medium cursor-pointer select-none whitespace-nowrap",
|
|
333
|
+
"transition-colors duration-200 ease-out",
|
|
334
|
+
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900",
|
|
335
|
+
"disabled:opacity-40 disabled:pointer-events-none",
|
|
336
|
+
cfg.bg,
|
|
337
|
+
flatSizeStyles[size],
|
|
338
|
+
fullWidth ? "w-full" : "",
|
|
339
|
+
className,
|
|
340
|
+
]
|
|
341
|
+
.filter(Boolean)
|
|
342
|
+
.join(" ")}
|
|
343
|
+
style={{
|
|
344
|
+
background: cfg.background,
|
|
345
|
+
boxShadow: currentShadow,
|
|
346
|
+
transform: pressed
|
|
347
|
+
? "translateY(0.5px) scale(0.97)"
|
|
348
|
+
: hovered && cfg.background
|
|
349
|
+
? "translateY(-0.5px)"
|
|
350
|
+
: "none",
|
|
351
|
+
transition:
|
|
352
|
+
"transform 240ms var(--ease-spring), box-shadow 200ms ease-out, color 200ms ease-out",
|
|
353
|
+
...style,
|
|
354
|
+
}}
|
|
355
|
+
disabled={disabled}
|
|
356
|
+
{...handlers}
|
|
357
|
+
{...rest}
|
|
358
|
+
>
|
|
359
|
+
<span
|
|
360
|
+
aria-hidden="true"
|
|
361
|
+
className={[
|
|
362
|
+
"pointer-events-none absolute inset-0 origin-bottom transition-transform duration-300 ease-out",
|
|
363
|
+
pressed ? "scale-y-100" : "scale-y-0 group-hover/btn:scale-y-100",
|
|
364
|
+
cfg.fill,
|
|
365
|
+
].join(" ")}
|
|
366
|
+
/>
|
|
367
|
+
<span className="relative z-10 inline-flex items-center gap-1.5">
|
|
368
|
+
{children}
|
|
369
|
+
</span>
|
|
370
|
+
</button>
|
|
371
|
+
);
|
|
372
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
export interface ButtonGroupItem {
|
|
4
|
+
label: string;
|
|
5
|
+
content: React.ReactNode;
|
|
6
|
+
onClick?: () => void;
|
|
7
|
+
href?: string;
|
|
8
|
+
/** Sized to its content; otherwise a square key for a lone icon */
|
|
9
|
+
wide?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface ButtonGroupProps {
|
|
13
|
+
items: ButtonGroupItem[];
|
|
14
|
+
label: string;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/* One bezel, one face, several keys: a linked control in the same material
|
|
19
|
+
as Button (sm), split by grooves rather than gaps. Each key fills from the
|
|
20
|
+
bottom on hover and sinks when pressed, like a layered Button's face. */
|
|
21
|
+
export function ButtonGroup({ items, label, className = "" }: ButtonGroupProps) {
|
|
22
|
+
return (
|
|
23
|
+
<div
|
|
24
|
+
role="group"
|
|
25
|
+
aria-label={label}
|
|
26
|
+
className={["inline-flex w-fit rounded-[12px] p-[3px]", className].join(" ")}
|
|
27
|
+
style={{ background: "var(--bezel-light)", boxShadow: "var(--bezel-light-shadow)" }}
|
|
28
|
+
>
|
|
29
|
+
<div
|
|
30
|
+
className="flex rounded-[9px] overflow-hidden"
|
|
31
|
+
style={{ background: "var(--face-light)", boxShadow: "var(--face-light-shadow)" }}
|
|
32
|
+
>
|
|
33
|
+
{items.map((item, i) => {
|
|
34
|
+
const keyClass = [
|
|
35
|
+
"group/key relative h-9 inline-flex items-center justify-center gap-1.5 cursor-pointer",
|
|
36
|
+
"text-[13px] font-medium text-neutral-800 outline-none",
|
|
37
|
+
"transition-[box-shadow,background] duration-150",
|
|
38
|
+
"active:bg-neutral-900/[0.04] active:[box-shadow:inset_0_1px_2px_rgba(17,17,17,0.08)]",
|
|
39
|
+
"focus-visible:[box-shadow:inset_0_0_0_2px_rgba(17,17,17,0.85)]",
|
|
40
|
+
item.wide ? "px-4" : "w-10",
|
|
41
|
+
].join(" ");
|
|
42
|
+
const inner = (
|
|
43
|
+
<>
|
|
44
|
+
<span
|
|
45
|
+
aria-hidden
|
|
46
|
+
className="pointer-events-none absolute inset-0 origin-bottom scale-y-0 bg-neutral-900/[0.025] transition-transform duration-300 ease-out group-hover/key:scale-y-100"
|
|
47
|
+
/>
|
|
48
|
+
<span className="relative inline-flex items-center gap-1.5">{item.content}</span>
|
|
49
|
+
</>
|
|
50
|
+
);
|
|
51
|
+
const common = {
|
|
52
|
+
"aria-label": item.label,
|
|
53
|
+
title: item.wide ? undefined : item.label,
|
|
54
|
+
className: keyClass,
|
|
55
|
+
style: { textShadow: "0 1px 0 rgba(255,255,255,0.9)" },
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<React.Fragment key={item.label}>
|
|
60
|
+
{i > 0 && (
|
|
61
|
+
<span
|
|
62
|
+
aria-hidden
|
|
63
|
+
className="w-px self-stretch my-2 bg-neutral-900/[0.08]"
|
|
64
|
+
style={{ boxShadow: "1px 0 0 rgba(255,255,255,0.9)" }}
|
|
65
|
+
/>
|
|
66
|
+
)}
|
|
67
|
+
{item.href ? (
|
|
68
|
+
<a href={item.href} {...common}>
|
|
69
|
+
{inner}
|
|
70
|
+
</a>
|
|
71
|
+
) : (
|
|
72
|
+
<button type="button" onClick={item.onClick} {...common}>
|
|
73
|
+
{inner}
|
|
74
|
+
</button>
|
|
75
|
+
)}
|
|
76
|
+
</React.Fragment>
|
|
77
|
+
);
|
|
78
|
+
})}
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { CardShell } from "./CardShell";
|
|
3
|
+
|
|
4
|
+
type CardTheme = "light" | "dark";
|
|
5
|
+
|
|
6
|
+
interface SplitCardProps {
|
|
7
|
+
title?: string;
|
|
8
|
+
subtitle?: string;
|
|
9
|
+
icon?: React.ReactNode;
|
|
10
|
+
image?: React.ReactNode;
|
|
11
|
+
imageSrc?: string;
|
|
12
|
+
imageAlt?: string;
|
|
13
|
+
actions?: React.ReactNode;
|
|
14
|
+
theme?: CardTheme;
|
|
15
|
+
className?: string;
|
|
16
|
+
children?: React.ReactNode;
|
|
17
|
+
layout?: "split";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface StackCardProps {
|
|
21
|
+
title?: string;
|
|
22
|
+
badge?: React.ReactNode;
|
|
23
|
+
subtitle?: string;
|
|
24
|
+
note?: string;
|
|
25
|
+
image?: React.ReactNode;
|
|
26
|
+
imageSrc?: string;
|
|
27
|
+
imageAlt?: string;
|
|
28
|
+
imageBg?: string;
|
|
29
|
+
actions?: React.ReactNode;
|
|
30
|
+
className?: string;
|
|
31
|
+
children?: React.ReactNode;
|
|
32
|
+
layout: "stack";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type CardProps = SplitCardProps | StackCardProps;
|
|
36
|
+
|
|
37
|
+
/* Both layouts use CardShell: content on the face, actions on the rail.
|
|
38
|
+
Media sits flush in the face — the face's own corners frame it, so there
|
|
39
|
+
is no box-inside-a-box. */
|
|
40
|
+
|
|
41
|
+
function MediaPlaceholder({ dark, bg, monogram }: { dark?: boolean; bg?: string; monogram?: string }) {
|
|
42
|
+
return (
|
|
43
|
+
<div
|
|
44
|
+
className={["relative w-full h-full flex items-center justify-center", bg ?? ""].join(" ")}
|
|
45
|
+
style={bg ? undefined : { background: dark ? "var(--media-well-dark)" : "var(--media-well-light)" }}
|
|
46
|
+
>
|
|
47
|
+
{/* A faint dot grid fading out from the centre: a slot, not a hole */}
|
|
48
|
+
<div
|
|
49
|
+
aria-hidden
|
|
50
|
+
className="absolute inset-0"
|
|
51
|
+
style={{
|
|
52
|
+
backgroundImage: `radial-gradient(${
|
|
53
|
+
bg ? "rgba(255,255,255,0.22)" : dark ? "rgba(255,255,255,0.09)" : "rgba(17,17,17,0.09)"
|
|
54
|
+
} 1px, transparent 1px)`,
|
|
55
|
+
backgroundSize: "12px 12px",
|
|
56
|
+
maskImage: "radial-gradient(70% 70% at 50% 50%, #000 0%, transparent 100%)",
|
|
57
|
+
WebkitMaskImage: "radial-gradient(70% 70% at 50% 50%, #000 0%, transparent 100%)",
|
|
58
|
+
}}
|
|
59
|
+
/>
|
|
60
|
+
{/* The title's initial, set huge and barely there and cropped by the
|
|
61
|
+
slot's edges: the empty half carries the card's type, not a glyph */}
|
|
62
|
+
{monogram && (
|
|
63
|
+
<span
|
|
64
|
+
aria-hidden
|
|
65
|
+
className={[
|
|
66
|
+
"absolute -right-3 -bottom-[0.32em] text-[220px] font-semibold leading-none tracking-[-0.06em] select-none",
|
|
67
|
+
dark ? "text-white/[0.045]" : "text-neutral-900/[0.045]",
|
|
68
|
+
].join(" ")}
|
|
69
|
+
>
|
|
70
|
+
{monogram}
|
|
71
|
+
</span>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function Media({
|
|
78
|
+
image,
|
|
79
|
+
imageSrc,
|
|
80
|
+
imageAlt,
|
|
81
|
+
dark,
|
|
82
|
+
bg,
|
|
83
|
+
monogram,
|
|
84
|
+
}: {
|
|
85
|
+
image?: React.ReactNode;
|
|
86
|
+
imageSrc?: string;
|
|
87
|
+
imageAlt: string;
|
|
88
|
+
dark?: boolean;
|
|
89
|
+
bg?: string;
|
|
90
|
+
monogram?: string;
|
|
91
|
+
}) {
|
|
92
|
+
if (image) return <>{image}</>;
|
|
93
|
+
if (imageSrc) {
|
|
94
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
95
|
+
return <img src={imageSrc} alt={imageAlt} loading="lazy" className="w-full h-full object-cover" />;
|
|
96
|
+
}
|
|
97
|
+
return <MediaPlaceholder dark={dark} bg={bg} monogram={monogram} />;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function IconTile({ dark, children }: { dark: boolean; children: React.ReactNode }) {
|
|
101
|
+
return (
|
|
102
|
+
<div
|
|
103
|
+
aria-hidden
|
|
104
|
+
className={[
|
|
105
|
+
"w-9 h-9 rounded-[11px] flex items-center justify-center shrink-0",
|
|
106
|
+
dark ? "text-neutral-100" : "text-neutral-700",
|
|
107
|
+
].join(" ")}
|
|
108
|
+
style={{
|
|
109
|
+
background: dark ? "var(--tile-dark)" : "var(--face-light)",
|
|
110
|
+
boxShadow: dark ? "var(--tile-dark-shadow)" : "var(--tile-light-shadow)",
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
{children}
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function Card(props: CardProps) {
|
|
119
|
+
if (props.layout === "stack") {
|
|
120
|
+
const {
|
|
121
|
+
title,
|
|
122
|
+
badge,
|
|
123
|
+
subtitle,
|
|
124
|
+
note,
|
|
125
|
+
image,
|
|
126
|
+
imageSrc,
|
|
127
|
+
imageAlt = "",
|
|
128
|
+
imageBg,
|
|
129
|
+
actions,
|
|
130
|
+
className = "",
|
|
131
|
+
children,
|
|
132
|
+
} = props;
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<CardShell
|
|
136
|
+
as="article"
|
|
137
|
+
className={className}
|
|
138
|
+
rail={actions}
|
|
139
|
+
>
|
|
140
|
+
{/* Hairline under the media so it reads as set into the face */}
|
|
141
|
+
<div className="relative h-52 shrink-0 overflow-hidden" style={{ boxShadow: "0 1px 0 rgba(17,17,17,0.07)" }}>
|
|
142
|
+
<Media image={image} imageSrc={imageSrc} imageAlt={imageAlt} bg={imageBg} />
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<div className="flex-1 flex flex-col p-6">
|
|
146
|
+
{(title || badge) && (
|
|
147
|
+
<div className="flex items-center gap-2">
|
|
148
|
+
{title && (
|
|
149
|
+
<h3 className="text-[17px] font-semibold text-neutral-900 tracking-[-0.015em] leading-snug">{title}</h3>
|
|
150
|
+
)}
|
|
151
|
+
{badge}
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
{subtitle && (
|
|
155
|
+
// Line breaks in the copy are for centred layouts; let it flow here
|
|
156
|
+
<p className="mt-1.5 text-[14px] text-neutral-600 leading-[1.55] text-pretty">
|
|
157
|
+
{subtitle.replace(/\n/g, " ")}
|
|
158
|
+
</p>
|
|
159
|
+
)}
|
|
160
|
+
{children}
|
|
161
|
+
{note && (
|
|
162
|
+
<p className="mt-auto pt-5 text-[12px] text-neutral-500 leading-relaxed text-pretty">{note}</p>
|
|
163
|
+
)}
|
|
164
|
+
</div>
|
|
165
|
+
</CardShell>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const {
|
|
170
|
+
title,
|
|
171
|
+
subtitle,
|
|
172
|
+
icon,
|
|
173
|
+
image,
|
|
174
|
+
imageSrc,
|
|
175
|
+
imageAlt = "",
|
|
176
|
+
actions,
|
|
177
|
+
theme = "light",
|
|
178
|
+
className = "",
|
|
179
|
+
children,
|
|
180
|
+
} = props as SplitCardProps;
|
|
181
|
+
const dark = theme === "dark";
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<CardShell
|
|
185
|
+
as="article"
|
|
186
|
+
theme={dark ? "dark" : "light"}
|
|
187
|
+
className={className}
|
|
188
|
+
faceClassName="flex-row! min-h-[220px]"
|
|
189
|
+
rail={actions}
|
|
190
|
+
>
|
|
191
|
+
{/* The title is the hero: display-sized and pinned to the top. The
|
|
192
|
+
subtitle answers it from the bottom edge, so the two frame the
|
|
193
|
+
face between them instead of huddling in one corner. */}
|
|
194
|
+
<div className="relative flex flex-col p-7 flex-1 min-w-0">
|
|
195
|
+
{icon && <IconTile dark={dark}>{icon}</IconTile>}
|
|
196
|
+
{title && (
|
|
197
|
+
<h3
|
|
198
|
+
className={[
|
|
199
|
+
"text-[34px] font-medium leading-[1.02] tracking-[-0.04em] text-balance max-w-[14ch]",
|
|
200
|
+
icon ? "mt-5" : "",
|
|
201
|
+
dark ? "text-white" : "text-neutral-900",
|
|
202
|
+
].join(" ")}
|
|
203
|
+
>
|
|
204
|
+
{title}
|
|
205
|
+
</h3>
|
|
206
|
+
)}
|
|
207
|
+
<div className="mt-auto pt-8 flex flex-col gap-2">
|
|
208
|
+
{subtitle && (
|
|
209
|
+
<p className="text-[14px] leading-[1.45] tracking-[-0.005em] text-neutral-500">{subtitle}</p>
|
|
210
|
+
)}
|
|
211
|
+
{children}
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
<div
|
|
215
|
+
className="relative w-[40%] shrink-0 overflow-hidden"
|
|
216
|
+
style={{ boxShadow: dark ? "-1px 0 0 rgba(255,255,255,0.07)" : "-1px 0 0 rgba(17,17,17,0.07)" }}
|
|
217
|
+
>
|
|
218
|
+
<Media
|
|
219
|
+
image={image}
|
|
220
|
+
imageSrc={imageSrc}
|
|
221
|
+
imageAlt={imageAlt}
|
|
222
|
+
dark={dark}
|
|
223
|
+
monogram={typeof title === "string" ? title.trim().charAt(0).toLowerCase() : undefined}
|
|
224
|
+
/>
|
|
225
|
+
</div>
|
|
226
|
+
</CardShell>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
interface CardGridProps {
|
|
231
|
+
children: React.ReactNode;
|
|
232
|
+
className?: string;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function CardGrid({ children, className = "" }: CardGridProps) {
|
|
236
|
+
return (
|
|
237
|
+
<div className={["grid grid-cols-1 sm:grid-cols-2 gap-4", className].join(" ")}>
|
|
238
|
+
{children}
|
|
239
|
+
</div>
|
|
240
|
+
);
|
|
241
|
+
}
|