@blurrow/ui 0.1.0 → 0.2.1
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 +60 -20
- package/dist/Accordion.d.ts +17 -0
- package/dist/Accordion.js +138 -0
- package/dist/Accordion.js.map +1 -0
- package/dist/Alert.d.ts +13 -0
- package/dist/Alert.js +69 -0
- package/dist/Alert.js.map +1 -0
- package/dist/AuthorCard.d.ts +12 -0
- package/dist/AuthorCard.js +57 -0
- package/dist/AuthorCard.js.map +1 -0
- package/dist/Avatar.d.ts +21 -0
- package/dist/Avatar.js +96 -0
- package/dist/Avatar.js.map +1 -0
- package/dist/Badge.d.ts +12 -0
- package/dist/Badge.js +46 -0
- package/dist/Badge.js.map +1 -0
- package/dist/Banner.d.ts +15 -0
- package/dist/Banner.js +68 -0
- package/dist/Banner.js.map +1 -0
- package/dist/Breadcrumb.d.ts +13 -0
- package/dist/Breadcrumb.js +45 -0
- package/dist/Breadcrumb.js.map +1 -0
- package/dist/Button.d.ts +19 -0
- package/dist/Button.js +282 -0
- package/dist/Button.js.map +1 -0
- package/dist/ButtonGroup.d.ts +19 -0
- package/dist/ButtonGroup.js +62 -0
- package/dist/ButtonGroup.js.map +1 -0
- package/dist/Card.d.ts +40 -0
- package/dist/Card.js +175 -0
- package/dist/Card.js.map +1 -0
- package/dist/CardShell.d.ts +18 -0
- package/dist/CardShell.js +58 -0
- package/dist/CardShell.js.map +1 -0
- package/dist/Carousel.d.ts +22 -0
- package/dist/Carousel.js +247 -0
- package/dist/Carousel.js.map +1 -0
- package/dist/Checkbox.d.ts +15 -0
- package/dist/Checkbox.js +84 -0
- package/dist/Checkbox.js.map +1 -0
- package/dist/CloseButton.d.ts +12 -0
- package/dist/CloseButton.js +63 -0
- package/dist/CloseButton.js.map +1 -0
- package/dist/CodeBlock.d.ts +12 -0
- package/dist/CodeBlock.js +221 -0
- package/dist/CodeBlock.js.map +1 -0
- package/dist/ColorPicker.d.ts +24 -0
- package/dist/ColorPicker.js +366 -0
- package/dist/ColorPicker.js.map +1 -0
- package/dist/Combobox.d.ts +34 -0
- package/dist/Combobox.js +255 -0
- package/dist/Combobox.js.map +1 -0
- package/dist/CommandPalette.d.ts +25 -0
- package/dist/CommandPalette.js +304 -0
- package/dist/CommandPalette.js.map +1 -0
- package/dist/ContextMenu.d.ts +16 -0
- package/dist/ContextMenu.js +209 -0
- package/dist/ContextMenu.js.map +1 -0
- package/dist/DataList.d.ts +15 -0
- package/dist/DataList.js +42 -0
- package/dist/DataList.js.map +1 -0
- package/dist/DatePicker.d.ts +65 -0
- package/dist/DatePicker.js +391 -0
- package/dist/DatePicker.js.map +1 -0
- package/dist/DateRangePicker.d.ts +34 -0
- package/dist/DateRangePicker.js +180 -0
- package/dist/DateRangePicker.js.map +1 -0
- package/dist/Drawer.d.ts +17 -0
- package/dist/Drawer.js +132 -0
- package/dist/Drawer.js.map +1 -0
- package/dist/DropdownMenu.d.ts +23 -0
- package/dist/DropdownMenu.js +185 -0
- package/dist/DropdownMenu.js.map +1 -0
- package/dist/EmptyState.d.ts +13 -0
- package/dist/EmptyState.js +55 -0
- package/dist/EmptyState.js.map +1 -0
- package/dist/FieldMessage.d.ts +26 -0
- package/dist/FieldMessage.js +95 -0
- package/dist/FieldMessage.js.map +1 -0
- package/dist/FileUpload.d.ts +32 -0
- package/dist/FileUpload.js +253 -0
- package/dist/FileUpload.js.map +1 -0
- package/dist/Floating.d.ts +32 -0
- package/dist/Floating.js +95 -0
- package/dist/Floating.js.map +1 -0
- package/dist/HoverCard.d.ts +18 -0
- package/dist/HoverCard.js +90 -0
- package/dist/HoverCard.js.map +1 -0
- package/dist/Input.d.ts +17 -0
- package/dist/Input.js +96 -0
- package/dist/Input.js.map +1 -0
- package/dist/Kbd.d.ts +9 -0
- package/dist/Kbd.js +24 -0
- package/dist/Kbd.js.map +1 -0
- package/dist/Modal.d.ts +15 -0
- package/dist/Modal.js +109 -0
- package/dist/Modal.js.map +1 -0
- package/dist/NumberInput.d.ts +23 -0
- package/dist/NumberInput.js +162 -0
- package/dist/NumberInput.js.map +1 -0
- package/dist/OtpInput.d.ts +26 -0
- package/dist/OtpInput.js +173 -0
- package/dist/OtpInput.js.map +1 -0
- package/dist/Pagination.d.ts +11 -0
- package/dist/Pagination.js +148 -0
- package/dist/Pagination.js.map +1 -0
- package/dist/Popover.d.ts +13 -0
- package/dist/Popover.js +98 -0
- package/dist/Popover.js.map +1 -0
- package/dist/Progress.d.ts +12 -0
- package/dist/Progress.js +61 -0
- package/dist/Progress.js.map +1 -0
- package/dist/QuoteCard.d.ts +13 -0
- package/dist/QuoteCard.js +92 -0
- package/dist/QuoteCard.js.map +1 -0
- package/dist/Radio.d.ts +19 -0
- package/dist/Radio.js +161 -0
- package/dist/Radio.js.map +1 -0
- package/dist/RadioCards.d.ts +27 -0
- package/dist/RadioCards.js +205 -0
- package/dist/RadioCards.js.map +1 -0
- package/dist/ReactionBar.d.ts +15 -0
- package/dist/ReactionBar.js +92 -0
- package/dist/ReactionBar.js.map +1 -0
- package/dist/ReadingProgress.d.ts +8 -0
- package/dist/ReadingProgress.js +51 -0
- package/dist/ReadingProgress.js.map +1 -0
- package/dist/ReadingTime.d.ts +9 -0
- package/dist/ReadingTime.js +39 -0
- package/dist/ReadingTime.js.map +1 -0
- package/dist/Select.d.ts +20 -0
- package/dist/Select.js +244 -0
- package/dist/Select.js.map +1 -0
- package/dist/Separator.d.ts +14 -0
- package/dist/Separator.js +33 -0
- package/dist/Separator.js.map +1 -0
- package/dist/ShareButtons.d.ts +10 -0
- package/dist/ShareButtons.js +107 -0
- package/dist/ShareButtons.js.map +1 -0
- package/dist/Sidebar.d.ts +30 -0
- package/dist/Sidebar.js +140 -0
- package/dist/Sidebar.js.map +1 -0
- package/dist/Skeleton.d.ts +15 -0
- package/dist/Skeleton.js +67 -0
- package/dist/Skeleton.js.map +1 -0
- package/dist/Slider.d.ts +27 -0
- package/dist/Slider.js +182 -0
- package/dist/Slider.js.map +1 -0
- package/dist/Spinner.d.ts +12 -0
- package/dist/Spinner.js +24 -0
- package/dist/Spinner.js.map +1 -0
- package/dist/SpotlightCard.d.ts +17 -0
- package/dist/SpotlightCard.js +87 -0
- package/dist/SpotlightCard.js.map +1 -0
- package/dist/Stat.d.ts +19 -0
- package/dist/Stat.js +71 -0
- package/dist/Stat.js.map +1 -0
- package/dist/StatCard.d.ts +24 -0
- package/dist/StatCard.js +212 -0
- package/dist/StatCard.js.map +1 -0
- package/dist/Steps.d.ts +14 -0
- package/dist/Steps.js +94 -0
- package/dist/Steps.js.map +1 -0
- package/dist/Table.d.ts +21 -0
- package/dist/Table.js +93 -0
- package/dist/Table.js.map +1 -0
- package/dist/TableOfContents.d.ts +17 -0
- package/dist/TableOfContents.js +92 -0
- package/dist/TableOfContents.js.map +1 -0
- package/dist/Tabs.d.ts +17 -0
- package/dist/Tabs.js +123 -0
- package/dist/Tabs.js.map +1 -0
- package/dist/TagInput.d.ts +16 -0
- package/dist/TagInput.js +169 -0
- package/dist/TagInput.js.map +1 -0
- package/dist/Textarea.d.ts +13 -0
- package/dist/Textarea.js +74 -0
- package/dist/Textarea.js.map +1 -0
- package/dist/TimePicker.d.ts +28 -0
- package/dist/TimePicker.js +264 -0
- package/dist/TimePicker.js.map +1 -0
- package/dist/Timeline.d.ts +19 -0
- package/dist/Timeline.js +76 -0
- package/dist/Timeline.js.map +1 -0
- package/dist/Toast.d.ts +18 -0
- package/dist/Toast.js +318 -0
- package/dist/Toast.js.map +1 -0
- package/dist/Toggle.d.ts +13 -0
- package/dist/Toggle.js +84 -0
- package/dist/Toggle.js.map +1 -0
- package/dist/Tooltip.d.ts +13 -0
- package/dist/Tooltip.js +138 -0
- package/dist/Tooltip.js.map +1 -0
- package/dist/cli.js +1644 -0
- package/dist/index.d.ts +68 -631
- package/dist/index.js +67 -5558
- package/dist/index.js.map +1 -1
- package/dist/registry/Accordion.tsx +140 -0
- package/dist/registry/Alert.tsx +95 -0
- package/dist/registry/AuthorCard.tsx +67 -0
- package/dist/registry/Avatar.tsx +116 -0
- package/dist/registry/Badge.tsx +55 -0
- package/dist/registry/Banner.tsx +86 -0
- package/dist/registry/Breadcrumb.tsx +60 -0
- package/dist/registry/Button.tsx +361 -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 +296 -0
- package/dist/registry/Checkbox.tsx +104 -0
- package/dist/registry/CloseButton.tsx +68 -0
- package/dist/registry/CodeBlock.tsx +202 -0
- package/dist/registry/ColorPicker.tsx +404 -0
- package/dist/registry/Combobox.tsx +318 -0
- package/dist/registry/CommandPalette.tsx +356 -0
- package/dist/registry/ContextMenu.tsx +241 -0
- package/dist/registry/DataList.tsx +49 -0
- package/dist/registry/DatePicker.tsx +494 -0
- package/dist/registry/DateRangePicker.tsx +232 -0
- package/dist/registry/Drawer.tsx +166 -0
- package/dist/registry/DropdownMenu.tsx +219 -0
- package/dist/registry/EmptyState.tsx +55 -0
- package/dist/registry/FieldMessage.tsx +123 -0
- package/dist/registry/FileUpload.tsx +318 -0
- package/dist/registry/Floating.tsx +134 -0
- package/dist/registry/HoverCard.tsx +113 -0
- package/dist/registry/Input.tsx +108 -0
- package/dist/registry/Kbd.tsx +23 -0
- package/dist/registry/Modal.tsx +130 -0
- package/dist/registry/NumberInput.tsx +183 -0
- package/dist/registry/OtpInput.tsx +219 -0
- package/dist/registry/Pagination.tsx +160 -0
- package/dist/registry/Popover.tsx +116 -0
- package/dist/registry/Progress.tsx +66 -0
- package/dist/registry/QuoteCard.tsx +93 -0
- package/dist/registry/Radio.tsx +205 -0
- package/dist/registry/RadioCards.tsx +247 -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 +265 -0
- package/dist/registry/Separator.tsx +52 -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/Slider.tsx +229 -0
- package/dist/registry/Spinner.tsx +32 -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 +123 -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/TimePicker.tsx +315 -0
- package/dist/registry/Timeline.tsx +102 -0
- package/dist/registry/Toast.tsx +388 -0
- package/dist/registry/Toggle.tsx +96 -0
- package/dist/registry/Tooltip.tsx +160 -0
- package/dist/registry/styles.css +1207 -0
- package/dist/registry/useFloatingPanel.ts +112 -0
- package/dist/registry/useModalBehavior.ts +77 -0
- package/dist/styles.css +868 -62
- package/dist/useFloatingPanel.d.ts +24 -0
- package/dist/useFloatingPanel.js +89 -0
- package/dist/useFloatingPanel.js.map +1 -0
- package/dist/useModalBehavior.d.ts +15 -0
- package/dist/useModalBehavior.js +57 -0
- package/dist/useModalBehavior.js.map +1 -0
- package/package.json +23 -6
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
export interface RadioCardOption {
|
|
7
|
+
value: string;
|
|
8
|
+
label: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
/** Something on the right of the title, like a price */
|
|
11
|
+
aside?: React.ReactNode;
|
|
12
|
+
icon?: React.ReactNode;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface RadioCardsProps {
|
|
17
|
+
options: RadioCardOption[];
|
|
18
|
+
value?: string;
|
|
19
|
+
defaultValue?: string;
|
|
20
|
+
onChange?: (value: string) => void;
|
|
21
|
+
/** Read out as the group's name */
|
|
22
|
+
label?: string;
|
|
23
|
+
/** Options per row from the small breakpoint up; they stack on phones */
|
|
24
|
+
columns?: 1 | 2 | 3;
|
|
25
|
+
name?: string;
|
|
26
|
+
className?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const COLS = { 1: "", 2: "sm:grid-cols-2", 3: "sm:grid-cols-3" } as const;
|
|
30
|
+
|
|
31
|
+
// RadioGroup's timings: the leading edge leaves at once, the trailing
|
|
32
|
+
// edge follows a beat later on a spring and settles into place
|
|
33
|
+
const LEAD = "260ms var(--ease-out-soft)";
|
|
34
|
+
const TRAIL = "480ms cubic-bezier(0.34, 1.25, 0.64, 1) 50ms";
|
|
35
|
+
|
|
36
|
+
/* Bigger choices, like a plan or a delivery speed: the segmented control
|
|
37
|
+
from Tabs, grown up. The options sit flat in one sunken well and a
|
|
38
|
+
single raised thumb flows to whichever is chosen, across rows and
|
|
39
|
+
columns: its leading edge goes first and the trailing edge catches
|
|
40
|
+
up, so it stretches toward the new option the way RadioGroup's
|
|
41
|
+
selection does, and you can see which way it went. Each keeps RadioGroup's own control on its right, so the two
|
|
42
|
+
read as one family. One Tab stop, arrows move. */
|
|
43
|
+
export function RadioCards({
|
|
44
|
+
options,
|
|
45
|
+
value,
|
|
46
|
+
defaultValue = "",
|
|
47
|
+
onChange,
|
|
48
|
+
label,
|
|
49
|
+
columns = 1,
|
|
50
|
+
name,
|
|
51
|
+
className = "",
|
|
52
|
+
}: RadioCardsProps) {
|
|
53
|
+
const uid = useId();
|
|
54
|
+
const [internal, setInternal] = useState(defaultValue);
|
|
55
|
+
const selected = value !== undefined ? value : internal;
|
|
56
|
+
const wellRef = useRef<HTMLDivElement>(null);
|
|
57
|
+
const refs = useRef<(HTMLButtonElement | null)[]>([]);
|
|
58
|
+
const thumbRef = useRef<HTMLSpanElement>(null);
|
|
59
|
+
// Where the thumb last landed, to tell which way the next move goes
|
|
60
|
+
const last = useRef<{ top: number; left: number } | null>(null);
|
|
61
|
+
|
|
62
|
+
const enabled = options.map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0);
|
|
63
|
+
const selectedIndex = options.findIndex((o) => o.value === selected);
|
|
64
|
+
const tabStop = selectedIndex >= 0 ? selectedIndex : enabled[0];
|
|
65
|
+
|
|
66
|
+
const selectedRef = useRef(selectedIndex);
|
|
67
|
+
|
|
68
|
+
/* The thumb is held by its four edges, measured from the well's, and
|
|
69
|
+
written straight onto it, so a move never re-renders anything.
|
|
70
|
+
Heading forward on an axis, the far edge leads and the near one
|
|
71
|
+
trails on a spring; heading back, the other way round. */
|
|
72
|
+
const place = (index: number, animate: boolean) => {
|
|
73
|
+
const thumb = thumbRef.current;
|
|
74
|
+
const el = refs.current[index];
|
|
75
|
+
const well = wellRef.current;
|
|
76
|
+
if (!thumb || !well) return;
|
|
77
|
+
if (!el) {
|
|
78
|
+
thumb.style.opacity = "0";
|
|
79
|
+
last.current = null;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const top = el.offsetTop;
|
|
83
|
+
const left = el.offsetLeft;
|
|
84
|
+
const from = animate ? last.current : null;
|
|
85
|
+
const edge = (d: number, near: string, far: string) =>
|
|
86
|
+
d === 0 ? [] : d > 0 ? [`${near} ${TRAIL}`, `${far} ${LEAD}`] : [`${near} ${LEAD}`, `${far} ${TRAIL}`];
|
|
87
|
+
const dy = from ? Math.sign(top - from.top) : 0;
|
|
88
|
+
const dx = from ? Math.sign(left - from.left) : 0;
|
|
89
|
+
thumb.style.transition = [...edge(dy, "top", "bottom"), ...edge(dx, "left", "right")].join(", ") || "none";
|
|
90
|
+
thumb.style.top = `${top}px`;
|
|
91
|
+
thumb.style.left = `${left}px`;
|
|
92
|
+
thumb.style.right = `${well.clientWidth - left - el.offsetWidth}px`;
|
|
93
|
+
thumb.style.bottom = `${well.clientHeight - top - el.offsetHeight}px`;
|
|
94
|
+
thumb.style.opacity = "1";
|
|
95
|
+
last.current = { top, left };
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Before paint, so the thumb is never seen out of place
|
|
99
|
+
useLayoutEffect(() => {
|
|
100
|
+
selectedRef.current = selectedIndex;
|
|
101
|
+
place(selectedIndex, true);
|
|
102
|
+
}, [selectedIndex]);
|
|
103
|
+
|
|
104
|
+
// Rewrapped text or a new breakpoint moves the options; follow them
|
|
105
|
+
// without animating. An observer reports once as soon as it's attached;
|
|
106
|
+
// that one is skipped, or it would cut short a move already under way.
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
const well = wellRef.current;
|
|
109
|
+
if (!well) return;
|
|
110
|
+
let attached = false;
|
|
111
|
+
const ro = new ResizeObserver(() => {
|
|
112
|
+
if (attached) place(selectedRef.current, false);
|
|
113
|
+
attached = true;
|
|
114
|
+
});
|
|
115
|
+
ro.observe(well);
|
|
116
|
+
return () => ro.disconnect();
|
|
117
|
+
}, []);
|
|
118
|
+
|
|
119
|
+
const select = (i: number) => {
|
|
120
|
+
const o = options[i];
|
|
121
|
+
if (!o || o.disabled) return;
|
|
122
|
+
setInternal(o.value);
|
|
123
|
+
onChange?.(o.value);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const move = (from: number, step: 1 | -1) => {
|
|
127
|
+
const at = enabled.indexOf(from);
|
|
128
|
+
const next = enabled[(at + step + enabled.length) % enabled.length];
|
|
129
|
+
refs.current[next]?.focus();
|
|
130
|
+
select(next);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<div
|
|
135
|
+
ref={wellRef}
|
|
136
|
+
role="radiogroup"
|
|
137
|
+
aria-label={label}
|
|
138
|
+
className={["relative grid grid-cols-1 gap-1 rounded-[18px] p-1.5", COLS[columns], className].join(" ")}
|
|
139
|
+
style={{ background: "var(--well)", boxShadow: "var(--well-shadow)" }}
|
|
140
|
+
>
|
|
141
|
+
<span
|
|
142
|
+
ref={thumbRef}
|
|
143
|
+
aria-hidden
|
|
144
|
+
className="pointer-events-none absolute rounded-[13px] opacity-0"
|
|
145
|
+
style={{ background: "var(--face)", boxShadow: "var(--thumb-shadow)" }}
|
|
146
|
+
/>
|
|
147
|
+
|
|
148
|
+
{options.map((o, i) => {
|
|
149
|
+
const on = i === selectedIndex;
|
|
150
|
+
return (
|
|
151
|
+
<button
|
|
152
|
+
key={o.value}
|
|
153
|
+
ref={(el) => {
|
|
154
|
+
refs.current[i] = el;
|
|
155
|
+
}}
|
|
156
|
+
type="button"
|
|
157
|
+
role="radio"
|
|
158
|
+
aria-checked={on}
|
|
159
|
+
aria-labelledby={`${uid}-${i}-label`}
|
|
160
|
+
aria-describedby={o.description ? `${uid}-${i}-desc` : undefined}
|
|
161
|
+
disabled={o.disabled}
|
|
162
|
+
tabIndex={i === tabStop ? 0 : -1}
|
|
163
|
+
onClick={() => select(i)}
|
|
164
|
+
onKeyDown={(e) => {
|
|
165
|
+
if (e.key === "ArrowDown" || e.key === "ArrowRight") {
|
|
166
|
+
e.preventDefault();
|
|
167
|
+
move(i, 1);
|
|
168
|
+
} else if (e.key === "ArrowUp" || e.key === "ArrowLeft") {
|
|
169
|
+
e.preventDefault();
|
|
170
|
+
move(i, -1);
|
|
171
|
+
}
|
|
172
|
+
}}
|
|
173
|
+
className={[
|
|
174
|
+
"group/card relative z-10 flex items-start gap-3 rounded-[13px] px-4 py-3.5 text-left",
|
|
175
|
+
"focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ink",
|
|
176
|
+
"disabled:opacity-40 disabled:cursor-not-allowed",
|
|
177
|
+
on ? "cursor-default" : "cursor-pointer",
|
|
178
|
+
].join(" ")}
|
|
179
|
+
>
|
|
180
|
+
{o.icon && (
|
|
181
|
+
<span
|
|
182
|
+
aria-hidden
|
|
183
|
+
className={[
|
|
184
|
+
"mt-px flex h-5 w-5 shrink-0 items-center justify-center transition-colors duration-200",
|
|
185
|
+
on ? "text-ink" : "text-ink-faint group-hover/card:text-ink-body",
|
|
186
|
+
].join(" ")}
|
|
187
|
+
>
|
|
188
|
+
{o.icon}
|
|
189
|
+
</span>
|
|
190
|
+
)}
|
|
191
|
+
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
192
|
+
<span className="flex items-baseline justify-between gap-3">
|
|
193
|
+
<span
|
|
194
|
+
id={`${uid}-${i}-label`}
|
|
195
|
+
className={[
|
|
196
|
+
"text-[13.5px] font-medium leading-snug transition-colors duration-150",
|
|
197
|
+
on ? "text-ink" : "text-ink-soft group-hover/card:text-ink",
|
|
198
|
+
].join(" ")}
|
|
199
|
+
>
|
|
200
|
+
{o.label}
|
|
201
|
+
</span>
|
|
202
|
+
{o.aside && (
|
|
203
|
+
<span
|
|
204
|
+
className={[
|
|
205
|
+
"shrink-0 text-[13px] tabular-nums transition-colors duration-150",
|
|
206
|
+
on ? "font-medium text-ink" : "text-ink-muted",
|
|
207
|
+
].join(" ")}
|
|
208
|
+
>
|
|
209
|
+
{o.aside}
|
|
210
|
+
</span>
|
|
211
|
+
)}
|
|
212
|
+
</span>
|
|
213
|
+
{o.description && (
|
|
214
|
+
<span id={`${uid}-${i}-desc`} className="text-[12px] leading-relaxed text-ink-muted">
|
|
215
|
+
{o.description}
|
|
216
|
+
</span>
|
|
217
|
+
)}
|
|
218
|
+
</span>
|
|
219
|
+
{/* RadioGroup's control: a sunken well, and when chosen a face in
|
|
220
|
+
the inverse material with a dot pressed into it */}
|
|
221
|
+
<span
|
|
222
|
+
aria-hidden
|
|
223
|
+
className={[
|
|
224
|
+
"relative mt-px h-[18px] w-[18px] shrink-0 rounded-full transition-[box-shadow] duration-200",
|
|
225
|
+
on ? "" : "group-hover/card:[box-shadow:var(--field-shadow-hover)]",
|
|
226
|
+
].join(" ")}
|
|
227
|
+
style={{ background: "var(--field)", boxShadow: "var(--field-shadow)" }}
|
|
228
|
+
>
|
|
229
|
+
<span
|
|
230
|
+
className="absolute inset-0 grid place-items-center rounded-full transition-[opacity,transform] duration-300 ease-[var(--ease-spring)]"
|
|
231
|
+
style={{
|
|
232
|
+
background: "var(--face-inverse)",
|
|
233
|
+
boxShadow: "var(--face-inverse-shadow)",
|
|
234
|
+
opacity: on ? 1 : 0,
|
|
235
|
+
transform: on ? "scale(1)" : "scale(0.4)",
|
|
236
|
+
}}
|
|
237
|
+
>
|
|
238
|
+
<span className="h-1.5 w-1.5 rounded-full bg-ink-inverse" style={{ boxShadow: "var(--dot-shadow)" }} />
|
|
239
|
+
</span>
|
|
240
|
+
</span>
|
|
241
|
+
</button>
|
|
242
|
+
);
|
|
243
|
+
})}
|
|
244
|
+
{name && <input type="hidden" name={name} value={selected} />}
|
|
245
|
+
</div>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
@@ -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-ink",
|
|
72
|
+
reaction.active ? "text-ink-inverse" : "text-ink-body hover:text-ink",
|
|
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-inverse)" : "var(--face)",
|
|
77
|
+
boxShadow: reaction.active ? "var(--face-inverse-shadow)" : "var(--face-shadow)",
|
|
78
|
+
textShadow: reaction.active ? "var(--emboss-inverse)" : "var(--emboss)",
|
|
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: "var(--progress-bar)",
|
|
46
|
+
boxShadow: "var(--progress-bar-glow)",
|
|
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-ink-muted",
|
|
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-ink-faint"
|
|
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,265 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useRef, useEffect, useId } from "react";
|
|
4
|
+
import { Portal } from "./Floating";
|
|
5
|
+
import { useFloatingPanel } from "./useFloatingPanel";
|
|
6
|
+
|
|
7
|
+
interface SelectOption {
|
|
8
|
+
value: string;
|
|
9
|
+
label: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface SelectProps {
|
|
13
|
+
options: SelectOption[];
|
|
14
|
+
value?: string;
|
|
15
|
+
onChange?: (value: string) => void;
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
label?: string;
|
|
18
|
+
hint?: string;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
fullWidth?: boolean;
|
|
21
|
+
className?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/* The closed select is a sunken field like Input; the open list is a
|
|
25
|
+
floating panel like DropdownMenu. Keyboard follows the ARIA listbox
|
|
26
|
+
pattern: arrows move, Enter picks, Escape backs out. */
|
|
27
|
+
export function Select({
|
|
28
|
+
options,
|
|
29
|
+
value,
|
|
30
|
+
onChange,
|
|
31
|
+
placeholder = "select…",
|
|
32
|
+
label,
|
|
33
|
+
hint,
|
|
34
|
+
disabled = false,
|
|
35
|
+
fullWidth = false,
|
|
36
|
+
className = "",
|
|
37
|
+
}: SelectProps) {
|
|
38
|
+
const [active, setActive] = useState(0);
|
|
39
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
40
|
+
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
41
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
42
|
+
const uid = useId();
|
|
43
|
+
// The list is portalled to the body and pinned under the field
|
|
44
|
+
const { open, mounted, close, show, setFloating, panelStyle } = useFloatingPanel(ref, {
|
|
45
|
+
matchWidth: true,
|
|
46
|
+
onEscape: () => buttonRef.current?.focus(),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const selectedIndex = options.findIndex((o) => o.value === value);
|
|
50
|
+
const selected = options[selectedIndex];
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!open) return;
|
|
54
|
+
listRef.current
|
|
55
|
+
?.querySelector(`[data-index="${active}"]`)
|
|
56
|
+
?.scrollIntoView({ block: "nearest" });
|
|
57
|
+
}, [open, active]);
|
|
58
|
+
|
|
59
|
+
const openList = () => {
|
|
60
|
+
setActive(selectedIndex >= 0 ? selectedIndex : 0);
|
|
61
|
+
show();
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const pick = (index: number) => {
|
|
65
|
+
const opt = options[index];
|
|
66
|
+
if (!opt) return;
|
|
67
|
+
onChange?.(opt.value);
|
|
68
|
+
close();
|
|
69
|
+
buttonRef.current?.focus();
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const onKeyDown = (e: React.KeyboardEvent) => {
|
|
73
|
+
if (!open) {
|
|
74
|
+
if (["ArrowDown", "ArrowUp", "Enter", " "].includes(e.key)) {
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
openList();
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
switch (e.key) {
|
|
81
|
+
case "ArrowDown":
|
|
82
|
+
e.preventDefault();
|
|
83
|
+
setActive((i) => Math.min(i + 1, options.length - 1));
|
|
84
|
+
break;
|
|
85
|
+
case "ArrowUp":
|
|
86
|
+
e.preventDefault();
|
|
87
|
+
setActive((i) => Math.max(i - 1, 0));
|
|
88
|
+
break;
|
|
89
|
+
case "Home":
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
setActive(0);
|
|
92
|
+
break;
|
|
93
|
+
case "End":
|
|
94
|
+
e.preventDefault();
|
|
95
|
+
setActive(options.length - 1);
|
|
96
|
+
break;
|
|
97
|
+
case "Enter":
|
|
98
|
+
case " ":
|
|
99
|
+
e.preventDefault();
|
|
100
|
+
pick(active);
|
|
101
|
+
break;
|
|
102
|
+
case "Escape":
|
|
103
|
+
e.preventDefault();
|
|
104
|
+
close();
|
|
105
|
+
break;
|
|
106
|
+
case "Tab":
|
|
107
|
+
close();
|
|
108
|
+
break;
|
|
109
|
+
default:
|
|
110
|
+
// Type-ahead: jump to the first option starting with the key
|
|
111
|
+
if (e.key.length === 1) {
|
|
112
|
+
const k = e.key.toLowerCase();
|
|
113
|
+
const hit = options.findIndex((o) => o.label.toLowerCase().startsWith(k));
|
|
114
|
+
if (hit >= 0) setActive(hit);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<div className={["flex flex-col gap-1.5", fullWidth ? "w-full" : ""].join(" ")}>
|
|
121
|
+
{label && (
|
|
122
|
+
<span
|
|
123
|
+
id={`${uid}-label`}
|
|
124
|
+
className="text-[12.5px] font-medium text-ink-label tracking-[-0.005em]"
|
|
125
|
+
>
|
|
126
|
+
{label}
|
|
127
|
+
</span>
|
|
128
|
+
)}
|
|
129
|
+
|
|
130
|
+
<div
|
|
131
|
+
ref={ref}
|
|
132
|
+
className={["relative", fullWidth ? "w-full" : "inline-block", className].join(" ")}
|
|
133
|
+
>
|
|
134
|
+
<button
|
|
135
|
+
ref={buttonRef}
|
|
136
|
+
type="button"
|
|
137
|
+
/* A select-only combobox: the role is what lets the button point
|
|
138
|
+
at the active option while focus stays on it */
|
|
139
|
+
role="combobox"
|
|
140
|
+
disabled={disabled}
|
|
141
|
+
onClick={() => (open ? close() : openList())}
|
|
142
|
+
onKeyDown={onKeyDown}
|
|
143
|
+
aria-haspopup="listbox"
|
|
144
|
+
aria-expanded={open}
|
|
145
|
+
aria-controls={open ? `${uid}-list` : undefined}
|
|
146
|
+
aria-labelledby={label ? `${uid}-label ${uid}-value` : undefined}
|
|
147
|
+
aria-activedescendant={open ? `${uid}-opt-${active}` : undefined}
|
|
148
|
+
className={[
|
|
149
|
+
"group/select relative isolate inline-flex items-center justify-between gap-3 w-full",
|
|
150
|
+
"rounded-xl px-4 py-3 text-[13.5px] font-medium text-ink",
|
|
151
|
+
"field cursor-pointer select-none",
|
|
152
|
+
"focus:outline-none disabled:opacity-40 disabled:pointer-events-none",
|
|
153
|
+
].join(" ")}
|
|
154
|
+
data-open={open || undefined}
|
|
155
|
+
>
|
|
156
|
+
<span
|
|
157
|
+
id={`${uid}-value`}
|
|
158
|
+
className={[
|
|
159
|
+
"relative z-10 truncate",
|
|
160
|
+
selected ? "text-ink" : "text-ink-faint font-normal",
|
|
161
|
+
].join(" ")}
|
|
162
|
+
>
|
|
163
|
+
{selected ? selected.label : placeholder}
|
|
164
|
+
</span>
|
|
165
|
+
<svg
|
|
166
|
+
aria-hidden
|
|
167
|
+
width="14"
|
|
168
|
+
height="14"
|
|
169
|
+
fill="none"
|
|
170
|
+
stroke="currentColor"
|
|
171
|
+
strokeWidth="2"
|
|
172
|
+
viewBox="0 0 24 24"
|
|
173
|
+
className={[
|
|
174
|
+
"relative z-10 shrink-0 transition-[transform,color] duration-300 ease-[var(--ease-spring)]",
|
|
175
|
+
open ? "rotate-180 text-ink" : "text-ink-faint group-hover/select:text-ink-label",
|
|
176
|
+
].join(" ")}
|
|
177
|
+
>
|
|
178
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
179
|
+
</svg>
|
|
180
|
+
|
|
181
|
+
{open && (
|
|
182
|
+
<span aria-hidden className="pointer-events-none absolute inset-0 z-5 rounded-xl overflow-hidden">
|
|
183
|
+
<span
|
|
184
|
+
className="block absolute inset-y-0 w-[30%] -left-[30%] animate-input-shimmer"
|
|
185
|
+
style={{
|
|
186
|
+
background:
|
|
187
|
+
"linear-gradient(90deg, transparent 0%, var(--shimmer) 50%, transparent 100%)",
|
|
188
|
+
}}
|
|
189
|
+
/>
|
|
190
|
+
</span>
|
|
191
|
+
)}
|
|
192
|
+
</button>
|
|
193
|
+
|
|
194
|
+
{mounted && (
|
|
195
|
+
<Portal>
|
|
196
|
+
<div
|
|
197
|
+
ref={setFloating}
|
|
198
|
+
className="p-1.5 rounded-[16px]"
|
|
199
|
+
style={{
|
|
200
|
+
...panelStyle,
|
|
201
|
+
maxWidth: "min(420px, calc(100vw - 2rem))",
|
|
202
|
+
width: "max-content",
|
|
203
|
+
}}
|
|
204
|
+
>
|
|
205
|
+
<div
|
|
206
|
+
ref={listRef}
|
|
207
|
+
id={`${uid}-list`}
|
|
208
|
+
role="listbox"
|
|
209
|
+
aria-labelledby={label ? `${uid}-label` : undefined}
|
|
210
|
+
className="bg-panel-face rounded-[11px] p-1 flex flex-col gap-0.5 max-h-72 overflow-y-auto scroll-thin"
|
|
211
|
+
style={{ boxShadow: "var(--panel-face-shadow)" }}
|
|
212
|
+
>
|
|
213
|
+
{options.map((opt, i) => {
|
|
214
|
+
const isSelected = opt.value === value;
|
|
215
|
+
const isActive = i === active;
|
|
216
|
+
return (
|
|
217
|
+
<div
|
|
218
|
+
key={opt.value}
|
|
219
|
+
id={`${uid}-opt-${i}`}
|
|
220
|
+
data-index={i}
|
|
221
|
+
role="option"
|
|
222
|
+
aria-selected={isSelected}
|
|
223
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
224
|
+
onClick={() => pick(i)}
|
|
225
|
+
onMouseMove={() => setActive(i)}
|
|
226
|
+
className={[
|
|
227
|
+
"flex items-center justify-between gap-3 w-full px-2.5 py-2 text-[13px] rounded-[8px] cursor-pointer",
|
|
228
|
+
"transition-colors duration-100",
|
|
229
|
+
isSelected ? "font-medium" : "",
|
|
230
|
+
// one ink per state: utilities sort by name, so text-ink-body would beat text-ink
|
|
231
|
+
isSelected || isActive ? "text-ink" : "text-ink-body",
|
|
232
|
+
isActive ? "bg-surface-active/80" : "",
|
|
233
|
+
].join(" ")}
|
|
234
|
+
style={{
|
|
235
|
+
animation: `select-option-in 300ms var(--ease-out-soft) ${40 + i * 22}ms both`,
|
|
236
|
+
}}
|
|
237
|
+
>
|
|
238
|
+
<span className="whitespace-nowrap">{opt.label}</span>
|
|
239
|
+
{isSelected && (
|
|
240
|
+
<svg
|
|
241
|
+
aria-hidden
|
|
242
|
+
className="shrink-0 text-ink"
|
|
243
|
+
width="13"
|
|
244
|
+
height="13"
|
|
245
|
+
fill="none"
|
|
246
|
+
stroke="currentColor"
|
|
247
|
+
strokeWidth="2.5"
|
|
248
|
+
viewBox="0 0 24 24"
|
|
249
|
+
>
|
|
250
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
251
|
+
</svg>
|
|
252
|
+
)}
|
|
253
|
+
</div>
|
|
254
|
+
);
|
|
255
|
+
})}
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
</Portal>
|
|
259
|
+
)}
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
{hint && <p className="text-[11.5px] text-ink-muted mt-0.5">{hint}</p>}
|
|
263
|
+
</div>
|
|
264
|
+
);
|
|
265
|
+
}
|