@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,136 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useRef, useEffect, useLayoutEffect, useId } from "react";
|
|
4
|
+
|
|
5
|
+
interface Tab {
|
|
6
|
+
key: string;
|
|
7
|
+
label: string;
|
|
8
|
+
content?: React.ReactNode;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface TabsProps {
|
|
12
|
+
tabs: Tab[];
|
|
13
|
+
defaultTab?: string;
|
|
14
|
+
onChange?: (key: string) => void;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/* A sunken well holding a raised thumb — the same segmented control as
|
|
19
|
+
Pagination. One Tab stop; arrows move between tabs and select them. */
|
|
20
|
+
export function Tabs({ tabs, defaultTab, onChange, className = "" }: TabsProps) {
|
|
21
|
+
const [active, setActive] = useState(defaultTab ?? tabs[0]?.key ?? "");
|
|
22
|
+
const [direction, setDirection] = useState<"left" | "right">("right");
|
|
23
|
+
const [pill, setPill] = useState({ left: 0, width: 0, ready: false });
|
|
24
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
25
|
+
const btnRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
|
26
|
+
const uid = useId();
|
|
27
|
+
|
|
28
|
+
const measurePill = (key: string) => {
|
|
29
|
+
const btn = btnRefs.current[key];
|
|
30
|
+
const list = listRef.current;
|
|
31
|
+
if (!btn || !list) return;
|
|
32
|
+
const lr = list.getBoundingClientRect();
|
|
33
|
+
const br = btn.getBoundingClientRect();
|
|
34
|
+
setPill({ left: br.left - lr.left, width: br.width, ready: true });
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
useLayoutEffect(() => { measurePill(active); }, []);
|
|
38
|
+
useEffect(() => { measurePill(active); }, [active]);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const ro = new ResizeObserver(() => measurePill(active));
|
|
42
|
+
if (listRef.current) ro.observe(listRef.current);
|
|
43
|
+
return () => ro.disconnect();
|
|
44
|
+
}, [active]);
|
|
45
|
+
|
|
46
|
+
const handleSelect = (key: string) => {
|
|
47
|
+
const prevIndex = tabs.findIndex((t) => t.key === active);
|
|
48
|
+
const nextIndex = tabs.findIndex((t) => t.key === key);
|
|
49
|
+
setDirection(nextIndex > prevIndex ? "right" : "left");
|
|
50
|
+
setActive(key);
|
|
51
|
+
onChange?.(key);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const onKeyDown = (e: React.KeyboardEvent) => {
|
|
55
|
+
const i = tabs.findIndex((t) => t.key === active);
|
|
56
|
+
let next = -1;
|
|
57
|
+
if (e.key === "ArrowRight") next = (i + 1) % tabs.length;
|
|
58
|
+
if (e.key === "ArrowLeft") next = (i - 1 + tabs.length) % tabs.length;
|
|
59
|
+
if (e.key === "Home") next = 0;
|
|
60
|
+
if (e.key === "End") next = tabs.length - 1;
|
|
61
|
+
if (next < 0) return;
|
|
62
|
+
e.preventDefault();
|
|
63
|
+
const key = tabs[next].key;
|
|
64
|
+
handleSelect(key);
|
|
65
|
+
btnRefs.current[key]?.focus();
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const activeTab = tabs.find((t) => t.key === active);
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div className={["flex flex-col gap-4", className].join(" ")}>
|
|
72
|
+
<div
|
|
73
|
+
ref={listRef}
|
|
74
|
+
role="tablist"
|
|
75
|
+
onKeyDown={onKeyDown}
|
|
76
|
+
className="relative flex w-full sm:inline-flex sm:w-auto sm:self-start items-center rounded-[13px] p-1"
|
|
77
|
+
style={{ background: "var(--well)", boxShadow: "var(--well-shadow)" }}
|
|
78
|
+
>
|
|
79
|
+
{pill.ready && (
|
|
80
|
+
<span
|
|
81
|
+
aria-hidden
|
|
82
|
+
className="absolute top-1 bottom-1 rounded-[9px] pointer-events-none"
|
|
83
|
+
style={{
|
|
84
|
+
left: pill.left,
|
|
85
|
+
width: pill.width,
|
|
86
|
+
background: "var(--face-light)",
|
|
87
|
+
boxShadow: "var(--thumb-shadow)",
|
|
88
|
+
transition: "left 300ms var(--ease-spring), width 300ms var(--ease-spring)",
|
|
89
|
+
}}
|
|
90
|
+
/>
|
|
91
|
+
)}
|
|
92
|
+
|
|
93
|
+
{tabs.map((tab) => {
|
|
94
|
+
const isActive = active === tab.key;
|
|
95
|
+
return (
|
|
96
|
+
<button
|
|
97
|
+
key={tab.key}
|
|
98
|
+
ref={(el) => { btnRefs.current[tab.key] = el; }}
|
|
99
|
+
id={`${uid}-tab-${tab.key}`}
|
|
100
|
+
role="tab"
|
|
101
|
+
type="button"
|
|
102
|
+
aria-selected={isActive}
|
|
103
|
+
aria-controls={tab.content ? `${uid}-panel-${tab.key}` : undefined}
|
|
104
|
+
tabIndex={isActive ? 0 : -1}
|
|
105
|
+
onClick={() => handleSelect(tab.key)}
|
|
106
|
+
className={[
|
|
107
|
+
"relative z-10 flex-1 sm:flex-none px-3.5 py-2 rounded-[9px] text-[13px] font-medium text-center whitespace-nowrap cursor-pointer",
|
|
108
|
+
"transition-colors duration-150",
|
|
109
|
+
"focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-neutral-900",
|
|
110
|
+
isActive ? "text-neutral-900" : "text-neutral-500 hover:text-neutral-800",
|
|
111
|
+
].join(" ")}
|
|
112
|
+
>
|
|
113
|
+
{tab.label}
|
|
114
|
+
</button>
|
|
115
|
+
);
|
|
116
|
+
})}
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
{activeTab?.content && (
|
|
120
|
+
<div
|
|
121
|
+
key={active}
|
|
122
|
+
id={`${uid}-panel-${active}`}
|
|
123
|
+
role="tabpanel"
|
|
124
|
+
aria-labelledby={`${uid}-tab-${active}`}
|
|
125
|
+
tabIndex={0}
|
|
126
|
+
className={[
|
|
127
|
+
"text-[13px] text-neutral-600 leading-relaxed rounded-lg focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neutral-900",
|
|
128
|
+
direction === "right" ? "animate-tab-slide-right" : "animate-tab-slide-left",
|
|
129
|
+
].join(" ")}
|
|
130
|
+
>
|
|
131
|
+
{activeTab.content}
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useRef, useEffect, useId } from "react";
|
|
4
|
+
|
|
5
|
+
interface TagInputProps {
|
|
6
|
+
value?: string[];
|
|
7
|
+
defaultValue?: string[];
|
|
8
|
+
onChange?: (tags: string[]) => void;
|
|
9
|
+
placeholder?: string;
|
|
10
|
+
label?: string;
|
|
11
|
+
hint?: string;
|
|
12
|
+
maxTags?: number;
|
|
13
|
+
fullWidth?: boolean;
|
|
14
|
+
className?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface AnimatedTag {
|
|
18
|
+
text: string;
|
|
19
|
+
state: "entering" | "visible" | "exiting";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function TagInput({
|
|
23
|
+
value,
|
|
24
|
+
defaultValue = [],
|
|
25
|
+
onChange,
|
|
26
|
+
placeholder = "add a tag…",
|
|
27
|
+
label,
|
|
28
|
+
hint,
|
|
29
|
+
maxTags,
|
|
30
|
+
fullWidth = false,
|
|
31
|
+
className = "",
|
|
32
|
+
}: TagInputProps) {
|
|
33
|
+
const [internal, setInternal] = useState<string[]>(defaultValue);
|
|
34
|
+
const [input, setInput] = useState("");
|
|
35
|
+
const [animTags, setAnimTags] = useState<AnimatedTag[]>(() =>
|
|
36
|
+
(value ?? defaultValue).map((t) => ({ text: t, state: "visible" as const }))
|
|
37
|
+
);
|
|
38
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
39
|
+
const uid = useId();
|
|
40
|
+
const prevValueRef = useRef<string[]>(value ?? defaultValue);
|
|
41
|
+
|
|
42
|
+
const tags = value !== undefined ? value : internal;
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
const prev = prevValueRef.current;
|
|
46
|
+
const next = tags;
|
|
47
|
+
|
|
48
|
+
const added = next.filter((t) => !prev.includes(t));
|
|
49
|
+
const removed = prev.filter((t) => !next.includes(t));
|
|
50
|
+
|
|
51
|
+
if (added.length === 0 && removed.length === 0) return;
|
|
52
|
+
|
|
53
|
+
setAnimTags((current) => {
|
|
54
|
+
const updated = current.map((at) =>
|
|
55
|
+
removed.includes(at.text) ? { ...at, state: "exiting" as const } : at
|
|
56
|
+
);
|
|
57
|
+
added.forEach((t) => {
|
|
58
|
+
if (!updated.find((at) => at.text === t)) {
|
|
59
|
+
updated.push({ text: t, state: "entering" });
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
return updated;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
if (added.length > 0) {
|
|
66
|
+
requestAnimationFrame(() => {
|
|
67
|
+
setAnimTags((current) =>
|
|
68
|
+
current.map((at) =>
|
|
69
|
+
at.state === "entering" ? { ...at, state: "visible" } : at
|
|
70
|
+
)
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (removed.length > 0) {
|
|
76
|
+
setTimeout(() => {
|
|
77
|
+
setAnimTags((current) => current.filter((at) => at.state !== "exiting"));
|
|
78
|
+
}, 180);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
prevValueRef.current = next;
|
|
82
|
+
}, [tags]);
|
|
83
|
+
|
|
84
|
+
const update = (next: string[]) => {
|
|
85
|
+
setInternal(next);
|
|
86
|
+
onChange?.(next);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const addTag = (raw: string) => {
|
|
90
|
+
const tag = raw.trim();
|
|
91
|
+
if (!tag || tags.includes(tag)) return;
|
|
92
|
+
if (maxTags && tags.length >= maxTags) return;
|
|
93
|
+
update([...tags, tag]);
|
|
94
|
+
setInput("");
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const removeTag = (index: number) => {
|
|
98
|
+
const tagText = tags[index];
|
|
99
|
+
if (!tagText) return;
|
|
100
|
+
setAnimTags((current) =>
|
|
101
|
+
current.map((at) =>
|
|
102
|
+
at.text === tagText ? { ...at, state: "exiting" as const } : at
|
|
103
|
+
)
|
|
104
|
+
);
|
|
105
|
+
setTimeout(() => {
|
|
106
|
+
update(tags.filter((_, i) => i !== index));
|
|
107
|
+
}, 180);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
111
|
+
if (e.key === "Enter" || e.key === ",") {
|
|
112
|
+
e.preventDefault();
|
|
113
|
+
addTag(input);
|
|
114
|
+
}
|
|
115
|
+
if (e.key === "Backspace" && !input && tags.length > 0) {
|
|
116
|
+
removeTag(tags.length - 1);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<div className={["flex flex-col gap-1.5", fullWidth ? "w-full" : ""].join(" ")}>
|
|
122
|
+
{label && (
|
|
123
|
+
<label htmlFor={`${uid}-input`} className="text-[12.5px] font-medium text-neutral-700 tracking-[-0.005em]">
|
|
124
|
+
{label}
|
|
125
|
+
</label>
|
|
126
|
+
)}
|
|
127
|
+
<div
|
|
128
|
+
onClick={() => inputRef.current?.focus()}
|
|
129
|
+
className={[
|
|
130
|
+
"flex flex-wrap items-center gap-1.5 rounded-xl px-3 py-2.5",
|
|
131
|
+
"transition-[box-shadow,background] duration-200 ease-out cursor-text",
|
|
132
|
+
"[background:var(--field)] [box-shadow:var(--field-shadow)]",
|
|
133
|
+
"hover:[background:var(--field-hover)] hover:[box-shadow:var(--field-shadow-hover)]",
|
|
134
|
+
"focus-within:[background:#ffffff] focus-within:[box-shadow:var(--field-shadow-focus)]",
|
|
135
|
+
fullWidth ? "w-full" : "",
|
|
136
|
+
className,
|
|
137
|
+
]
|
|
138
|
+
.filter(Boolean)
|
|
139
|
+
.join(" ")}
|
|
140
|
+
>
|
|
141
|
+
{animTags.map((at, i) => (
|
|
142
|
+
<span
|
|
143
|
+
key={at.text}
|
|
144
|
+
className={[
|
|
145
|
+
"inline-flex items-center gap-1 pl-2 pr-1 py-0.5 rounded-[7px] text-[11.5px] font-medium text-neutral-700 select-none",
|
|
146
|
+
"transition-all duration-180 ease-[cubic-bezier(0.34,1.4,0.64,1)]",
|
|
147
|
+
at.state === "entering" ? "opacity-0 scale-75" : "",
|
|
148
|
+
at.state === "visible" ? "opacity-100 scale-100" : "",
|
|
149
|
+
at.state === "exiting" ? "opacity-0 scale-75" : "",
|
|
150
|
+
].join(" ")}
|
|
151
|
+
style={{
|
|
152
|
+
/* Tags are small raised faces sitting in the sunken field */
|
|
153
|
+
background: "var(--face-light)",
|
|
154
|
+
boxShadow: "var(--face-light-shadow)",
|
|
155
|
+
}}
|
|
156
|
+
>
|
|
157
|
+
{at.text}
|
|
158
|
+
<button
|
|
159
|
+
type="button"
|
|
160
|
+
onClick={(e) => {
|
|
161
|
+
e.stopPropagation();
|
|
162
|
+
const idx = tags.indexOf(at.text);
|
|
163
|
+
if (idx !== -1) removeTag(idx);
|
|
164
|
+
}}
|
|
165
|
+
aria-label={`Remove ${at.text}`}
|
|
166
|
+
className="w-4 h-4 inline-flex items-center justify-center rounded-[5px] text-neutral-400 hover:text-neutral-900 hover:bg-neutral-900/[0.06] cursor-pointer transition-colors"
|
|
167
|
+
>
|
|
168
|
+
<svg width="8" height="8" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
|
|
169
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
170
|
+
</svg>
|
|
171
|
+
</button>
|
|
172
|
+
</span>
|
|
173
|
+
))}
|
|
174
|
+
<input
|
|
175
|
+
ref={inputRef}
|
|
176
|
+
id={`${uid}-input`}
|
|
177
|
+
type="text"
|
|
178
|
+
value={input}
|
|
179
|
+
onChange={(e) => setInput(e.target.value)}
|
|
180
|
+
onKeyDown={handleKeyDown}
|
|
181
|
+
onBlur={() => { if (input) addTag(input); }}
|
|
182
|
+
placeholder={tags.length === 0 ? placeholder : ""}
|
|
183
|
+
aria-label={label ? undefined : placeholder}
|
|
184
|
+
aria-describedby={hint ? `${uid}-hint` : undefined}
|
|
185
|
+
className="flex-1 min-w-[80px] bg-transparent text-[13.5px] text-neutral-900 placeholder:text-neutral-400 outline-none py-0.5"
|
|
186
|
+
/>
|
|
187
|
+
</div>
|
|
188
|
+
{hint && <p id={`${uid}-hint`} className="text-[11.5px] text-neutral-500 mt-0.5">{hint}</p>}
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useRef, useCallback, useEffect } from "react";
|
|
4
|
+
|
|
5
|
+
interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
|
6
|
+
label?: string;
|
|
7
|
+
hint?: string;
|
|
8
|
+
error?: string;
|
|
9
|
+
fullWidth?: boolean;
|
|
10
|
+
autoResize?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function Textarea({
|
|
14
|
+
label,
|
|
15
|
+
hint,
|
|
16
|
+
error,
|
|
17
|
+
fullWidth = false,
|
|
18
|
+
autoResize = false,
|
|
19
|
+
className = "",
|
|
20
|
+
id,
|
|
21
|
+
rows = 3,
|
|
22
|
+
onChange,
|
|
23
|
+
...rest
|
|
24
|
+
}: TextareaProps) {
|
|
25
|
+
const textareaId = id ?? label?.toLowerCase().replace(/\s+/g, "-");
|
|
26
|
+
const messageId = textareaId && (error || hint) ? `${textareaId}-message` : undefined;
|
|
27
|
+
const ref = useRef<HTMLTextAreaElement>(null);
|
|
28
|
+
|
|
29
|
+
const resize = useCallback(() => {
|
|
30
|
+
if (!autoResize || !ref.current) return;
|
|
31
|
+
ref.current.style.height = "auto";
|
|
32
|
+
ref.current.style.height = ref.current.scrollHeight + "px";
|
|
33
|
+
}, [autoResize]);
|
|
34
|
+
|
|
35
|
+
useEffect(() => { resize(); }, [resize]);
|
|
36
|
+
|
|
37
|
+
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
38
|
+
resize();
|
|
39
|
+
onChange?.(e);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className={["group/field flex flex-col gap-1.5", fullWidth ? "w-full" : ""].join(" ")}>
|
|
44
|
+
{label && (
|
|
45
|
+
<label
|
|
46
|
+
htmlFor={textareaId}
|
|
47
|
+
className="text-[12.5px] font-medium text-neutral-700 tracking-[-0.005em] transition-colors duration-200 group-focus-within/field:text-neutral-950"
|
|
48
|
+
>
|
|
49
|
+
{label}
|
|
50
|
+
</label>
|
|
51
|
+
)}
|
|
52
|
+
<textarea
|
|
53
|
+
ref={ref}
|
|
54
|
+
id={textareaId}
|
|
55
|
+
rows={rows}
|
|
56
|
+
onChange={handleChange}
|
|
57
|
+
className={[
|
|
58
|
+
"field rounded-xl px-4 py-3 text-[13.5px] text-neutral-900 resize-none scroll-thin",
|
|
59
|
+
"placeholder:text-neutral-400 placeholder:transition-colors placeholder:duration-200 focus:placeholder:text-neutral-300",
|
|
60
|
+
"disabled:opacity-40 disabled:cursor-not-allowed",
|
|
61
|
+
fullWidth ? "w-full" : "",
|
|
62
|
+
className,
|
|
63
|
+
]
|
|
64
|
+
.filter(Boolean)
|
|
65
|
+
.join(" ")}
|
|
66
|
+
aria-invalid={error ? true : undefined}
|
|
67
|
+
aria-describedby={messageId}
|
|
68
|
+
{...rest}
|
|
69
|
+
/>
|
|
70
|
+
{error && (
|
|
71
|
+
<p id={messageId} role="alert" className="text-[11.5px] text-red-600 font-medium flex items-center gap-1 mt-0.5">
|
|
72
|
+
<svg width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
73
|
+
<circle cx="12" cy="12" r="10" />
|
|
74
|
+
<path strokeLinecap="round" d="M12 8v4m0 4h.01" />
|
|
75
|
+
</svg>
|
|
76
|
+
{error}
|
|
77
|
+
</p>
|
|
78
|
+
)}
|
|
79
|
+
{hint && !error && (
|
|
80
|
+
<p id={messageId} className="text-[11.5px] text-neutral-500 mt-0.5">{hint}</p>
|
|
81
|
+
)}
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
type TimelineVariant = "default" | "success" | "warning" | "accent";
|
|
4
|
+
|
|
5
|
+
interface TimelineItem {
|
|
6
|
+
key: string;
|
|
7
|
+
title: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
date?: string;
|
|
10
|
+
icon?: React.ReactNode;
|
|
11
|
+
variant?: TimelineVariant;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface TimelineProps {
|
|
15
|
+
items: TimelineItem[];
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/* Each event is a small raised node with a lit dot, like an indicator
|
|
20
|
+
lamp; the variant only changes the lamp's colour. Nodes are joined by
|
|
21
|
+
one continuous groove, and titles sit on the node's centre line. */
|
|
22
|
+
const lamp: Record<TimelineVariant, string> = {
|
|
23
|
+
default: "#262626",
|
|
24
|
+
success: "#10b981",
|
|
25
|
+
warning: "#f59e0b",
|
|
26
|
+
accent: "#8b5cf6",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const iconText: Record<TimelineVariant, string> = {
|
|
30
|
+
default: "text-neutral-600",
|
|
31
|
+
success: "text-emerald-600",
|
|
32
|
+
warning: "text-amber-600",
|
|
33
|
+
accent: "text-violet-600",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function Timeline({ items, className = "" }: TimelineProps) {
|
|
37
|
+
return (
|
|
38
|
+
<div
|
|
39
|
+
className={["rounded-[18px] p-1.5", className].filter(Boolean).join(" ")}
|
|
40
|
+
style={{ background: "var(--panel-bezel)", boxShadow: "var(--panel-shadow)" }}
|
|
41
|
+
>
|
|
42
|
+
<ol
|
|
43
|
+
className="bg-white rounded-[13px] px-5 py-5 flex flex-col"
|
|
44
|
+
style={{ boxShadow: "var(--panel-face-shadow)" }}
|
|
45
|
+
>
|
|
46
|
+
{items.map((item, index) => {
|
|
47
|
+
const variant = item.variant ?? "default";
|
|
48
|
+
const isLast = index === items.length - 1;
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<li key={item.key} className={["relative flex gap-4", isLast ? "" : "pb-6"].join(" ")}>
|
|
52
|
+
{!isLast && (
|
|
53
|
+
<span
|
|
54
|
+
aria-hidden
|
|
55
|
+
className="absolute left-[11px] top-[30px] bottom-[6px] w-[2px] rounded-full"
|
|
56
|
+
style={{ background: "var(--well)", boxShadow: "var(--well-shadow)" }}
|
|
57
|
+
/>
|
|
58
|
+
)}
|
|
59
|
+
|
|
60
|
+
<span
|
|
61
|
+
aria-hidden
|
|
62
|
+
className={[
|
|
63
|
+
"relative w-6 h-6 rounded-full flex items-center justify-center shrink-0",
|
|
64
|
+
iconText[variant],
|
|
65
|
+
].join(" ")}
|
|
66
|
+
style={{ background: "var(--face-light)", boxShadow: "var(--tile-light-shadow)" }}
|
|
67
|
+
>
|
|
68
|
+
{item.icon ?? (
|
|
69
|
+
<span
|
|
70
|
+
className="w-2 h-2 rounded-full"
|
|
71
|
+
style={{
|
|
72
|
+
background: lamp[variant],
|
|
73
|
+
boxShadow: `0 0 6px ${lamp[variant]}66, inset 0 1px 1px rgba(255,255,255,0.35)`,
|
|
74
|
+
}}
|
|
75
|
+
/>
|
|
76
|
+
)}
|
|
77
|
+
</span>
|
|
78
|
+
|
|
79
|
+
<div className="flex-1 min-w-0">
|
|
80
|
+
<div className="flex items-baseline justify-between gap-3">
|
|
81
|
+
<p className="text-[13.5px] font-medium text-neutral-900 leading-6 tracking-[-0.005em]">
|
|
82
|
+
{item.title}
|
|
83
|
+
</p>
|
|
84
|
+
{item.date && (
|
|
85
|
+
<time className="text-[12px] text-neutral-400 tabular-nums shrink-0">
|
|
86
|
+
{item.date}
|
|
87
|
+
</time>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
{item.description && (
|
|
91
|
+
<p className="text-[13px] text-neutral-500 leading-relaxed mt-0.5">
|
|
92
|
+
{item.description}
|
|
93
|
+
</p>
|
|
94
|
+
)}
|
|
95
|
+
</div>
|
|
96
|
+
</li>
|
|
97
|
+
);
|
|
98
|
+
})}
|
|
99
|
+
</ol>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|