@hoardodile/ui 0.0.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 (356) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +70 -0
  3. package/dist/components/alert.d.ts +13 -0
  4. package/dist/components/alert.js +97 -0
  5. package/dist/components/alert.js.map +1 -0
  6. package/dist/components/app-dialog.d.ts +62 -0
  7. package/dist/components/app-dialog.js +656 -0
  8. package/dist/components/app-dialog.js.map +1 -0
  9. package/dist/components/badge.d.ts +11 -0
  10. package/dist/components/badge.js +70 -0
  11. package/dist/components/badge.js.map +1 -0
  12. package/dist/components/breadcrumb.d.ts +12 -0
  13. package/dist/components/breadcrumb.js +617 -0
  14. package/dist/components/breadcrumb.js.map +1 -0
  15. package/dist/components/button.d.ts +17 -0
  16. package/dist/components/button.js +78 -0
  17. package/dist/components/button.js.map +1 -0
  18. package/dist/components/caption-bar.d.ts +44 -0
  19. package/dist/components/caption-bar.js +827 -0
  20. package/dist/components/caption-bar.js.map +1 -0
  21. package/dist/components/chart-card.d.ts +15 -0
  22. package/dist/components/chart-card.js +27 -0
  23. package/dist/components/chart-card.js.map +1 -0
  24. package/dist/components/checkbox.d.ts +6 -0
  25. package/dist/components/checkbox.js +76 -0
  26. package/dist/components/checkbox.js.map +1 -0
  27. package/dist/components/chip-button.d.ts +28 -0
  28. package/dist/components/chip-button.js +87 -0
  29. package/dist/components/chip-button.js.map +1 -0
  30. package/dist/components/close-confirm-dialog.d.ts +19 -0
  31. package/dist/components/close-confirm-dialog.js +743 -0
  32. package/dist/components/close-confirm-dialog.js.map +1 -0
  33. package/dist/components/color-picker.d.ts +32 -0
  34. package/dist/components/color-picker.js +667 -0
  35. package/dist/components/color-picker.js.map +1 -0
  36. package/dist/components/combobox.d.ts +32 -0
  37. package/dist/components/combobox.js +1211 -0
  38. package/dist/components/combobox.js.map +1 -0
  39. package/dist/components/confirm-by-typing-dialog.d.ts +48 -0
  40. package/dist/components/confirm-by-typing-dialog.js +759 -0
  41. package/dist/components/confirm-by-typing-dialog.js.map +1 -0
  42. package/dist/components/confirm-dialog.d.ts +34 -0
  43. package/dist/components/confirm-dialog.js +710 -0
  44. package/dist/components/confirm-dialog.js.map +1 -0
  45. package/dist/components/count-badge.d.ts +13 -0
  46. package/dist/components/count-badge.js +39 -0
  47. package/dist/components/count-badge.js.map +1 -0
  48. package/dist/components/dialog.d.ts +40 -0
  49. package/dist/components/dialog.js +571 -0
  50. package/dist/components/dialog.js.map +1 -0
  51. package/dist/components/dropdown-menu.d.ts +45 -0
  52. package/dist/components/dropdown-menu.js +1003 -0
  53. package/dist/components/dropdown-menu.js.map +1 -0
  54. package/dist/components/dropdown-select.d.ts +29 -0
  55. package/dist/components/dropdown-select.js +1036 -0
  56. package/dist/components/dropdown-select.js.map +1 -0
  57. package/dist/components/empty.d.ts +15 -0
  58. package/dist/components/empty.js +119 -0
  59. package/dist/components/empty.js.map +1 -0
  60. package/dist/components/filter-rail.d.ts +48 -0
  61. package/dist/components/filter-rail.js +231 -0
  62. package/dist/components/filter-rail.js.map +1 -0
  63. package/dist/components/fixed-action-bar.d.ts +24 -0
  64. package/dist/components/fixed-action-bar.js +48 -0
  65. package/dist/components/fixed-action-bar.js.map +1 -0
  66. package/dist/components/font-picker.d.ts +45 -0
  67. package/dist/components/font-picker.js +759 -0
  68. package/dist/components/font-picker.js.map +1 -0
  69. package/dist/components/form.d.ts +17 -0
  70. package/dist/components/form.js +150 -0
  71. package/dist/components/form.js.map +1 -0
  72. package/dist/components/group-label.d.ts +12 -0
  73. package/dist/components/group-label.js +10 -0
  74. package/dist/components/group-label.js.map +1 -0
  75. package/dist/components/icon-tile.d.ts +16 -0
  76. package/dist/components/icon-tile.js +67 -0
  77. package/dist/components/icon-tile.js.map +1 -0
  78. package/dist/components/icon-toggle.d.ts +24 -0
  79. package/dist/components/icon-toggle.js +81 -0
  80. package/dist/components/icon-toggle.js.map +1 -0
  81. package/dist/components/icon.d.ts +2 -0
  82. package/dist/components/icon.js +49 -0
  83. package/dist/components/icon.js.map +1 -0
  84. package/dist/components/image-crop-panel.d.ts +80 -0
  85. package/dist/components/image-crop-panel.js +1104 -0
  86. package/dist/components/image-crop-panel.js.map +1 -0
  87. package/dist/components/image-cropper.d.ts +63 -0
  88. package/dist/components/image-cropper.js +166 -0
  89. package/dist/components/image-cropper.js.map +1 -0
  90. package/dist/components/input-group.d.ts +22 -0
  91. package/dist/components/input-group.js +270 -0
  92. package/dist/components/input-group.js.map +1 -0
  93. package/dist/components/input.d.ts +16 -0
  94. package/dist/components/input.js +60 -0
  95. package/dist/components/input.js.map +1 -0
  96. package/dist/components/label.d.ts +5 -0
  97. package/dist/components/label.js +40 -0
  98. package/dist/components/label.js.map +1 -0
  99. package/dist/components/list-empty-row.d.ts +16 -0
  100. package/dist/components/list-empty-row.js +40 -0
  101. package/dist/components/list-empty-row.js.map +1 -0
  102. package/dist/components/management-empty.d.ts +14 -0
  103. package/dist/components/management-empty.js +40 -0
  104. package/dist/components/management-empty.js.map +1 -0
  105. package/dist/components/management-skeleton.d.ts +11 -0
  106. package/dist/components/management-skeleton.js +72 -0
  107. package/dist/components/management-skeleton.js.map +1 -0
  108. package/dist/components/meta-chip.d.ts +17 -0
  109. package/dist/components/meta-chip.js +49 -0
  110. package/dist/components/meta-chip.js.map +1 -0
  111. package/dist/components/mobile-drawer.d.ts +29 -0
  112. package/dist/components/mobile-drawer.js +331 -0
  113. package/dist/components/mobile-drawer.js.map +1 -0
  114. package/dist/components/page-header.d.ts +20 -0
  115. package/dist/components/page-header.js +44 -0
  116. package/dist/components/page-header.js.map +1 -0
  117. package/dist/components/page-scaffold.d.ts +34 -0
  118. package/dist/components/page-scaffold.js +157 -0
  119. package/dist/components/page-scaffold.js.map +1 -0
  120. package/dist/components/pagination-bar.d.ts +22 -0
  121. package/dist/components/pagination-bar.js +703 -0
  122. package/dist/components/pagination-bar.js.map +1 -0
  123. package/dist/components/pagination.d.ts +22 -0
  124. package/dist/components/pagination.js +680 -0
  125. package/dist/components/pagination.js.map +1 -0
  126. package/dist/components/panel-toolbar.d.ts +37 -0
  127. package/dist/components/panel-toolbar.js +781 -0
  128. package/dist/components/panel-toolbar.js.map +1 -0
  129. package/dist/components/pill-tabs.d.ts +33 -0
  130. package/dist/components/pill-tabs.js +66 -0
  131. package/dist/components/pill-tabs.js.map +1 -0
  132. package/dist/components/plugin-download-consent.d.ts +52 -0
  133. package/dist/components/plugin-download-consent.js +780 -0
  134. package/dist/components/plugin-download-consent.js.map +1 -0
  135. package/dist/components/popover.d.ts +26 -0
  136. package/dist/components/popover.js +343 -0
  137. package/dist/components/popover.js.map +1 -0
  138. package/dist/components/progress.d.ts +10 -0
  139. package/dist/components/progress.js +103 -0
  140. package/dist/components/progress.js.map +1 -0
  141. package/dist/components/query-state-view.d.ts +36 -0
  142. package/dist/components/query-state-view.js +17 -0
  143. package/dist/components/query-state-view.js.map +1 -0
  144. package/dist/components/radio-group.d.ts +8 -0
  145. package/dist/components/radio-group.js +60 -0
  146. package/dist/components/radio-group.js.map +1 -0
  147. package/dist/components/scroll-area.d.ts +7 -0
  148. package/dist/components/scroll-area.js +80 -0
  149. package/dist/components/scroll-area.js.map +1 -0
  150. package/dist/components/search-field.d.ts +49 -0
  151. package/dist/components/search-field.js +586 -0
  152. package/dist/components/search-field.js.map +1 -0
  153. package/dist/components/secondary-stat.d.ts +13 -0
  154. package/dist/components/secondary-stat.js +56 -0
  155. package/dist/components/secondary-stat.js.map +1 -0
  156. package/dist/components/section-header.d.ts +23 -0
  157. package/dist/components/section-header.js +76 -0
  158. package/dist/components/section-header.js.map +1 -0
  159. package/dist/components/section-label.d.ts +20 -0
  160. package/dist/components/section-label.js +46 -0
  161. package/dist/components/section-label.js.map +1 -0
  162. package/dist/components/section-tabs.d.ts +47 -0
  163. package/dist/components/section-tabs.js +145 -0
  164. package/dist/components/section-tabs.js.map +1 -0
  165. package/dist/components/separator.d.ts +14 -0
  166. package/dist/components/separator.js +49 -0
  167. package/dist/components/separator.js.map +1 -0
  168. package/dist/components/sheet.d.ts +22 -0
  169. package/dist/components/sheet.js +469 -0
  170. package/dist/components/sheet.js.map +1 -0
  171. package/dist/components/sidebar.d.ts +69 -0
  172. package/dist/components/sidebar.js +1645 -0
  173. package/dist/components/sidebar.js.map +1 -0
  174. package/dist/components/skeleton.d.ts +8 -0
  175. package/dist/components/skeleton.js +41 -0
  176. package/dist/components/skeleton.js.map +1 -0
  177. package/dist/components/slider.d.ts +6 -0
  178. package/dist/components/slider.js +75 -0
  179. package/dist/components/slider.js.map +1 -0
  180. package/dist/components/sortable-chip-list.d.ts +26 -0
  181. package/dist/components/sortable-chip-list.js +59 -0
  182. package/dist/components/sortable-chip-list.js.map +1 -0
  183. package/dist/components/spinner.d.ts +5 -0
  184. package/dist/components/spinner.js +65 -0
  185. package/dist/components/spinner.js.map +1 -0
  186. package/dist/components/surface.d.ts +13 -0
  187. package/dist/components/surface.js +51 -0
  188. package/dist/components/surface.js.map +1 -0
  189. package/dist/components/switch.d.ts +8 -0
  190. package/dist/components/switch.js +53 -0
  191. package/dist/components/switch.js.map +1 -0
  192. package/dist/components/table.d.ts +12 -0
  193. package/dist/components/table.js +129 -0
  194. package/dist/components/table.js.map +1 -0
  195. package/dist/components/textarea.d.ts +15 -0
  196. package/dist/components/textarea.js +58 -0
  197. package/dist/components/textarea.js.map +1 -0
  198. package/dist/components/toast.d.ts +19 -0
  199. package/dist/components/toast.js +805 -0
  200. package/dist/components/toast.js.map +1 -0
  201. package/dist/components/toggle.d.ts +12 -0
  202. package/dist/components/toggle.js +63 -0
  203. package/dist/components/toggle.js.map +1 -0
  204. package/dist/components/tooltip.d.ts +9 -0
  205. package/dist/components/tooltip.js +82 -0
  206. package/dist/components/tooltip.js.map +1 -0
  207. package/dist/hooks/use-dialog-footer-actions.d.ts +18 -0
  208. package/dist/hooks/use-dialog-footer-actions.js +11 -0
  209. package/dist/hooks/use-dialog-footer-actions.js.map +1 -0
  210. package/dist/hooks/use-form-field.d.ts +33 -0
  211. package/dist/hooks/use-form-field.js +36 -0
  212. package/dist/hooks/use-form-field.js.map +1 -0
  213. package/dist/hooks/use-mobile.d.ts +34 -0
  214. package/dist/hooks/use-mobile.js +29 -0
  215. package/dist/hooks/use-mobile.js.map +1 -0
  216. package/dist/hooks/use-pinch-pan.d.ts +56 -0
  217. package/dist/hooks/use-pinch-pan.js +223 -0
  218. package/dist/hooks/use-pinch-pan.js.map +1 -0
  219. package/dist/hooks/useMobileBackToClose.d.ts +61 -0
  220. package/dist/hooks/useMobileBackToClose.js +234 -0
  221. package/dist/hooks/useMobileBackToClose.js.map +1 -0
  222. package/dist/icon-BOgL5bpL.d.ts +85 -0
  223. package/dist/icons/actions.d.ts +4 -0
  224. package/dist/icons/actions.js +520 -0
  225. package/dist/icons/actions.js.map +1 -0
  226. package/dist/icons/icon-style.d.ts +2 -0
  227. package/dist/icons/icon-style.js +51 -0
  228. package/dist/icons/icon-style.js.map +1 -0
  229. package/dist/icons/marks.d.ts +15 -0
  230. package/dist/icons/marks.js +48 -0
  231. package/dist/icons/marks.js.map +1 -0
  232. package/dist/icons/registry.d.ts +177 -0
  233. package/dist/icons/registry.js +1180 -0
  234. package/dist/icons/registry.js.map +1 -0
  235. package/dist/index.d.ts +6 -0
  236. package/dist/index.js +653 -0
  237. package/dist/index.js.map +1 -0
  238. package/dist/lib/merge-config.d.ts +15 -0
  239. package/dist/lib/merge-config.js +20 -0
  240. package/dist/lib/merge-config.js.map +1 -0
  241. package/dist/lib/pagination.d.ts +19 -0
  242. package/dist/lib/pagination.js +26 -0
  243. package/dist/lib/pagination.js.map +1 -0
  244. package/dist/lib/utils.d.ts +5 -0
  245. package/dist/lib/utils.js +26 -0
  246. package/dist/lib/utils.js.map +1 -0
  247. package/dist/styles/theme.css +480 -0
  248. package/dist/styles/theme.css.map +1 -0
  249. package/dist/styles/theme.d.ts +2 -0
  250. package/dist/viewport.d.ts +64 -0
  251. package/dist/viewport.js +13 -0
  252. package/dist/viewport.js.map +1 -0
  253. package/package.json +122 -0
  254. package/src/components/alert.tsx +76 -0
  255. package/src/components/app-dialog.tsx +187 -0
  256. package/src/components/badge.tsx +52 -0
  257. package/src/components/breadcrumb.tsx +129 -0
  258. package/src/components/button.tsx +70 -0
  259. package/src/components/caption-bar.test.tsx +151 -0
  260. package/src/components/caption-bar.tsx +392 -0
  261. package/src/components/chart-card.tsx +31 -0
  262. package/src/components/checkbox.tsx +28 -0
  263. package/src/components/chip-button.tsx +69 -0
  264. package/src/components/close-confirm-dialog.tsx +99 -0
  265. package/src/components/color-picker.tsx +186 -0
  266. package/src/components/combobox.tsx +335 -0
  267. package/src/components/confirm-by-typing-dialog.tsx +130 -0
  268. package/src/components/confirm-dialog.test.tsx +39 -0
  269. package/src/components/confirm-dialog.tsx +98 -0
  270. package/src/components/count-badge.tsx +23 -0
  271. package/src/components/dialog.tsx +315 -0
  272. package/src/components/dropdown-menu.tsx +311 -0
  273. package/src/components/dropdown-select.tsx +211 -0
  274. package/src/components/empty.tsx +104 -0
  275. package/src/components/filter-rail.tsx +139 -0
  276. package/src/components/fixed-action-bar.tsx +46 -0
  277. package/src/components/font-picker.tsx +257 -0
  278. package/src/components/form.tsx +127 -0
  279. package/src/components/group-label.tsx +13 -0
  280. package/src/components/icon-tile.tsx +35 -0
  281. package/src/components/icon-toggle.tsx +57 -0
  282. package/src/components/icon.tsx +66 -0
  283. package/src/components/image-crop-panel.test.tsx +167 -0
  284. package/src/components/image-crop-panel.tsx +465 -0
  285. package/src/components/image-cropper.test.tsx +144 -0
  286. package/src/components/image-cropper.tsx +265 -0
  287. package/src/components/input-group.tsx +156 -0
  288. package/src/components/input.tsx +45 -0
  289. package/src/components/label.tsx +20 -0
  290. package/src/components/list-empty-row.tsx +27 -0
  291. package/src/components/management-empty.tsx +25 -0
  292. package/src/components/management-skeleton.tsx +23 -0
  293. package/src/components/meta-chip.tsx +40 -0
  294. package/src/components/mobile-drawer.test.tsx +52 -0
  295. package/src/components/mobile-drawer.tsx +98 -0
  296. package/src/components/page-header.tsx +47 -0
  297. package/src/components/page-scaffold.test.tsx +24 -0
  298. package/src/components/page-scaffold.tsx +131 -0
  299. package/src/components/pagination-bar.test.tsx +87 -0
  300. package/src/components/pagination-bar.tsx +139 -0
  301. package/src/components/pagination.tsx +139 -0
  302. package/src/components/panel-toolbar.tsx +99 -0
  303. package/src/components/pill-tabs.test.tsx +84 -0
  304. package/src/components/pill-tabs.tsx +77 -0
  305. package/src/components/plugin-download-consent.test.tsx +90 -0
  306. package/src/components/plugin-download-consent.tsx +183 -0
  307. package/src/components/popover.tsx +133 -0
  308. package/src/components/progress.tsx +88 -0
  309. package/src/components/query-state-view.tsx +47 -0
  310. package/src/components/radio-group.tsx +36 -0
  311. package/src/components/scroll-area.tsx +52 -0
  312. package/src/components/search-field.test.tsx +86 -0
  313. package/src/components/search-field.tsx +119 -0
  314. package/src/components/secondary-stat.tsx +24 -0
  315. package/src/components/section-header.tsx +54 -0
  316. package/src/components/section-label.tsx +40 -0
  317. package/src/components/section-tabs.test.tsx +154 -0
  318. package/src/components/section-tabs.tsx +190 -0
  319. package/src/components/separator.tsx +38 -0
  320. package/src/components/sheet.tsx +170 -0
  321. package/src/components/sidebar.tsx +725 -0
  322. package/src/components/skeleton.tsx +18 -0
  323. package/src/components/slider.tsx +52 -0
  324. package/src/components/sortable-chip-list.tsx +56 -0
  325. package/src/components/spinner.tsx +35 -0
  326. package/src/components/surface.tsx +37 -0
  327. package/src/components/switch.tsx +30 -0
  328. package/src/components/table.tsx +116 -0
  329. package/src/components/textarea.tsx +41 -0
  330. package/src/components/toast.tsx +239 -0
  331. package/src/components/toggle.tsx +45 -0
  332. package/src/components/tooltip.tsx +64 -0
  333. package/src/hooks/use-dialog-footer-actions.ts +23 -0
  334. package/src/hooks/use-form-field.ts +62 -0
  335. package/src/hooks/use-mobile.ts +54 -0
  336. package/src/hooks/use-pinch-pan.test.tsx +42 -0
  337. package/src/hooks/use-pinch-pan.ts +340 -0
  338. package/src/hooks/useMobileBackToClose.test.tsx +140 -0
  339. package/src/hooks/useMobileBackToClose.ts +508 -0
  340. package/src/icons/actions.ts +10 -0
  341. package/src/icons/icon-style.test.tsx +69 -0
  342. package/src/icons/icon-style.ts +104 -0
  343. package/src/icons/marks.tsx +72 -0
  344. package/src/icons/registry.test.ts +65 -0
  345. package/src/icons/registry.ts +1163 -0
  346. package/src/index.ts +22 -0
  347. package/src/lib/merge-config.test.ts +109 -0
  348. package/src/lib/merge-config.ts +22 -0
  349. package/src/lib/pagination.test.ts +55 -0
  350. package/src/lib/pagination.ts +42 -0
  351. package/src/lib/utils.ts +6 -0
  352. package/src/styles/scan-contract.test.ts +74 -0
  353. package/src/styles/theme.css +798 -0
  354. package/src/test/i18n.tsx +22 -0
  355. package/src/test/setup.ts +104 -0
  356. package/src/viewport.ts +69 -0
@@ -0,0 +1,133 @@
1
+ import * as React from "react"
2
+ import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
3
+
4
+ import { cn } from "@hoardodile/ui/lib/utils"
5
+ import { useMobileBackToClose } from "@hoardodile/ui/hooks/useMobileBackToClose"
6
+
7
+ function Popover({
8
+ open,
9
+ defaultOpen,
10
+ onOpenChange,
11
+ ...props
12
+ }: Omit<PopoverPrimitive.Root.Props, "onOpenChange"> & {
13
+ /**
14
+ * `eventDetails` is absent for closes triggered by the mobile back
15
+ * gesture (a synthetic close with no underlying Base UI event).
16
+ */
17
+ onOpenChange?: (
18
+ open: boolean,
19
+ eventDetails?: PopoverPrimitive.Root.ChangeEventDetails,
20
+ ) => void
21
+ }) {
22
+ const [uncontrolledOpen, setUncontrolledOpen] = React.useState(
23
+ defaultOpen ?? false,
24
+ )
25
+ const isControlled = open !== undefined
26
+ const currentOpen = isControlled ? open : uncontrolledOpen
27
+ function handleOpenChange(
28
+ next: boolean,
29
+ eventDetails?: PopoverPrimitive.Root.ChangeEventDetails,
30
+ ) {
31
+ if (!isControlled) setUncontrolledOpen(next)
32
+ onOpenChange?.(next, eventDetails)
33
+ }
34
+ useMobileBackToClose(currentOpen, handleOpenChange)
35
+ return (
36
+ <PopoverPrimitive.Root
37
+ data-slot="popover"
38
+ open={currentOpen}
39
+ onOpenChange={handleOpenChange}
40
+ {...props}
41
+ />
42
+ )
43
+ }
44
+
45
+ function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
46
+ return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
47
+ }
48
+
49
+ function PopoverContent({
50
+ className,
51
+ align = "center",
52
+ alignOffset = 0,
53
+ side = "bottom",
54
+ sideOffset = 4,
55
+ container,
56
+ ...props
57
+ }: PopoverPrimitive.Popup.Props &
58
+ Pick<
59
+ PopoverPrimitive.Positioner.Props,
60
+ "align" | "alignOffset" | "side" | "sideOffset"
61
+ > & {
62
+ /**
63
+ * Override the portal target. Used by the video player so popovers
64
+ * stay attached to the fullscreen container; otherwise they'd be
65
+ * portalled to `document.body` and rendered behind the fullscreen
66
+ * surface, where every tap registers as outside-click and dismisses
67
+ * the popover immediately.
68
+ */
69
+ container?: PopoverPrimitive.Portal.Props["container"]
70
+ }) {
71
+ return (
72
+ <PopoverPrimitive.Portal container={container}>
73
+ <PopoverPrimitive.Positioner
74
+ align={align}
75
+ alignOffset={alignOffset}
76
+ side={side}
77
+ sideOffset={sideOffset}
78
+ className="isolate z-50"
79
+ >
80
+ <PopoverPrimitive.Popup
81
+ data-slot="popover-content"
82
+ className={cn(
83
+ "z-50 flex w-72 origin-(--transform-origin) flex-col gap-4 rounded-md bg-popover p-4 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
84
+ className
85
+ )}
86
+ {...props}
87
+ />
88
+ </PopoverPrimitive.Positioner>
89
+ </PopoverPrimitive.Portal>
90
+ )
91
+ }
92
+
93
+ function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
94
+ return (
95
+ <div
96
+ data-slot="popover-header"
97
+ className={cn("flex flex-col gap-1 text-sm", className)}
98
+ {...props}
99
+ />
100
+ )
101
+ }
102
+
103
+ function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
104
+ return (
105
+ <PopoverPrimitive.Title
106
+ data-slot="popover-title"
107
+ className={cn("font-medium", className)}
108
+ {...props}
109
+ />
110
+ )
111
+ }
112
+
113
+ function PopoverDescription({
114
+ className,
115
+ ...props
116
+ }: PopoverPrimitive.Description.Props) {
117
+ return (
118
+ <PopoverPrimitive.Description
119
+ data-slot="popover-description"
120
+ className={cn("text-muted-foreground", className)}
121
+ {...props}
122
+ />
123
+ )
124
+ }
125
+
126
+ export {
127
+ Popover,
128
+ PopoverContent,
129
+ PopoverDescription,
130
+ PopoverHeader,
131
+ PopoverTitle,
132
+ PopoverTrigger,
133
+ }
@@ -0,0 +1,88 @@
1
+ "use client"
2
+
3
+ import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
4
+
5
+ import { cn } from "@hoardodile/ui/lib/utils"
6
+
7
+ function Progress({
8
+ className,
9
+ children,
10
+ value,
11
+ ...props
12
+ }: ProgressPrimitive.Root.Props) {
13
+ return (
14
+ // `relative` makes the root the containing block for loose children
15
+ // (e.g. `sr-only` labels for screen readers): without it they anchor
16
+ // to the nearest positioned ancestor instead — typically the app
17
+ // shell, outside the page's scroll clip — and stack a second,
18
+ // viewport-level scrollbar on long pages (Settings → App).
19
+ <ProgressPrimitive.Root
20
+ value={value}
21
+ data-slot="progress"
22
+ className={cn("relative flex flex-wrap gap-3", className)}
23
+ {...props}
24
+ >
25
+ {children}
26
+ <ProgressTrack>
27
+ <ProgressIndicator />
28
+ </ProgressTrack>
29
+ </ProgressPrimitive.Root>
30
+ )
31
+ }
32
+
33
+ function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
34
+ return (
35
+ <ProgressPrimitive.Track
36
+ className={cn(
37
+ "relative flex h-1.5 w-full items-center overflow-x-hidden rounded-full bg-muted",
38
+ className
39
+ )}
40
+ data-slot="progress-track"
41
+ {...props}
42
+ />
43
+ )
44
+ }
45
+
46
+ function ProgressIndicator({
47
+ className,
48
+ ...props
49
+ }: ProgressPrimitive.Indicator.Props) {
50
+ return (
51
+ <ProgressPrimitive.Indicator
52
+ data-slot="progress-indicator"
53
+ className={cn("h-full bg-primary transition-all", className)}
54
+ {...props}
55
+ />
56
+ )
57
+ }
58
+
59
+ function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
60
+ return (
61
+ <ProgressPrimitive.Label
62
+ className={cn("text-sm font-medium", className)}
63
+ data-slot="progress-label"
64
+ {...props}
65
+ />
66
+ )
67
+ }
68
+
69
+ function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
70
+ return (
71
+ <ProgressPrimitive.Value
72
+ className={cn(
73
+ "ml-auto text-sm text-muted-foreground tabular-nums",
74
+ className
75
+ )}
76
+ data-slot="progress-value"
77
+ {...props}
78
+ />
79
+ )
80
+ }
81
+
82
+ export {
83
+ Progress,
84
+ ProgressTrack,
85
+ ProgressIndicator,
86
+ ProgressLabel,
87
+ ProgressValue,
88
+ }
@@ -0,0 +1,47 @@
1
+ import type { ReactNode } from "react"
2
+
3
+ /**
4
+ * Slot-based renderer for the loading/empty/error/data quartet that
5
+ * wraps almost every list query in the app. Reduces:
6
+ *
7
+ * ```tsx
8
+ * {q.isLoading ? <Skeleton/> : null}
9
+ * {!q.isLoading && q.isError ? <Error/> : null}
10
+ * {!q.isLoading && !q.isError && data.length === 0 ? <Empty/> : null}
11
+ * {!q.isLoading && !q.isError && data.length > 0 ? <List data={data}/> : null}
12
+ * ```
13
+ *
14
+ * to a single declarative element. Pass a `result` describing the
15
+ * query's status, a derived `data`, and the slot renderers.
16
+ *
17
+ * Decoupled from TanStack Query so it can be used with any data source
18
+ * (URL state, suspense, manual fetches) and stays cheap to test.
19
+ */
20
+ export type QueryStateResult<T> = {
21
+ readonly isLoading: boolean
22
+ readonly isError: boolean
23
+ readonly error?: Error | null
24
+ readonly data: T | undefined
25
+ }
26
+
27
+ export type QueryStateViewProps<T> = {
28
+ readonly result: QueryStateResult<T>
29
+ readonly isEmpty?: (data: T) => boolean
30
+ readonly loading: ReactNode
31
+ readonly empty?: ReactNode
32
+ readonly error?: (error: Error) => ReactNode
33
+ readonly children: (data: T) => ReactNode
34
+ }
35
+
36
+ export function QueryStateView<T>(props: QueryStateViewProps<T>): ReactNode {
37
+ const { result, isEmpty, loading, empty, error, children } = props
38
+ if (result.isLoading) return loading
39
+ if (result.isError) {
40
+ const renderError = error
41
+ const err = result.error ?? new Error("Unknown error")
42
+ return renderError !== undefined ? renderError(err) : null
43
+ }
44
+ if (result.data === undefined) return null
45
+ if (isEmpty?.(result.data) === true) return empty ?? null
46
+ return children(result.data)
47
+ }
@@ -0,0 +1,36 @@
1
+ import { Radio as RadioPrimitive } from "@base-ui/react/radio"
2
+ import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group"
3
+
4
+ import { cn } from "@hoardodile/ui/lib/utils"
5
+
6
+ function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) {
7
+ return (
8
+ <RadioGroupPrimitive
9
+ data-slot="radio-group"
10
+ className={cn("grid w-full gap-3", className)}
11
+ {...props}
12
+ />
13
+ )
14
+ }
15
+
16
+ function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
17
+ return (
18
+ <RadioPrimitive.Root
19
+ data-slot="radio-group-item"
20
+ className={cn(
21
+ "group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
22
+ className
23
+ )}
24
+ {...props}
25
+ >
26
+ <RadioPrimitive.Indicator
27
+ data-slot="radio-group-indicator"
28
+ className="flex size-4 items-center justify-center"
29
+ >
30
+ <span className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-foreground" />
31
+ </RadioPrimitive.Indicator>
32
+ </RadioPrimitive.Root>
33
+ )
34
+ }
35
+
36
+ export { RadioGroup, RadioGroupItem }
@@ -0,0 +1,52 @@
1
+ import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
2
+
3
+ import { cn } from "@hoardodile/ui/lib/utils"
4
+
5
+ function ScrollArea({
6
+ className,
7
+ children,
8
+ ...props
9
+ }: ScrollAreaPrimitive.Root.Props) {
10
+ return (
11
+ <ScrollAreaPrimitive.Root
12
+ data-slot="scroll-area"
13
+ className={cn("relative", className)}
14
+ {...props}
15
+ >
16
+ <ScrollAreaPrimitive.Viewport
17
+ data-slot="scroll-area-viewport"
18
+ className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
19
+ >
20
+ {children}
21
+ </ScrollAreaPrimitive.Viewport>
22
+ <ScrollBar />
23
+ <ScrollAreaPrimitive.Corner />
24
+ </ScrollAreaPrimitive.Root>
25
+ )
26
+ }
27
+
28
+ function ScrollBar({
29
+ className,
30
+ orientation = "vertical",
31
+ ...props
32
+ }: ScrollAreaPrimitive.Scrollbar.Props) {
33
+ return (
34
+ <ScrollAreaPrimitive.Scrollbar
35
+ data-slot="scroll-area-scrollbar"
36
+ data-orientation={orientation}
37
+ orientation={orientation}
38
+ className={cn(
39
+ "flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
40
+ className
41
+ )}
42
+ {...props}
43
+ >
44
+ <ScrollAreaPrimitive.Thumb
45
+ data-slot="scroll-area-thumb"
46
+ className="relative flex-1 rounded-full bg-border"
47
+ />
48
+ </ScrollAreaPrimitive.Scrollbar>
49
+ )
50
+ }
51
+
52
+ export { ScrollArea, ScrollBar }
@@ -0,0 +1,86 @@
1
+ import { render, screen, waitFor } from "@testing-library/react"
2
+ import userEvent from "@testing-library/user-event"
3
+ import { describe, expect, test, vi } from "vitest"
4
+ import { SearchField } from "./search-field"
5
+
6
+ describe("SearchField", () => {
7
+ test("debounces keystrokes before committing", async () => {
8
+ const user = userEvent.setup()
9
+ const onCommit = vi.fn()
10
+ render(
11
+ <SearchField value="" onCommit={onCommit} testId="search" delayMs={50} />,
12
+ )
13
+ await user.type(screen.getByTestId("search"), "hello")
14
+ expect(onCommit).not.toHaveBeenCalledWith("hello")
15
+ await waitFor(() => {
16
+ expect(onCommit).toHaveBeenCalledWith("hello")
17
+ })
18
+ })
19
+
20
+ test("re-syncs with external resets", () => {
21
+ const { rerender } = render(
22
+ <SearchField value="foo" onCommit={() => {}} testId="search" />,
23
+ )
24
+ const input = screen.getByTestId("search") as HTMLInputElement
25
+ expect(input.value).toBe("foo")
26
+ rerender(<SearchField value="" onCommit={() => {}} testId="search" />)
27
+ expect(input.value).toBe("")
28
+ })
29
+
30
+ test("commits only on Enter in commitOnEnterOnly mode", async () => {
31
+ const user = userEvent.setup()
32
+ const onCommit = vi.fn()
33
+ const onSubmit = vi.fn()
34
+ render(
35
+ <SearchField
36
+ value=""
37
+ onCommit={onCommit}
38
+ onSubmit={onSubmit}
39
+ commitOnEnterOnly
40
+ testId="search"
41
+ delayMs={10}
42
+ />,
43
+ )
44
+ await user.type(screen.getByTestId("search"), "harbor")
45
+ // Typing never commits on its own in this mode.
46
+ await waitFor(() => {
47
+ expect(onCommit).not.toHaveBeenCalled()
48
+ })
49
+ await user.keyboard("{Enter}")
50
+ expect(onSubmit).toHaveBeenCalledWith("harbor")
51
+ expect(onCommit).not.toHaveBeenCalled()
52
+ })
53
+
54
+ test("commits immediately and submits on Enter in debounced mode", async () => {
55
+ const user = userEvent.setup()
56
+ const onCommit = vi.fn()
57
+ const onSubmit = vi.fn()
58
+ render(
59
+ <SearchField
60
+ value=""
61
+ onCommit={onCommit}
62
+ onSubmit={onSubmit}
63
+ testId="search"
64
+ delayMs={10}
65
+ />,
66
+ )
67
+ await user.type(screen.getByTestId("search"), "harbor")
68
+ await user.keyboard("{Enter}")
69
+ expect(onCommit).toHaveBeenCalledWith("harbor")
70
+ expect(onSubmit).toHaveBeenCalledWith("harbor")
71
+ })
72
+
73
+ test("submits the draft through the form", async () => {
74
+ const user = userEvent.setup()
75
+ const onSubmit = vi.fn()
76
+ render(<SearchField value="" onSubmit={onSubmit} testId="search" />)
77
+ await user.type(screen.getByTestId("search"), "harbor")
78
+ await user.keyboard("{Enter}")
79
+ expect(onSubmit).toHaveBeenCalledWith("harbor")
80
+ })
81
+
82
+ test("renders the global-search-input testid by default", () => {
83
+ render(<SearchField value="" />)
84
+ expect(screen.getByTestId("global-search-input")).not.toBeNull()
85
+ })
86
+ })
@@ -0,0 +1,119 @@
1
+ import { Icon } from "@hoardodile/ui/components/icon"
2
+ import { Magnifier } from "@hoardodile/ui/icons/registry"
3
+ import { cn } from "@hoardodile/ui/lib/utils"
4
+ import {
5
+ type KeyboardEvent,
6
+ type ReactNode,
7
+ type Ref,
8
+ useEffect,
9
+ useState,
10
+ } from "react"
11
+ import { useDebounce } from "react-use"
12
+
13
+ export type SearchFieldProps = {
14
+ /** The committed value; the input re-syncs to it on external changes. */
15
+ readonly value: string
16
+ readonly placeholder?: string
17
+ /**
18
+ * Layout overrides — the hero form passes `h-11 px-4` (the sidebar
19
+ * h-nav and the hero h-11 share one anatomy).
20
+ */
21
+ readonly className?: string
22
+ /** Optional actions rendered at the trailing edge of the field. */
23
+ readonly actions?: ReactNode
24
+ /** Input length cap (e.g. the app's max search-query length). */
25
+ readonly maxLength?: number
26
+ /**
27
+ * Debounced commit of the draft (unless {@link commitOnEnterOnly}).
28
+ * Filter bars pass their live patch here.
29
+ */
30
+ readonly onCommit?: (value: string) => void
31
+ /** Enter / form submit — navigation and apply-on-demand surfaces. */
32
+ readonly onSubmit?: (value: string) => void
33
+ /**
34
+ * When true, keystrokes never debounce-commit; the value is delivered
35
+ * only through {@link onSubmit} (Enter). Used by apply-on-demand
36
+ * surfaces where typing stages and Enter applies.
37
+ */
38
+ readonly commitOnEnterOnly?: boolean
39
+ readonly delayMs?: number
40
+ /** Optional ref to the inner input (sidebar "/" and Ctrl/Cmd+K). */
41
+ readonly inputRef?: Ref<HTMLInputElement>
42
+ readonly testId?: string
43
+ }
44
+
45
+ /**
46
+ * The one search field: muted fill, rounded-lg, leading magnifier,
47
+ * muted placeholder — sidebar (h-nav) and hero (h-11) share this
48
+ * anatomy, as do the index-page filter bars. Commit semantics are the
49
+ * caller's: debounced {@link onCommit} for live filtering, Enter through
50
+ * {@link onSubmit} for navigation and apply-on-demand surfaces.
51
+ *
52
+ * The root is a div, not a form: the field nests inside page forms (the
53
+ * new character/resource editors, which wrap their whole body in one), so
54
+ * a nested form would be invalid HTML. Enter is captured on keydown and
55
+ * preventDefaulted, so it never submits an enclosing form either.
56
+ */
57
+ export function SearchField(props: SearchFieldProps) {
58
+ const {
59
+ value,
60
+ placeholder,
61
+ className,
62
+ actions,
63
+ maxLength,
64
+ onCommit,
65
+ onSubmit,
66
+ commitOnEnterOnly,
67
+ delayMs,
68
+ inputRef,
69
+ testId,
70
+ } = props
71
+ const [draft, setDraft] = useState(value)
72
+
73
+ useEffect(() => {
74
+ setDraft(value)
75
+ }, [value])
76
+
77
+ useDebounce(
78
+ () => {
79
+ if (commitOnEnterOnly) return
80
+ if (draft !== value) onCommit?.(draft)
81
+ },
82
+ delayMs ?? 300,
83
+ [draft],
84
+ )
85
+
86
+ function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
87
+ if (event.key !== "Enter") return
88
+ event.preventDefault()
89
+ if (commitOnEnterOnly) {
90
+ onSubmit?.(draft)
91
+ return
92
+ }
93
+ if (draft !== value) onCommit?.(draft)
94
+ onSubmit?.(draft)
95
+ }
96
+
97
+ return (
98
+ <div
99
+ onKeyDown={handleKeyDown}
100
+ className={cn(
101
+ "flex h-nav items-center gap-2 rounded-lg bg-muted px-3",
102
+ className,
103
+ )}
104
+ >
105
+ <Icon icon={Magnifier} className="shrink-0 text-muted-foreground" />
106
+ <input
107
+ ref={inputRef}
108
+ type="text"
109
+ value={draft}
110
+ onChange={(ev) => setDraft(ev.target.value)}
111
+ placeholder={placeholder}
112
+ maxLength={maxLength}
113
+ className="min-w-0 flex-1 bg-transparent text-ui text-foreground outline-none placeholder:text-muted-foreground"
114
+ data-testid={testId ?? "global-search-input"}
115
+ />
116
+ {actions}
117
+ </div>
118
+ )
119
+ }
@@ -0,0 +1,24 @@
1
+ import { SectionLabel } from "./section-label"
2
+
3
+ /**
4
+ * Secondary stat — the quiet supporting readout beside a KPI: uppercase
5
+ * tracking-label label over a bold tabular numeral.
6
+ */
7
+ export function SecondaryStat({
8
+ label,
9
+ value,
10
+ className,
11
+ }: {
12
+ readonly label: string
13
+ readonly value: string
14
+ readonly className?: string
15
+ }) {
16
+ return (
17
+ <div className={className}>
18
+ <SectionLabel>{label}</SectionLabel>
19
+ <div className="mt-1 text-xl font-bold tabular-nums text-foreground">
20
+ {value}
21
+ </div>
22
+ </div>
23
+ )
24
+ }
@@ -0,0 +1,54 @@
1
+ import { Icon, type IconType } from "@hoardodile/ui/components/icon"
2
+ import type { ReactNode } from "react"
3
+
4
+ /**
5
+ * Section header: icon 16 + 16px semibold title, muted count on the
6
+ * baseline; the right slot holds trailing controls ("View all" links,
7
+ * toggles, marquee chevrons).
8
+ */
9
+ export function SectionHeader({
10
+ icon,
11
+ title,
12
+ count,
13
+ viewAll,
14
+ onViewAll,
15
+ right,
16
+ className,
17
+ }: {
18
+ readonly icon?: IconType
19
+ readonly title: string
20
+ /** Muted count rendered right after the title, e.g. "(2)". */
21
+ readonly count?: string
22
+ /** Label for the right-aligned "View all" text link. */
23
+ readonly viewAll?: string
24
+ readonly onViewAll?: () => void
25
+ /** Right-aligned trailing slot (the ml-auto position). */
26
+ readonly right?: ReactNode
27
+ readonly className?: string
28
+ }) {
29
+ return (
30
+ <div className={`flex items-center gap-2 ${className ?? ""}`}>
31
+ {icon !== undefined ? (
32
+ <Icon icon={icon} className="text-secondary-foreground" />
33
+ ) : null}
34
+ <span className="text-base font-semibold text-foreground">{title}</span>
35
+ {count !== undefined ? (
36
+ <span className="text-xs text-muted-foreground">{count}</span>
37
+ ) : null}
38
+ {viewAll !== undefined || right !== undefined ? (
39
+ <span className="ml-auto flex items-center gap-4">
40
+ {viewAll !== undefined ? (
41
+ <button
42
+ type="button"
43
+ onClick={onViewAll}
44
+ className="cursor-pointer text-xs text-muted-foreground hover:text-secondary-foreground"
45
+ >
46
+ {viewAll}
47
+ </button>
48
+ ) : null}
49
+ {right}
50
+ </span>
51
+ ) : null}
52
+ </div>
53
+ )
54
+ }
@@ -0,0 +1,40 @@
1
+ import { cn } from "@hoardodile/ui/lib/utils"
2
+ import type { ReactNode } from "react"
3
+
4
+ /**
5
+ * Section label — the 12px uppercase tracking-label voice (DESIGN.md —
6
+ * Typography), one component instead of a class string repeated across
7
+ * panels, rails, dialogs and settings. Ink tones: `muted` (default, the
8
+ * quiet section voice), `foreground` (structural headings), `danger`
9
+ * (the dialog eyebrow of a destructive ritual). Callers with a
10
+ * right-aligned count arrange it inside `children` (flex via
11
+ * `className`) — the voice is the unit.
12
+ */
13
+ export function SectionLabel({
14
+ tone = "muted",
15
+ size = "xs",
16
+ className,
17
+ children,
18
+ }: {
19
+ tone?: "muted" | "foreground" | "danger"
20
+ size?: "xs" | "tiny"
21
+ className?: string
22
+ children: ReactNode
23
+ }) {
24
+ return (
25
+ <div
26
+ className={cn(
27
+ size === "tiny" ? "text-tiny leading-none" : "text-xs",
28
+ "font-semibold tracking-label uppercase",
29
+ tone === "foreground"
30
+ ? "text-foreground"
31
+ : tone === "danger"
32
+ ? "text-destructive"
33
+ : "text-muted-foreground",
34
+ className,
35
+ )}
36
+ >
37
+ {children}
38
+ </div>
39
+ )
40
+ }