@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,160 @@
1
+ "use client";
2
+
3
+ import React, { useRef, useState, useEffect, useLayoutEffect } from "react";
4
+
5
+ interface PaginationProps {
6
+ currentPage: number;
7
+ totalPages: number;
8
+ onPageChange: (page: number) => void;
9
+ className?: string;
10
+ }
11
+
12
+ export function Pagination({
13
+ currentPage,
14
+ totalPages,
15
+ onPageChange,
16
+ className = "",
17
+ }: PaginationProps) {
18
+ const slots = buildSlots(currentPage, totalPages);
19
+ const [pill, setPill] = useState({ left: 0, width: 0, ready: false });
20
+ const containerRef = useRef<HTMLDivElement>(null);
21
+ const btnRefs = useRef<Record<string, HTMLButtonElement | null>>({});
22
+
23
+ const measurePill = (page: number) => {
24
+ const btn = btnRefs.current[String(page)];
25
+ const container = containerRef.current;
26
+ if (!btn || !container) return;
27
+ const cr = container.getBoundingClientRect();
28
+ const br = btn.getBoundingClientRect();
29
+ setPill({ left: br.left - cr.left, width: br.width, ready: true });
30
+ };
31
+
32
+ /* Only the thumb moves: it glides to the new page without overshoot,
33
+ and the numbers themselves stay still. Measured before paint, so it
34
+ is never seen out of place. */
35
+ useLayoutEffect(() => {
36
+ measurePill(currentPage);
37
+ }, [currentPage]);
38
+
39
+ useEffect(() => {
40
+ const ro = new ResizeObserver(() => measurePill(currentPage));
41
+ if (containerRef.current) ro.observe(containerRef.current);
42
+ return () => ro.disconnect();
43
+ }, [currentPage]);
44
+
45
+ return (
46
+ <nav
47
+ aria-label="Pagination"
48
+ className={[
49
+ "inline-flex items-center gap-0.5 rounded-[13px] p-1",
50
+ className,
51
+ ].join(" ")}
52
+ style={{ background: "var(--well)", boxShadow: "var(--well-shadow)" }}
53
+ >
54
+ <button
55
+ onClick={() => onPageChange(currentPage - 1)}
56
+ disabled={currentPage <= 1}
57
+ aria-label="Previous page"
58
+ className="group/nav relative overflow-hidden h-8 px-3 rounded-[9px] flex items-center gap-1.5 text-[13px] font-medium text-ink-body hover:text-ink transition-colors cursor-pointer disabled:opacity-30 disabled:pointer-events-none"
59
+ >
60
+ <span className="pointer-events-none absolute inset-0 rounded-[9px] opacity-0 group-hover/nav:opacity-100 transition-opacity duration-200 [background:var(--face)] [box-shadow:var(--thumb-shadow)]" />
61
+ <svg className="relative z-10" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
62
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
63
+ </svg>
64
+ <span className="relative z-10 hidden sm:inline">prev</span>
65
+ </button>
66
+
67
+ <div ref={containerRef} className="relative flex items-center">
68
+ {pill.ready && (
69
+ <span
70
+ aria-hidden
71
+ className="absolute top-0 bottom-0 rounded-[9px] pointer-events-none z-0"
72
+ style={{
73
+ left: pill.left,
74
+ width: pill.width,
75
+ background: "var(--face)",
76
+ boxShadow: "var(--thumb-shadow)",
77
+ transition: "left 340ms var(--ease-out-soft), width 340ms var(--ease-out-soft)",
78
+ }}
79
+ />
80
+ )}
81
+
82
+ {slots.map((slot, i) =>
83
+ slot === "..." ? (
84
+ <span
85
+ key={`dot-${i}`}
86
+ aria-hidden
87
+ className="w-8 h-8 flex items-center justify-center text-[11px] text-ink-faint select-none relative z-10"
88
+ >
89
+ ···
90
+ </span>
91
+ ) : (
92
+ <button
93
+ key={`slot-${i}`}
94
+ ref={(el) => {
95
+ btnRefs.current[String(slot)] = el;
96
+ }}
97
+ onClick={() => slot !== currentPage && onPageChange(slot as number)}
98
+ aria-label={`Page ${slot}`}
99
+ aria-current={slot === currentPage ? "page" : undefined}
100
+ className={[
101
+ "relative z-10 min-w-8 h-8 px-1 rounded-[9px] flex items-center justify-center text-[13px] font-medium transition-colors duration-200 cursor-pointer tabular-nums",
102
+ slot === currentPage
103
+ ? "text-ink"
104
+ : "text-ink-muted hover:text-ink",
105
+ ].join(" ")}
106
+ >
107
+ {slot}
108
+ </button>
109
+ ),
110
+ )}
111
+ </div>
112
+
113
+ <button
114
+ onClick={() => onPageChange(currentPage + 1)}
115
+ disabled={currentPage >= totalPages}
116
+ aria-label="Next page"
117
+ className="group/nav relative overflow-hidden h-8 px-3 rounded-[9px] flex items-center gap-1.5 text-[13px] font-medium text-ink-body hover:text-ink transition-colors cursor-pointer disabled:opacity-30 disabled:pointer-events-none"
118
+ >
119
+ <span className="pointer-events-none absolute inset-0 rounded-[9px] opacity-0 group-hover/nav:opacity-100 transition-opacity duration-200 [background:var(--face)] [box-shadow:var(--thumb-shadow)]" />
120
+ <span className="relative z-10 hidden sm:inline">next</span>
121
+ <svg className="relative z-10" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
122
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
123
+ </svg>
124
+ </button>
125
+ </nav>
126
+ );
127
+ }
128
+
129
+ function buildSlots(current: number, total: number): (number | "...")[] {
130
+ if (total <= 7) {
131
+ return Array.from({ length: total }, (_, i) => i + 1);
132
+ }
133
+
134
+ const slots: (number | "...")[] = new Array(7);
135
+
136
+ slots[0] = 1;
137
+ slots[6] = total;
138
+
139
+ if (current <= 3) {
140
+ slots[1] = 2;
141
+ slots[2] = 3;
142
+ slots[3] = 4;
143
+ slots[4] = 5;
144
+ slots[5] = "...";
145
+ } else if (current >= total - 2) {
146
+ slots[1] = "...";
147
+ slots[2] = total - 4;
148
+ slots[3] = total - 3;
149
+ slots[4] = total - 2;
150
+ slots[5] = total - 1;
151
+ } else {
152
+ slots[1] = "...";
153
+ slots[2] = current - 1;
154
+ slots[3] = current;
155
+ slots[4] = current + 1;
156
+ slots[5] = "...";
157
+ }
158
+
159
+ return slots;
160
+ }
@@ -0,0 +1,116 @@
1
+ "use client";
2
+
3
+ import React, { useState, useRef, useEffect, useId } from "react";
4
+ import { ENTER_FROM, LAYER, ORIGIN, Portal, useAnchoredPosition } from "./Floating";
5
+
6
+ type PopoverPosition = "top" | "bottom" | "left" | "right";
7
+
8
+ interface PopoverProps {
9
+ trigger: React.ReactNode;
10
+ children: React.ReactNode;
11
+ position?: PopoverPosition;
12
+ className?: string;
13
+ }
14
+
15
+ export function Popover({
16
+ trigger,
17
+ children,
18
+ position = "bottom",
19
+ className = "",
20
+ }: PopoverProps) {
21
+ const [open, setOpen] = useState(false);
22
+ const [visible, setVisible] = useState(false);
23
+ const [mounted, setMounted] = useState(false);
24
+ const ref = useRef<HTMLDivElement>(null);
25
+ const uid = useId();
26
+ // Portalled and pinned beside the trigger, flipping if there's no room
27
+ const { setFloating, floatingRef, side } = useAnchoredPosition(ref, { open: mounted, side: position, align: "center" });
28
+
29
+ /* Mounted and faded out during render; the effect only times the rest */
30
+ if (open && !mounted) setMounted(true);
31
+ if (!open && visible) setVisible(false);
32
+
33
+ useEffect(() => {
34
+ if (open) {
35
+ requestAnimationFrame(() =>
36
+ requestAnimationFrame(() => {
37
+ setVisible(true);
38
+ // The panel lives at the end of <body>; bring focus to it so Tab reaches its contents
39
+ const panel = floatingRef.current;
40
+ const first = panel?.querySelector<HTMLElement>(
41
+ 'button:not([disabled]), [href], input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])',
42
+ );
43
+ (first ?? panel)?.focus({ preventScroll: true });
44
+ }),
45
+ );
46
+ } else {
47
+ const t = setTimeout(() => setMounted(false), 180);
48
+ return () => clearTimeout(t);
49
+ }
50
+ }, [open, floatingRef]);
51
+
52
+ useEffect(() => {
53
+ function onOutside(e: MouseEvent) {
54
+ const t = e.target as Node;
55
+ if (ref.current?.contains(t) || floatingRef.current?.contains(t)) return;
56
+ setOpen(false);
57
+ }
58
+ document.addEventListener("mousedown", onOutside);
59
+ return () => document.removeEventListener("mousedown", onOutside);
60
+ }, [floatingRef]);
61
+
62
+ useEffect(() => {
63
+ if (!open) return;
64
+ const onKey = (e: KeyboardEvent) => {
65
+ if (e.key === "Escape") {
66
+ setOpen(false);
67
+ ref.current?.querySelector<HTMLElement>("[data-popover-trigger] button, [data-popover-trigger] a")?.focus();
68
+ }
69
+ };
70
+ document.addEventListener("keydown", onKey);
71
+ return () => document.removeEventListener("keydown", onKey);
72
+ }, [open]);
73
+
74
+ return (
75
+ <div ref={ref} className={["relative inline-flex", className].join(" ")}>
76
+ <div data-popover-trigger onClick={() => setOpen((v) => !v)} className="inline-flex cursor-pointer">
77
+ {React.isValidElement(trigger)
78
+ ? React.cloneElement(trigger as React.ReactElement<Record<string, unknown>>, {
79
+ "aria-haspopup": "dialog",
80
+ "aria-expanded": open,
81
+ "aria-controls": open ? `${uid}-popover` : undefined,
82
+ })
83
+ : trigger}
84
+ </div>
85
+ {mounted && (
86
+ <Portal>
87
+ <div
88
+ ref={setFloating}
89
+ id={`${uid}-popover`}
90
+ role="dialog"
91
+ tabIndex={-1}
92
+ className="outline-none min-w-[220px] max-w-[calc(100vw-16px)] rounded-[16px] p-1.5 transition-[opacity,transform] duration-200 ease-[var(--ease-spring)]"
93
+ style={{
94
+ position: "fixed",
95
+ top: 0,
96
+ left: 0,
97
+ zIndex: LAYER.floating,
98
+ transformOrigin: ORIGIN[side],
99
+ opacity: visible ? 1 : 0,
100
+ transform: visible ? "none" : `${ENTER_FROM[side]} scale(0.96)`,
101
+ background: "var(--panel-bezel)",
102
+ boxShadow: "var(--overlay-shadow)",
103
+ }}
104
+ >
105
+ <div
106
+ className="bg-panel-face rounded-[11px] p-3.5 text-[13px] text-ink-body leading-relaxed"
107
+ style={{ boxShadow: "var(--panel-face-shadow)" }}
108
+ >
109
+ {children}
110
+ </div>
111
+ </div>
112
+ </Portal>
113
+ )}
114
+ </div>
115
+ );
116
+ }
@@ -0,0 +1,66 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useRef, useState } from "react";
4
+
5
+ interface ProgressProps {
6
+ value: number;
7
+ max?: number;
8
+ label?: string;
9
+ showValue?: boolean;
10
+ className?: string;
11
+ }
12
+
13
+ export function Progress({
14
+ value,
15
+ max = 100,
16
+ label,
17
+ showValue = false,
18
+ className = "",
19
+ }: ProgressProps) {
20
+ const pct = Math.min(Math.max((value / max) * 100, 0), 100);
21
+ const [displayed, setDisplayed] = useState(0);
22
+ const t = useRef<ReturnType<typeof setTimeout> | null>(null);
23
+
24
+ useEffect(() => {
25
+ t.current = setTimeout(() => setDisplayed(pct), 80);
26
+ return () => { if (t.current) clearTimeout(t.current); };
27
+ }, [pct]);
28
+
29
+ return (
30
+ <div className={["flex flex-col gap-2", className].join(" ")}>
31
+ {(label || showValue) && (
32
+ <div className="flex items-center justify-between gap-4">
33
+ {label && (
34
+ <span className="text-[13px] font-medium text-ink-label truncate">{label}</span>
35
+ )}
36
+ {showValue && (
37
+ <span aria-hidden className="text-[12px] tabular-nums text-ink-muted shrink-0">
38
+ {Math.round(pct)}%
39
+ </span>
40
+ )}
41
+ </div>
42
+ )}
43
+ <div
44
+ className="w-full h-2 rounded-full p-px overflow-hidden"
45
+ role="progressbar"
46
+ aria-label={label}
47
+ aria-valuenow={value}
48
+ aria-valuemin={0}
49
+ aria-valuemax={max}
50
+ style={{ background: "var(--well)", boxShadow: "var(--well-shadow)" }}
51
+ >
52
+ <div
53
+ className="h-full rounded-full relative overflow-hidden"
54
+ style={{
55
+ width: `${displayed}%`,
56
+ /* Never collapses below a pill, so 1% still reads as started */
57
+ minWidth: displayed > 0 ? 6 : 0,
58
+ background: "var(--face-inverse)",
59
+ boxShadow: "var(--fill-shadow)",
60
+ transition: "width 700ms var(--ease-out-soft)",
61
+ }}
62
+ />
63
+ </div>
64
+ </div>
65
+ );
66
+ }
@@ -0,0 +1,93 @@
1
+ import React from "react";
2
+ import { CardShell } from "./CardShell";
3
+
4
+ interface QuoteCardProps {
5
+ quote: string;
6
+ author: string;
7
+ role?: string;
8
+ avatarSrc?: string;
9
+ theme?: "light" | "dark";
10
+ className?: string;
11
+ }
12
+
13
+ /* Built on CardShell: the quote owns the face; who said it sits on the rail,
14
+ so a stretched card grows the face instead of opening a gap mid-card. */
15
+ export function QuoteCard({
16
+ quote,
17
+ author,
18
+ role,
19
+ avatarSrc,
20
+ theme = "light",
21
+ className = "",
22
+ }: QuoteCardProps) {
23
+ const dark = theme === "dark";
24
+ // Handles like "@name" shouldn't render an "@" monogram
25
+ const initial = (author.match(/[\p{L}\p{N}]/u)?.[0] ?? "?").toLowerCase();
26
+
27
+ const rail = (
28
+ <figcaption className="flex items-center gap-2.5 min-w-0">
29
+ {avatarSrc ? (
30
+ // eslint-disable-next-line @next/next/no-img-element
31
+ <img
32
+ src={avatarSrc}
33
+ alt=""
34
+ loading="lazy"
35
+ className="w-7 h-7 rounded-full object-cover shrink-0"
36
+ style={{ boxShadow: dark ? "0 0 0 0.5px var(--photo-edge-dark)" : "0 0 0 0.5px var(--photo-edge)" }}
37
+ />
38
+ ) : (
39
+ <span
40
+ aria-hidden
41
+ className={[
42
+ "w-7 h-7 rounded-full flex items-center justify-center text-[11.5px] font-semibold shrink-0",
43
+ dark ? "text-white" : "text-ink-label",
44
+ ].join(" ")}
45
+ style={{
46
+ background: dark ? "var(--tile-dark)" : "var(--tile)",
47
+ boxShadow: dark ? "var(--tile-dark-shadow)" : "var(--tile-shadow)",
48
+ }}
49
+ >
50
+ {initial}
51
+ </span>
52
+ )}
53
+ <span className="min-w-0 truncate">
54
+ <cite
55
+ className={[
56
+ "not-italic font-semibold tracking-[-0.008em]",
57
+ dark ? "text-white" : "text-ink",
58
+ ].join(" ")}
59
+ >
60
+ {author}
61
+ </cite>
62
+ {role && <span> · {role}</span>}
63
+ </span>
64
+ </figcaption>
65
+ );
66
+
67
+ return (
68
+ <CardShell as="figure" theme={theme} className={className} rail={rail} railKind="text">
69
+ <div className="flex-1 flex flex-col p-8">
70
+ <svg
71
+ aria-hidden
72
+ width="22"
73
+ height="17"
74
+ viewBox="0 0 56 44"
75
+ className={["shrink-0 mb-auto", dark ? "text-white/25" : "text-ink-ghost"].join(" ")}
76
+ >
77
+ <path
78
+ fill="currentColor"
79
+ d="M0 44V27.5C0 12.6 7.4 3.4 22.2 0l2.6 6.2C16.9 8.7 13 13.7 12.6 21H24v23H0zm32 0V27.5C32 12.6 39.4 3.4 54.2 0l2.6 6.2C48.9 8.7 45 13.7 44.6 21H56v23H32z"
80
+ />
81
+ </svg>
82
+ <blockquote
83
+ className={[
84
+ "m-0 pt-8 text-[19px] leading-[1.55] tracking-[-0.015em] text-pretty",
85
+ dark ? "text-neutral-50" : "text-ink",
86
+ ].join(" ")}
87
+ >
88
+ {quote}
89
+ </blockquote>
90
+ </div>
91
+ </CardShell>
92
+ );
93
+ }
@@ -0,0 +1,205 @@
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
+
37
+ /* Where the face sits over a well, in the group's own coordinates. Read
38
+ straight off the DOM, so setting it from an effect is a measurement,
39
+ not derived state */
40
+ function wellBox(group: HTMLElement | null, el: HTMLElement | null, dir: Blob["dir"]): Blob | null {
41
+ if (!group || !el) return null;
42
+ return {
43
+ top: el.offsetTop,
44
+ bottom: group.clientHeight - el.offsetTop - el.offsetHeight,
45
+ left: el.offsetLeft,
46
+ size: el.offsetWidth,
47
+ dir,
48
+ };
49
+ }
50
+
51
+ export function RadioGroup({
52
+ options,
53
+ value,
54
+ defaultValue = "",
55
+ onChange,
56
+ name,
57
+ className = "",
58
+ }: RadioGroupProps) {
59
+ const [internal, setInternal] = useState(defaultValue);
60
+ const selected = value !== undefined ? value : internal;
61
+ const uid = useId();
62
+ const refs = useRef<(HTMLButtonElement | null)[]>([]);
63
+ const groupRef = useRef<HTMLDivElement>(null);
64
+ const prevIndex = useRef(-1);
65
+ const [blob, setBlob] = useState<Blob | null>(null);
66
+
67
+ const select = (val: string, disabled?: boolean) => {
68
+ if (disabled) return;
69
+ setInternal(val);
70
+ onChange?.(val);
71
+ };
72
+
73
+ const enabled = options.map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0);
74
+ const selectedIndex = options.findIndex((o) => o.value === selected);
75
+ const tabStop = selectedIndex >= 0 ? selectedIndex : enabled[0];
76
+
77
+ const measure = (dir: Blob["dir"]) =>
78
+ setBlob(wellBox(groupRef.current, refs.current[prevIndex.current], dir));
79
+
80
+ useLayoutEffect(() => {
81
+ const prev = prevIndex.current;
82
+ prevIndex.current = selectedIndex;
83
+ measure(prev < 0 || selectedIndex < 0 ? 0 : selectedIndex > prev ? 1 : selectedIndex < prev ? -1 : 0);
84
+ }, [selectedIndex]);
85
+
86
+ // Rewrapped descriptions move the wells; follow them without animating.
87
+ // An observer reports once on attach — that one is skipped, or it would
88
+ // cut short a move already under way.
89
+ useEffect(() => {
90
+ const group = groupRef.current;
91
+ if (!group) return;
92
+ let attached = false;
93
+ const ro = new ResizeObserver(() => {
94
+ if (attached) measure(0);
95
+ attached = true;
96
+ });
97
+ ro.observe(group);
98
+ return () => ro.disconnect();
99
+ }, []);
100
+
101
+ const move = (from: number, step: 1 | -1) => {
102
+ const at = enabled.indexOf(from);
103
+ const next = enabled[(at + step + enabled.length) % enabled.length];
104
+ refs.current[next]?.focus();
105
+ select(options[next].value);
106
+ };
107
+
108
+ return (
109
+ <div
110
+ ref={groupRef}
111
+ role="radiogroup"
112
+ aria-label={name}
113
+ className={["relative flex flex-col gap-1", className].join(" ")}
114
+ >
115
+ {options.map((opt, i) => {
116
+ const active = selected === opt.value;
117
+ return (
118
+ <div
119
+ key={opt.value}
120
+ onClick={() => select(opt.value, opt.disabled)}
121
+ className={[
122
+ "flex items-center justify-between gap-4 select-none py-1.5",
123
+ opt.disabled ? "opacity-40 pointer-events-none" : "cursor-pointer group/radio",
124
+ ].join(" ")}
125
+ >
126
+ <div className="flex flex-col gap-0.5 min-w-0">
127
+ <span id={`${uid}-${i}-label`} className="text-[13px] font-medium text-ink leading-snug">
128
+ {opt.label}
129
+ </span>
130
+ {opt.description && (
131
+ <span id={`${uid}-${i}-desc`} className="text-[12px] text-ink-muted leading-relaxed">
132
+ {opt.description}
133
+ </span>
134
+ )}
135
+ </div>
136
+
137
+ {/* Clicks bubble to the row, so the control itself has no onClick */}
138
+ <button
139
+ ref={(el) => {
140
+ refs.current[i] = el;
141
+ }}
142
+ type="button"
143
+ role="radio"
144
+ aria-checked={active}
145
+ aria-labelledby={`${uid}-${i}-label`}
146
+ aria-describedby={opt.description ? `${uid}-${i}-desc` : undefined}
147
+ disabled={opt.disabled}
148
+ tabIndex={i === tabStop ? 0 : -1}
149
+ onKeyDown={(e) => {
150
+ if (e.key === "ArrowDown" || e.key === "ArrowRight") {
151
+ e.preventDefault();
152
+ move(i, 1);
153
+ } else if (e.key === "ArrowUp" || e.key === "ArrowLeft") {
154
+ e.preventDefault();
155
+ move(i, -1);
156
+ }
157
+ }}
158
+ className={[
159
+ "shrink-0 w-[22px] h-[22px] rounded-full cursor-pointer",
160
+ "transition-[box-shadow] duration-200 ease-out",
161
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink",
162
+ active ? "" : "group-hover/radio:[box-shadow:var(--field-shadow-hover)]",
163
+ ].join(" ")}
164
+ style={{ background: "var(--field)", boxShadow: "var(--field-shadow)" }}
165
+ />
166
+ </div>
167
+ );
168
+ })}
169
+
170
+ {blob && (
171
+ <>
172
+ <span
173
+ aria-hidden
174
+ className="pointer-events-none absolute rounded-full"
175
+ style={{
176
+ top: blob.top,
177
+ bottom: blob.bottom,
178
+ left: blob.left,
179
+ width: blob.size,
180
+ background: "var(--face-inverse)",
181
+ boxShadow: "var(--face-inverse-shadow)",
182
+ transition:
183
+ blob.dir === 0
184
+ ? "none"
185
+ : blob.dir > 0
186
+ ? `top ${TRAIL}, bottom ${LEAD}`
187
+ : `top ${LEAD}, bottom ${TRAIL}`,
188
+ }}
189
+ />
190
+ {/* The dot rides the trailing edge's spring, arriving as it settles */}
191
+ <span
192
+ aria-hidden
193
+ className="pointer-events-none absolute w-2 h-2 rounded-full bg-ink-inverse"
194
+ style={{
195
+ top: blob.top + (blob.size - 8) / 2,
196
+ left: blob.left + (blob.size - 8) / 2,
197
+ boxShadow: "var(--dot-shadow)",
198
+ transition: blob.dir === 0 ? "none" : `top ${TRAIL}`,
199
+ }}
200
+ />
201
+ </>
202
+ )}
203
+ </div>
204
+ );
205
+ }