@vaam-apps/ui 0.1.0

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 (285) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/LICENSE +21 -0
  3. package/README.md +286 -0
  4. package/dist/components/data/code.d.ts +37 -0
  5. package/dist/components/data/code.d.ts.map +1 -0
  6. package/dist/components/data/code.js +37 -0
  7. package/dist/components/data/code.js.map +1 -0
  8. package/dist/components/data/copy-button.d.ts +39 -0
  9. package/dist/components/data/copy-button.d.ts.map +1 -0
  10. package/dist/components/data/copy-button.js +50 -0
  11. package/dist/components/data/copy-button.js.map +1 -0
  12. package/dist/components/data/detail-row.d.ts +69 -0
  13. package/dist/components/data/detail-row.d.ts.map +1 -0
  14. package/dist/components/data/detail-row.js +27 -0
  15. package/dist/components/data/detail-row.js.map +1 -0
  16. package/dist/components/data/id-display.d.ts +33 -0
  17. package/dist/components/data/id-display.d.ts.map +1 -0
  18. package/dist/components/data/id-display.js +25 -0
  19. package/dist/components/data/id-display.js.map +1 -0
  20. package/dist/components/data/masked-value.d.ts +47 -0
  21. package/dist/components/data/masked-value.d.ts.map +1 -0
  22. package/dist/components/data/masked-value.js +46 -0
  23. package/dist/components/data/masked-value.js.map +1 -0
  24. package/dist/components/data/money.d.ts +28 -0
  25. package/dist/components/data/money.d.ts.map +1 -0
  26. package/dist/components/data/money.js +22 -0
  27. package/dist/components/data/money.js.map +1 -0
  28. package/dist/components/data/phone-display.d.ts +45 -0
  29. package/dist/components/data/phone-display.d.ts.map +1 -0
  30. package/dist/components/data/phone-display.js +23 -0
  31. package/dist/components/data/phone-display.js.map +1 -0
  32. package/dist/components/data/stat-tile.d.ts +33 -0
  33. package/dist/components/data/stat-tile.d.ts.map +1 -0
  34. package/dist/components/data/stat-tile.js +18 -0
  35. package/dist/components/data/stat-tile.js.map +1 -0
  36. package/dist/components/data/timestamp-display.d.ts +14 -0
  37. package/dist/components/data/timestamp-display.d.ts.map +1 -0
  38. package/dist/components/data/timestamp-display.js +77 -0
  39. package/dist/components/data/timestamp-display.js.map +1 -0
  40. package/dist/components/patterns/inline-banner.d.ts +22 -0
  41. package/dist/components/patterns/inline-banner.d.ts.map +1 -0
  42. package/dist/components/patterns/inline-banner.js +11 -0
  43. package/dist/components/patterns/inline-banner.js.map +1 -0
  44. package/dist/components/patterns/inline-empty-state.d.ts +20 -0
  45. package/dist/components/patterns/inline-empty-state.d.ts.map +1 -0
  46. package/dist/components/patterns/inline-empty-state.js +11 -0
  47. package/dist/components/patterns/inline-empty-state.js.map +1 -0
  48. package/dist/components/patterns/live-row.d.ts +23 -0
  49. package/dist/components/patterns/live-row.d.ts.map +1 -0
  50. package/dist/components/patterns/live-row.js +48 -0
  51. package/dist/components/patterns/live-row.js.map +1 -0
  52. package/dist/components/patterns/payload-inspector.d.ts +25 -0
  53. package/dist/components/patterns/payload-inspector.d.ts.map +1 -0
  54. package/dist/components/patterns/payload-inspector.js +33 -0
  55. package/dist/components/patterns/payload-inspector.js.map +1 -0
  56. package/dist/components/patterns/route-skeleton.d.ts +15 -0
  57. package/dist/components/patterns/route-skeleton.d.ts.map +1 -0
  58. package/dist/components/patterns/route-skeleton.js +7 -0
  59. package/dist/components/patterns/route-skeleton.js.map +1 -0
  60. package/dist/components/patterns/screen-layout.d.ts +14 -0
  61. package/dist/components/patterns/screen-layout.d.ts.map +1 -0
  62. package/dist/components/patterns/screen-layout.js +11 -0
  63. package/dist/components/patterns/screen-layout.js.map +1 -0
  64. package/dist/components/patterns/stale-write-banner.d.ts +26 -0
  65. package/dist/components/patterns/stale-write-banner.d.ts.map +1 -0
  66. package/dist/components/patterns/stale-write-banner.js +28 -0
  67. package/dist/components/patterns/stale-write-banner.js.map +1 -0
  68. package/dist/components/patterns/state-timeline.d.ts +58 -0
  69. package/dist/components/patterns/state-timeline.d.ts.map +1 -0
  70. package/dist/components/patterns/state-timeline.js +103 -0
  71. package/dist/components/patterns/state-timeline.js.map +1 -0
  72. package/dist/components/primitives/badge.d.ts +27 -0
  73. package/dist/components/primitives/badge.d.ts.map +1 -0
  74. package/dist/components/primitives/badge.js +33 -0
  75. package/dist/components/primitives/badge.js.map +1 -0
  76. package/dist/components/primitives/button.d.ts +12 -0
  77. package/dist/components/primitives/button.d.ts.map +1 -0
  78. package/dist/components/primitives/button.js +42 -0
  79. package/dist/components/primitives/button.js.map +1 -0
  80. package/dist/components/primitives/calendar.d.ts +39 -0
  81. package/dist/components/primitives/calendar.d.ts.map +1 -0
  82. package/dist/components/primitives/calendar.js +102 -0
  83. package/dist/components/primitives/calendar.js.map +1 -0
  84. package/dist/components/primitives/card.d.ts +24 -0
  85. package/dist/components/primitives/card.d.ts.map +1 -0
  86. package/dist/components/primitives/card.js +28 -0
  87. package/dist/components/primitives/card.js.map +1 -0
  88. package/dist/components/primitives/checkbox.d.ts +42 -0
  89. package/dist/components/primitives/checkbox.d.ts.map +1 -0
  90. package/dist/components/primitives/checkbox.js +31 -0
  91. package/dist/components/primitives/checkbox.js.map +1 -0
  92. package/dist/components/primitives/chip-select.d.ts +38 -0
  93. package/dist/components/primitives/chip-select.d.ts.map +1 -0
  94. package/dist/components/primitives/chip-select.js +25 -0
  95. package/dist/components/primitives/chip-select.js.map +1 -0
  96. package/dist/components/primitives/command-menu.d.ts +54 -0
  97. package/dist/components/primitives/command-menu.d.ts.map +1 -0
  98. package/dist/components/primitives/command-menu.js +24 -0
  99. package/dist/components/primitives/command-menu.js.map +1 -0
  100. package/dist/components/primitives/confirm-dialog.d.ts +52 -0
  101. package/dist/components/primitives/confirm-dialog.d.ts.map +1 -0
  102. package/dist/components/primitives/confirm-dialog.js +30 -0
  103. package/dist/components/primitives/confirm-dialog.js.map +1 -0
  104. package/dist/components/primitives/date-picker.d.ts +68 -0
  105. package/dist/components/primitives/date-picker.d.ts.map +1 -0
  106. package/dist/components/primitives/date-picker.js +107 -0
  107. package/dist/components/primitives/date-picker.js.map +1 -0
  108. package/dist/components/primitives/dialog.d.ts +50 -0
  109. package/dist/components/primitives/dialog.d.ts.map +1 -0
  110. package/dist/components/primitives/dialog.js +90 -0
  111. package/dist/components/primitives/dialog.js.map +1 -0
  112. package/dist/components/primitives/drawer.d.ts +71 -0
  113. package/dist/components/primitives/drawer.d.ts.map +1 -0
  114. package/dist/components/primitives/drawer.js +93 -0
  115. package/dist/components/primitives/drawer.js.map +1 -0
  116. package/dist/components/primitives/dropdown-menu.d.ts +40 -0
  117. package/dist/components/primitives/dropdown-menu.d.ts.map +1 -0
  118. package/dist/components/primitives/dropdown-menu.js +53 -0
  119. package/dist/components/primitives/dropdown-menu.js.map +1 -0
  120. package/dist/components/primitives/form-field.d.ts +94 -0
  121. package/dist/components/primitives/form-field.d.ts.map +1 -0
  122. package/dist/components/primitives/form-field.js +27 -0
  123. package/dist/components/primitives/form-field.js.map +1 -0
  124. package/dist/components/primitives/inline-confirm.d.ts +62 -0
  125. package/dist/components/primitives/inline-confirm.d.ts.map +1 -0
  126. package/dist/components/primitives/inline-confirm.js +11 -0
  127. package/dist/components/primitives/inline-confirm.js.map +1 -0
  128. package/dist/components/primitives/input.d.ts +4 -0
  129. package/dist/components/primitives/input.d.ts.map +1 -0
  130. package/dist/components/primitives/input.js +13 -0
  131. package/dist/components/primitives/input.js.map +1 -0
  132. package/dist/components/primitives/label.d.ts +26 -0
  133. package/dist/components/primitives/label.d.ts.map +1 -0
  134. package/dist/components/primitives/label.js +33 -0
  135. package/dist/components/primitives/label.js.map +1 -0
  136. package/dist/components/primitives/pagination.d.ts +54 -0
  137. package/dist/components/primitives/pagination.d.ts.map +1 -0
  138. package/dist/components/primitives/pagination.js +31 -0
  139. package/dist/components/primitives/pagination.js.map +1 -0
  140. package/dist/components/primitives/popover.d.ts +12 -0
  141. package/dist/components/primitives/popover.d.ts.map +1 -0
  142. package/dist/components/primitives/popover.js +22 -0
  143. package/dist/components/primitives/popover.js.map +1 -0
  144. package/dist/components/primitives/progress.d.ts +25 -0
  145. package/dist/components/primitives/progress.d.ts.map +1 -0
  146. package/dist/components/primitives/progress.js +25 -0
  147. package/dist/components/primitives/progress.js.map +1 -0
  148. package/dist/components/primitives/radio-group.d.ts +47 -0
  149. package/dist/components/primitives/radio-group.d.ts.map +1 -0
  150. package/dist/components/primitives/radio-group.js +11 -0
  151. package/dist/components/primitives/radio-group.js.map +1 -0
  152. package/dist/components/primitives/select.d.ts +39 -0
  153. package/dist/components/primitives/select.d.ts.map +1 -0
  154. package/dist/components/primitives/select.js +142 -0
  155. package/dist/components/primitives/select.js.map +1 -0
  156. package/dist/components/primitives/separator.d.ts +9 -0
  157. package/dist/components/primitives/separator.d.ts.map +1 -0
  158. package/dist/components/primitives/separator.js +16 -0
  159. package/dist/components/primitives/separator.js.map +1 -0
  160. package/dist/components/primitives/side-nav.d.ts +65 -0
  161. package/dist/components/primitives/side-nav.d.ts.map +1 -0
  162. package/dist/components/primitives/side-nav.js +100 -0
  163. package/dist/components/primitives/side-nav.js.map +1 -0
  164. package/dist/components/primitives/skeleton.d.ts +22 -0
  165. package/dist/components/primitives/skeleton.d.ts.map +1 -0
  166. package/dist/components/primitives/skeleton.js +25 -0
  167. package/dist/components/primitives/skeleton.js.map +1 -0
  168. package/dist/components/primitives/spinner.d.ts +20 -0
  169. package/dist/components/primitives/spinner.d.ts.map +1 -0
  170. package/dist/components/primitives/spinner.js +21 -0
  171. package/dist/components/primitives/spinner.js.map +1 -0
  172. package/dist/components/primitives/switch.d.ts +29 -0
  173. package/dist/components/primitives/switch.d.ts.map +1 -0
  174. package/dist/components/primitives/switch.js +24 -0
  175. package/dist/components/primitives/switch.js.map +1 -0
  176. package/dist/components/primitives/table.d.ts +23 -0
  177. package/dist/components/primitives/table.d.ts.map +1 -0
  178. package/dist/components/primitives/table.js +81 -0
  179. package/dist/components/primitives/table.js.map +1 -0
  180. package/dist/components/primitives/tabs.d.ts +38 -0
  181. package/dist/components/primitives/tabs.d.ts.map +1 -0
  182. package/dist/components/primitives/tabs.js +84 -0
  183. package/dist/components/primitives/tabs.js.map +1 -0
  184. package/dist/components/primitives/textarea.d.ts +4 -0
  185. package/dist/components/primitives/textarea.d.ts.map +1 -0
  186. package/dist/components/primitives/textarea.js +11 -0
  187. package/dist/components/primitives/textarea.js.map +1 -0
  188. package/dist/components/primitives/toast.d.ts +24 -0
  189. package/dist/components/primitives/toast.d.ts.map +1 -0
  190. package/dist/components/primitives/toast.js +58 -0
  191. package/dist/components/primitives/toast.js.map +1 -0
  192. package/dist/components/primitives/tooltip.d.ts +30 -0
  193. package/dist/components/primitives/tooltip.d.ts.map +1 -0
  194. package/dist/components/primitives/tooltip.js +6 -0
  195. package/dist/components/primitives/tooltip.js.map +1 -0
  196. package/dist/components/status/state-chip.d.ts +53 -0
  197. package/dist/components/status/state-chip.d.ts.map +1 -0
  198. package/dist/components/status/state-chip.js +8 -0
  199. package/dist/components/status/state-chip.js.map +1 -0
  200. package/dist/components/status/state-mark.d.ts +21 -0
  201. package/dist/components/status/state-mark.d.ts.map +1 -0
  202. package/dist/components/status/state-mark.js +90 -0
  203. package/dist/components/status/state-mark.js.map +1 -0
  204. package/dist/components/status/status-pill.d.ts +62 -0
  205. package/dist/components/status/status-pill.d.ts.map +1 -0
  206. package/dist/components/status/status-pill.js +65 -0
  207. package/dist/components/status/status-pill.js.map +1 -0
  208. package/dist/components/status/status-tokens.d.ts +110 -0
  209. package/dist/components/status/status-tokens.d.ts.map +1 -0
  210. package/dist/components/status/status-tokens.js +102 -0
  211. package/dist/components/status/status-tokens.js.map +1 -0
  212. package/dist/index.d.ts +76 -0
  213. package/dist/index.d.ts.map +1 -0
  214. package/dist/index.js +95 -0
  215. package/dist/index.js.map +1 -0
  216. package/dist/lib/cn.d.ts +11 -0
  217. package/dist/lib/cn.d.ts.map +1 -0
  218. package/dist/lib/cn.js +78 -0
  219. package/dist/lib/cn.js.map +1 -0
  220. package/dist/lib/money.d.ts +73 -0
  221. package/dist/lib/money.d.ts.map +1 -0
  222. package/dist/lib/money.js +123 -0
  223. package/dist/lib/money.js.map +1 -0
  224. package/dist/lib/omit-undefined.d.ts +17 -0
  225. package/dist/lib/omit-undefined.d.ts.map +1 -0
  226. package/dist/lib/omit-undefined.js +24 -0
  227. package/dist/lib/omit-undefined.js.map +1 -0
  228. package/package.json +100 -0
  229. package/src/components/data/code.tsx +43 -0
  230. package/src/components/data/copy-button.tsx +97 -0
  231. package/src/components/data/detail-row.tsx +113 -0
  232. package/src/components/data/id-display.tsx +49 -0
  233. package/src/components/data/masked-value.tsx +96 -0
  234. package/src/components/data/money.tsx +46 -0
  235. package/src/components/data/phone-display.tsx +60 -0
  236. package/src/components/data/stat-tile.tsx +68 -0
  237. package/src/components/data/timestamp-display.tsx +106 -0
  238. package/src/components/patterns/inline-banner.tsx +60 -0
  239. package/src/components/patterns/inline-empty-state.tsx +45 -0
  240. package/src/components/patterns/live-row.tsx +75 -0
  241. package/src/components/patterns/payload-inspector.tsx +157 -0
  242. package/src/components/patterns/route-skeleton.tsx +77 -0
  243. package/src/components/patterns/screen-layout.tsx +44 -0
  244. package/src/components/patterns/stale-write-banner.tsx +44 -0
  245. package/src/components/patterns/state-timeline.tsx +229 -0
  246. package/src/components/primitives/badge.tsx +40 -0
  247. package/src/components/primitives/button.tsx +54 -0
  248. package/src/components/primitives/calendar.tsx +136 -0
  249. package/src/components/primitives/card.tsx +70 -0
  250. package/src/components/primitives/checkbox.tsx +101 -0
  251. package/src/components/primitives/chip-select.tsx +105 -0
  252. package/src/components/primitives/command-menu.tsx +92 -0
  253. package/src/components/primitives/confirm-dialog.tsx +109 -0
  254. package/src/components/primitives/date-picker.tsx +271 -0
  255. package/src/components/primitives/dialog.tsx +195 -0
  256. package/src/components/primitives/drawer.tsx +326 -0
  257. package/src/components/primitives/dropdown-menu.tsx +128 -0
  258. package/src/components/primitives/form-field.tsx +136 -0
  259. package/src/components/primitives/inline-confirm.tsx +118 -0
  260. package/src/components/primitives/input.tsx +21 -0
  261. package/src/components/primitives/label.tsx +43 -0
  262. package/src/components/primitives/pagination.tsx +110 -0
  263. package/src/components/primitives/popover.tsx +41 -0
  264. package/src/components/primitives/progress.tsx +66 -0
  265. package/src/components/primitives/radio-group.tsx +90 -0
  266. package/src/components/primitives/select.tsx +254 -0
  267. package/src/components/primitives/separator.tsx +49 -0
  268. package/src/components/primitives/side-nav.tsx +253 -0
  269. package/src/components/primitives/skeleton.tsx +25 -0
  270. package/src/components/primitives/spinner.tsx +39 -0
  271. package/src/components/primitives/switch.tsx +74 -0
  272. package/src/components/primitives/table.tsx +157 -0
  273. package/src/components/primitives/tabs.tsx +163 -0
  274. package/src/components/primitives/textarea.tsx +21 -0
  275. package/src/components/primitives/toast.tsx +117 -0
  276. package/src/components/primitives/tooltip.tsx +38 -0
  277. package/src/components/status/state-chip.tsx +70 -0
  278. package/src/components/status/state-mark.tsx +154 -0
  279. package/src/components/status/status-pill.tsx +153 -0
  280. package/src/components/status/status-tokens.ts +173 -0
  281. package/src/index.ts +96 -0
  282. package/src/lib/cn.ts +168 -0
  283. package/src/lib/money.ts +164 -0
  284. package/src/lib/omit-undefined.ts +25 -0
  285. package/src/styles/theme.css +381 -0
@@ -0,0 +1,254 @@
1
+ "use client";
2
+
3
+ import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from "@headlessui/react";
4
+ import { Check, ChevronDown } from "lucide-react";
5
+ import {
6
+ Children,
7
+ createContext,
8
+ isValidElement,
9
+ type ReactElement,
10
+ type ReactNode,
11
+ useContext,
12
+ useMemo,
13
+ useState,
14
+ } from "react";
15
+ import { cn } from "../../lib/cn";
16
+ import { omitUndefined } from "../../lib/omit-undefined";
17
+
18
+ /**
19
+ * D17: Radix `Select` → Headless UI `Listbox`. Flagged in the design doc as
20
+ * "the largest single API-shape change in the whole primitives migration" —
21
+ * but Radix `Select`'s own public shape (`Select value/onValueChange` →
22
+ * `SelectTrigger` → `SelectValue` → `SelectContent` → `SelectItem`) is kept
23
+ * byte-identical here, so none of this console's eleven call sites need any
24
+ * change. That's possible because Radix `SelectValue` and Headless UI's
25
+ * `Listbox` solve the "what does the trigger display" problem differently:
26
+ * Radix derives it internally from whichever `SelectItem` matches the
27
+ * current value; `Listbox` has no equivalent (`ListboxButton`'s render prop
28
+ * only exposes the raw `value`, not a matching option's own children/label
29
+ * — e.g. `routes-screen.tsx`'s provider select shows `provider.displayName`
30
+ * for a `value={provider.id}`, not the id itself). `findItemLabel` below
31
+ * replicates Radix's behaviour by walking `Select`'s own `children` tree
32
+ * (not the DOM — `ListboxOptions` may not be mounted while closed) to find
33
+ * the `SelectItem` whose `value` matches, and using *its* children as the
34
+ * label. Keyboard behaviour (type-ahead, `Escape`, arrow-key nav) is
35
+ * genuinely Headless UI's own `Listbox`, not reimplemented here — this file
36
+ * only adds the label-lookup Radix's `SelectValue` used to give for free.
37
+ */
38
+
39
+ interface SelectContextValue {
40
+ value: string | undefined;
41
+ itemLabel: (value: string) => ReactNode | undefined;
42
+ }
43
+ const SelectContext = createContext<SelectContextValue | null>(null);
44
+
45
+ function useSelectContext(component: string): SelectContextValue {
46
+ const ctx = useContext(SelectContext);
47
+ if (ctx === null) {
48
+ throw new Error(`<${component} /> must be rendered inside <Select>.`);
49
+ }
50
+ return ctx;
51
+ }
52
+
53
+ function findItemLabel(node: ReactNode, value: string): ReactNode | undefined {
54
+ let found: ReactNode | undefined;
55
+ Children.forEach(node, (child) => {
56
+ if (found !== undefined || !isValidElement(child)) return;
57
+ const el = child as ReactElement<{ value?: string; children?: ReactNode }>;
58
+ if (el.type === SelectItem) {
59
+ if (el.props.value === value) found = el.props.children;
60
+ return;
61
+ }
62
+ if (el.props?.children != null) {
63
+ found = findItemLabel(el.props.children, value);
64
+ }
65
+ });
66
+ return found;
67
+ }
68
+
69
+ /**
70
+ * Note the `| undefined` on the optional props below. This package
71
+ * compiles under `exactOptionalPropertyTypes`, where a bare `value?:
72
+ * string` means "you may omit this key" and NOT "you may pass
73
+ * `undefined`" — so the ordinary controlled pattern,
74
+ * `const [v, setV] = useState<string>()` followed by `value={v}`, is a
75
+ * type error. Found writing this component's own story, which is the
76
+ * first code outside the original application to use it.
77
+ */
78
+ export interface SelectProps {
79
+ value?: string | undefined;
80
+ defaultValue?: string | undefined;
81
+ onValueChange?: ((value: string) => void) | undefined;
82
+ disabled?: boolean | undefined;
83
+ children: ReactNode;
84
+ }
85
+
86
+ export function Select({ value, defaultValue, onValueChange, disabled, children }: SelectProps) {
87
+ const [internalValue, setInternalValue] = useState(defaultValue);
88
+ const isControlled = value !== undefined;
89
+ const currentValue = isControlled ? value : internalValue;
90
+
91
+ function handleChange(next: string) {
92
+ if (!isControlled) setInternalValue(next);
93
+ onValueChange?.(next);
94
+ }
95
+
96
+ const itemLabel = useMemo(() => (v: string) => findItemLabel(children, v), [children]);
97
+
98
+ return (
99
+ // `as="div"` + `relative`: with `SelectContent` no longer portaled
100
+ // (see its own comment), the options position themselves against this
101
+ // element. Headless UI's `Listbox` renders a fragment by default, which
102
+ // would leave `absolute` resolving against whatever ancestor happened
103
+ // to be positioned — usually the drawer, putting the dropdown in the
104
+ // wrong place entirely.
105
+ <Listbox
106
+ as="div"
107
+ className="relative"
108
+ value={currentValue ?? ""}
109
+ onChange={handleChange}
110
+ {...omitUndefined({ disabled })}
111
+ >
112
+ <SelectContext.Provider value={{ value: currentValue, itemLabel }}>
113
+ {children}
114
+ </SelectContext.Provider>
115
+ </Listbox>
116
+ );
117
+ }
118
+
119
+ // Unconsumed today (grepped across `admin/`) — kept for API parity. Radix's
120
+ // `SelectGroup` had no visual treatment of its own beyond semantic
121
+ // grouping; Headless UI's `Listbox` has no equivalent, so this stays a
122
+ // plain wrapper rather than reaching for `Menu`'s `MenuSection` (a
123
+ // different component family).
124
+ export function SelectGroup({ children }: { children: ReactNode }) {
125
+ return <fieldset className="contents border-0 p-0 m-0 min-w-0">{children}</fieldset>;
126
+ }
127
+
128
+ export function SelectTrigger({
129
+ id,
130
+ className,
131
+ children,
132
+ }: {
133
+ id?: string;
134
+ className?: string;
135
+ children: ReactNode;
136
+ }) {
137
+ useSelectContext("SelectTrigger");
138
+ return (
139
+ <ListboxButton
140
+ id={id}
141
+ className={cn(
142
+ "select select-bordered flex w-full items-center justify-between font-sans text-prose",
143
+ className,
144
+ )}
145
+ >
146
+ {children}
147
+ <ChevronDown
148
+ size={14}
149
+ strokeWidth={1.5}
150
+ className="text-muted-foreground"
151
+ aria-hidden="true"
152
+ />
153
+ </ListboxButton>
154
+ );
155
+ }
156
+
157
+ export function SelectValue({ placeholder }: { placeholder?: string }) {
158
+ const { value, itemLabel } = useSelectContext("SelectValue");
159
+ if (value === undefined || value === "") {
160
+ return <span className="text-subtle-foreground">{placeholder}</span>;
161
+ }
162
+ return <>{itemLabel(value) ?? value}</>;
163
+ }
164
+
165
+ export function SelectContent({
166
+ className,
167
+ children,
168
+ }: {
169
+ className?: string;
170
+ children: ReactNode;
171
+ }) {
172
+ useSelectContext("SelectContent");
173
+ return (
174
+ <ListboxOptions
175
+ // `portal={false}` is a correctness fix, not a preference.
176
+ //
177
+ // Headless UI's `anchor` prop portals the options into
178
+ // `#headlessui-portal-root`, a top-level sibling of `<body>`. Inside a
179
+ // `vaul` drawer that is fatal: vaul's `Content` mounts a Radix
180
+ // `FocusScope` with `trapped: true`, a document-level `focusin`
181
+ // listener that force-refocuses back into the drawer the instant
182
+ // focus lands outside it. The portaled listbox is outside, so its
183
+ // enter transition stalls mid-flight and it never becomes usable.
184
+ //
185
+ // Measured on the live console rather than inferred — opening the
186
+ // registration status select inside its stacked drawer gave:
187
+ //
188
+ // parentChain [..., DIV#headlessui-portal-root, BODY]
189
+ // insideAnyDrawer false
190
+ // opacity "0"
191
+ // pointerEvents "none"
192
+ // rect [122, 10] (collapsed, not four options tall)
193
+ //
194
+ // — byte-for-byte the signature #274 recorded for a nested Dialog.
195
+ // #282 fixed that class for `Dialog` and never covered `Select`, so
196
+ // every select inside a drawer has been silently unusable since.
197
+ //
198
+ // Rendering inline keeps the options inside the drawer's own subtree,
199
+ // so the focus trap contains them instead of fighting them. The cost
200
+ // is losing `anchor`'s collision detection; `top-full` + `w-full`
201
+ // below reproduces the same "directly under the trigger, matching its
202
+ // width" placement, which is what every call site here wants anyway.
203
+ portal={false}
204
+ // No `transition`, and no `data-closed:*` classes. Headless UI's
205
+ // transition machinery holds `data-closed` until it observes the
206
+ // enter transition finish; inside a drawer it never does. Measured
207
+ // after the portal fix, 1.4s after opening: `data-closed` and
208
+ // `data-enter` both still present, `opacity: 0`, on an element that
209
+ // was otherwise correct (inside the drawer, `pointer-events: auto`,
210
+ // full 146px height, all four options present).
211
+ //
212
+ // That is the same failure mode twice now — #274's nested Dialog was
213
+ // also a stalled enter transition, not a mispositioned element. A
214
+ // 100ms fade on a select dropdown is not worth a second component
215
+ // that silently does not open, so it is gone rather than debugged.
216
+ className={cn(
217
+ "absolute top-full left-0 z-50 mt-1 max-h-80 w-full min-w-[8rem] overflow-y-auto rounded-md border border-edge bg-surface-2 p-1 shadow-[var(--shadow-popover)] focus:outline-none",
218
+ className,
219
+ )}
220
+ >
221
+ {children}
222
+ </ListboxOptions>
223
+ );
224
+ }
225
+
226
+ export function SelectItem({
227
+ value,
228
+ className,
229
+ children,
230
+ }: {
231
+ value: string;
232
+ className?: string;
233
+ children: ReactNode;
234
+ }) {
235
+ return (
236
+ <ListboxOption
237
+ value={value}
238
+ className={cn(
239
+ "relative flex cursor-pointer items-center rounded-sm py-1.5 pr-2 pl-7 text-body text-foreground outline-none",
240
+ "data-focus:bg-surface-3",
241
+ className,
242
+ )}
243
+ >
244
+ {({ selected }) => (
245
+ <>
246
+ <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
247
+ {selected && <Check size={14} strokeWidth={1.5} aria-hidden="true" />}
248
+ </span>
249
+ {children}
250
+ </>
251
+ )}
252
+ </ListboxOption>
253
+ );
254
+ }
@@ -0,0 +1,49 @@
1
+ import { forwardRef } from "react";
2
+ import { cn } from "../../lib/cn";
3
+
4
+ // D6: `@radix-ui/react-separator` is deleted outright — the Radix primitive
5
+ // was already a near-trivial wrapper around a styled `<div>` with a
6
+ // `role`/`aria-orientation` toggle for the decorative/semantic distinction,
7
+ // so nothing behavioural is lost by hand-rolling that same toggle here.
8
+ //
9
+ // The non-decorative branch renders a real `<hr>` rather than a `<div
10
+ // role="separator">` — found via Biome's own a11y lint, not assumed: an
11
+ // ARIA `separator` role on a generic element is the *interactive* (focusable,
12
+ // resizable-pane-divider) variant per WAI-ARIA, which requires `tabIndex`
13
+ // and `aria-valuenow`; neither is meaningful for a plain visual rule. `<hr>`
14
+ // carries the equivalent *static* separator semantics natively, with no
15
+ // required ARIA attributes, and is what Biome's `useSemanticElements` rule
16
+ // itself suggests.
17
+ export interface SeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
18
+ orientation?: "horizontal" | "vertical";
19
+ /** Matches Radix's own default: `true` renders a purely visual rule with
20
+ * no `separator` semantics exposed to assistive tech (`role="none"` on a
21
+ * `<div>`); `false` renders a native `<hr>` with `aria-orientation`. */
22
+ decorative?: boolean;
23
+ }
24
+
25
+ export const Separator = forwardRef<HTMLDivElement, SeparatorProps>(
26
+ ({ className, orientation = "horizontal", decorative = true, ...props }, ref) => {
27
+ const sharedClassName = cn(
28
+ "shrink-0 border-0 bg-edge",
29
+ orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
30
+ className,
31
+ );
32
+ if (decorative) {
33
+ return <div ref={ref} role="none" className={sharedClassName} {...props} />;
34
+ }
35
+ // No consumer forwards a ref through the non-decorative branch today
36
+ // (grepped across `admin/`) — the cast is a documented, zero-risk
37
+ // approximation rather than widening `Separator`'s public ref type to
38
+ // `HTMLDivElement | HTMLHRElement` for a case nothing exercises.
39
+ return (
40
+ <hr
41
+ ref={ref as unknown as React.Ref<HTMLHRElement>}
42
+ aria-orientation={orientation}
43
+ className={sharedClassName}
44
+ {...props}
45
+ />
46
+ );
47
+ },
48
+ );
49
+ Separator.displayName = "Separator";
@@ -0,0 +1,253 @@
1
+ "use client";
2
+
3
+ import { Disclosure, DisclosureButton, DisclosurePanel } from "@headlessui/react";
4
+ import { ChevronDown } from "lucide-react";
5
+ import type { ComponentType, ReactNode } from "react";
6
+ import { cn } from "../../lib/cn";
7
+
8
+ /**
9
+ * The console's information architecture (console-redesign.md §4), as data.
10
+ * `@vaam-apps/ui` owns the shape; `frontends/apps/admin/app/nav-groups.ts` owns the actual
11
+ * content (routes, groupings, icons) — this file never hardcodes a route.
12
+ */
13
+ export interface NavItem {
14
+ label: string;
15
+ href: string;
16
+ /** Any lucide-react icon component. Typed structurally so this file
17
+ * doesn't need to depend on `lucide-react`'s own exported icon type name. */
18
+ icon: ComponentType<{ size?: number; className?: string; "aria-hidden"?: boolean | "true" }>;
19
+ }
20
+
21
+ export interface NavGroup {
22
+ /** Small-caps section header, e.g. "Messaging" (console-redesign.md §4). */
23
+ label: string;
24
+ items: NavItem[];
25
+ }
26
+
27
+ export interface SideNavProps {
28
+ /** Flat, ungrouped, always first — §4 ("Dashboard"). */
29
+ topItem: NavItem;
30
+ groups: NavGroup[];
31
+ /** De-emphasized footer utility rows (§1.1: "administrivia, not content"). */
32
+ footerItems: NavItem[];
33
+ /** Current pathname, for active-row highlighting. Plain string, not a
34
+ * `next/navigation` call — this package has no dependency on Next.js, so
35
+ * the caller (`frontends/apps/admin/app/console-shell.tsx`) resolves it via
36
+ * `usePathname()` and passes it down. */
37
+ currentPath: string;
38
+ /** App-specific account/sign-out markup (§4's footer "Signed in as
39
+ * <email> · Sign out" row) — rendered as-is, never built here, so this
40
+ * component stays free of any auth-specific knowledge. */
41
+ accountSlot?: ReactNode;
42
+ className?: string;
43
+ }
44
+
45
+ function isActive(href: string, currentPath: string): boolean {
46
+ if (href === "/") return currentPath === "/";
47
+ return currentPath === href || currentPath.startsWith(`${href}/`);
48
+ }
49
+
50
+ /**
51
+ * One nav row.
52
+ *
53
+ * **CSS-driven, not `useMediaQuery`-driven — found live, corrected after
54
+ * an earlier revision of this file used `@uidotdev/usehooks`' hook here
55
+ * and wrapped `SideNav` in `next/dynamic({ ssr: false })` to work around
56
+ * its SSR crash (see `console-shell.tsx`'s own history / this file's own
57
+ * git log for the full finding).** That fix traded a real cost — the
58
+ * sidebar never appeared in the server-rendered HTML, so every full page
59
+ * load painted an empty slab and popped the real nav in after hydration —
60
+ * for a problem the breakpoint switch itself never needed JS to solve:
61
+ * `variant="responsive"` below expresses "full label off-canvas and at
62
+ * `xl`, `sr-only` at the `lg` icon-rail band" purely with `lg:sr-only
63
+ * xl:not-sr-only`, no hook, no client-only state, no hydration boundary.
64
+ *
65
+ * - **`"responsive"`**: the label is visible off-canvas (`<1024px`, where
66
+ * this row is never reached without the group around it already being
67
+ * fully expanded) and at `≥1280px`, `sr-only` in the `1024–1279px` icon
68
+ * rail band — a daisyUI `.tooltip`/`data-tip` (D5) carries the label
69
+ * back on hover/focus there. Used for every row that can appear in the
70
+ * *persistent* sidebar: `topItem`, `footerItems`, and each group's own
71
+ * "always expanded" desktop rows (`GroupSection` below).
72
+ * - **`"always"`**: the label is always visible, no responsive classes at
73
+ * all. Used only inside the off-canvas accordion tree, which is itself
74
+ * gated to `<1024px` by its own `lg:hidden` wrapper — a per-row
75
+ * breakpoint switch would be redundant there, since that whole tree
76
+ * never renders at any width where a switch could matter.
77
+ */
78
+ function NavLink({
79
+ item,
80
+ active,
81
+ variant,
82
+ dim = false,
83
+ }: {
84
+ item: NavItem;
85
+ active: boolean;
86
+ variant: "responsive" | "always";
87
+ dim?: boolean;
88
+ }) {
89
+ const Icon = item.icon;
90
+ const responsive = variant === "responsive";
91
+ return (
92
+ <a
93
+ href={item.href}
94
+ aria-current={active ? "page" : undefined}
95
+ data-tip={item.label}
96
+ className={cn(
97
+ "flex items-center gap-3 rounded-field px-3 py-2 transition-colors",
98
+ dim ? "text-caption" : "text-body",
99
+ responsive && "tooltip tooltip-right lg:justify-center lg:px-0 xl:justify-start xl:px-3",
100
+ active && !dim && "bg-base-300 font-medium text-foreground",
101
+ active && dim && "text-foreground",
102
+ !active && "text-muted-foreground hover:bg-base-300/60 hover:text-foreground",
103
+ )}
104
+ >
105
+ <Icon size={16} className="shrink-0" aria-hidden="true" />
106
+ <span className={cn(responsive && "lg:sr-only xl:not-sr-only")}>{item.label}</span>
107
+ </a>
108
+ );
109
+ }
110
+
111
+ /**
112
+ * One group's worth of rows, rendered as **two parallel trees**, one per
113
+ * §6.1 regime, each visible only at its own breakpoint via a plain
114
+ * `hidden`/`lg:hidden` toggle — not one tree whose *content* changes based
115
+ * on a JS-read viewport width. The two regimes genuinely need different
116
+ * DOM, not just different visibility, because the off-canvas tree is
117
+ * interactive (a collapsible `Disclosure`) and the persistent tree is not
118
+ * (every group is always fully expanded there, matching §1.1's LottieFiles
119
+ * reference, which shows no user-collapsible groups on desktop at all) —
120
+ * duplicating ~4 rows per group across two trees is a irrelevant DOM cost
121
+ * next to the alternative (a client-only nav that never appears in the
122
+ * first paint).
123
+ *
124
+ * - **Off-canvas** (`lg:hidden`, so effectively `<1024px` — both the phone
125
+ * band and the tablet band §6.1 explicitly keeps behind the hamburger):
126
+ * a Headless UI `Disclosure`, collapsed by default, expanded only if it
127
+ * contains the current route — "roughly 6–8 tappable rows on open, not
128
+ * 18" (§4). `defaultOpen` is computed from `currentPath`, which is
129
+ * available identically on the server and the client, so this renders
130
+ * correctly on first paint with no hydration mismatch.
131
+ * - **Persistent** (`hidden lg:flex`, so `≥1024px`): always fully
132
+ * expanded. The header/rail-divider split (small-caps text at `≥1280px`,
133
+ * a hairline divider instead in the `1024–1279px` icon rail band) is the
134
+ * same CSS-toggle technique `NavLink`'s own `"responsive"` variant uses.
135
+ */
136
+ function GroupSection({ group, currentPath }: { group: NavGroup; currentPath: string }) {
137
+ const hasActiveItem = group.items.some((item) => isActive(item.href, currentPath));
138
+
139
+ return (
140
+ <>
141
+ <div className="lg:hidden">
142
+ <Disclosure defaultOpen={hasActiveItem}>
143
+ {({ open }) => (
144
+ <div className="flex flex-col gap-0.5">
145
+ <DisclosureButton className="flex items-center justify-between rounded-field px-3 py-1.5 text-left text-caption text-subtle-foreground uppercase tracking-wide hover:text-foreground">
146
+ {group.label}
147
+ <ChevronDown
148
+ size={14}
149
+ aria-hidden="true"
150
+ className={cn("transition-transform", open && "rotate-180")}
151
+ />
152
+ </DisclosureButton>
153
+ <DisclosurePanel className="flex flex-col gap-0.5">
154
+ {group.items.map((item) => (
155
+ <NavLink
156
+ key={item.href}
157
+ item={item}
158
+ active={isActive(item.href, currentPath)}
159
+ variant="always"
160
+ />
161
+ ))}
162
+ </DisclosurePanel>
163
+ </div>
164
+ )}
165
+ </Disclosure>
166
+ </div>
167
+
168
+ <div className="hidden lg:flex lg:flex-col lg:gap-0.5">
169
+ <p className="hidden px-3 py-1.5 text-caption text-subtle-foreground uppercase tracking-wide xl:block">
170
+ {group.label}
171
+ </p>
172
+ <div className="mx-3 my-1 border-edge-subtle border-t xl:hidden" aria-hidden="true" />
173
+ {group.items.map((item) => (
174
+ <NavLink
175
+ key={item.href}
176
+ item={item}
177
+ active={isActive(item.href, currentPath)}
178
+ variant="responsive"
179
+ />
180
+ ))}
181
+ </div>
182
+ </>
183
+ );
184
+ }
185
+
186
+ /**
187
+ * The console's side navigation (console-redesign.md §4, §6.2). A pure
188
+ * structural/layout component — routing, session data, and the drawer's
189
+ * own off-canvas/persistent CSS split all live at the call site
190
+ * (`frontends/apps/admin/app/console-shell.tsx`, D7): this component only ever decides
191
+ * "flat top item → grouped middle → de-emphasized footer" composition and
192
+ * the label/icon-only/accordion treatment per breakpoint.
193
+ *
194
+ * Breakpoint behaviour (§6.1 is the authoritative table here — see that
195
+ * section's own reasoning for why it supersedes §4's looser "Tablet
196
+ * (768–1024px)" prose, which pre-dates that table and was never updated to
197
+ * match it) — entirely CSS-driven (`lg:`/`xl:` Tailwind variants), never a
198
+ * JS breakpoint read:
199
+ * - `<1024px` (phone and tablet alike): off-canvas, full labels,
200
+ * collapsible accordion groups.
201
+ * - `1024–1279px`: persistent icon-only rail, tooltip on hover/focus.
202
+ * - `≥1280px`: persistent full sidebar with labels.
203
+ *
204
+ * This means the whole nav — every row, every group, every breakpoint's
205
+ * shape — is present in the server-rendered HTML on first paint. No
206
+ * `next/dynamic({ ssr: false })`, no client-only mount flash. Verify that
207
+ * claim directly, not by trusting this comment: `curl` a page and grep the
208
+ * raw HTML for a nav item's label.
209
+ */
210
+ export function SideNav({
211
+ topItem,
212
+ groups,
213
+ footerItems,
214
+ currentPath,
215
+ accountSlot,
216
+ className,
217
+ }: SideNavProps) {
218
+ return (
219
+ <nav
220
+ aria-label="Primary"
221
+ className={cn("flex h-full flex-col gap-4 overflow-y-auto bg-base-200 py-4", className)}
222
+ >
223
+ <div className="flex flex-col gap-0.5 px-2">
224
+ <NavLink item={topItem} active={isActive(topItem.href, currentPath)} variant="responsive" />
225
+ </div>
226
+
227
+ <div className="flex flex-1 flex-col gap-4 px-2">
228
+ {groups.map((group) => (
229
+ <GroupSection key={group.label} group={group} currentPath={currentPath} />
230
+ ))}
231
+ </div>
232
+
233
+ <div className="flex flex-col gap-2 border-edge-subtle border-t px-2 pt-3">
234
+ <div className="flex flex-col gap-0.5">
235
+ {footerItems.map((item) => (
236
+ <NavLink
237
+ key={item.href}
238
+ item={item}
239
+ active={isActive(item.href, currentPath)}
240
+ variant="responsive"
241
+ dim
242
+ />
243
+ ))}
244
+ </div>
245
+ {/* Hidden in the icon rail band only (no room for the account
246
+ block at 64px); shown off-canvas and at the full-label desktop
247
+ width — same `lg:hidden xl:block` toggle technique as the rest
248
+ of this file, not a JS breakpoint check. */}
249
+ {accountSlot != null && <div className="px-3 lg:hidden xl:block">{accountSlot}</div>}
250
+ </div>
251
+ </nav>
252
+ );
253
+ }
@@ -0,0 +1,25 @@
1
+ import type { HTMLAttributes } from "react";
2
+ import { cn } from "../../lib/cn";
3
+
4
+ /**
5
+ * Static — never shimmering (design doc §3.8 rule 2: "no skeleton
6
+ * shimmer"; §5.2: skeleton rows must match the real row height exactly so
7
+ * nothing shifts on load). House rule, not negotiable — see this repo's
8
+ * own AGENTS.md-lineage note; do not add animation here.
9
+ *
10
+ * Deliberately hand-rolled `<div>`, not daisyUI's own `.skeleton` class:
11
+ * read `daisyui/components/skeleton.css` directly before deciding this —
12
+ * daisyUI's `.skeleton` ships a `background-position` shimmer under
13
+ * `animation:1.8s ease-in-out infinite skeleton` (guarded only by
14
+ * `prefers-reduced-motion`, which is not the same thing as "off by
15
+ * default"), which is exactly the animation this house rule forbids.
16
+ * Radius stays `rounded-sm` (now `--radius-field`, 12px, via Phase 0's
17
+ * token rewrite) rather than moving to `--radius-box` — this component
18
+ * stands in for table cells and text lines across every screen, all
19
+ * field-scale content, not card-scale surfaces, so the token that already
20
+ * existed here is also the correct D8 tier, not a value left untouched by
21
+ * omission.
22
+ */
23
+ export function Skeleton({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
24
+ return <div className={cn("rounded-sm bg-surface-3", className)} {...props} />;
25
+ }
@@ -0,0 +1,39 @@
1
+ import { cn } from "../../lib/cn";
2
+
3
+ export interface SpinnerProps {
4
+ size?: "xs" | "sm" | "md" | "lg";
5
+ /** Announced to assistive technology. `undefined` renders the spinner
6
+ * `aria-hidden`, which is correct when a visible sibling already says
7
+ * what is loading — two announcements of the same wait is worse than
8
+ * one. */
9
+ label?: string | undefined;
10
+ className?: string | undefined;
11
+ }
12
+
13
+ const SIZE_CLASS = {
14
+ xs: "loading-xs",
15
+ sm: "loading-sm",
16
+ md: "loading-md",
17
+ lg: "loading-lg",
18
+ } as const;
19
+
20
+ /**
21
+ * An indeterminate busy indicator, for a wait whose *shape* is unknown.
22
+ *
23
+ * Prefer `Skeleton` wherever the shape of what is arriving is known —
24
+ * a table, a card, a detail pane. A skeleton keeps the layout from
25
+ * jumping when content lands and tells the reader what to expect; a
26
+ * spinner tells them only that something is happening. This is for the
27
+ * rest: a button mid-submit, a poll with no placeholder to draw.
28
+ */
29
+ export function Spinner({ size = "sm", label, className }: SpinnerProps) {
30
+ return (
31
+ <span
32
+ className={cn("loading loading-spinner", SIZE_CLASS[size], className)}
33
+ role={label === undefined ? undefined : "status"}
34
+ aria-hidden={label === undefined ? true : undefined}
35
+ >
36
+ {label !== undefined && <span className="sr-only">{label}</span>}
37
+ </span>
38
+ );
39
+ }