@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,123 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+
5
+ interface Column<T> {
6
+ key: keyof T | string;
7
+ label: string;
8
+ align?: "left" | "right" | "center";
9
+ render?: (value: unknown, row: T) => React.ReactNode;
10
+ width?: string;
11
+ }
12
+
13
+ interface TableProps<T extends Record<string, unknown>> {
14
+ columns: Column<T>[];
15
+ rows: T[];
16
+ title?: string;
17
+ description?: string;
18
+ className?: string;
19
+ onRowClick?: (row: T) => void;
20
+ }
21
+
22
+ const alignClass = (align?: "left" | "right" | "center") =>
23
+ align === "right" ? "text-right" : align === "center" ? "text-center" : "text-left";
24
+
25
+ /* Title and column headers live in the bezel; rows sit on the raised face. */
26
+ export function Table<T extends Record<string, unknown>>({
27
+ columns,
28
+ rows,
29
+ title,
30
+ description,
31
+ className = "",
32
+ onRowClick,
33
+ }: TableProps<T>) {
34
+ return (
35
+ <div
36
+ className={["w-full rounded-[18px] p-1.5", title || description ? "pt-0" : "", className].join(" ")}
37
+ style={{ background: "var(--panel-bezel)", boxShadow: "var(--panel-shadow)" }}
38
+ >
39
+ {(title || description) && (
40
+ <div className="px-3.5 pt-3.5 pb-3">
41
+ {title && <p className="text-[13px] font-semibold text-ink tracking-[-0.01em]">{title}</p>}
42
+ {description && <p className="text-[12px] text-ink-muted mt-0.5">{description}</p>}
43
+ </div>
44
+ )}
45
+
46
+ <div
47
+ className="bg-panel-face rounded-[13px] overflow-x-auto scroll-thin"
48
+ style={{ boxShadow: "var(--panel-face-shadow)" }}
49
+ >
50
+ <table className="w-full text-left border-collapse">
51
+ {title && <caption className="sr-only">{title}</caption>}
52
+ <thead>
53
+ <tr className="border-b border-line">
54
+ {columns.map((col) => (
55
+ <th
56
+ key={String(col.key)}
57
+ scope="col"
58
+ style={col.width ? { width: col.width } : undefined}
59
+ className={[
60
+ "px-4 pt-3 pb-2.5 text-[12px] font-medium text-ink-muted lowercase whitespace-nowrap",
61
+ alignClass(col.align),
62
+ ].join(" ")}
63
+ >
64
+ {col.label}
65
+ </th>
66
+ ))}
67
+ </tr>
68
+ </thead>
69
+ <tbody>
70
+ {rows.map((row, ri) => (
71
+ <tr
72
+ key={ri}
73
+ onClick={() => onRowClick?.(row)}
74
+ // Clickable rows are reachable and operable from the keyboard too
75
+ tabIndex={onRowClick ? 0 : undefined}
76
+ onKeyDown={
77
+ onRowClick
78
+ ? (e) => {
79
+ if (e.key === "Enter" || e.key === " ") {
80
+ e.preventDefault();
81
+ onRowClick(row);
82
+ }
83
+ }
84
+ : undefined
85
+ }
86
+ className={[
87
+ "border-b border-line last:border-0 transition-colors duration-100",
88
+ onRowClick
89
+ ? "cursor-pointer hover:bg-surface-hover focus-visible:bg-surface-hover outline-none focus-visible:shadow-[inset_2px_0_0_var(--ink)]"
90
+ : "",
91
+ ].join(" ")}
92
+ >
93
+ {columns.map((col, ci) => {
94
+ const val = row[col.key as keyof T] as unknown;
95
+ return (
96
+ <td
97
+ key={String(col.key)}
98
+ className={[
99
+ "px-4 py-3 text-[13px]",
100
+ ci === 0 ? "font-medium text-ink" : "text-ink-body tabular-nums",
101
+ alignClass(col.align),
102
+ ].join(" ")}
103
+ >
104
+ {col.render ? col.render(val, row) : String(val ?? "")}
105
+ </td>
106
+ );
107
+ })}
108
+ </tr>
109
+ ))}
110
+ {rows.length === 0 && (
111
+ <tr>
112
+ <td colSpan={columns.length} className="px-5 py-12 text-center">
113
+ <p className="text-[13px] font-medium text-ink-label">nothing here yet</p>
114
+ <p className="text-[12px] text-ink-muted mt-0.5">rows will show up here once there are some.</p>
115
+ </td>
116
+ </tr>
117
+ )}
118
+ </tbody>
119
+ </table>
120
+ </div>
121
+ </div>
122
+ );
123
+ }
@@ -0,0 +1,116 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
4
+
5
+ export interface TocItem {
6
+ key: string;
7
+ label: string;
8
+ level: 1 | 2 | 3;
9
+ }
10
+
11
+ interface TableOfContentsProps {
12
+ items: TocItem[];
13
+ activeKey?: string;
14
+ defaultActiveKey?: string;
15
+ onSelect?: (key: string) => void;
16
+ className?: string;
17
+ }
18
+
19
+ const indent: Record<1 | 2 | 3, number> = { 1: 16, 2: 28, 3: 40 };
20
+
21
+ /* A hairline track runs down the left edge; a slim dark marker glides
22
+ along it to the active heading. Nothing else moves — the active label
23
+ just darkens. */
24
+ export function TableOfContents({
25
+ items,
26
+ activeKey,
27
+ defaultActiveKey,
28
+ onSelect,
29
+ className = "",
30
+ }: TableOfContentsProps) {
31
+ const [internal, setInternal] = useState(defaultActiveKey ?? items[0]?.key ?? "");
32
+ const current = activeKey !== undefined ? activeKey : internal;
33
+ const refs = useRef<Record<string, HTMLButtonElement | null>>({});
34
+ const [marker, setMarker] = useState<{ top: number; height: number } | null>(null);
35
+ const [ready, setReady] = useState(false);
36
+
37
+ const measure = () => {
38
+ const el = refs.current[current];
39
+ setMarker(el ? { top: el.offsetTop, height: el.offsetHeight } : null);
40
+ };
41
+
42
+ useLayoutEffect(() => {
43
+ measure();
44
+ // eslint-disable-next-line react-hooks/exhaustive-deps
45
+ }, [current]);
46
+
47
+ useEffect(() => {
48
+ // Only animate moves after the first placement
49
+ const id = requestAnimationFrame(() => setReady(true));
50
+ return () => cancelAnimationFrame(id);
51
+ }, []);
52
+
53
+ const handleSelect = (key: string) => {
54
+ setInternal(key);
55
+ onSelect?.(key);
56
+ };
57
+
58
+ const slide = ready ? "top 360ms var(--ease-out-soft), height 360ms var(--ease-out-soft)" : "none";
59
+
60
+ return (
61
+ <nav
62
+ aria-label="On this page"
63
+ className={["rounded-[18px] p-1.5", className].filter(Boolean).join(" ")}
64
+ style={{ background: "var(--panel-bezel)", boxShadow: "var(--panel-shadow)" }}
65
+ >
66
+ <div
67
+ className="bg-panel-face rounded-[13px] px-5 pt-4 pb-4"
68
+ style={{ boxShadow: "var(--panel-face-shadow)" }}
69
+ >
70
+ <p aria-hidden className="text-[12px] font-medium text-ink-faint mb-3">
71
+ on this page
72
+ </p>
73
+
74
+ <div className="relative">
75
+ <span aria-hidden className="absolute left-0 top-0 bottom-0 w-px bg-ink/[0.08]" />
76
+ {marker && (
77
+ <span
78
+ aria-hidden
79
+ className="absolute -left-[0.5px] w-[2px] rounded-full bg-ink"
80
+ style={{ top: marker.top + 6, height: marker.height - 12, transition: slide }}
81
+ />
82
+ )}
83
+
84
+ {items.map((item) => {
85
+ const isActive = current === item.key;
86
+ return (
87
+ <button
88
+ key={item.key}
89
+ ref={(el) => {
90
+ refs.current[item.key] = el;
91
+ }}
92
+ type="button"
93
+ onClick={() => handleSelect(item.key)}
94
+ aria-current={isActive ? "location" : undefined}
95
+ className={[
96
+ // Weight stays fixed so the active item doesn't reflow its line
97
+ "relative block w-full text-left py-1.5 text-[13px] leading-5 rounded-[6px] cursor-pointer",
98
+ "transition-colors duration-200 outline-none",
99
+ "focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ink",
100
+ isActive
101
+ ? "text-ink font-medium"
102
+ : item.level === 1
103
+ ? "text-ink-body hover:text-ink"
104
+ : "text-ink-faint hover:text-ink-label",
105
+ ].join(" ")}
106
+ style={{ paddingLeft: indent[item.level] }}
107
+ >
108
+ {item.label}
109
+ </button>
110
+ );
111
+ })}
112
+ </div>
113
+ </div>
114
+ </nav>
115
+ );
116
+ }
@@ -0,0 +1,136 @@
1
+ "use client";
2
+
3
+ import React, { useState, useRef, useEffect, useLayoutEffect, useId } from "react";
4
+
5
+ interface Tab {
6
+ key: string;
7
+ label: string;
8
+ content?: React.ReactNode;
9
+ }
10
+
11
+ interface TabsProps {
12
+ tabs: Tab[];
13
+ defaultTab?: string;
14
+ onChange?: (key: string) => void;
15
+ className?: string;
16
+ }
17
+
18
+ /* A sunken well holding a raised thumb — the same segmented control as
19
+ Pagination. One Tab stop; arrows move between tabs and select them. */
20
+ export function Tabs({ tabs, defaultTab, onChange, className = "" }: TabsProps) {
21
+ const [active, setActive] = useState(defaultTab ?? tabs[0]?.key ?? "");
22
+ const [direction, setDirection] = useState<"left" | "right">("right");
23
+ const [pill, setPill] = useState({ left: 0, width: 0, ready: false });
24
+ const listRef = useRef<HTMLDivElement>(null);
25
+ const btnRefs = useRef<Record<string, HTMLButtonElement | null>>({});
26
+ const uid = useId();
27
+
28
+ const measurePill = (key: string) => {
29
+ const btn = btnRefs.current[key];
30
+ const list = listRef.current;
31
+ if (!btn || !list) return;
32
+ const lr = list.getBoundingClientRect();
33
+ const br = btn.getBoundingClientRect();
34
+ setPill({ left: br.left - lr.left, width: br.width, ready: true });
35
+ };
36
+
37
+ // Before paint, so the thumb is never seen out of place
38
+ useLayoutEffect(() => { measurePill(active); }, [active]);
39
+
40
+ useEffect(() => {
41
+ const ro = new ResizeObserver(() => measurePill(active));
42
+ if (listRef.current) ro.observe(listRef.current);
43
+ return () => ro.disconnect();
44
+ }, [active]);
45
+
46
+ const handleSelect = (key: string) => {
47
+ const prevIndex = tabs.findIndex((t) => t.key === active);
48
+ const nextIndex = tabs.findIndex((t) => t.key === key);
49
+ setDirection(nextIndex > prevIndex ? "right" : "left");
50
+ setActive(key);
51
+ onChange?.(key);
52
+ };
53
+
54
+ const onKeyDown = (e: React.KeyboardEvent) => {
55
+ const i = tabs.findIndex((t) => t.key === active);
56
+ let next = -1;
57
+ if (e.key === "ArrowRight") next = (i + 1) % tabs.length;
58
+ if (e.key === "ArrowLeft") next = (i - 1 + tabs.length) % tabs.length;
59
+ if (e.key === "Home") next = 0;
60
+ if (e.key === "End") next = tabs.length - 1;
61
+ if (next < 0) return;
62
+ e.preventDefault();
63
+ const key = tabs[next].key;
64
+ handleSelect(key);
65
+ btnRefs.current[key]?.focus();
66
+ };
67
+
68
+ const activeTab = tabs.find((t) => t.key === active);
69
+
70
+ return (
71
+ <div className={["flex flex-col gap-4", className].join(" ")}>
72
+ <div
73
+ ref={listRef}
74
+ role="tablist"
75
+ onKeyDown={onKeyDown}
76
+ className="relative flex w-full sm:inline-flex sm:w-auto sm:self-start items-center rounded-[13px] p-1"
77
+ style={{ background: "var(--well)", boxShadow: "var(--well-shadow)" }}
78
+ >
79
+ {pill.ready && (
80
+ <span
81
+ aria-hidden
82
+ className="absolute top-1 bottom-1 rounded-[9px] pointer-events-none"
83
+ style={{
84
+ left: pill.left,
85
+ width: pill.width,
86
+ background: "var(--face)",
87
+ boxShadow: "var(--thumb-shadow)",
88
+ transition: "left 300ms var(--ease-spring), width 300ms var(--ease-spring)",
89
+ }}
90
+ />
91
+ )}
92
+
93
+ {tabs.map((tab) => {
94
+ const isActive = active === tab.key;
95
+ return (
96
+ <button
97
+ key={tab.key}
98
+ ref={(el) => { btnRefs.current[tab.key] = el; }}
99
+ id={`${uid}-tab-${tab.key}`}
100
+ role="tab"
101
+ type="button"
102
+ aria-selected={isActive}
103
+ aria-controls={tab.content ? `${uid}-panel-${tab.key}` : undefined}
104
+ tabIndex={isActive ? 0 : -1}
105
+ onClick={() => handleSelect(tab.key)}
106
+ className={[
107
+ "relative z-10 flex-1 sm:flex-none px-3.5 py-2 rounded-[9px] text-[13px] font-medium text-center whitespace-nowrap cursor-pointer",
108
+ "transition-colors duration-150",
109
+ "focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ink",
110
+ isActive ? "text-ink" : "text-ink-muted hover:text-ink-soft",
111
+ ].join(" ")}
112
+ >
113
+ {tab.label}
114
+ </button>
115
+ );
116
+ })}
117
+ </div>
118
+
119
+ {activeTab?.content && (
120
+ <div
121
+ key={active}
122
+ id={`${uid}-panel-${active}`}
123
+ role="tabpanel"
124
+ aria-labelledby={`${uid}-tab-${active}`}
125
+ tabIndex={0}
126
+ className={[
127
+ "text-[13px] text-ink-body leading-relaxed rounded-lg focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ink",
128
+ direction === "right" ? "animate-tab-slide-right" : "animate-tab-slide-left",
129
+ ].join(" ")}
130
+ >
131
+ {activeTab.content}
132
+ </div>
133
+ )}
134
+ </div>
135
+ );
136
+ }
@@ -0,0 +1,191 @@
1
+ "use client";
2
+
3
+ import React, { useState, useRef, useEffect, useId } from "react";
4
+
5
+ interface TagInputProps {
6
+ value?: string[];
7
+ defaultValue?: string[];
8
+ onChange?: (tags: string[]) => void;
9
+ placeholder?: string;
10
+ label?: string;
11
+ hint?: string;
12
+ maxTags?: number;
13
+ fullWidth?: boolean;
14
+ className?: string;
15
+ }
16
+
17
+ interface AnimatedTag {
18
+ text: string;
19
+ state: "entering" | "visible" | "exiting";
20
+ }
21
+
22
+ export function TagInput({
23
+ value,
24
+ defaultValue = [],
25
+ onChange,
26
+ placeholder = "add a tag…",
27
+ label,
28
+ hint,
29
+ maxTags,
30
+ fullWidth = false,
31
+ className = "",
32
+ }: TagInputProps) {
33
+ const [internal, setInternal] = useState<string[]>(defaultValue);
34
+ const [input, setInput] = useState("");
35
+ const [animTags, setAnimTags] = useState<AnimatedTag[]>(() =>
36
+ (value ?? defaultValue).map((t) => ({ text: t, state: "visible" as const }))
37
+ );
38
+ const inputRef = useRef<HTMLInputElement>(null);
39
+ const uid = useId();
40
+ const prevValueRef = useRef<string[]>(value ?? defaultValue);
41
+
42
+ const tags = value !== undefined ? value : internal;
43
+
44
+ useEffect(() => {
45
+ const prev = prevValueRef.current;
46
+ const next = tags;
47
+
48
+ const added = next.filter((t) => !prev.includes(t));
49
+ const removed = prev.filter((t) => !next.includes(t));
50
+
51
+ if (added.length === 0 && removed.length === 0) return;
52
+
53
+ setAnimTags((current) => {
54
+ const updated = current.map((at) =>
55
+ removed.includes(at.text) ? { ...at, state: "exiting" as const } : at
56
+ );
57
+ added.forEach((t) => {
58
+ if (!updated.find((at) => at.text === t)) {
59
+ updated.push({ text: t, state: "entering" });
60
+ }
61
+ });
62
+ return updated;
63
+ });
64
+
65
+ if (added.length > 0) {
66
+ requestAnimationFrame(() => {
67
+ setAnimTags((current) =>
68
+ current.map((at) =>
69
+ at.state === "entering" ? { ...at, state: "visible" } : at
70
+ )
71
+ );
72
+ });
73
+ }
74
+
75
+ if (removed.length > 0) {
76
+ setTimeout(() => {
77
+ setAnimTags((current) => current.filter((at) => at.state !== "exiting"));
78
+ }, 180);
79
+ }
80
+
81
+ prevValueRef.current = next;
82
+ }, [tags]);
83
+
84
+ const update = (next: string[]) => {
85
+ setInternal(next);
86
+ onChange?.(next);
87
+ };
88
+
89
+ const addTag = (raw: string) => {
90
+ const tag = raw.trim();
91
+ if (!tag || tags.includes(tag)) return;
92
+ if (maxTags && tags.length >= maxTags) return;
93
+ update([...tags, tag]);
94
+ setInput("");
95
+ };
96
+
97
+ const removeTag = (index: number) => {
98
+ const tagText = tags[index];
99
+ if (!tagText) return;
100
+ setAnimTags((current) =>
101
+ current.map((at) =>
102
+ at.text === tagText ? { ...at, state: "exiting" as const } : at
103
+ )
104
+ );
105
+ setTimeout(() => {
106
+ update(tags.filter((_, i) => i !== index));
107
+ }, 180);
108
+ };
109
+
110
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
111
+ if (e.key === "Enter" || e.key === ",") {
112
+ e.preventDefault();
113
+ addTag(input);
114
+ }
115
+ if (e.key === "Backspace" && !input && tags.length > 0) {
116
+ removeTag(tags.length - 1);
117
+ }
118
+ };
119
+
120
+ return (
121
+ <div className={["flex flex-col gap-1.5", fullWidth ? "w-full" : ""].join(" ")}>
122
+ {label && (
123
+ <label htmlFor={`${uid}-input`} className="text-[12.5px] font-medium text-ink-label tracking-[-0.005em]">
124
+ {label}
125
+ </label>
126
+ )}
127
+ <div
128
+ onClick={() => inputRef.current?.focus()}
129
+ className={[
130
+ "flex flex-wrap items-center gap-1.5 rounded-xl px-3 py-2.5",
131
+ "transition-[box-shadow,background] duration-200 ease-out cursor-text",
132
+ "[background:var(--field)] [box-shadow:var(--field-shadow)]",
133
+ "hover:[background:var(--field-hover)] hover:[box-shadow:var(--field-shadow-hover)]",
134
+ "focus-within:[background:var(--field-focus)] focus-within:[box-shadow:var(--field-shadow-focus)]",
135
+ fullWidth ? "w-full" : "",
136
+ className,
137
+ ]
138
+ .filter(Boolean)
139
+ .join(" ")}
140
+ >
141
+ {animTags.map((at) => (
142
+ <span
143
+ key={at.text}
144
+ className={[
145
+ "inline-flex items-center gap-1 pl-2 pr-1 py-0.5 rounded-[7px] text-[11.5px] font-medium text-ink-label select-none",
146
+ "transition-all duration-180 ease-[cubic-bezier(0.34,1.4,0.64,1)]",
147
+ at.state === "entering" ? "opacity-0 scale-75" : "",
148
+ at.state === "visible" ? "opacity-100 scale-100" : "",
149
+ at.state === "exiting" ? "opacity-0 scale-75" : "",
150
+ ].join(" ")}
151
+ style={{
152
+ /* Tags are small raised faces sitting in the sunken field */
153
+ background: "var(--face)",
154
+ boxShadow: "var(--face-shadow)",
155
+ }}
156
+ >
157
+ {at.text}
158
+ <button
159
+ type="button"
160
+ onClick={(e) => {
161
+ e.stopPropagation();
162
+ const idx = tags.indexOf(at.text);
163
+ if (idx !== -1) removeTag(idx);
164
+ }}
165
+ aria-label={`Remove ${at.text}`}
166
+ className="w-4 h-4 inline-flex items-center justify-center rounded-[5px] text-ink-faint hover:text-ink hover:bg-ink/[0.06] cursor-pointer transition-colors"
167
+ >
168
+ <svg width="8" height="8" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
169
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
170
+ </svg>
171
+ </button>
172
+ </span>
173
+ ))}
174
+ <input
175
+ ref={inputRef}
176
+ id={`${uid}-input`}
177
+ type="text"
178
+ value={input}
179
+ onChange={(e) => setInput(e.target.value)}
180
+ onKeyDown={handleKeyDown}
181
+ onBlur={() => { if (input) addTag(input); }}
182
+ placeholder={tags.length === 0 ? placeholder : ""}
183
+ aria-label={label ? undefined : placeholder}
184
+ aria-describedby={hint ? `${uid}-hint` : undefined}
185
+ className="flex-1 min-w-[80px] bg-transparent text-[13.5px] text-ink placeholder:text-ink-faint outline-none py-0.5"
186
+ />
187
+ </div>
188
+ {hint && <p id={`${uid}-hint`} className="text-[11.5px] text-ink-muted mt-0.5">{hint}</p>}
189
+ </div>
190
+ );
191
+ }
@@ -0,0 +1,84 @@
1
+ "use client";
2
+
3
+ import React, { useRef, useCallback, useEffect } from "react";
4
+
5
+ interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
6
+ label?: string;
7
+ hint?: string;
8
+ error?: string;
9
+ fullWidth?: boolean;
10
+ autoResize?: boolean;
11
+ }
12
+
13
+ export function Textarea({
14
+ label,
15
+ hint,
16
+ error,
17
+ fullWidth = false,
18
+ autoResize = false,
19
+ className = "",
20
+ id,
21
+ rows = 3,
22
+ onChange,
23
+ ...rest
24
+ }: TextareaProps) {
25
+ const textareaId = id ?? label?.toLowerCase().replace(/\s+/g, "-");
26
+ const messageId = textareaId && (error || hint) ? `${textareaId}-message` : undefined;
27
+ const ref = useRef<HTMLTextAreaElement>(null);
28
+
29
+ const resize = useCallback(() => {
30
+ if (!autoResize || !ref.current) return;
31
+ ref.current.style.height = "auto";
32
+ ref.current.style.height = ref.current.scrollHeight + "px";
33
+ }, [autoResize]);
34
+
35
+ useEffect(() => { resize(); }, [resize]);
36
+
37
+ const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
38
+ resize();
39
+ onChange?.(e);
40
+ };
41
+
42
+ return (
43
+ <div className={["group/field flex flex-col gap-1.5", fullWidth ? "w-full" : ""].join(" ")}>
44
+ {label && (
45
+ <label
46
+ htmlFor={textareaId}
47
+ className="text-[12.5px] font-medium text-ink-label tracking-[-0.005em] transition-colors duration-200 group-focus-within/field:text-ink-strong"
48
+ >
49
+ {label}
50
+ </label>
51
+ )}
52
+ <textarea
53
+ ref={ref}
54
+ id={textareaId}
55
+ rows={rows}
56
+ onChange={handleChange}
57
+ className={[
58
+ "field rounded-xl px-4 py-3 text-[13.5px] text-ink resize-none scroll-thin",
59
+ "placeholder:text-ink-faint placeholder:transition-colors placeholder:duration-200 focus:placeholder:text-ink-ghost",
60
+ "disabled:opacity-40 disabled:cursor-not-allowed",
61
+ fullWidth ? "w-full" : "",
62
+ className,
63
+ ]
64
+ .filter(Boolean)
65
+ .join(" ")}
66
+ aria-invalid={error ? true : undefined}
67
+ aria-describedby={messageId}
68
+ {...rest}
69
+ />
70
+ {error && (
71
+ <p id={messageId} role="alert" className="text-[11.5px] text-danger font-medium flex items-center gap-1 mt-0.5">
72
+ <svg width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
73
+ <circle cx="12" cy="12" r="10" />
74
+ <path strokeLinecap="round" d="M12 8v4m0 4h.01" />
75
+ </svg>
76
+ {error}
77
+ </p>
78
+ )}
79
+ {hint && !error && (
80
+ <p id={messageId} className="text-[11.5px] text-ink-muted mt-0.5">{hint}</p>
81
+ )}
82
+ </div>
83
+ );
84
+ }