@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,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
+ }
@@ -0,0 +1,102 @@
1
+ import React from "react";
2
+
3
+ type TimelineVariant = "default" | "success" | "warning" | "accent";
4
+
5
+ interface TimelineItem {
6
+ key: string;
7
+ title: string;
8
+ description?: string;
9
+ date?: string;
10
+ icon?: React.ReactNode;
11
+ variant?: TimelineVariant;
12
+ }
13
+
14
+ interface TimelineProps {
15
+ items: TimelineItem[];
16
+ className?: string;
17
+ }
18
+
19
+ /* Each event is a small raised node with a lit dot, like an indicator
20
+ lamp; the variant only changes the lamp's colour. Nodes are joined by
21
+ one continuous groove, and titles sit on the node's centre line. */
22
+ const lamp: Record<TimelineVariant, string> = {
23
+ default: "var(--lamp-default)",
24
+ success: "var(--lamp-success)",
25
+ warning: "var(--lamp-warning)",
26
+ accent: "var(--lamp-accent)",
27
+ };
28
+
29
+ const iconText: Record<TimelineVariant, string> = {
30
+ default: "text-ink-body",
31
+ success: "text-success-soft",
32
+ warning: "text-warning",
33
+ accent: "text-accent-soft",
34
+ };
35
+
36
+ export function Timeline({ items, className = "" }: TimelineProps) {
37
+ return (
38
+ <div
39
+ className={["rounded-[18px] p-1.5", className].filter(Boolean).join(" ")}
40
+ style={{ background: "var(--panel-bezel)", boxShadow: "var(--panel-shadow)" }}
41
+ >
42
+ <ol
43
+ className="bg-panel-face rounded-[13px] px-5 py-5 flex flex-col"
44
+ style={{ boxShadow: "var(--panel-face-shadow)" }}
45
+ >
46
+ {items.map((item, index) => {
47
+ const variant = item.variant ?? "default";
48
+ const isLast = index === items.length - 1;
49
+
50
+ return (
51
+ <li key={item.key} className={["relative flex gap-4", isLast ? "" : "pb-6"].join(" ")}>
52
+ {!isLast && (
53
+ <span
54
+ aria-hidden
55
+ className="absolute left-[11px] top-[30px] bottom-[6px] w-[2px] rounded-full"
56
+ style={{ background: "var(--well)", boxShadow: "var(--well-shadow)" }}
57
+ />
58
+ )}
59
+
60
+ <span
61
+ aria-hidden
62
+ className={[
63
+ "relative w-6 h-6 rounded-full flex items-center justify-center shrink-0",
64
+ iconText[variant],
65
+ ].join(" ")}
66
+ style={{ background: "var(--face)", boxShadow: "var(--tile-shadow)" }}
67
+ >
68
+ {item.icon ?? (
69
+ <span
70
+ className="w-2 h-2 rounded-full"
71
+ style={{
72
+ background: lamp[variant],
73
+ boxShadow: `0 0 6px color-mix(in srgb, ${lamp[variant]} 40%, transparent), var(--lamp-lip)`,
74
+ }}
75
+ />
76
+ )}
77
+ </span>
78
+
79
+ <div className="flex-1 min-w-0">
80
+ <div className="flex items-baseline justify-between gap-3">
81
+ <p className="text-[13.5px] font-medium text-ink leading-6 tracking-[-0.005em]">
82
+ {item.title}
83
+ </p>
84
+ {item.date && (
85
+ <time className="text-[12px] text-ink-faint tabular-nums shrink-0">
86
+ {item.date}
87
+ </time>
88
+ )}
89
+ </div>
90
+ {item.description && (
91
+ <p className="text-[13px] text-ink-muted leading-relaxed mt-0.5">
92
+ {item.description}
93
+ </p>
94
+ )}
95
+ </div>
96
+ </li>
97
+ );
98
+ })}
99
+ </ol>
100
+ </div>
101
+ );
102
+ }