@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,78 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
type Theme = "light" | "dark";
|
|
4
|
+
|
|
5
|
+
interface CardShellProps {
|
|
6
|
+
theme?: Theme;
|
|
7
|
+
/** Controls and context: actions, attribution, deltas. Lives on the chassis. */
|
|
8
|
+
rail?: React.ReactNode;
|
|
9
|
+
/** "actions" for buttons; "text" for plain context, indented to the face's text edge */
|
|
10
|
+
railKind?: "actions" | "text";
|
|
11
|
+
as?: "div" | "figure" | "article";
|
|
12
|
+
className?: string;
|
|
13
|
+
faceClassName?: string;
|
|
14
|
+
children: React.ReactNode;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/* Every card is built like every other object in the kit: a chassis (bezel)
|
|
18
|
+
holding a face. Content goes on the face; controls and context go on the
|
|
19
|
+
rail, the strip of chassis below it. The two are nearly the same tone —
|
|
20
|
+
the hairline around the face is what separates them.
|
|
21
|
+
|
|
22
|
+
Geometry, measured from the card's outer edge:
|
|
23
|
+
- chassis 24px radius, 6px bezel → face 18px radius (concentric)
|
|
24
|
+
- rail content sits 12px in from the left and bottom, 10px below the face
|
|
25
|
+
- a button's label then starts 30px in — the same edge as the face's text,
|
|
26
|
+
and plain-text rails are indented to that edge too */
|
|
27
|
+
export function CardShell({
|
|
28
|
+
theme = "light",
|
|
29
|
+
rail,
|
|
30
|
+
railKind = "actions",
|
|
31
|
+
as: Tag = "div",
|
|
32
|
+
className = "",
|
|
33
|
+
faceClassName = "",
|
|
34
|
+
children,
|
|
35
|
+
}: CardShellProps) {
|
|
36
|
+
const dark = theme === "dark";
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<Tag
|
|
40
|
+
className={[
|
|
41
|
+
"rim relative m-0 rounded-[24px] p-1.5 flex flex-col h-full",
|
|
42
|
+
rail ? "pb-0" : "",
|
|
43
|
+
className,
|
|
44
|
+
].join(" ")}
|
|
45
|
+
style={{
|
|
46
|
+
background: dark ? "var(--card-bezel-dark)" : "var(--card-bezel-light)",
|
|
47
|
+
boxShadow: dark ? "var(--card-dark-shadow)" : "var(--card-light-shadow)",
|
|
48
|
+
["--rim" as string]: dark ? "rgba(255,255,255,0.1)" : "rgba(255,255,255,1)",
|
|
49
|
+
} as React.CSSProperties}
|
|
50
|
+
>
|
|
51
|
+
<div
|
|
52
|
+
className={["relative flex-1 min-h-0 rounded-[18px] overflow-hidden flex flex-col", faceClassName].join(" ")}
|
|
53
|
+
style={{
|
|
54
|
+
background: dark ? "var(--card-dark)" : "var(--card-light)",
|
|
55
|
+
boxShadow: dark ? "var(--card-face-dark-shadow)" : "var(--card-face-light-shadow)",
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
{children}
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
{rail && (
|
|
62
|
+
<div className="relative shrink-0 px-1.5 pt-2.5 pb-3">
|
|
63
|
+
<div
|
|
64
|
+
className={[
|
|
65
|
+
// 40px = a small button's full height, so text rails and button
|
|
66
|
+
// rails come out the same height and line up across a row
|
|
67
|
+
"min-h-10 flex items-center gap-2 text-[12.5px]",
|
|
68
|
+
railKind === "text" ? "pl-[18px] pr-3 gap-2.5" : "",
|
|
69
|
+
dark ? "text-neutral-400" : "text-neutral-500",
|
|
70
|
+
].join(" ")}
|
|
71
|
+
>
|
|
72
|
+
{rail}
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
)}
|
|
76
|
+
</Tag>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect, useRef } from "react";
|
|
4
|
+
import { Button } from "./Button";
|
|
5
|
+
import { CardShell } from "./CardShell";
|
|
6
|
+
|
|
7
|
+
interface CarouselProps {
|
|
8
|
+
items: React.ReactNode[];
|
|
9
|
+
autoPlay?: boolean;
|
|
10
|
+
interval?: number;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const SLIDE_MS = 680;
|
|
15
|
+
|
|
16
|
+
interface Slot {
|
|
17
|
+
itemIndex: number;
|
|
18
|
+
key: string;
|
|
19
|
+
role: "current" | "prev";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function Carousel({
|
|
23
|
+
items,
|
|
24
|
+
autoPlay = false,
|
|
25
|
+
interval = 4000,
|
|
26
|
+
className = "",
|
|
27
|
+
}: CarouselProps) {
|
|
28
|
+
const [slots, setSlots] = useState<Slot[]>([
|
|
29
|
+
{ itemIndex: 0, key: "s-0-0", role: "current" },
|
|
30
|
+
]);
|
|
31
|
+
const [direction, setDirection] = useState<"left" | "right">("right");
|
|
32
|
+
const [currentIdx, setCurrentIdx] = useState(0);
|
|
33
|
+
// Autoplay never moves content out from under someone looking at it
|
|
34
|
+
const [paused, setPaused] = useState(false);
|
|
35
|
+
|
|
36
|
+
const currentIdxRef = useRef(0);
|
|
37
|
+
const lockedRef = useRef(false);
|
|
38
|
+
const tcRef = useRef(0);
|
|
39
|
+
const cleanupRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
40
|
+
|
|
41
|
+
const go = (nextIdx: number, dir: "left" | "right") => {
|
|
42
|
+
if (lockedRef.current || nextIdx === currentIdxRef.current) return;
|
|
43
|
+
|
|
44
|
+
const prevIdx = currentIdxRef.current;
|
|
45
|
+
const tc = ++tcRef.current;
|
|
46
|
+
|
|
47
|
+
lockedRef.current = true;
|
|
48
|
+
currentIdxRef.current = nextIdx;
|
|
49
|
+
|
|
50
|
+
setDirection(dir);
|
|
51
|
+
setCurrentIdx(nextIdx);
|
|
52
|
+
setSlots([
|
|
53
|
+
{ itemIndex: nextIdx, key: `c-${nextIdx}-${tc}`, role: "current" },
|
|
54
|
+
{ itemIndex: prevIdx, key: `p-${prevIdx}-${tc}`, role: "prev" },
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
if (cleanupRef.current) clearTimeout(cleanupRef.current);
|
|
58
|
+
cleanupRef.current = setTimeout(() => {
|
|
59
|
+
setSlots([{ itemIndex: nextIdx, key: `c-${nextIdx}-${tc}`, role: "current" }]);
|
|
60
|
+
lockedRef.current = false;
|
|
61
|
+
}, SLIDE_MS + 20);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const goNext = () => go((currentIdxRef.current + 1) % items.length, "right");
|
|
65
|
+
const goPrev = () => go((currentIdxRef.current - 1 + items.length) % items.length, "left");
|
|
66
|
+
|
|
67
|
+
const goNextRef = useRef(goNext);
|
|
68
|
+
goNextRef.current = goNext;
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (!autoPlay || paused) return;
|
|
72
|
+
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
|
73
|
+
const id = setInterval(() => goNextRef.current(), interval);
|
|
74
|
+
return () => clearInterval(id);
|
|
75
|
+
}, [autoPlay, interval, paused]);
|
|
76
|
+
|
|
77
|
+
const onKeyDown = (e: React.KeyboardEvent) => {
|
|
78
|
+
if (e.key === "ArrowRight") { e.preventDefault(); goNextRef.current(); }
|
|
79
|
+
if (e.key === "ArrowLeft") { e.preventDefault(); goPrev(); }
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
useEffect(() => () => { if (cleanupRef.current) clearTimeout(cleanupRef.current); }, []);
|
|
83
|
+
|
|
84
|
+
// ── Swipe / pointer drag ──────────────────────────────────────────
|
|
85
|
+
const dragRef = useRef<{ x: number; y: number; active: boolean }>({ x: 0, y: 0, active: false });
|
|
86
|
+
const THRESHOLD = 48;
|
|
87
|
+
|
|
88
|
+
const onPointerDown = (e: React.PointerEvent) => {
|
|
89
|
+
dragRef.current = { x: e.clientX, y: e.clientY, active: true };
|
|
90
|
+
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const onPointerUp = (e: React.PointerEvent) => {
|
|
94
|
+
if (!dragRef.current.active) return;
|
|
95
|
+
const dx = e.clientX - dragRef.current.x;
|
|
96
|
+
const dy = e.clientY - dragRef.current.y;
|
|
97
|
+
dragRef.current.active = false;
|
|
98
|
+
if (Math.abs(dx) > THRESHOLD && Math.abs(dx) > Math.abs(dy) * 1.5) {
|
|
99
|
+
dx < 0 ? goNextRef.current() : goPrev();
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const onPointerCancel = () => { dragRef.current.active = false; };
|
|
104
|
+
|
|
105
|
+
/* A plain slide: the incoming and outgoing slides travel together as
|
|
106
|
+
one strip, a full width each. The curve is a strong ease-out, so the
|
|
107
|
+
strip moves the instant you click and then settles — an ease-in-out
|
|
108
|
+
hesitates first, which reads as lag. Driven by the Web Animations API
|
|
109
|
+
on mount, so it never depends on a stylesheet. */
|
|
110
|
+
const slide = (role: Slot["role"]) => (el: HTMLDivElement | null) => {
|
|
111
|
+
if (!el || el.dataset.animated || slots.length < 2) return;
|
|
112
|
+
el.dataset.animated = "1";
|
|
113
|
+
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
|
114
|
+
const sign = direction === "right" ? 1 : -1;
|
|
115
|
+
const frames =
|
|
116
|
+
role === "current"
|
|
117
|
+
? [{ transform: `translateX(${sign * 100}%)` }, { transform: "translateX(0)" }]
|
|
118
|
+
: [{ transform: "translateX(0)" }, { transform: `translateX(${-sign * 100}%)` }];
|
|
119
|
+
el.animate(frames, { duration: SLIDE_MS, easing: "cubic-bezier(0.22, 1, 0.36, 1)", fill: "both" });
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const controls = (
|
|
123
|
+
<div className="w-full flex items-center justify-between">
|
|
124
|
+
<div className="flex items-center gap-3 pl-1.5">
|
|
125
|
+
{/* Dots sit in a well; the active one fills over the autoplay
|
|
126
|
+
interval, and the fill holds while autoplay is paused */}
|
|
127
|
+
<div
|
|
128
|
+
className="flex items-center gap-1 rounded-full p-[5px]"
|
|
129
|
+
style={{ background: "var(--well)", boxShadow: "var(--well-shadow)" }}
|
|
130
|
+
>
|
|
131
|
+
{items.map((_, i) => {
|
|
132
|
+
const active = i === currentIdx;
|
|
133
|
+
return (
|
|
134
|
+
<button
|
|
135
|
+
key={i}
|
|
136
|
+
onClick={() => go(i, i > currentIdxRef.current ? "right" : "left")}
|
|
137
|
+
className={[
|
|
138
|
+
"relative h-1.5 rounded-full overflow-hidden cursor-pointer",
|
|
139
|
+
"transition-[width,background] duration-300 ease-[var(--ease-out-soft)]",
|
|
140
|
+
"after:absolute after:-inset-2 after:content-['']",
|
|
141
|
+
active ? "w-6 bg-neutral-900/15" : "w-1.5 bg-neutral-900/20 hover:bg-neutral-900/40",
|
|
142
|
+
].join(" ")}
|
|
143
|
+
aria-label={`Go to slide ${i + 1}`}
|
|
144
|
+
aria-current={active ? "true" : undefined}
|
|
145
|
+
>
|
|
146
|
+
{active && (
|
|
147
|
+
<span
|
|
148
|
+
key={`${currentIdx}-${autoPlay}`}
|
|
149
|
+
aria-hidden
|
|
150
|
+
className={["absolute inset-0 rounded-full origin-left", autoPlay ? "carousel-progress" : ""].join(" ")}
|
|
151
|
+
style={{
|
|
152
|
+
background: "var(--face-dark)",
|
|
153
|
+
animationDuration: `${interval}ms`,
|
|
154
|
+
animationPlayState: paused ? "paused" : "running",
|
|
155
|
+
}}
|
|
156
|
+
/>
|
|
157
|
+
)}
|
|
158
|
+
</button>
|
|
159
|
+
);
|
|
160
|
+
})}
|
|
161
|
+
</div>
|
|
162
|
+
<span className="text-[12px] font-medium text-neutral-600 tabular-nums">
|
|
163
|
+
{currentIdx + 1}
|
|
164
|
+
<span className="text-neutral-400"> / {items.length}</span>
|
|
165
|
+
</span>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<div className="flex gap-2">
|
|
169
|
+
<Button variant="secondary" size="sm" square onClick={goPrev} aria-label="Previous slide">
|
|
170
|
+
<svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
171
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
|
172
|
+
</svg>
|
|
173
|
+
</Button>
|
|
174
|
+
<Button variant="secondary" size="sm" square onClick={goNext} aria-label="Next slide">
|
|
175
|
+
<svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
176
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
177
|
+
</svg>
|
|
178
|
+
</Button>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
/* Built on the card chassis like every other surface: slides are the
|
|
184
|
+
face, controls live on the rail below it. */
|
|
185
|
+
return (
|
|
186
|
+
<section
|
|
187
|
+
aria-roledescription="carousel"
|
|
188
|
+
aria-label="Carousel"
|
|
189
|
+
onKeyDown={onKeyDown}
|
|
190
|
+
onMouseEnter={() => setPaused(true)}
|
|
191
|
+
onMouseLeave={() => setPaused(false)}
|
|
192
|
+
onFocus={() => setPaused(true)}
|
|
193
|
+
onBlur={(e) => {
|
|
194
|
+
if (!e.currentTarget.contains(e.relatedTarget as Node)) setPaused(false);
|
|
195
|
+
}}
|
|
196
|
+
className={className}
|
|
197
|
+
>
|
|
198
|
+
<CardShell rail={controls}>
|
|
199
|
+
<div
|
|
200
|
+
className="relative w-full overflow-hidden cursor-grab active:cursor-grabbing touch-pan-y"
|
|
201
|
+
style={{ minHeight: 300 }}
|
|
202
|
+
aria-live={autoPlay && !paused ? "off" : "polite"}
|
|
203
|
+
onPointerDown={onPointerDown}
|
|
204
|
+
onPointerUp={onPointerUp}
|
|
205
|
+
onPointerCancel={onPointerCancel}
|
|
206
|
+
>
|
|
207
|
+
{slots.map((slot) => (
|
|
208
|
+
<div
|
|
209
|
+
key={slot.key}
|
|
210
|
+
role="group"
|
|
211
|
+
aria-roledescription="slide"
|
|
212
|
+
aria-label={`${slot.itemIndex + 1} of ${items.length}`}
|
|
213
|
+
aria-hidden={slot.role === "prev"}
|
|
214
|
+
ref={slide(slot.role)}
|
|
215
|
+
className={["absolute inset-0 will-change-transform [backface-visibility:hidden]", slot.role === "prev" ? "pointer-events-none" : ""].join(" ")}
|
|
216
|
+
>
|
|
217
|
+
{items[slot.itemIndex]}
|
|
218
|
+
</div>
|
|
219
|
+
))}
|
|
220
|
+
</div>
|
|
221
|
+
</CardShell>
|
|
222
|
+
</section>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
type SlideTheme = "dark" | "light" | "tint";
|
|
227
|
+
|
|
228
|
+
interface CarouselSlideProps {
|
|
229
|
+
theme?: SlideTheme;
|
|
230
|
+
/** Background for the "tint" theme */
|
|
231
|
+
tint?: string;
|
|
232
|
+
icon?: React.ReactNode;
|
|
233
|
+
title: React.ReactNode;
|
|
234
|
+
meta?: React.ReactNode;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/* A ready-made slide in the cards' vocabulary: the card's own material,
|
|
238
|
+
a raised icon tile, and the same fading dot grid as empty media. */
|
|
239
|
+
export function CarouselSlide({ theme = "dark", tint = "#8A8E7A", icon, title, meta }: CarouselSlideProps) {
|
|
240
|
+
const dark = theme !== "light";
|
|
241
|
+
return (
|
|
242
|
+
<div
|
|
243
|
+
className="relative w-full h-full flex flex-col justify-end gap-6 p-8 overflow-hidden select-none"
|
|
244
|
+
style={{
|
|
245
|
+
background: theme === "dark" ? "var(--card-dark)" : theme === "light" ? "var(--card-light)" : tint,
|
|
246
|
+
}}
|
|
247
|
+
>
|
|
248
|
+
<div
|
|
249
|
+
aria-hidden
|
|
250
|
+
className="absolute inset-y-0 right-0 w-3/5"
|
|
251
|
+
style={{
|
|
252
|
+
backgroundImage: `radial-gradient(${
|
|
253
|
+
theme === "tint" ? "rgba(255,255,255,0.22)" : dark ? "rgba(255,255,255,0.09)" : "rgba(17,17,17,0.08)"
|
|
254
|
+
} 1px, transparent 1px)`,
|
|
255
|
+
backgroundSize: "12px 12px",
|
|
256
|
+
maskImage: "radial-gradient(60% 70% at 70% 50%, #000 0%, transparent 100%)",
|
|
257
|
+
WebkitMaskImage: "radial-gradient(60% 70% at 70% 50%, #000 0%, transparent 100%)",
|
|
258
|
+
}}
|
|
259
|
+
/>
|
|
260
|
+
{icon && (
|
|
261
|
+
<div
|
|
262
|
+
aria-hidden
|
|
263
|
+
className={[
|
|
264
|
+
"relative w-9 h-9 rounded-[11px] flex items-center justify-center mb-auto",
|
|
265
|
+
dark ? "text-neutral-100" : "text-neutral-700",
|
|
266
|
+
].join(" ")}
|
|
267
|
+
style={{
|
|
268
|
+
background: dark ? "var(--tile-dark)" : "var(--face-light)",
|
|
269
|
+
boxShadow: dark ? "var(--tile-dark-shadow)" : "var(--tile-light-shadow)",
|
|
270
|
+
}}
|
|
271
|
+
>
|
|
272
|
+
{icon}
|
|
273
|
+
</div>
|
|
274
|
+
)}
|
|
275
|
+
<div className="relative">
|
|
276
|
+
<p
|
|
277
|
+
className={[
|
|
278
|
+
"text-[22px] font-medium leading-[1.2] tracking-[-0.015em] mb-2",
|
|
279
|
+
dark ? "text-white" : "text-neutral-900",
|
|
280
|
+
].join(" ")}
|
|
281
|
+
>
|
|
282
|
+
{title}
|
|
283
|
+
</p>
|
|
284
|
+
{meta && (
|
|
285
|
+
<p className={["text-[13px]", theme === "tint" ? "text-white/65" : dark ? "text-neutral-400" : "text-neutral-500"].join(" ")}>
|
|
286
|
+
{meta}
|
|
287
|
+
</p>
|
|
288
|
+
)}
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
);
|
|
292
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useId } from "react";
|
|
4
|
+
|
|
5
|
+
interface CheckboxProps {
|
|
6
|
+
checked?: boolean;
|
|
7
|
+
defaultChecked?: boolean;
|
|
8
|
+
onChange?: (checked: boolean) => void;
|
|
9
|
+
label?: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
indeterminate?: boolean;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function Checkbox({
|
|
17
|
+
checked,
|
|
18
|
+
defaultChecked = false,
|
|
19
|
+
onChange,
|
|
20
|
+
label,
|
|
21
|
+
description,
|
|
22
|
+
disabled = false,
|
|
23
|
+
indeterminate = false,
|
|
24
|
+
className = "",
|
|
25
|
+
}: CheckboxProps) {
|
|
26
|
+
const [internal, setInternal] = useState(defaultChecked);
|
|
27
|
+
const uid = useId();
|
|
28
|
+
const isChecked = checked !== undefined ? checked : internal;
|
|
29
|
+
|
|
30
|
+
const toggle = () => {
|
|
31
|
+
if (disabled) return;
|
|
32
|
+
const next = !isChecked;
|
|
33
|
+
setInternal(next);
|
|
34
|
+
onChange?.(next);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const active = isChecked || indeterminate;
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div
|
|
41
|
+
className={[
|
|
42
|
+
"flex items-center justify-between gap-4 select-none",
|
|
43
|
+
disabled ? "opacity-40 pointer-events-none" : "cursor-pointer",
|
|
44
|
+
className,
|
|
45
|
+
].join(" ")}
|
|
46
|
+
onClick={toggle}
|
|
47
|
+
>
|
|
48
|
+
{(label || description) && (
|
|
49
|
+
<div className="flex flex-col gap-0.5 min-w-0">
|
|
50
|
+
{label && (
|
|
51
|
+
<span id={`${uid}-label`} className="text-[13px] font-medium text-neutral-900 leading-snug">{label}</span>
|
|
52
|
+
)}
|
|
53
|
+
{description && (
|
|
54
|
+
<span id={`${uid}-desc`} className="text-[12px] text-neutral-500 leading-relaxed">{description}</span>
|
|
55
|
+
)}
|
|
56
|
+
</div>
|
|
57
|
+
)}
|
|
58
|
+
|
|
59
|
+
{/* Clicks bubble to the row, so the control itself has no onClick */}
|
|
60
|
+
<button
|
|
61
|
+
type="button"
|
|
62
|
+
role="checkbox"
|
|
63
|
+
aria-checked={indeterminate ? "mixed" : isChecked}
|
|
64
|
+
aria-labelledby={label ? `${uid}-label` : undefined}
|
|
65
|
+
aria-describedby={description ? `${uid}-desc` : undefined}
|
|
66
|
+
disabled={disabled}
|
|
67
|
+
className={[
|
|
68
|
+
"shrink-0 w-[22px] h-[22px] rounded-[7px] flex items-center justify-center cursor-pointer",
|
|
69
|
+
"transition-[box-shadow,transform,scale] duration-200 ease-out active:scale-90",
|
|
70
|
+
].join(" ")}
|
|
71
|
+
style={{
|
|
72
|
+
background: active ? "var(--face-dark)" : "var(--field)",
|
|
73
|
+
boxShadow: active ? "var(--face-dark-shadow)" : "var(--field-shadow)",
|
|
74
|
+
}}
|
|
75
|
+
>
|
|
76
|
+
{active && (
|
|
77
|
+
indeterminate ? (
|
|
78
|
+
<span className="animate-checkbox-in">
|
|
79
|
+
<svg width="10" height="2" viewBox="0 0 10 2" fill="none">
|
|
80
|
+
<rect width="10" height="2" rx="1" fill="white" />
|
|
81
|
+
</svg>
|
|
82
|
+
</span>
|
|
83
|
+
) : (
|
|
84
|
+
<svg width="13" height="10" viewBox="0 0 13 10" fill="none">
|
|
85
|
+
<path
|
|
86
|
+
d="M1.5 5l3.5 3.5L11.5 1"
|
|
87
|
+
stroke="white"
|
|
88
|
+
strokeWidth="2"
|
|
89
|
+
strokeLinecap="round"
|
|
90
|
+
strokeLinejoin="round"
|
|
91
|
+
className="animate-draw-check"
|
|
92
|
+
/>
|
|
93
|
+
</svg>
|
|
94
|
+
)
|
|
95
|
+
)}
|
|
96
|
+
</button>
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
|
|
5
|
+
interface CloseButtonProps {
|
|
6
|
+
onClick?: () => void;
|
|
7
|
+
label?: string;
|
|
8
|
+
size?: "sm" | "md";
|
|
9
|
+
tone?: "light" | "dark";
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const sizes = {
|
|
14
|
+
sm: { box: "w-6 h-6 rounded-[7px]", icon: 9 },
|
|
15
|
+
md: { box: "w-7 h-7 rounded-[8px]", icon: 10 },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/* Quiet until hovered, then it rises into a raised face like every other
|
|
19
|
+
pressable thing — so it reads as a control, not decoration. */
|
|
20
|
+
export function CloseButton({
|
|
21
|
+
onClick,
|
|
22
|
+
label = "Close",
|
|
23
|
+
size = "md",
|
|
24
|
+
tone = "light",
|
|
25
|
+
className = "",
|
|
26
|
+
}: CloseButtonProps) {
|
|
27
|
+
const dark = tone === "dark";
|
|
28
|
+
const s = sizes[size];
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<button
|
|
32
|
+
type="button"
|
|
33
|
+
onClick={onClick}
|
|
34
|
+
aria-label={label}
|
|
35
|
+
className={[
|
|
36
|
+
"group/close relative shrink-0 flex items-center justify-center cursor-pointer",
|
|
37
|
+
"transition-[color,background,scale] duration-150 active:scale-95",
|
|
38
|
+
"focus-visible:outline-2 focus-visible:outline-offset-2",
|
|
39
|
+
dark
|
|
40
|
+
? "text-white/45 hover:text-white focus-visible:outline-white"
|
|
41
|
+
: "text-neutral-400 hover:text-neutral-900 focus-visible:outline-neutral-900",
|
|
42
|
+
s.box,
|
|
43
|
+
className,
|
|
44
|
+
].join(" ")}
|
|
45
|
+
>
|
|
46
|
+
<span
|
|
47
|
+
aria-hidden
|
|
48
|
+
className={[
|
|
49
|
+
"absolute inset-0 rounded-[inherit] opacity-0 group-hover/close:opacity-100 transition-opacity duration-150",
|
|
50
|
+
dark
|
|
51
|
+
? "[background:var(--tile-dark)] [box-shadow:var(--tile-dark-shadow)]"
|
|
52
|
+
: "[background:var(--face-light)] [box-shadow:var(--face-light-shadow)]",
|
|
53
|
+
].join(" ")}
|
|
54
|
+
/>
|
|
55
|
+
<svg
|
|
56
|
+
className="relative"
|
|
57
|
+
width={s.icon}
|
|
58
|
+
height={s.icon}
|
|
59
|
+
fill="none"
|
|
60
|
+
stroke="currentColor"
|
|
61
|
+
strokeWidth="2.5"
|
|
62
|
+
viewBox="0 0 24 24"
|
|
63
|
+
>
|
|
64
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
65
|
+
</svg>
|
|
66
|
+
</button>
|
|
67
|
+
);
|
|
68
|
+
}
|