@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.
Files changed (53) hide show
  1. package/README.md +28 -0
  2. package/dist/cli.js +1477 -0
  3. package/dist/registry/Accordion.tsx +140 -0
  4. package/dist/registry/Alert.tsx +92 -0
  5. package/dist/registry/AuthorCard.tsx +67 -0
  6. package/dist/registry/Avatar.tsx +116 -0
  7. package/dist/registry/Badge.tsx +58 -0
  8. package/dist/registry/Banner.tsx +89 -0
  9. package/dist/registry/Breadcrumb.tsx +60 -0
  10. package/dist/registry/Button.tsx +372 -0
  11. package/dist/registry/ButtonGroup.tsx +82 -0
  12. package/dist/registry/Card.tsx +241 -0
  13. package/dist/registry/CardShell.tsx +78 -0
  14. package/dist/registry/Carousel.tsx +292 -0
  15. package/dist/registry/Checkbox.tsx +99 -0
  16. package/dist/registry/CloseButton.tsx +68 -0
  17. package/dist/registry/CodeBlock.tsx +202 -0
  18. package/dist/registry/CommandPalette.tsx +349 -0
  19. package/dist/registry/DataList.tsx +49 -0
  20. package/dist/registry/Drawer.tsx +164 -0
  21. package/dist/registry/DropdownMenu.tsx +257 -0
  22. package/dist/registry/EmptyState.tsx +55 -0
  23. package/dist/registry/Input.tsx +97 -0
  24. package/dist/registry/Kbd.tsx +24 -0
  25. package/dist/registry/Modal.tsx +128 -0
  26. package/dist/registry/Pagination.tsx +163 -0
  27. package/dist/registry/Popover.tsx +121 -0
  28. package/dist/registry/Progress.tsx +66 -0
  29. package/dist/registry/QuoteCard.tsx +93 -0
  30. package/dist/registry/Radio.tsx +202 -0
  31. package/dist/registry/ReactionBar.tsx +101 -0
  32. package/dist/registry/ReadingProgress.tsx +51 -0
  33. package/dist/registry/ReadingTime.tsx +35 -0
  34. package/dist/registry/Select.tsx +290 -0
  35. package/dist/registry/ShareButtons.tsx +134 -0
  36. package/dist/registry/Sidebar.tsx +182 -0
  37. package/dist/registry/Skeleton.tsx +83 -0
  38. package/dist/registry/SpotlightCard.tsx +106 -0
  39. package/dist/registry/Stat.tsx +82 -0
  40. package/dist/registry/StatCard.tsx +256 -0
  41. package/dist/registry/Steps.tsx +109 -0
  42. package/dist/registry/Table.tsx +121 -0
  43. package/dist/registry/TableOfContents.tsx +116 -0
  44. package/dist/registry/Tabs.tsx +136 -0
  45. package/dist/registry/TagInput.tsx +191 -0
  46. package/dist/registry/Textarea.tsx +84 -0
  47. package/dist/registry/Timeline.tsx +102 -0
  48. package/dist/registry/Toast.tsx +385 -0
  49. package/dist/registry/Toggle.tsx +99 -0
  50. package/dist/registry/Tooltip.tsx +212 -0
  51. package/dist/registry/styles.css +401 -0
  52. package/dist/registry/useModalBehavior.ts +77 -0
  53. 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
+ }