@blurrow/ui 0.2.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 (271) hide show
  1. package/README.md +48 -36
  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 +172 -5
  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 +6 -6
  199. package/dist/registry/Alert.tsx +13 -10
  200. package/dist/registry/AuthorCard.tsx +7 -7
  201. package/dist/registry/Avatar.tsx +5 -5
  202. package/dist/registry/Badge.tsx +12 -15
  203. package/dist/registry/Banner.tsx +15 -18
  204. package/dist/registry/Breadcrumb.tsx +3 -3
  205. package/dist/registry/Button.tsx +58 -69
  206. package/dist/registry/ButtonGroup.tsx +9 -9
  207. package/dist/registry/Card.tsx +15 -15
  208. package/dist/registry/CardShell.tsx +6 -6
  209. package/dist/registry/Carousel.tsx +19 -15
  210. package/dist/registry/Checkbox.tsx +14 -9
  211. package/dist/registry/CloseButton.tsx +2 -2
  212. package/dist/registry/CodeBlock.tsx +15 -15
  213. package/dist/registry/ColorPicker.tsx +404 -0
  214. package/dist/registry/Combobox.tsx +318 -0
  215. package/dist/registry/CommandPalette.tsx +31 -24
  216. package/dist/registry/ContextMenu.tsx +241 -0
  217. package/dist/registry/DataList.tsx +4 -4
  218. package/dist/registry/DatePicker.tsx +494 -0
  219. package/dist/registry/DateRangePicker.tsx +232 -0
  220. package/dist/registry/Drawer.tsx +17 -15
  221. package/dist/registry/DropdownMenu.tsx +39 -77
  222. package/dist/registry/EmptyState.tsx +8 -8
  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 +54 -43
  228. package/dist/registry/Kbd.tsx +4 -5
  229. package/dist/registry/Modal.tsx +17 -15
  230. package/dist/registry/NumberInput.tsx +183 -0
  231. package/dist/registry/OtpInput.tsx +219 -0
  232. package/dist/registry/Pagination.tsx +15 -18
  233. package/dist/registry/Popover.tsx +40 -45
  234. package/dist/registry/Progress.tsx +4 -4
  235. package/dist/registry/QuoteCard.tsx +7 -7
  236. package/dist/registry/Radio.tsx +24 -21
  237. package/dist/registry/RadioCards.tsx +247 -0
  238. package/dist/registry/ReactionBar.tsx +5 -5
  239. package/dist/registry/ReadingProgress.tsx +2 -2
  240. package/dist/registry/ReadingTime.tsx +2 -2
  241. package/dist/registry/Select.tsx +28 -53
  242. package/dist/registry/Separator.tsx +52 -0
  243. package/dist/registry/ShareButtons.tsx +2 -2
  244. package/dist/registry/Sidebar.tsx +18 -18
  245. package/dist/registry/Skeleton.tsx +1 -1
  246. package/dist/registry/Slider.tsx +229 -0
  247. package/dist/registry/Spinner.tsx +32 -0
  248. package/dist/registry/SpotlightCard.tsx +6 -6
  249. package/dist/registry/Stat.tsx +4 -4
  250. package/dist/registry/StatCard.tsx +19 -19
  251. package/dist/registry/Steps.tsx +7 -7
  252. package/dist/registry/Table.tsx +12 -10
  253. package/dist/registry/TableOfContents.tsx +8 -8
  254. package/dist/registry/Tabs.tsx +6 -6
  255. package/dist/registry/TagInput.tsx +9 -9
  256. package/dist/registry/Textarea.tsx +5 -5
  257. package/dist/registry/TimePicker.tsx +315 -0
  258. package/dist/registry/Timeline.tsx +14 -14
  259. package/dist/registry/Toast.tsx +15 -12
  260. package/dist/registry/Toggle.tsx +7 -10
  261. package/dist/registry/Tooltip.tsx +21 -73
  262. package/dist/registry/styles.css +868 -62
  263. package/dist/registry/useFloatingPanel.ts +112 -0
  264. package/dist/styles.css +868 -62
  265. package/dist/useFloatingPanel.d.ts +24 -0
  266. package/dist/useFloatingPanel.js +89 -0
  267. package/dist/useFloatingPanel.js.map +1 -0
  268. package/dist/useModalBehavior.d.ts +15 -0
  269. package/dist/useModalBehavior.js +57 -0
  270. package/dist/useModalBehavior.js.map +1 -0
  271. package/package.json +1 -1
@@ -0,0 +1,315 @@
1
+ "use client";
2
+
3
+ import React, { useId, useRef, useState } from "react";
4
+ import { ClearKey } from "./DatePicker";
5
+ import { Portal } from "./Floating";
6
+ import { FieldLabel, FieldMessage } from "./FieldMessage";
7
+ import { useFloatingPanel } from "./useFloatingPanel";
8
+
9
+ interface TimePickerProps {
10
+ /** 24-hour "HH:mm", like "14:30" */
11
+ value?: string | null;
12
+ defaultValue?: string | null;
13
+ onChange?: (time: string | null) => void;
14
+ /** Minutes between choices in the minute column */
15
+ step?: number;
16
+ /** Show 1–12 with am / pm instead of 0–23 */
17
+ hour12?: boolean;
18
+ /** Earliest and latest times, as "HH:mm" */
19
+ min?: string;
20
+ max?: string;
21
+ label?: string;
22
+ hint?: string;
23
+ error?: string;
24
+ placeholder?: string;
25
+ clearable?: boolean;
26
+ disabled?: boolean;
27
+ fullWidth?: boolean;
28
+ name?: string;
29
+ className?: string;
30
+ }
31
+
32
+ const pad = (n: number) => String(n).padStart(2, "0");
33
+ const toMinutes = (t?: string | null) => {
34
+ if (!t) return null;
35
+ const m = /^(\d{1,2}):(\d{2})$/.exec(t);
36
+ return m ? Number(m[1]) * 60 + Number(m[2]) : null;
37
+ };
38
+ const fromMinutes = (m: number) => `${pad(Math.floor(m / 60))}:${pad(m % 60)}`;
39
+
40
+ export function formatTime(t: string, hour12: boolean) {
41
+ const m = toMinutes(t);
42
+ if (m === null) return t;
43
+ const h = Math.floor(m / 60);
44
+ return hour12 ? `${h % 12 || 12}:${pad(m % 60)} ${h < 12 ? "am" : "pm"}` : fromMinutes(m);
45
+ }
46
+
47
+ type Column = "hour" | "minute" | "period";
48
+
49
+ /* A field that opens a panel of columns: hours, minutes and, for a
50
+ 12-hour clock, am / pm. The chosen cell in each is a raised key in the
51
+ inverse material. Arrows move within a column and between columns;
52
+ Enter or picking a minute closes it. */
53
+ export function TimePicker({
54
+ value,
55
+ defaultValue = null,
56
+ onChange,
57
+ step = 15,
58
+ hour12 = false,
59
+ min,
60
+ max,
61
+ label,
62
+ hint,
63
+ error,
64
+ placeholder = "pick a time",
65
+ clearable = false,
66
+ disabled = false,
67
+ fullWidth = false,
68
+ name,
69
+ className = "",
70
+ }: TimePickerProps) {
71
+ const uid = useId();
72
+ const ref = useRef<HTMLDivElement>(null);
73
+ const buttonRef = useRef<HTMLButtonElement>(null);
74
+ const [internal, setInternal] = useState<string | null>(defaultValue);
75
+ const current = value !== undefined ? value : internal;
76
+ const total = toMinutes(current);
77
+ const { open, mounted, toggle, close, setFloating, panelStyle } = useFloatingPanel(ref, {
78
+ onEscape: () => buttonRef.current?.focus(),
79
+ });
80
+ const messageId = error || hint ? `${uid}-message` : undefined;
81
+
82
+ const lo = toMinutes(min) ?? 0;
83
+ const hi = toMinutes(max) ?? 24 * 60 - 1;
84
+ const hour = total === null ? null : Math.floor(total / 60);
85
+ const minute = total === null ? null : total % 60;
86
+
87
+ const set = (t: string | null) => {
88
+ setInternal(t);
89
+ onChange?.(t);
90
+ };
91
+
92
+ /* Picks a time, nudging the minutes into range when the hour changes */
93
+ const choose = (h: number, m: number) => {
94
+ let t = h * 60 + m;
95
+ if (t < lo) t = lo;
96
+ if (t > hi) t = hi;
97
+ set(fromMinutes(t));
98
+ };
99
+
100
+ const hours = hour12 ? Array.from({ length: 12 }, (_, i) => (i === 0 ? 12 : i)) : Array.from({ length: 24 }, (_, i) => i);
101
+ const minutes = Array.from({ length: Math.ceil(60 / step) }, (_, i) => i * step).filter((m) => m < 60);
102
+ const isPm = hour !== null && hour >= 12;
103
+ const to24 = (h12: number, pm: boolean) => (h12 % 12) + (pm ? 12 : 0);
104
+
105
+ const hourOff = (h24: number) => h24 * 60 + 59 < lo || h24 * 60 > hi;
106
+ const minuteOff = (m: number) => hour !== null && (hour * 60 + m < lo || hour * 60 + m > hi);
107
+
108
+ type Cell = { key: string; label: string; selected: boolean; off: boolean; pick: () => void };
109
+ const columns: { id: Column; label: string; cells: Cell[] }[] = [
110
+ {
111
+ id: "hour",
112
+ label: "hour",
113
+ cells: hours.map((h) => {
114
+ const h24 = hour12 ? to24(h, isPm) : h;
115
+ return {
116
+ key: `h${h}`,
117
+ label: hour12 ? String(h) : pad(h),
118
+ selected: hour === h24,
119
+ off: hourOff(h24),
120
+ pick: () => choose(h24, minute ?? 0),
121
+ };
122
+ }),
123
+ },
124
+ {
125
+ id: "minute",
126
+ label: "minute",
127
+ cells: minutes.map((m) => ({
128
+ key: `m${m}`,
129
+ label: pad(m),
130
+ selected: minute === m,
131
+ off: minuteOff(m),
132
+ pick: () => {
133
+ choose(hour ?? Math.floor(lo / 60), m);
134
+ close();
135
+ buttonRef.current?.focus();
136
+ },
137
+ })),
138
+ },
139
+ ];
140
+ if (hour12) {
141
+ columns.push({
142
+ id: "period",
143
+ label: "am or pm",
144
+ cells: (["am", "pm"] as const).map((p) => {
145
+ const pm = p === "pm";
146
+ const h24 = to24(hour === null ? 12 : hour % 12 || 12, pm);
147
+ return {
148
+ key: p,
149
+ label: p,
150
+ selected: hour !== null && isPm === pm,
151
+ off: pm ? hi < 12 * 60 : lo >= 12 * 60,
152
+ pick: () => choose(h24, minute ?? 0),
153
+ };
154
+ }),
155
+ });
156
+ }
157
+
158
+ const colRefs = useRef<Record<string, HTMLDivElement | null>>({});
159
+
160
+ /* Each column scrolls its chosen cell into the middle when it mounts */
161
+ const attachColumn = (id: Column) => (el: HTMLDivElement | null) => {
162
+ colRefs.current[id] = el;
163
+ const sel = el?.querySelector<HTMLElement>('[aria-selected="true"]');
164
+ if (!el || el.dataset.scrolled) return;
165
+ el.dataset.scrolled = "1";
166
+ if (sel) el.scrollTop = sel.offsetTop - el.clientHeight / 2 + sel.offsetHeight / 2;
167
+ // The panel lives at the end of <body>, so focus goes to it rather than waiting for Tab
168
+ if (id === "hour") el.focus({ preventScroll: true });
169
+ };
170
+
171
+ const onColumnKey = (ci: number) => (e: React.KeyboardEvent) => {
172
+ const col = columns[ci];
173
+ const enabled = col.cells.map((c, i) => (c.off ? -1 : i)).filter((i) => i >= 0);
174
+ const at = col.cells.findIndex((c) => c.selected);
175
+ const pos = enabled.indexOf(at);
176
+ const focusCol = (n: number) =>
177
+ colRefs.current[columns[Math.max(0, Math.min(columns.length - 1, n))].id]?.focus();
178
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
179
+ e.preventDefault();
180
+ const d = e.key === "ArrowDown" ? 1 : -1;
181
+ const next = enabled[pos < 0 ? 0 : (pos + d + enabled.length) % enabled.length];
182
+ // the minute column closes on pick; from the keyboard, just move
183
+ if (col.id === "minute") choose(hour ?? Math.floor(lo / 60), minutes[next]);
184
+ else col.cells[next]?.pick();
185
+ requestAnimationFrame(() =>
186
+ colRefs.current[col.id]?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: "nearest" }),
187
+ );
188
+ } else if (e.key === "ArrowRight") {
189
+ e.preventDefault();
190
+ focusCol(ci + 1);
191
+ } else if (e.key === "ArrowLeft") {
192
+ e.preventDefault();
193
+ focusCol(ci - 1);
194
+ } else if (e.key === "Enter") {
195
+ e.preventDefault();
196
+ close();
197
+ buttonRef.current?.focus();
198
+ }
199
+ };
200
+
201
+ return (
202
+ <div className={["group/field flex flex-col gap-1.5", fullWidth ? "w-full" : ""].join(" ")}>
203
+ {label && <FieldLabel id={`${uid}-label`}>{label}</FieldLabel>}
204
+ <div ref={ref} className={["relative", fullWidth ? "w-full" : "inline-block w-44", className].join(" ")}>
205
+ {/* The field's error styling keys off aria-invalid, as Input's does */}
206
+ {/* eslint-disable-next-line jsx-a11y/role-supports-aria-props */}
207
+ <button
208
+ ref={buttonRef}
209
+ type="button"
210
+ disabled={disabled}
211
+ onClick={toggle}
212
+ aria-haspopup="dialog"
213
+ aria-expanded={open}
214
+ aria-labelledby={label ? `${uid}-label ${uid}-value` : undefined}
215
+ aria-invalid={error ? true : undefined}
216
+ aria-describedby={messageId}
217
+ data-open={open || undefined}
218
+ className={[
219
+ "field group/time relative isolate flex w-full cursor-pointer items-center gap-2.5 rounded-xl py-3 pl-3.5 text-left text-[13.5px]",
220
+ clearable && current ? "pr-10" : "pr-4",
221
+ "focus:outline-none disabled:opacity-40 disabled:pointer-events-none",
222
+ ].join(" ")}
223
+ >
224
+ <svg
225
+ aria-hidden
226
+ width="15"
227
+ height="15"
228
+ fill="none"
229
+ stroke="currentColor"
230
+ strokeWidth="2"
231
+ strokeLinecap="round"
232
+ viewBox="0 0 24 24"
233
+ className={[
234
+ "shrink-0 transition-[color,transform] duration-300 ease-[var(--ease-spring)]",
235
+ open ? "scale-110 text-ink-soft" : "text-ink-faint group-hover/time:text-ink-body",
236
+ ].join(" ")}
237
+ >
238
+ <circle cx="12" cy="12" r="9" />
239
+ <path d="M12 7v5l3 2" />
240
+ </svg>
241
+ <span id={`${uid}-value`} className={["tabular-nums", current ? "font-medium text-ink" : "text-ink-faint"].join(" ")}>
242
+ {current ? formatTime(current, hour12) : placeholder}
243
+ </span>
244
+ </button>
245
+ {clearable && current && !disabled && (
246
+ <ClearKey
247
+ label="clear time"
248
+ onClear={() => {
249
+ set(null);
250
+ buttonRef.current?.focus();
251
+ }}
252
+ />
253
+ )}
254
+ {name && <input type="hidden" name={name} value={current ?? ""} />}
255
+ </div>
256
+
257
+ {mounted && (
258
+ <Portal>
259
+ <div
260
+ ref={setFloating}
261
+ role="dialog"
262
+ aria-label={label ? `choose ${label}` : "choose a time"}
263
+ className="rounded-[16px] p-1.5"
264
+ style={panelStyle}
265
+ >
266
+ <div className="flex gap-1 rounded-[11px] bg-panel-face p-1.5" style={{ boxShadow: "var(--panel-face-shadow)" }}>
267
+ {columns.map((col, ci) => (
268
+ <div
269
+ key={col.id}
270
+ ref={attachColumn(col.id)}
271
+ role="listbox"
272
+ aria-label={col.label}
273
+ tabIndex={0}
274
+ onKeyDown={onColumnKey(ci)}
275
+ className="flex max-h-56 w-14 flex-col gap-0.5 overflow-y-auto rounded-[9px] p-0.5 scroll-thin outline-none focus-visible:ring-2 focus-visible:ring-ink"
276
+ style={{ scrollbarWidth: "none" }}
277
+ >
278
+ {col.cells.map((c) => (
279
+ <div
280
+ key={c.key}
281
+ role="option"
282
+ aria-selected={c.selected}
283
+ aria-disabled={c.off || undefined}
284
+ onClick={() => !c.off && c.pick()}
285
+ className={[
286
+ "grid h-8 shrink-0 place-items-center rounded-[8px] text-[13px] tabular-nums transition-colors duration-100",
287
+ c.off
288
+ ? "cursor-not-allowed text-ink-ghost"
289
+ : c.selected
290
+ ? "cursor-pointer font-semibold text-ink-inverse"
291
+ : "cursor-pointer text-ink-body hover:bg-surface-active hover:text-ink",
292
+ ].join(" ")}
293
+ style={
294
+ c.selected && !c.off
295
+ ? {
296
+ background: "var(--face-inverse)",
297
+ boxShadow: "var(--face-inverse-shadow)",
298
+ textShadow: "var(--emboss-inverse)",
299
+ }
300
+ : undefined
301
+ }
302
+ >
303
+ {c.label}
304
+ </div>
305
+ ))}
306
+ </div>
307
+ ))}
308
+ </div>
309
+ </div>
310
+ </Portal>
311
+ )}
312
+ <FieldMessage id={messageId} error={error} hint={hint} />
313
+ </div>
314
+ );
315
+ }
@@ -20,17 +20,17 @@ interface TimelineProps {
20
20
  lamp; the variant only changes the lamp's colour. Nodes are joined by
21
21
  one continuous groove, and titles sit on the node's centre line. */
22
22
  const lamp: Record<TimelineVariant, string> = {
23
- default: "#262626",
24
- success: "#10b981",
25
- warning: "#f59e0b",
26
- accent: "#8b5cf6",
23
+ default: "var(--lamp-default)",
24
+ success: "var(--lamp-success)",
25
+ warning: "var(--lamp-warning)",
26
+ accent: "var(--lamp-accent)",
27
27
  };
28
28
 
29
29
  const iconText: Record<TimelineVariant, string> = {
30
- default: "text-neutral-600",
31
- success: "text-emerald-600",
32
- warning: "text-amber-600",
33
- accent: "text-violet-600",
30
+ default: "text-ink-body",
31
+ success: "text-success-soft",
32
+ warning: "text-warning",
33
+ accent: "text-accent-soft",
34
34
  };
35
35
 
36
36
  export function Timeline({ items, className = "" }: TimelineProps) {
@@ -40,7 +40,7 @@ export function Timeline({ items, className = "" }: TimelineProps) {
40
40
  style={{ background: "var(--panel-bezel)", boxShadow: "var(--panel-shadow)" }}
41
41
  >
42
42
  <ol
43
- className="bg-white rounded-[13px] px-5 py-5 flex flex-col"
43
+ className="bg-panel-face rounded-[13px] px-5 py-5 flex flex-col"
44
44
  style={{ boxShadow: "var(--panel-face-shadow)" }}
45
45
  >
46
46
  {items.map((item, index) => {
@@ -63,14 +63,14 @@ export function Timeline({ items, className = "" }: TimelineProps) {
63
63
  "relative w-6 h-6 rounded-full flex items-center justify-center shrink-0",
64
64
  iconText[variant],
65
65
  ].join(" ")}
66
- style={{ background: "var(--face-light)", boxShadow: "var(--tile-light-shadow)" }}
66
+ style={{ background: "var(--face)", boxShadow: "var(--tile-shadow)" }}
67
67
  >
68
68
  {item.icon ?? (
69
69
  <span
70
70
  className="w-2 h-2 rounded-full"
71
71
  style={{
72
72
  background: lamp[variant],
73
- boxShadow: `0 0 6px ${lamp[variant]}66, inset 0 1px 1px rgba(255,255,255,0.35)`,
73
+ boxShadow: `0 0 6px color-mix(in srgb, ${lamp[variant]} 40%, transparent), var(--lamp-lip)`,
74
74
  }}
75
75
  />
76
76
  )}
@@ -78,17 +78,17 @@ export function Timeline({ items, className = "" }: TimelineProps) {
78
78
 
79
79
  <div className="flex-1 min-w-0">
80
80
  <div className="flex items-baseline justify-between gap-3">
81
- <p className="text-[13.5px] font-medium text-neutral-900 leading-6 tracking-[-0.005em]">
81
+ <p className="text-[13.5px] font-medium text-ink leading-6 tracking-[-0.005em]">
82
82
  {item.title}
83
83
  </p>
84
84
  {item.date && (
85
- <time className="text-[12px] text-neutral-400 tabular-nums shrink-0">
85
+ <time className="text-[12px] text-ink-faint tabular-nums shrink-0">
86
86
  {item.date}
87
87
  </time>
88
88
  )}
89
89
  </div>
90
90
  {item.description && (
91
- <p className="text-[13px] text-neutral-500 leading-relaxed mt-0.5">
91
+ <p className="text-[13px] text-ink-muted leading-relaxed mt-0.5">
92
92
  {item.description}
93
93
  </p>
94
94
  )}
@@ -41,7 +41,7 @@ const variantConfig: Record<
41
41
  { tile: string; icon: React.ReactNode }
42
42
  > = {
43
43
  default: {
44
- tile: "var(--face-dark)",
44
+ tile: "var(--face-inverse)",
45
45
  icon: (
46
46
  <svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.75" viewBox="0 0 24 24">
47
47
  <circle cx="12" cy="12" r="10" />
@@ -50,7 +50,7 @@ const variantConfig: Record<
50
50
  ),
51
51
  },
52
52
  success: {
53
- tile: "linear-gradient(180deg, #10b981 0%, #059669 100%)",
53
+ tile: "var(--tile-success)",
54
54
  icon: (
55
55
  <svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.75" viewBox="0 0 24 24">
56
56
  <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
@@ -58,7 +58,7 @@ const variantConfig: Record<
58
58
  ),
59
59
  },
60
60
  warning: {
61
- tile: "linear-gradient(180deg, #fbbf24 0%, #f59e0b 100%)",
61
+ tile: "var(--tile-warning)",
62
62
  icon: (
63
63
  <svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.75" viewBox="0 0 24 24">
64
64
  <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
@@ -66,7 +66,7 @@ const variantConfig: Record<
66
66
  ),
67
67
  },
68
68
  error: {
69
- tile: "linear-gradient(180deg, #f87171 0%, #dc2626 100%)",
69
+ tile: "var(--tile-error)",
70
70
  icon: (
71
71
  <svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.75" viewBox="0 0 24 24">
72
72
  <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
@@ -199,26 +199,29 @@ function ToastCard({
199
199
  style={{ background: "var(--panel-bezel)", boxShadow: "var(--overlay-shadow)" }}
200
200
  >
201
201
  <div
202
- className="bg-white rounded-[13px] pl-3 pr-2 py-3 flex items-start gap-3"
202
+ className="bg-panel-face rounded-[13px] pl-3 pr-2 py-3 flex items-start gap-3"
203
203
  style={{ boxShadow: "var(--panel-face-shadow)" }}
204
204
  >
205
205
  <div
206
206
  aria-hidden
207
- className="shrink-0 w-7 h-7 rounded-[8px] flex items-center justify-center text-white"
207
+ className={[
208
+ "shrink-0 w-7 h-7 rounded-[8px] flex items-center justify-center",
209
+ // the default tile is the inverse face; status tiles stay white-on-colour
210
+ tile === "var(--face-inverse)" ? "text-ink-inverse" : "text-white",
211
+ ].join(" ")}
208
212
  style={{
209
213
  background: tile,
210
- boxShadow:
211
- "0 0 3px rgba(0,0,0,0.212), 0 1px 2px rgba(0,0,0,0.15), inset 0 2px 3px -2px rgba(255,255,255,0.188)",
214
+ boxShadow: "var(--tile-status-shadow)",
212
215
  }}
213
216
  >
214
217
  {icon}
215
218
  </div>
216
219
  <div className="flex-1 min-w-0 pt-[5px]">
217
- <p className="text-[13px] font-semibold text-neutral-900 leading-tight tracking-[-0.01em]">
220
+ <p className="text-[13px] font-semibold text-ink leading-tight tracking-[-0.01em]">
218
221
  {item.title}
219
222
  </p>
220
223
  {item.description && (
221
- <p className="text-[12.5px] text-neutral-500 mt-1 leading-relaxed">
224
+ <p className="text-[12.5px] text-ink-muted mt-1 leading-relaxed">
222
225
  {item.description}
223
226
  </p>
224
227
  )}
@@ -371,8 +374,8 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
371
374
  }}
372
375
  >
373
376
  <span
374
- className="text-[10.5px] font-medium text-neutral-500 px-2 py-0.5 rounded-full"
375
- style={{ background: "var(--face-light)", boxShadow: "var(--face-light-shadow)" }}
377
+ className="text-[10.5px] font-medium text-ink-muted px-2 py-0.5 rounded-full"
378
+ style={{ background: "var(--face)", boxShadow: "var(--face-shadow)" }}
376
379
  >
377
380
  +{showCount} more
378
381
  </span>
@@ -42,12 +42,12 @@ export function Toggle({
42
42
  {(label || description) && (
43
43
  <div className="flex flex-col gap-0.5 min-w-0">
44
44
  {label && (
45
- <span id={`${uid}-label`} className="text-[13px] font-medium text-neutral-900 leading-snug">
45
+ <span id={`${uid}-label`} className="text-[13px] font-medium text-ink leading-snug">
46
46
  {label}
47
47
  </span>
48
48
  )}
49
49
  {description && (
50
- <span id={`${uid}-desc`} className="text-[12px] text-neutral-500 leading-relaxed">
50
+ <span id={`${uid}-desc`} className="text-[12px] text-ink-muted leading-relaxed">
51
51
  {description}
52
52
  </span>
53
53
  )}
@@ -68,14 +68,12 @@ export function Toggle({
68
68
  className={[
69
69
  "relative shrink-0 w-[46px] h-[26px] rounded-[13px] cursor-pointer",
70
70
  "transition-[background-color,box-shadow] duration-200 ease-out",
71
- "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900",
72
- isOn ? "bg-neutral-900" : "bg-neutral-200/70",
71
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink",
72
+ isOn ? "bg-track-on" : "bg-track",
73
73
  ].join(" ")}
74
74
  style={{
75
75
  /* The track is a recessed channel; the knob is a raised face inside it */
76
- boxShadow: isOn
77
- ? "inset 0 1px 3px rgba(0,0,0,0.5), inset 0 0 2px rgba(0,0,0,0.3), 0 1px 2px -1px rgba(255,255,255,0.48)"
78
- : "inset 0 1px 2px rgba(17,17,17,0.12), inset 0 0 2px rgba(17,17,17,0.12), 0 1px 2px -1px rgba(255,255,255,0.64)",
76
+ boxShadow: isOn ? "var(--track-on-shadow)" : "var(--track-shadow)",
79
77
  }}
80
78
  >
81
79
  <span
@@ -85,9 +83,8 @@ export function Toggle({
85
83
  left: 3,
86
84
  /* The knob stretches while held, like a finger dragging it */
87
85
  width: pressed ? 24 : 20,
88
- background: "var(--face-light)",
89
- boxShadow:
90
- "0 0 3px rgba(17,17,17,0.119), 0 1px 2px rgba(0,0,0,0.14), 0 3px 6px -1px rgba(0,0,0,0.12), inset 0 2px 3px -2px rgba(255,255,255,1), inset 0 -2px 3px -2px rgba(0,0,0,0.04)",
86
+ background: "var(--face)",
87
+ boxShadow: "var(--knob-shadow)",
91
88
  transform: `translateX(${isOn ? (pressed ? 16 : 20) : 0}px) translateY(-50%)`,
92
89
  transition:
93
90
  "transform 260ms var(--ease-spring), width 200ms var(--ease-out-soft)",
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import React, { useState, useRef, useCallback, useEffect, useId } from "react";
4
+ import { ENTER_FROM, LAYER, ORIGIN, Portal, useAnchoredPosition } from "./Floating";
4
5
 
5
6
  type TooltipPosition = "top" | "bottom" | "left" | "right";
6
7
 
@@ -11,20 +12,6 @@ interface TooltipProps {
11
12
  className?: string;
12
13
  }
13
14
 
14
- const origin: Record<TooltipPosition, string> = {
15
- top: "origin-bottom",
16
- bottom: "origin-top",
17
- left: "origin-right",
18
- right: "origin-left",
19
- };
20
-
21
- const enterTranslate: Record<TooltipPosition, string> = {
22
- top: "translate-y-1",
23
- bottom: "-translate-y-1",
24
- left: "translate-x-1",
25
- right: "-translate-x-1",
26
- };
27
-
28
15
  export function Tooltip({
29
16
  content,
30
17
  position = "top",
@@ -34,9 +21,7 @@ export function Tooltip({
34
21
  const [open, setOpen] = useState(false);
35
22
  const [visible, setVisible] = useState(false);
36
23
  const [mounted, setMounted] = useState(false);
37
- const [coords, setCoords] = useState({ top: 0, left: 0 });
38
24
  const triggerRef = useRef<HTMLDivElement>(null);
39
- const tooltipRef = useRef<HTMLDivElement>(null);
40
25
  const enterTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
41
26
  const exitTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
42
27
  const autoHideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -65,54 +50,18 @@ export function Tooltip({
65
50
  }
66
51
  }, [hide]);
67
52
 
53
+ // Mounted during render; the effect only waits for it to paint
54
+ if (open && !mounted) setMounted(true);
55
+
68
56
  useEffect(() => {
69
57
  if (open) {
70
- setMounted(true);
71
58
  requestAnimationFrame(() => requestAnimationFrame(() => setVisible(true)));
72
59
  }
73
60
  }, [open]);
74
61
 
75
- const measure = useCallback(() => {
76
- if (!triggerRef.current || !tooltipRef.current) return;
77
- const tr = triggerRef.current.getBoundingClientRect();
78
- const tp = tooltipRef.current.getBoundingClientRect();
79
- const gap = 7;
80
-
81
- let top = 0;
82
- let left = 0;
83
-
84
- switch (position) {
85
- case "top":
86
- top = -(tp.height + gap);
87
- left = (tr.width - tp.width) / 2;
88
- break;
89
- case "bottom":
90
- top = tr.height + gap;
91
- left = (tr.width - tp.width) / 2;
92
- break;
93
- case "left":
94
- top = (tr.height - tp.height) / 2;
95
- left = -(tp.width + gap);
96
- break;
97
- case "right":
98
- top = (tr.height - tp.height) / 2;
99
- left = tr.width + gap;
100
- break;
101
- }
102
-
103
- const viewportPadding = 8;
104
- const absLeft = triggerRef.current.getBoundingClientRect().left + left;
105
- const overflowRight = absLeft + tp.width - (window.innerWidth - viewportPadding);
106
- const overflowLeft = viewportPadding - absLeft;
107
- if (overflowRight > 0) left -= overflowRight;
108
- if (overflowLeft > 0) left += overflowLeft;
109
-
110
- setCoords({ top, left });
111
- }, [position]);
112
-
113
- useEffect(() => {
114
- if (mounted) measure();
115
- }, [mounted, measure]);
62
+ /* Portalled to the body and placed before its first paint, flipping
63
+ to the other side when there's no room */
64
+ const { setFloating, side } = useAnchoredPosition(triggerRef, { open: mounted, side: position, align: "center", gap: 7 });
116
65
 
117
66
  useEffect(() => {
118
67
  if (!open) return;
@@ -183,29 +132,28 @@ export function Tooltip({
183
132
  })
184
133
  : children}
185
134
  {mounted && (
135
+ <Portal>
186
136
  <div
187
- ref={tooltipRef}
137
+ ref={setFloating}
188
138
  id={`${uid}-tip`}
189
139
  role="tooltip"
190
- className={[
191
- "absolute z-50 px-2.5 py-1.5 rounded-[9px] text-white text-[11.5px] font-medium whitespace-nowrap pointer-events-none",
192
- "transition-[opacity,transform,scale,translate] duration-150 ease-[var(--ease-spring)]",
193
- origin[position],
194
- visible
195
- ? "opacity-100 scale-100 translate-x-0 translate-y-0"
196
- : ["opacity-0 scale-95", enterTranslate[position]].join(" "),
197
- ].join(" ")}
140
+ className="px-2.5 py-1.5 rounded-[9px] text-ink-inverse text-[11.5px] font-medium whitespace-nowrap pointer-events-none transition-[opacity,transform] duration-150 ease-[var(--ease-spring)]"
198
141
  style={{
199
- top: coords.top,
200
- left: coords.left,
201
- background: "var(--face-dark)",
202
- boxShadow:
203
- "0 0 3px rgba(0,0,0,0.35), 0 2px 6px rgba(0,0,0,0.22), 0 8px 24px -4px rgba(0,0,0,0.3), inset 0 2px 3px -2px rgba(255,255,255,0.105)",
204
- textShadow: "0 -1px 0 rgba(0,0,0,0.4)",
142
+ position: "fixed",
143
+ top: 0,
144
+ left: 0,
145
+ zIndex: LAYER.tooltip,
146
+ transformOrigin: ORIGIN[side],
147
+ opacity: visible ? 1 : 0,
148
+ transform: visible ? "none" : `${ENTER_FROM[side]} scale(0.95)`,
149
+ background: "var(--face-inverse)",
150
+ boxShadow: "var(--tooltip-shadow)",
151
+ textShadow: "var(--emboss-inverse)",
205
152
  }}
206
153
  >
207
154
  {content}
208
155
  </div>
156
+ </Portal>
209
157
  )}
210
158
  </div>
211
159
  );