@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.
Files changed (272) hide show
  1. package/README.md +60 -20
  2. package/dist/Accordion.d.ts +17 -0
  3. package/dist/Accordion.js +138 -0
  4. package/dist/Accordion.js.map +1 -0
  5. package/dist/Alert.d.ts +13 -0
  6. package/dist/Alert.js +69 -0
  7. package/dist/Alert.js.map +1 -0
  8. package/dist/AuthorCard.d.ts +12 -0
  9. package/dist/AuthorCard.js +57 -0
  10. package/dist/AuthorCard.js.map +1 -0
  11. package/dist/Avatar.d.ts +21 -0
  12. package/dist/Avatar.js +96 -0
  13. package/dist/Avatar.js.map +1 -0
  14. package/dist/Badge.d.ts +12 -0
  15. package/dist/Badge.js +46 -0
  16. package/dist/Badge.js.map +1 -0
  17. package/dist/Banner.d.ts +15 -0
  18. package/dist/Banner.js +68 -0
  19. package/dist/Banner.js.map +1 -0
  20. package/dist/Breadcrumb.d.ts +13 -0
  21. package/dist/Breadcrumb.js +45 -0
  22. package/dist/Breadcrumb.js.map +1 -0
  23. package/dist/Button.d.ts +19 -0
  24. package/dist/Button.js +282 -0
  25. package/dist/Button.js.map +1 -0
  26. package/dist/ButtonGroup.d.ts +19 -0
  27. package/dist/ButtonGroup.js +62 -0
  28. package/dist/ButtonGroup.js.map +1 -0
  29. package/dist/Card.d.ts +40 -0
  30. package/dist/Card.js +175 -0
  31. package/dist/Card.js.map +1 -0
  32. package/dist/CardShell.d.ts +18 -0
  33. package/dist/CardShell.js +58 -0
  34. package/dist/CardShell.js.map +1 -0
  35. package/dist/Carousel.d.ts +22 -0
  36. package/dist/Carousel.js +247 -0
  37. package/dist/Carousel.js.map +1 -0
  38. package/dist/Checkbox.d.ts +15 -0
  39. package/dist/Checkbox.js +84 -0
  40. package/dist/Checkbox.js.map +1 -0
  41. package/dist/CloseButton.d.ts +12 -0
  42. package/dist/CloseButton.js +63 -0
  43. package/dist/CloseButton.js.map +1 -0
  44. package/dist/CodeBlock.d.ts +12 -0
  45. package/dist/CodeBlock.js +221 -0
  46. package/dist/CodeBlock.js.map +1 -0
  47. package/dist/ColorPicker.d.ts +24 -0
  48. package/dist/ColorPicker.js +366 -0
  49. package/dist/ColorPicker.js.map +1 -0
  50. package/dist/Combobox.d.ts +34 -0
  51. package/dist/Combobox.js +255 -0
  52. package/dist/Combobox.js.map +1 -0
  53. package/dist/CommandPalette.d.ts +25 -0
  54. package/dist/CommandPalette.js +304 -0
  55. package/dist/CommandPalette.js.map +1 -0
  56. package/dist/ContextMenu.d.ts +16 -0
  57. package/dist/ContextMenu.js +209 -0
  58. package/dist/ContextMenu.js.map +1 -0
  59. package/dist/DataList.d.ts +15 -0
  60. package/dist/DataList.js +42 -0
  61. package/dist/DataList.js.map +1 -0
  62. package/dist/DatePicker.d.ts +65 -0
  63. package/dist/DatePicker.js +391 -0
  64. package/dist/DatePicker.js.map +1 -0
  65. package/dist/DateRangePicker.d.ts +34 -0
  66. package/dist/DateRangePicker.js +180 -0
  67. package/dist/DateRangePicker.js.map +1 -0
  68. package/dist/Drawer.d.ts +17 -0
  69. package/dist/Drawer.js +132 -0
  70. package/dist/Drawer.js.map +1 -0
  71. package/dist/DropdownMenu.d.ts +23 -0
  72. package/dist/DropdownMenu.js +185 -0
  73. package/dist/DropdownMenu.js.map +1 -0
  74. package/dist/EmptyState.d.ts +13 -0
  75. package/dist/EmptyState.js +55 -0
  76. package/dist/EmptyState.js.map +1 -0
  77. package/dist/FieldMessage.d.ts +26 -0
  78. package/dist/FieldMessage.js +95 -0
  79. package/dist/FieldMessage.js.map +1 -0
  80. package/dist/FileUpload.d.ts +32 -0
  81. package/dist/FileUpload.js +253 -0
  82. package/dist/FileUpload.js.map +1 -0
  83. package/dist/Floating.d.ts +32 -0
  84. package/dist/Floating.js +95 -0
  85. package/dist/Floating.js.map +1 -0
  86. package/dist/HoverCard.d.ts +18 -0
  87. package/dist/HoverCard.js +90 -0
  88. package/dist/HoverCard.js.map +1 -0
  89. package/dist/Input.d.ts +17 -0
  90. package/dist/Input.js +96 -0
  91. package/dist/Input.js.map +1 -0
  92. package/dist/Kbd.d.ts +9 -0
  93. package/dist/Kbd.js +24 -0
  94. package/dist/Kbd.js.map +1 -0
  95. package/dist/Modal.d.ts +15 -0
  96. package/dist/Modal.js +109 -0
  97. package/dist/Modal.js.map +1 -0
  98. package/dist/NumberInput.d.ts +23 -0
  99. package/dist/NumberInput.js +162 -0
  100. package/dist/NumberInput.js.map +1 -0
  101. package/dist/OtpInput.d.ts +26 -0
  102. package/dist/OtpInput.js +173 -0
  103. package/dist/OtpInput.js.map +1 -0
  104. package/dist/Pagination.d.ts +11 -0
  105. package/dist/Pagination.js +148 -0
  106. package/dist/Pagination.js.map +1 -0
  107. package/dist/Popover.d.ts +13 -0
  108. package/dist/Popover.js +98 -0
  109. package/dist/Popover.js.map +1 -0
  110. package/dist/Progress.d.ts +12 -0
  111. package/dist/Progress.js +61 -0
  112. package/dist/Progress.js.map +1 -0
  113. package/dist/QuoteCard.d.ts +13 -0
  114. package/dist/QuoteCard.js +92 -0
  115. package/dist/QuoteCard.js.map +1 -0
  116. package/dist/Radio.d.ts +19 -0
  117. package/dist/Radio.js +161 -0
  118. package/dist/Radio.js.map +1 -0
  119. package/dist/RadioCards.d.ts +27 -0
  120. package/dist/RadioCards.js +205 -0
  121. package/dist/RadioCards.js.map +1 -0
  122. package/dist/ReactionBar.d.ts +15 -0
  123. package/dist/ReactionBar.js +92 -0
  124. package/dist/ReactionBar.js.map +1 -0
  125. package/dist/ReadingProgress.d.ts +8 -0
  126. package/dist/ReadingProgress.js +51 -0
  127. package/dist/ReadingProgress.js.map +1 -0
  128. package/dist/ReadingTime.d.ts +9 -0
  129. package/dist/ReadingTime.js +39 -0
  130. package/dist/ReadingTime.js.map +1 -0
  131. package/dist/Select.d.ts +20 -0
  132. package/dist/Select.js +244 -0
  133. package/dist/Select.js.map +1 -0
  134. package/dist/Separator.d.ts +14 -0
  135. package/dist/Separator.js +33 -0
  136. package/dist/Separator.js.map +1 -0
  137. package/dist/ShareButtons.d.ts +10 -0
  138. package/dist/ShareButtons.js +107 -0
  139. package/dist/ShareButtons.js.map +1 -0
  140. package/dist/Sidebar.d.ts +30 -0
  141. package/dist/Sidebar.js +140 -0
  142. package/dist/Sidebar.js.map +1 -0
  143. package/dist/Skeleton.d.ts +15 -0
  144. package/dist/Skeleton.js +67 -0
  145. package/dist/Skeleton.js.map +1 -0
  146. package/dist/Slider.d.ts +27 -0
  147. package/dist/Slider.js +182 -0
  148. package/dist/Slider.js.map +1 -0
  149. package/dist/Spinner.d.ts +12 -0
  150. package/dist/Spinner.js +24 -0
  151. package/dist/Spinner.js.map +1 -0
  152. package/dist/SpotlightCard.d.ts +17 -0
  153. package/dist/SpotlightCard.js +87 -0
  154. package/dist/SpotlightCard.js.map +1 -0
  155. package/dist/Stat.d.ts +19 -0
  156. package/dist/Stat.js +71 -0
  157. package/dist/Stat.js.map +1 -0
  158. package/dist/StatCard.d.ts +24 -0
  159. package/dist/StatCard.js +212 -0
  160. package/dist/StatCard.js.map +1 -0
  161. package/dist/Steps.d.ts +14 -0
  162. package/dist/Steps.js +94 -0
  163. package/dist/Steps.js.map +1 -0
  164. package/dist/Table.d.ts +21 -0
  165. package/dist/Table.js +93 -0
  166. package/dist/Table.js.map +1 -0
  167. package/dist/TableOfContents.d.ts +17 -0
  168. package/dist/TableOfContents.js +92 -0
  169. package/dist/TableOfContents.js.map +1 -0
  170. package/dist/Tabs.d.ts +17 -0
  171. package/dist/Tabs.js +123 -0
  172. package/dist/Tabs.js.map +1 -0
  173. package/dist/TagInput.d.ts +16 -0
  174. package/dist/TagInput.js +169 -0
  175. package/dist/TagInput.js.map +1 -0
  176. package/dist/Textarea.d.ts +13 -0
  177. package/dist/Textarea.js +74 -0
  178. package/dist/Textarea.js.map +1 -0
  179. package/dist/TimePicker.d.ts +28 -0
  180. package/dist/TimePicker.js +264 -0
  181. package/dist/TimePicker.js.map +1 -0
  182. package/dist/Timeline.d.ts +19 -0
  183. package/dist/Timeline.js +76 -0
  184. package/dist/Timeline.js.map +1 -0
  185. package/dist/Toast.d.ts +18 -0
  186. package/dist/Toast.js +318 -0
  187. package/dist/Toast.js.map +1 -0
  188. package/dist/Toggle.d.ts +13 -0
  189. package/dist/Toggle.js +84 -0
  190. package/dist/Toggle.js.map +1 -0
  191. package/dist/Tooltip.d.ts +13 -0
  192. package/dist/Tooltip.js +138 -0
  193. package/dist/Tooltip.js.map +1 -0
  194. package/dist/cli.js +1644 -0
  195. package/dist/index.d.ts +68 -631
  196. package/dist/index.js +67 -5558
  197. package/dist/index.js.map +1 -1
  198. package/dist/registry/Accordion.tsx +140 -0
  199. package/dist/registry/Alert.tsx +95 -0
  200. package/dist/registry/AuthorCard.tsx +67 -0
  201. package/dist/registry/Avatar.tsx +116 -0
  202. package/dist/registry/Badge.tsx +55 -0
  203. package/dist/registry/Banner.tsx +86 -0
  204. package/dist/registry/Breadcrumb.tsx +60 -0
  205. package/dist/registry/Button.tsx +361 -0
  206. package/dist/registry/ButtonGroup.tsx +82 -0
  207. package/dist/registry/Card.tsx +241 -0
  208. package/dist/registry/CardShell.tsx +78 -0
  209. package/dist/registry/Carousel.tsx +296 -0
  210. package/dist/registry/Checkbox.tsx +104 -0
  211. package/dist/registry/CloseButton.tsx +68 -0
  212. package/dist/registry/CodeBlock.tsx +202 -0
  213. package/dist/registry/ColorPicker.tsx +404 -0
  214. package/dist/registry/Combobox.tsx +318 -0
  215. package/dist/registry/CommandPalette.tsx +356 -0
  216. package/dist/registry/ContextMenu.tsx +241 -0
  217. package/dist/registry/DataList.tsx +49 -0
  218. package/dist/registry/DatePicker.tsx +494 -0
  219. package/dist/registry/DateRangePicker.tsx +232 -0
  220. package/dist/registry/Drawer.tsx +166 -0
  221. package/dist/registry/DropdownMenu.tsx +219 -0
  222. package/dist/registry/EmptyState.tsx +55 -0
  223. package/dist/registry/FieldMessage.tsx +123 -0
  224. package/dist/registry/FileUpload.tsx +318 -0
  225. package/dist/registry/Floating.tsx +134 -0
  226. package/dist/registry/HoverCard.tsx +113 -0
  227. package/dist/registry/Input.tsx +108 -0
  228. package/dist/registry/Kbd.tsx +23 -0
  229. package/dist/registry/Modal.tsx +130 -0
  230. package/dist/registry/NumberInput.tsx +183 -0
  231. package/dist/registry/OtpInput.tsx +219 -0
  232. package/dist/registry/Pagination.tsx +160 -0
  233. package/dist/registry/Popover.tsx +116 -0
  234. package/dist/registry/Progress.tsx +66 -0
  235. package/dist/registry/QuoteCard.tsx +93 -0
  236. package/dist/registry/Radio.tsx +205 -0
  237. package/dist/registry/RadioCards.tsx +247 -0
  238. package/dist/registry/ReactionBar.tsx +101 -0
  239. package/dist/registry/ReadingProgress.tsx +51 -0
  240. package/dist/registry/ReadingTime.tsx +35 -0
  241. package/dist/registry/Select.tsx +265 -0
  242. package/dist/registry/Separator.tsx +52 -0
  243. package/dist/registry/ShareButtons.tsx +134 -0
  244. package/dist/registry/Sidebar.tsx +182 -0
  245. package/dist/registry/Skeleton.tsx +83 -0
  246. package/dist/registry/Slider.tsx +229 -0
  247. package/dist/registry/Spinner.tsx +32 -0
  248. package/dist/registry/SpotlightCard.tsx +106 -0
  249. package/dist/registry/Stat.tsx +82 -0
  250. package/dist/registry/StatCard.tsx +256 -0
  251. package/dist/registry/Steps.tsx +109 -0
  252. package/dist/registry/Table.tsx +123 -0
  253. package/dist/registry/TableOfContents.tsx +116 -0
  254. package/dist/registry/Tabs.tsx +136 -0
  255. package/dist/registry/TagInput.tsx +191 -0
  256. package/dist/registry/Textarea.tsx +84 -0
  257. package/dist/registry/TimePicker.tsx +315 -0
  258. package/dist/registry/Timeline.tsx +102 -0
  259. package/dist/registry/Toast.tsx +388 -0
  260. package/dist/registry/Toggle.tsx +96 -0
  261. package/dist/registry/Tooltip.tsx +160 -0
  262. package/dist/registry/styles.css +1207 -0
  263. package/dist/registry/useFloatingPanel.ts +112 -0
  264. package/dist/registry/useModalBehavior.ts +77 -0
  265. package/dist/styles.css +868 -62
  266. package/dist/useFloatingPanel.d.ts +24 -0
  267. package/dist/useFloatingPanel.js +89 -0
  268. package/dist/useFloatingPanel.js.map +1 -0
  269. package/dist/useModalBehavior.d.ts +15 -0
  270. package/dist/useModalBehavior.js +57 -0
  271. package/dist/useModalBehavior.js.map +1 -0
  272. 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
+ }