@blurrow/ui 0.1.0 → 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/README.md +28 -0
- package/dist/cli.js +1477 -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/package.json +23 -6
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
|
4
|
+
|
|
5
|
+
interface RadioOption {
|
|
6
|
+
value: string;
|
|
7
|
+
label: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface RadioGroupProps {
|
|
13
|
+
options: RadioOption[];
|
|
14
|
+
value?: string;
|
|
15
|
+
defaultValue?: string;
|
|
16
|
+
onChange?: (value: string) => void;
|
|
17
|
+
name?: string;
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/* Every radio is a sunken field; the selection is one dark face that
|
|
22
|
+
flows between them. Its leading edge leaves first and the trailing edge
|
|
23
|
+
catches up on a spring, so it stretches across the gap like a drop of
|
|
24
|
+
liquid and settles into the new well. One Tab stop for the group,
|
|
25
|
+
arrows move between options (ARIA radio group pattern). */
|
|
26
|
+
interface Blob {
|
|
27
|
+
top: number;
|
|
28
|
+
bottom: number;
|
|
29
|
+
left: number;
|
|
30
|
+
size: number;
|
|
31
|
+
dir: -1 | 0 | 1;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const LEAD = "260ms var(--ease-out-soft)";
|
|
35
|
+
const TRAIL = "480ms cubic-bezier(0.34, 1.25, 0.64, 1) 50ms";
|
|
36
|
+
export function RadioGroup({
|
|
37
|
+
options,
|
|
38
|
+
value,
|
|
39
|
+
defaultValue = "",
|
|
40
|
+
onChange,
|
|
41
|
+
name,
|
|
42
|
+
className = "",
|
|
43
|
+
}: RadioGroupProps) {
|
|
44
|
+
const [internal, setInternal] = useState(defaultValue);
|
|
45
|
+
const selected = value !== undefined ? value : internal;
|
|
46
|
+
const uid = useId();
|
|
47
|
+
const refs = useRef<(HTMLButtonElement | null)[]>([]);
|
|
48
|
+
const groupRef = useRef<HTMLDivElement>(null);
|
|
49
|
+
const prevIndex = useRef(-1);
|
|
50
|
+
const [blob, setBlob] = useState<Blob | null>(null);
|
|
51
|
+
|
|
52
|
+
const select = (val: string, disabled?: boolean) => {
|
|
53
|
+
if (disabled) return;
|
|
54
|
+
setInternal(val);
|
|
55
|
+
onChange?.(val);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const enabled = options.map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0);
|
|
59
|
+
const selectedIndex = options.findIndex((o) => o.value === selected);
|
|
60
|
+
const tabStop = selectedIndex >= 0 ? selectedIndex : enabled[0];
|
|
61
|
+
|
|
62
|
+
const measure = (dir: Blob["dir"]) => {
|
|
63
|
+
const group = groupRef.current;
|
|
64
|
+
const el = refs.current[prevIndex.current];
|
|
65
|
+
if (!group || !el) return setBlob(null);
|
|
66
|
+
setBlob({
|
|
67
|
+
top: el.offsetTop,
|
|
68
|
+
bottom: group.clientHeight - el.offsetTop - el.offsetHeight,
|
|
69
|
+
left: el.offsetLeft,
|
|
70
|
+
size: el.offsetWidth,
|
|
71
|
+
dir,
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
useLayoutEffect(() => {
|
|
76
|
+
const prev = prevIndex.current;
|
|
77
|
+
prevIndex.current = selectedIndex;
|
|
78
|
+
measure(prev < 0 || selectedIndex < 0 ? 0 : selectedIndex > prev ? 1 : selectedIndex < prev ? -1 : 0);
|
|
79
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
80
|
+
}, [selectedIndex]);
|
|
81
|
+
|
|
82
|
+
// Rewrapped descriptions move the wells; follow them without animating.
|
|
83
|
+
// An observer reports once on attach — that one is skipped, or it would
|
|
84
|
+
// cut short a move already under way.
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
const group = groupRef.current;
|
|
87
|
+
if (!group) return;
|
|
88
|
+
let attached = false;
|
|
89
|
+
const ro = new ResizeObserver(() => {
|
|
90
|
+
if (attached) measure(0);
|
|
91
|
+
attached = true;
|
|
92
|
+
});
|
|
93
|
+
ro.observe(group);
|
|
94
|
+
return () => ro.disconnect();
|
|
95
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
const move = (from: number, step: 1 | -1) => {
|
|
99
|
+
const at = enabled.indexOf(from);
|
|
100
|
+
const next = enabled[(at + step + enabled.length) % enabled.length];
|
|
101
|
+
refs.current[next]?.focus();
|
|
102
|
+
select(options[next].value);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div
|
|
107
|
+
ref={groupRef}
|
|
108
|
+
role="radiogroup"
|
|
109
|
+
aria-label={name}
|
|
110
|
+
className={["relative flex flex-col gap-1", className].join(" ")}
|
|
111
|
+
>
|
|
112
|
+
{options.map((opt, i) => {
|
|
113
|
+
const active = selected === opt.value;
|
|
114
|
+
return (
|
|
115
|
+
<div
|
|
116
|
+
key={opt.value}
|
|
117
|
+
onClick={() => select(opt.value, opt.disabled)}
|
|
118
|
+
className={[
|
|
119
|
+
"flex items-center justify-between gap-4 select-none py-1.5",
|
|
120
|
+
opt.disabled ? "opacity-40 pointer-events-none" : "cursor-pointer group/radio",
|
|
121
|
+
].join(" ")}
|
|
122
|
+
>
|
|
123
|
+
<div className="flex flex-col gap-0.5 min-w-0">
|
|
124
|
+
<span id={`${uid}-${i}-label`} className="text-[13px] font-medium text-neutral-900 leading-snug">
|
|
125
|
+
{opt.label}
|
|
126
|
+
</span>
|
|
127
|
+
{opt.description && (
|
|
128
|
+
<span id={`${uid}-${i}-desc`} className="text-[12px] text-neutral-500 leading-relaxed">
|
|
129
|
+
{opt.description}
|
|
130
|
+
</span>
|
|
131
|
+
)}
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
{/* Clicks bubble to the row, so the control itself has no onClick */}
|
|
135
|
+
<button
|
|
136
|
+
ref={(el) => {
|
|
137
|
+
refs.current[i] = el;
|
|
138
|
+
}}
|
|
139
|
+
type="button"
|
|
140
|
+
role="radio"
|
|
141
|
+
aria-checked={active}
|
|
142
|
+
aria-labelledby={`${uid}-${i}-label`}
|
|
143
|
+
aria-describedby={opt.description ? `${uid}-${i}-desc` : undefined}
|
|
144
|
+
disabled={opt.disabled}
|
|
145
|
+
tabIndex={i === tabStop ? 0 : -1}
|
|
146
|
+
onKeyDown={(e) => {
|
|
147
|
+
if (e.key === "ArrowDown" || e.key === "ArrowRight") {
|
|
148
|
+
e.preventDefault();
|
|
149
|
+
move(i, 1);
|
|
150
|
+
} else if (e.key === "ArrowUp" || e.key === "ArrowLeft") {
|
|
151
|
+
e.preventDefault();
|
|
152
|
+
move(i, -1);
|
|
153
|
+
}
|
|
154
|
+
}}
|
|
155
|
+
className={[
|
|
156
|
+
"shrink-0 w-[22px] h-[22px] rounded-full cursor-pointer",
|
|
157
|
+
"transition-[box-shadow] duration-200 ease-out",
|
|
158
|
+
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900",
|
|
159
|
+
active ? "" : "group-hover/radio:[box-shadow:var(--field-shadow-hover)]",
|
|
160
|
+
].join(" ")}
|
|
161
|
+
style={{ background: "var(--field)", boxShadow: "var(--field-shadow)" }}
|
|
162
|
+
/>
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
})}
|
|
166
|
+
|
|
167
|
+
{blob && (
|
|
168
|
+
<>
|
|
169
|
+
<span
|
|
170
|
+
aria-hidden
|
|
171
|
+
className="pointer-events-none absolute rounded-full"
|
|
172
|
+
style={{
|
|
173
|
+
top: blob.top,
|
|
174
|
+
bottom: blob.bottom,
|
|
175
|
+
left: blob.left,
|
|
176
|
+
width: blob.size,
|
|
177
|
+
background: "var(--face-dark)",
|
|
178
|
+
boxShadow: "var(--face-dark-shadow)",
|
|
179
|
+
transition:
|
|
180
|
+
blob.dir === 0
|
|
181
|
+
? "none"
|
|
182
|
+
: blob.dir > 0
|
|
183
|
+
? `top ${TRAIL}, bottom ${LEAD}`
|
|
184
|
+
: `top ${LEAD}, bottom ${TRAIL}`,
|
|
185
|
+
}}
|
|
186
|
+
/>
|
|
187
|
+
{/* The dot rides the trailing edge's spring, arriving as it settles */}
|
|
188
|
+
<span
|
|
189
|
+
aria-hidden
|
|
190
|
+
className="pointer-events-none absolute w-2 h-2 rounded-full bg-white"
|
|
191
|
+
style={{
|
|
192
|
+
top: blob.top + (blob.size - 8) / 2,
|
|
193
|
+
left: blob.left + (blob.size - 8) / 2,
|
|
194
|
+
boxShadow: "0 1px 1px rgba(0,0,0,0.3)",
|
|
195
|
+
transition: blob.dir === 0 ? "none" : `top ${TRAIL}`,
|
|
196
|
+
}}
|
|
197
|
+
/>
|
|
198
|
+
</>
|
|
199
|
+
)}
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useCallback, useRef } from "react";
|
|
4
|
+
|
|
5
|
+
export interface ReactionItem {
|
|
6
|
+
emoji: string;
|
|
7
|
+
label: string;
|
|
8
|
+
count: number;
|
|
9
|
+
active?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface ReactionBarProps {
|
|
13
|
+
reactions?: ReactionItem[];
|
|
14
|
+
className?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const defaultReactions: ReactionItem[] = [
|
|
18
|
+
{ emoji: "👏", label: "clap", count: 0 },
|
|
19
|
+
{ emoji: "❤️", label: "love", count: 0 },
|
|
20
|
+
{ emoji: "🔥", label: "fire", count: 0 },
|
|
21
|
+
{ emoji: "💡", label: "insightful", count: 0 },
|
|
22
|
+
{ emoji: "🎉", label: "celebrate", count: 0 },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
export function ReactionBar({
|
|
26
|
+
reactions: initialReactions,
|
|
27
|
+
className = "",
|
|
28
|
+
}: ReactionBarProps) {
|
|
29
|
+
const [reactions, setReactions] = useState<ReactionItem[]>(
|
|
30
|
+
initialReactions ?? defaultReactions
|
|
31
|
+
);
|
|
32
|
+
const [animatingKey, setAnimatingKey] = useState<string | null>(null);
|
|
33
|
+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
34
|
+
|
|
35
|
+
const handleClick = useCallback(
|
|
36
|
+
(index: number) => {
|
|
37
|
+
setReactions((prev) =>
|
|
38
|
+
prev.map((r, i) => {
|
|
39
|
+
if (i !== index) return r;
|
|
40
|
+
return {
|
|
41
|
+
...r,
|
|
42
|
+
active: !r.active,
|
|
43
|
+
count: r.active ? r.count - 1 : r.count + 1,
|
|
44
|
+
};
|
|
45
|
+
})
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const key = reactions[index].label;
|
|
49
|
+
setAnimatingKey(key);
|
|
50
|
+
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
51
|
+
timeoutRef.current = setTimeout(() => setAnimatingKey(null), 300);
|
|
52
|
+
},
|
|
53
|
+
[reactions]
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div
|
|
58
|
+
className={["flex flex-wrap gap-2", className].filter(Boolean).join(" ")}
|
|
59
|
+
>
|
|
60
|
+
{reactions.map((reaction, index) => {
|
|
61
|
+
const isAnimating = animatingKey === reaction.label;
|
|
62
|
+
return (
|
|
63
|
+
<button
|
|
64
|
+
key={reaction.label}
|
|
65
|
+
type="button"
|
|
66
|
+
onClick={() => handleClick(index)}
|
|
67
|
+
aria-pressed={!!reaction.active}
|
|
68
|
+
aria-label={`${reaction.label}, ${reaction.count}`}
|
|
69
|
+
className={[
|
|
70
|
+
"inline-flex items-center gap-1.5 rounded-[11px] pl-2.5 pr-3 py-[7px] text-[13px] font-medium cursor-pointer",
|
|
71
|
+
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900",
|
|
72
|
+
reaction.active ? "text-white" : "text-neutral-600 hover:text-neutral-900",
|
|
73
|
+
].join(" ")}
|
|
74
|
+
style={{
|
|
75
|
+
/* Off is a raised face; on is the dark face — pressed in and lit */
|
|
76
|
+
background: reaction.active ? "var(--face-dark)" : "var(--face-light)",
|
|
77
|
+
boxShadow: reaction.active ? "var(--face-dark-shadow)" : "var(--face-light-shadow)",
|
|
78
|
+
textShadow: reaction.active ? "0 -1px 0 rgba(0,0,0,0.4)" : "0 1px 0 rgba(255,255,255,0.9)",
|
|
79
|
+
transform: isAnimating ? "scale(1.08)" : "scale(1)",
|
|
80
|
+
transition:
|
|
81
|
+
"transform 250ms var(--ease-spring), color 200ms ease, box-shadow 200ms ease",
|
|
82
|
+
}}
|
|
83
|
+
>
|
|
84
|
+
<span aria-hidden className="text-[15px] leading-none">{reaction.emoji}</span>
|
|
85
|
+
<span
|
|
86
|
+
className="tabular-nums leading-none"
|
|
87
|
+
style={{
|
|
88
|
+
transform: isAnimating ? "scale(1.15)" : "scale(1)",
|
|
89
|
+
display: "inline-block",
|
|
90
|
+
transition:
|
|
91
|
+
"transform 250ms cubic-bezier(0.34, 1.4, 0.64, 1)",
|
|
92
|
+
}}
|
|
93
|
+
>
|
|
94
|
+
{reaction.count}
|
|
95
|
+
</span>
|
|
96
|
+
</button>
|
|
97
|
+
);
|
|
98
|
+
})}
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect } from "react";
|
|
4
|
+
|
|
5
|
+
interface ReadingProgressProps {
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function ReadingProgress({ className = "" }: ReadingProgressProps) {
|
|
10
|
+
const [progress, setProgress] = useState(0);
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
let frame = 0;
|
|
14
|
+
const measure = () => {
|
|
15
|
+
frame = 0;
|
|
16
|
+
const scrollTop = window.scrollY;
|
|
17
|
+
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
|
|
18
|
+
setProgress(docHeight > 0 ? (scrollTop / docHeight) * 100 : 0);
|
|
19
|
+
};
|
|
20
|
+
// One measurement per frame, however fast the scroll events arrive
|
|
21
|
+
const update = () => {
|
|
22
|
+
if (!frame) frame = requestAnimationFrame(measure);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
window.addEventListener("scroll", update, { passive: true });
|
|
26
|
+
window.addEventListener("resize", update);
|
|
27
|
+
measure();
|
|
28
|
+
return () => {
|
|
29
|
+
window.removeEventListener("scroll", update);
|
|
30
|
+
window.removeEventListener("resize", update);
|
|
31
|
+
if (frame) cancelAnimationFrame(frame);
|
|
32
|
+
};
|
|
33
|
+
}, []);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div
|
|
37
|
+
aria-hidden
|
|
38
|
+
className={["fixed top-0 left-0 right-0 z-[80] h-[2px] bg-transparent pointer-events-none", className].join(" ")}
|
|
39
|
+
>
|
|
40
|
+
<div
|
|
41
|
+
className="h-full w-full origin-left"
|
|
42
|
+
style={{
|
|
43
|
+
// transform instead of width: no layout on every scroll frame
|
|
44
|
+
transform: `scaleX(${progress / 100})`,
|
|
45
|
+
background: "linear-gradient(90deg, #0a0a0a 0%, #262626 100%)",
|
|
46
|
+
boxShadow: "0 0 8px rgba(17,17,17,0.3)",
|
|
47
|
+
}}
|
|
48
|
+
/>
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
interface ReadingTimeProps {
|
|
4
|
+
minutes: number;
|
|
5
|
+
className?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function ReadingTime({ minutes, className = "" }: ReadingTimeProps) {
|
|
9
|
+
return (
|
|
10
|
+
<span
|
|
11
|
+
className={[
|
|
12
|
+
"inline-flex items-center gap-1.5 text-[12px] font-medium text-neutral-500",
|
|
13
|
+
className,
|
|
14
|
+
]
|
|
15
|
+
.filter(Boolean)
|
|
16
|
+
.join(" ")}
|
|
17
|
+
>
|
|
18
|
+
<svg
|
|
19
|
+
width="13"
|
|
20
|
+
height="13"
|
|
21
|
+
viewBox="0 0 24 24"
|
|
22
|
+
fill="none"
|
|
23
|
+
stroke="currentColor"
|
|
24
|
+
strokeWidth="2"
|
|
25
|
+
strokeLinecap="round"
|
|
26
|
+
strokeLinejoin="round"
|
|
27
|
+
className="text-neutral-400"
|
|
28
|
+
>
|
|
29
|
+
<circle cx="12" cy="12" r="10" />
|
|
30
|
+
<polyline points="12 6 12 12 16 14" />
|
|
31
|
+
</svg>
|
|
32
|
+
{minutes} min read
|
|
33
|
+
</span>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useRef, useEffect, useId } from "react";
|
|
4
|
+
|
|
5
|
+
interface SelectOption {
|
|
6
|
+
value: string;
|
|
7
|
+
label: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface SelectProps {
|
|
11
|
+
options: SelectOption[];
|
|
12
|
+
value?: string;
|
|
13
|
+
onChange?: (value: string) => void;
|
|
14
|
+
placeholder?: string;
|
|
15
|
+
label?: string;
|
|
16
|
+
hint?: string;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
fullWidth?: boolean;
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/* The closed select is a sunken field like Input; the open list is a
|
|
23
|
+
floating panel like DropdownMenu. Keyboard follows the ARIA listbox
|
|
24
|
+
pattern: arrows move, Enter picks, Escape backs out. */
|
|
25
|
+
export function Select({
|
|
26
|
+
options,
|
|
27
|
+
value,
|
|
28
|
+
onChange,
|
|
29
|
+
placeholder = "select…",
|
|
30
|
+
label,
|
|
31
|
+
hint,
|
|
32
|
+
disabled = false,
|
|
33
|
+
fullWidth = false,
|
|
34
|
+
className = "",
|
|
35
|
+
}: SelectProps) {
|
|
36
|
+
const [open, setOpen] = useState(false);
|
|
37
|
+
// The list stays mounted while it folds away, and only unfolds once painted
|
|
38
|
+
const [mounted, setMounted] = useState(false);
|
|
39
|
+
const [entered, setEntered] = useState(false);
|
|
40
|
+
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
41
|
+
const [active, setActive] = useState(0);
|
|
42
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
43
|
+
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
44
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
45
|
+
const uid = useId();
|
|
46
|
+
|
|
47
|
+
const selectedIndex = options.findIndex((o) => o.value === value);
|
|
48
|
+
const selected = options[selectedIndex];
|
|
49
|
+
|
|
50
|
+
const close = () => {
|
|
51
|
+
setOpen(false);
|
|
52
|
+
setEntered(false);
|
|
53
|
+
if (closeTimer.current) clearTimeout(closeTimer.current);
|
|
54
|
+
closeTimer.current = setTimeout(() => setMounted(false), 200);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
useEffect(() => () => {
|
|
58
|
+
if (closeTimer.current) clearTimeout(closeTimer.current);
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
function onOutside(e: MouseEvent) {
|
|
63
|
+
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
64
|
+
close();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
document.addEventListener("mousedown", onOutside);
|
|
68
|
+
return () => document.removeEventListener("mousedown", onOutside);
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (!open) return;
|
|
73
|
+
listRef.current
|
|
74
|
+
?.querySelector(`[data-index="${active}"]`)
|
|
75
|
+
?.scrollIntoView({ block: "nearest" });
|
|
76
|
+
}, [open, active]);
|
|
77
|
+
|
|
78
|
+
const openList = () => {
|
|
79
|
+
setActive(selectedIndex >= 0 ? selectedIndex : 0);
|
|
80
|
+
if (closeTimer.current) clearTimeout(closeTimer.current);
|
|
81
|
+
setOpen(true);
|
|
82
|
+
setMounted(true);
|
|
83
|
+
requestAnimationFrame(() => requestAnimationFrame(() => setEntered(true)));
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const pick = (index: number) => {
|
|
87
|
+
const opt = options[index];
|
|
88
|
+
if (!opt) return;
|
|
89
|
+
onChange?.(opt.value);
|
|
90
|
+
close();
|
|
91
|
+
buttonRef.current?.focus();
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const onKeyDown = (e: React.KeyboardEvent) => {
|
|
95
|
+
if (!open) {
|
|
96
|
+
if (["ArrowDown", "ArrowUp", "Enter", " "].includes(e.key)) {
|
|
97
|
+
e.preventDefault();
|
|
98
|
+
openList();
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
switch (e.key) {
|
|
103
|
+
case "ArrowDown":
|
|
104
|
+
e.preventDefault();
|
|
105
|
+
setActive((i) => Math.min(i + 1, options.length - 1));
|
|
106
|
+
break;
|
|
107
|
+
case "ArrowUp":
|
|
108
|
+
e.preventDefault();
|
|
109
|
+
setActive((i) => Math.max(i - 1, 0));
|
|
110
|
+
break;
|
|
111
|
+
case "Home":
|
|
112
|
+
e.preventDefault();
|
|
113
|
+
setActive(0);
|
|
114
|
+
break;
|
|
115
|
+
case "End":
|
|
116
|
+
e.preventDefault();
|
|
117
|
+
setActive(options.length - 1);
|
|
118
|
+
break;
|
|
119
|
+
case "Enter":
|
|
120
|
+
case " ":
|
|
121
|
+
e.preventDefault();
|
|
122
|
+
pick(active);
|
|
123
|
+
break;
|
|
124
|
+
case "Escape":
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
close();
|
|
127
|
+
break;
|
|
128
|
+
case "Tab":
|
|
129
|
+
close();
|
|
130
|
+
break;
|
|
131
|
+
default:
|
|
132
|
+
// Type-ahead: jump to the first option starting with the key
|
|
133
|
+
if (e.key.length === 1) {
|
|
134
|
+
const k = e.key.toLowerCase();
|
|
135
|
+
const hit = options.findIndex((o) => o.label.toLowerCase().startsWith(k));
|
|
136
|
+
if (hit >= 0) setActive(hit);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<div className={["flex flex-col gap-1.5", fullWidth ? "w-full" : ""].join(" ")}>
|
|
143
|
+
{label && (
|
|
144
|
+
<span
|
|
145
|
+
id={`${uid}-label`}
|
|
146
|
+
className="text-[12.5px] font-medium text-neutral-700 tracking-[-0.005em]"
|
|
147
|
+
>
|
|
148
|
+
{label}
|
|
149
|
+
</span>
|
|
150
|
+
)}
|
|
151
|
+
|
|
152
|
+
<div
|
|
153
|
+
ref={ref}
|
|
154
|
+
className={["relative", fullWidth ? "w-full" : "inline-block", className].join(" ")}
|
|
155
|
+
>
|
|
156
|
+
<button
|
|
157
|
+
ref={buttonRef}
|
|
158
|
+
type="button"
|
|
159
|
+
disabled={disabled}
|
|
160
|
+
onClick={() => (open ? close() : openList())}
|
|
161
|
+
onKeyDown={onKeyDown}
|
|
162
|
+
aria-haspopup="listbox"
|
|
163
|
+
aria-expanded={open}
|
|
164
|
+
aria-controls={open ? `${uid}-list` : undefined}
|
|
165
|
+
aria-labelledby={label ? `${uid}-label ${uid}-value` : undefined}
|
|
166
|
+
aria-activedescendant={open ? `${uid}-opt-${active}` : undefined}
|
|
167
|
+
className={[
|
|
168
|
+
"group/select relative isolate inline-flex items-center justify-between gap-3 w-full",
|
|
169
|
+
"rounded-xl px-4 py-3 text-[13.5px] font-medium text-neutral-900",
|
|
170
|
+
"field cursor-pointer select-none",
|
|
171
|
+
"focus:outline-none disabled:opacity-40 disabled:pointer-events-none",
|
|
172
|
+
].join(" ")}
|
|
173
|
+
data-open={open || undefined}
|
|
174
|
+
>
|
|
175
|
+
<span
|
|
176
|
+
id={`${uid}-value`}
|
|
177
|
+
className={[
|
|
178
|
+
"relative z-10 truncate",
|
|
179
|
+
selected ? "text-neutral-900" : "text-neutral-400 font-normal",
|
|
180
|
+
].join(" ")}
|
|
181
|
+
>
|
|
182
|
+
{selected ? selected.label : placeholder}
|
|
183
|
+
</span>
|
|
184
|
+
<svg
|
|
185
|
+
aria-hidden
|
|
186
|
+
width="14"
|
|
187
|
+
height="14"
|
|
188
|
+
fill="none"
|
|
189
|
+
stroke="currentColor"
|
|
190
|
+
strokeWidth="2"
|
|
191
|
+
viewBox="0 0 24 24"
|
|
192
|
+
className={[
|
|
193
|
+
"relative z-10 shrink-0 transition-[transform,color] duration-300 ease-[var(--ease-spring)]",
|
|
194
|
+
open ? "rotate-180 text-neutral-900" : "text-neutral-400 group-hover/select:text-neutral-700",
|
|
195
|
+
].join(" ")}
|
|
196
|
+
>
|
|
197
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
198
|
+
</svg>
|
|
199
|
+
|
|
200
|
+
{open && (
|
|
201
|
+
<span aria-hidden className="pointer-events-none absolute inset-0 z-5 rounded-xl overflow-hidden">
|
|
202
|
+
<span
|
|
203
|
+
className="block absolute inset-y-0 w-[30%] -left-[30%] animate-input-shimmer"
|
|
204
|
+
style={{
|
|
205
|
+
background:
|
|
206
|
+
"linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.55) 50%, transparent 100%)",
|
|
207
|
+
}}
|
|
208
|
+
/>
|
|
209
|
+
</span>
|
|
210
|
+
)}
|
|
211
|
+
</button>
|
|
212
|
+
|
|
213
|
+
{mounted && (
|
|
214
|
+
<div
|
|
215
|
+
className="absolute left-0 z-50 mt-2 p-1.5 rounded-[16px] origin-top"
|
|
216
|
+
style={{
|
|
217
|
+
minWidth: "100%",
|
|
218
|
+
maxWidth: "min(420px, calc(100vw - 2rem))",
|
|
219
|
+
width: "max-content",
|
|
220
|
+
background: "var(--panel-bezel)",
|
|
221
|
+
boxShadow: "var(--overlay-shadow)",
|
|
222
|
+
/* Unfolds downward out of the field: a clip reveals it top to
|
|
223
|
+
bottom while it fades and settles. The clip's sides reach
|
|
224
|
+
past the panel so its shadow is never cut off. */
|
|
225
|
+
opacity: entered ? 1 : 0,
|
|
226
|
+
transform: entered ? "none" : "translateY(-4px) scale(0.98)",
|
|
227
|
+
clipPath: entered ? "inset(-48px -48px -48px -48px)" : "inset(-48px -48px 55% -48px)",
|
|
228
|
+
transition: entered
|
|
229
|
+
? "opacity 180ms ease-out, transform 360ms var(--ease-out-soft), clip-path 360ms var(--ease-out-soft)"
|
|
230
|
+
: "opacity 160ms ease-in, transform 200ms ease-in, clip-path 200ms ease-in",
|
|
231
|
+
}}
|
|
232
|
+
>
|
|
233
|
+
<div
|
|
234
|
+
ref={listRef}
|
|
235
|
+
id={`${uid}-list`}
|
|
236
|
+
role="listbox"
|
|
237
|
+
aria-labelledby={label ? `${uid}-label` : undefined}
|
|
238
|
+
className="bg-white rounded-[11px] p-1 flex flex-col gap-0.5 max-h-72 overflow-y-auto scroll-thin"
|
|
239
|
+
style={{ boxShadow: "var(--panel-face-shadow)" }}
|
|
240
|
+
>
|
|
241
|
+
{options.map((opt, i) => {
|
|
242
|
+
const isSelected = opt.value === value;
|
|
243
|
+
const isActive = i === active;
|
|
244
|
+
return (
|
|
245
|
+
<div
|
|
246
|
+
key={opt.value}
|
|
247
|
+
id={`${uid}-opt-${i}`}
|
|
248
|
+
data-index={i}
|
|
249
|
+
role="option"
|
|
250
|
+
aria-selected={isSelected}
|
|
251
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
252
|
+
onClick={() => pick(i)}
|
|
253
|
+
onMouseMove={() => setActive(i)}
|
|
254
|
+
className={[
|
|
255
|
+
"flex items-center justify-between gap-3 w-full px-2.5 py-2 text-[13px] rounded-[8px] cursor-pointer",
|
|
256
|
+
"transition-colors duration-100",
|
|
257
|
+
isSelected ? "font-medium text-neutral-900" : "text-neutral-600",
|
|
258
|
+
isActive ? "bg-neutral-100/80 text-neutral-900" : "",
|
|
259
|
+
].join(" ")}
|
|
260
|
+
style={{
|
|
261
|
+
animation: `select-option-in 300ms var(--ease-out-soft) ${40 + i * 22}ms both`,
|
|
262
|
+
}}
|
|
263
|
+
>
|
|
264
|
+
<span className="whitespace-nowrap">{opt.label}</span>
|
|
265
|
+
{isSelected && (
|
|
266
|
+
<svg
|
|
267
|
+
aria-hidden
|
|
268
|
+
className="shrink-0 text-neutral-900"
|
|
269
|
+
width="13"
|
|
270
|
+
height="13"
|
|
271
|
+
fill="none"
|
|
272
|
+
stroke="currentColor"
|
|
273
|
+
strokeWidth="2.5"
|
|
274
|
+
viewBox="0 0 24 24"
|
|
275
|
+
>
|
|
276
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
277
|
+
</svg>
|
|
278
|
+
)}
|
|
279
|
+
</div>
|
|
280
|
+
);
|
|
281
|
+
})}
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
)}
|
|
285
|
+
</div>
|
|
286
|
+
|
|
287
|
+
{hint && <p className="text-[11.5px] text-neutral-500 mt-0.5">{hint}</p>}
|
|
288
|
+
</div>
|
|
289
|
+
);
|
|
290
|
+
}
|