@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,139 @@
1
+ import { Button } from "@hoardodile/ui/components/button"
2
+ import { Checkbox } from "@hoardodile/ui/components/checkbox"
3
+ import { Separator } from "@hoardodile/ui/components/separator"
4
+ import { cn } from "@hoardodile/ui/lib/utils"
5
+ import { type ReactNode, useId } from "react"
6
+
7
+ type FilterRailProps = {
8
+ /** Rail title, e.g. "Filters". */
9
+ readonly title: ReactNode
10
+ /** Header link that resets every staged filter (applies immediately). */
11
+ readonly clearAllLabel: ReactNode
12
+ readonly onClearAll: () => void
13
+ /** Footer apply button label, e.g. "Show 24 resources". */
14
+ readonly resultLabel: ReactNode
15
+ readonly onApply: () => void
16
+ /** When false (live-search mode) the footer apply button is omitted. */
17
+ readonly showApply?: boolean
18
+ /**
19
+ * Live-search toggle row above the apply button — checked means every
20
+ * filter change applies immediately instead of staging. Rendered when
21
+ * provided.
22
+ */
23
+ readonly liveSearch?: boolean
24
+ readonly onLiveSearchChange?: (live: boolean) => void
25
+ readonly liveSearchLabel?: ReactNode
26
+ readonly children: ReactNode
27
+ readonly className?: string
28
+ }
29
+
30
+ /**
31
+ * Filter rail: the faceted filter surface on index pages. Lives in the
32
+ * AppShell's right panel column on wide screens and in a route-owned
33
+ * drawer below the panel breakpoint, so it always fills its parent's
34
+ * height and scrolls its own sections.
35
+ */
36
+ export function FilterRail(props: FilterRailProps) {
37
+ const {
38
+ title,
39
+ clearAllLabel,
40
+ onClearAll,
41
+ resultLabel,
42
+ onApply,
43
+ showApply = true,
44
+ liveSearch,
45
+ onLiveSearchChange,
46
+ liveSearchLabel,
47
+ children,
48
+ className,
49
+ } = props
50
+ const liveToggleId = useId()
51
+ return (
52
+ <div
53
+ className={cn("flex h-full w-full flex-col overflow-hidden", className)}
54
+ >
55
+ <div className="flex items-baseline px-5 pt-5">
56
+ <span className="text-xs font-semibold tracking-label uppercase text-foreground">
57
+ {title}
58
+ </span>
59
+ <button
60
+ type="button"
61
+ className="ml-auto cursor-pointer text-xs text-muted-foreground hover:text-secondary-foreground"
62
+ data-testid="filter-rail-clear-all"
63
+ onClick={onClearAll}
64
+ >
65
+ {clearAllLabel}
66
+ </button>
67
+ </div>
68
+ <div className="flex-1 overflow-y-auto px-5 pb-5">{children}</div>
69
+ {liveSearch !== undefined ? (
70
+ <>
71
+ <Separator size="seam" />
72
+ <div className="mb-3 flex items-center justify-between px-5 pt-3">
73
+ <div className="flex items-center gap-2">
74
+ <Checkbox
75
+ id={liveToggleId}
76
+ checked={liveSearch}
77
+ onCheckedChange={(v) => onLiveSearchChange?.(v === true)}
78
+ data-testid="live-search-toggle"
79
+ />
80
+ <label
81
+ htmlFor={liveToggleId}
82
+ className="cursor-pointer text-xs text-muted-foreground"
83
+ >
84
+ {liveSearchLabel}
85
+ </label>
86
+ </div>
87
+ </div>
88
+ </>
89
+ ) : null}
90
+ {showApply ? (
91
+ <div className="mb-3 px-5">
92
+ <Button
93
+ type="button"
94
+ className="w-full justify-center"
95
+ onClick={onApply}
96
+ data-testid="filter-rail-apply"
97
+ >
98
+ {resultLabel}
99
+ </Button>
100
+ </div>
101
+ ) : null}
102
+ </div>
103
+ )
104
+ }
105
+
106
+ type FilterRailSectionProps = {
107
+ /** Section label (uppercase tracking-label); omitted for the
108
+ label-less first panel section. */
109
+ readonly label?: ReactNode
110
+ /**
111
+ * Pad the label and content (`px-5`) while the seam stays full-bleed —
112
+ * detail panels whose sections take their own padding without touching
113
+ * the edge-to-edge separators.
114
+ */
115
+ readonly padded?: boolean
116
+ readonly children: ReactNode
117
+ }
118
+
119
+ /** One facet group; sections are separated by 2px structural seams. */
120
+ export function FilterRailSection(props: FilterRailSectionProps) {
121
+ const { label, children, padded = false } = props
122
+ return (
123
+ <section className="mt-5 first:mt-4 first:pt-0 first:*:data-[slot=separator]:hidden">
124
+ <Separator size="seam" className="mb-4" />
125
+ <div className={padded ? "px-5" : undefined}>
126
+ {label !== undefined ? (
127
+ <div className="text-xs font-semibold tracking-label uppercase text-foreground">
128
+ {label}
129
+ </div>
130
+ ) : null}
131
+ {label !== undefined ? (
132
+ <div className="mt-2 flex flex-col">{children}</div>
133
+ ) : (
134
+ children
135
+ )}
136
+ </div>
137
+ </section>
138
+ )
139
+ }
@@ -0,0 +1,46 @@
1
+ import { cn } from "@hoardodile/ui/lib/utils"
2
+ import type { ReactNode } from "react"
3
+
4
+ type FixedActionBarProps = {
5
+ readonly children: ReactNode
6
+ readonly className?: string
7
+ /**
8
+ * 0–100 shows the 2px top progress strip while an upload is staging;
9
+ * omit when idle. The track doubles as the seam.
10
+ */
11
+ readonly progress?: number
12
+ }
13
+
14
+ /**
15
+ * Fixed bottom action bar for long forms. Pinned to the viewport bottom
16
+ * so the submit control is always reachable, starting after the sidebar
17
+ * on md+; the top progress strip separates it from scrolling content (a
18
+ * quiet muted track when idle, a foreground fill while staging). The
19
+ * inner row mirrors PageScaffold's gutters and centers at `max-w-content`,
20
+ * so the buttons right-align with the page column's edge. Forms using it
21
+ * reserve clearance with bottom padding.
22
+ */
23
+ export function FixedActionBar(props: FixedActionBarProps) {
24
+ return (
25
+ <div
26
+ className={cn(
27
+ "fixed inset-x-0 bottom-0 z-40 bg-background/90 backdrop-blur supports-backdrop-filter:bg-background/60 md:left-sidebar",
28
+ props.className,
29
+ )}
30
+ >
31
+ <div className="mx-auto w-full max-w-content">
32
+ <div className="h-0.5 bg-muted">
33
+ {props.progress !== undefined ? (
34
+ <div
35
+ className="h-full bg-foreground transition-[width] duration-(--duration-2) ease-standard"
36
+ style={{ width: `${props.progress}%` }}
37
+ />
38
+ ) : null}
39
+ </div>
40
+ </div>
41
+ <div className="mx-auto flex w-full max-w-content items-center justify-end gap-2 px-3 py-4 sm:px-6 lg:px-10">
42
+ {props.children}
43
+ </div>
44
+ </div>
45
+ )
46
+ }
@@ -0,0 +1,257 @@
1
+ import {
2
+ closestCenter,
3
+ DndContext,
4
+ type DragEndEvent,
5
+ KeyboardSensor,
6
+ PointerSensor,
7
+ useSensor,
8
+ useSensors,
9
+ } from "@dnd-kit/core"
10
+ import {
11
+ arrayMove,
12
+ rectSortingStrategy,
13
+ SortableContext,
14
+ sortableKeyboardCoordinates,
15
+ useSortable,
16
+ } from "@dnd-kit/sortable"
17
+ import { CSS } from "@dnd-kit/utilities"
18
+ import { Input } from "@hoardodile/ui/components/input"
19
+ import { Switch } from "@hoardodile/ui/components/switch"
20
+ import { Cross } from "@hoardodile/ui/icons/marks"
21
+ import { SortVertical } from "@hoardodile/ui/icons/registry"
22
+ import { cn } from "@hoardodile/ui/lib/utils"
23
+ import { type ReactNode, useState } from "react"
24
+ import { useTranslation } from "react-i18next"
25
+
26
+ export type FontPickerProps = {
27
+ readonly value: readonly string[]
28
+ readonly onChange: (value: string[]) => void
29
+ /**
30
+ * Controlled "inherit app font" state. When `onInheritChange` is
31
+ * provided the picker renders an inherit switch; the switch only
32
+ * decides whether the chosen stack is used (implementation-level
33
+ * fallback) — the picker itself always stays visible and editable,
34
+ * so the user can keep a stack ready while inheriting.
35
+ */
36
+ readonly inherit?: boolean
37
+ readonly onInheritChange?: (checked: boolean) => void
38
+ readonly inheritedFonts?: readonly string[]
39
+ readonly inheritedFamily?: string
40
+ /** Preset tag rows (web/system/extra) — the consumer renders them
41
+ (registry knowledge and localized labels are app-side). */
42
+ readonly presetTags?: ReactNode
43
+ /**
44
+ * Resolves an entered name to its canonical stack value (and may load
45
+ * its CSS); returning `undefined` keeps the raw name.
46
+ */
47
+ readonly resolveFont?: (name: string) => string | undefined
48
+ /** Label and chip family for a stack entry. */
49
+ readonly resolveEntry?: (name: string) => { label: string; family?: string }
50
+ /** `font-family` for the live preview stack. */
51
+ readonly previewFamily?: string
52
+ readonly "data-testid"?: string
53
+ readonly "aria-label"?: string
54
+ }
55
+
56
+ /**
57
+ * Font picker with tag-style layout: optional preset tag rows on top,
58
+ * a custom input below them, the selected fonts as draggable chips, and
59
+ * an always-visible live preview. The font registry itself lives in the
60
+ * consumer — presets, CSS loading and localized display names come in
61
+ * through props.
62
+ */
63
+ export function FontPicker(props: FontPickerProps) {
64
+ const {
65
+ value,
66
+ onChange,
67
+ inherit = false,
68
+ onInheritChange,
69
+ inheritedFonts,
70
+ inheritedFamily,
71
+ presetTags,
72
+ resolveFont,
73
+ resolveEntry,
74
+ previewFamily,
75
+ "data-testid": testId,
76
+ "aria-label": ariaLabel,
77
+ } = props
78
+ const { t } = useTranslation("ui", { useSuspense: false })
79
+
80
+ const [customInput, setCustomInput] = useState("")
81
+
82
+ function addFont(name: string) {
83
+ const trimmed = name.trim()
84
+ if (trimmed.length === 0) return
85
+ const resolved = resolveFont?.(trimmed) ?? trimmed
86
+ if (value.includes(resolved)) return
87
+ onChange([...value, resolved])
88
+ }
89
+
90
+ function removeFont(index: number) {
91
+ onChange(value.filter((_, i) => i !== index))
92
+ }
93
+
94
+ function handleDragEnd(event: DragEndEvent) {
95
+ const { active, over } = event
96
+ if (over === null || active.id === over.id) return
97
+ const oldIndex = value.indexOf(String(active.id))
98
+ const newIndex = value.indexOf(String(over.id))
99
+ onChange(arrayMove([...value], oldIndex, newIndex))
100
+ }
101
+
102
+ function handleCustomKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
103
+ if (e.key === "Enter") {
104
+ e.preventDefault()
105
+ addFont(customInput)
106
+ setCustomInput("")
107
+ }
108
+ }
109
+
110
+ const sensors = useSensors(
111
+ useSensor(PointerSensor, { activationConstraint: { distance: 3 } }),
112
+ useSensor(KeyboardSensor, {
113
+ coordinateGetter: sortableKeyboardCoordinates,
114
+ }),
115
+ )
116
+
117
+ return (
118
+ <div className="flex flex-col gap-4" data-testid={testId}>
119
+ {onInheritChange !== undefined && (
120
+ <div className="flex items-center gap-2">
121
+ <Switch
122
+ checked={inherit}
123
+ onCheckedChange={onInheritChange}
124
+ aria-label={t("fontPicker.inherit")}
125
+ />
126
+ <span className="text-sm">{t("fontPicker.inherit")}</span>
127
+ </div>
128
+ )}
129
+
130
+ {inherit && (
131
+ <p className="text-xs text-muted-foreground">
132
+ {t("fontPicker.inheritedHint")}
133
+ {inheritedFonts !== undefined && inheritedFonts.length > 0 && (
134
+ <span className="ml-1" style={{ fontFamily: inheritedFamily }}>
135
+ ({inheritedFonts.join(" → ")})
136
+ </span>
137
+ )}
138
+ </p>
139
+ )}
140
+
141
+ {presetTags !== undefined ? (
142
+ <div className="flex flex-col gap-2">{presetTags}</div>
143
+ ) : null}
144
+
145
+ {/* Custom font input */}
146
+ <Input
147
+ value={customInput}
148
+ onChange={(e) => setCustomInput(e.target.value)}
149
+ onKeyDown={handleCustomKeyDown}
150
+ placeholder={t("fontPicker.addCustom")}
151
+ aria-label={ariaLabel ?? t("fontPicker.addCustom")}
152
+ />
153
+
154
+ {/* Selected font chips with drag sorting */}
155
+ {value.length > 0 && (
156
+ <div className="flex flex-col gap-2">
157
+ <p className="text-xs text-muted-foreground">{t("fontPicker.selected")}</p>
158
+ <DndContext
159
+ sensors={sensors}
160
+ collisionDetection={closestCenter}
161
+ onDragEnd={handleDragEnd}
162
+ >
163
+ <SortableContext
164
+ items={value as string[]}
165
+ strategy={rectSortingStrategy}
166
+ >
167
+ <div className="flex flex-wrap gap-2">
168
+ {value.map((name, index) => (
169
+ <SortableFontChip
170
+ key={name}
171
+ name={name}
172
+ resolveEntry={resolveEntry}
173
+ onRemove={() => removeFont(index)}
174
+ />
175
+ ))}
176
+ </div>
177
+ </SortableContext>
178
+ </DndContext>
179
+ </div>
180
+ )}
181
+
182
+ {/* Live preview — the stack rendered in its own family. Always
183
+ visible: with no stack chosen the sample simply renders in
184
+ the system font. */}
185
+ <div className="rounded-xl bg-background px-5 py-4">
186
+ <div
187
+ className="text-lg text-foreground"
188
+ style={{ fontFamily: previewFamily }}
189
+ >
190
+ {t("fontPicker.description")}
191
+ </div>
192
+ {value.length > 0 && (
193
+ <div className="mt-2 text-tiny text-muted-foreground">
194
+ {value
195
+ .map((name) => resolveEntry?.(name)?.label ?? name)
196
+ .join(" · ")}
197
+ </div>
198
+ )}
199
+ </div>
200
+ </div>
201
+ )
202
+ }
203
+
204
+ type SortableFontChipProps = {
205
+ readonly name: string
206
+ readonly resolveEntry?: FontPickerProps["resolveEntry"]
207
+ readonly onRemove: () => void
208
+ }
209
+
210
+ function SortableFontChip(props: SortableFontChipProps) {
211
+ const { name, resolveEntry, onRemove } = props
212
+ const {
213
+ attributes,
214
+ listeners,
215
+ setNodeRef,
216
+ transform,
217
+ transition,
218
+ isDragging,
219
+ } = useSortable({ id: name, transition: null })
220
+
221
+ const style: React.CSSProperties = {
222
+ transform: CSS.Translate.toString(transform),
223
+ transition,
224
+ opacity: isDragging ? 0.5 : 1,
225
+ }
226
+
227
+ const entry = resolveEntry?.(name)
228
+ const displayName = entry?.label ?? name
229
+
230
+ return (
231
+ <div
232
+ ref={setNodeRef}
233
+ style={style}
234
+ className={cn(
235
+ "inline-flex items-center gap-1 rounded-md border bg-card px-2 py-1 text-sm",
236
+ isDragging && "z-10",
237
+ )}
238
+ >
239
+ <button
240
+ type="button"
241
+ className="cursor-grab text-muted-foreground active:cursor-grabbing"
242
+ {...attributes}
243
+ {...listeners}
244
+ >
245
+ <SortVertical className="size-3.5" />
246
+ </button>
247
+ <span style={{ fontFamily: entry?.family ?? name }}>{displayName}</span>
248
+ <button
249
+ type="button"
250
+ onClick={onRemove}
251
+ className="text-muted-foreground hover:text-destructive"
252
+ >
253
+ <Cross className="size-3.5" />
254
+ </button>
255
+ </div>
256
+ )
257
+ }
@@ -0,0 +1,127 @@
1
+ import { useRender } from "@base-ui/react/use-render"
2
+ import { Label } from "@hoardodile/ui/components/label"
3
+ import {
4
+ FormFieldContext,
5
+ FormItemContext,
6
+ useFormField,
7
+ } from "@hoardodile/ui/hooks/use-form-field"
8
+ import { cn } from "@hoardodile/ui/lib/utils"
9
+ import * as React from "react"
10
+ import {
11
+ Controller,
12
+ type ControllerProps,
13
+ type FieldPath,
14
+ type FieldValues,
15
+ FormProvider,
16
+ } from "react-hook-form"
17
+
18
+ const Form = FormProvider
19
+
20
+ const FormField = <
21
+ TFieldValues extends FieldValues = FieldValues,
22
+ TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
23
+ >({
24
+ ...props
25
+ }: ControllerProps<TFieldValues, TName>) => {
26
+ return (
27
+ <FormFieldContext.Provider value={{ name: props.name }}>
28
+ <Controller {...props} />
29
+ </FormFieldContext.Provider>
30
+ )
31
+ }
32
+
33
+ function FormItem({ className, ...props }: React.ComponentProps<"div">) {
34
+ const id = React.useId()
35
+
36
+ return (
37
+ <FormItemContext.Provider value={{ id }}>
38
+ <div
39
+ data-slot="form-item"
40
+ className={cn("grid gap-2", className)}
41
+ {...props}
42
+ />
43
+ </FormItemContext.Provider>
44
+ )
45
+ }
46
+
47
+ function FormLabel({
48
+ className,
49
+ ...props
50
+ }: React.ComponentProps<typeof Label>) {
51
+ const { error, formItemId } = useFormField()
52
+
53
+ return (
54
+ <Label
55
+ data-slot="form-label"
56
+ data-error={!!error}
57
+ className={cn("data-[error=true]:text-destructive", className)}
58
+ htmlFor={formItemId}
59
+ {...props}
60
+ />
61
+ )
62
+ }
63
+
64
+ function FormControl({ children }: { children: React.ReactElement }) {
65
+ const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
66
+
67
+ return useRender({
68
+ render: children,
69
+ props: {
70
+ "data-slot": "form-control",
71
+ id: formItemId,
72
+ "aria-describedby": error
73
+ ? `${formDescriptionId} ${formMessageId}`
74
+ : formDescriptionId,
75
+ "aria-invalid": !!error,
76
+ },
77
+ })
78
+ }
79
+
80
+ function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
81
+ const { formDescriptionId } = useFormField()
82
+
83
+ return (
84
+ <p
85
+ data-slot="form-description"
86
+ id={formDescriptionId}
87
+ className={cn("text-sm text-muted-foreground", className)}
88
+ {...props}
89
+ />
90
+ )
91
+ }
92
+
93
+ function FormMessage({
94
+ className,
95
+ children,
96
+ ...props
97
+ }: React.ComponentProps<"p">) {
98
+ const { error, formMessageId } = useFormField()
99
+ const body = error ? String(error.message ?? "") : children
100
+
101
+ if (!body) {
102
+ return null
103
+ }
104
+
105
+ return (
106
+ <p
107
+ data-slot="form-message"
108
+ id={formMessageId}
109
+ role="alert"
110
+ className={cn("text-sm text-destructive", className)}
111
+ {...props}
112
+ >
113
+ {body}
114
+ </p>
115
+ )
116
+ }
117
+
118
+ export {
119
+ Form,
120
+ FormControl,
121
+ FormDescription,
122
+ FormField,
123
+ FormItem,
124
+ FormLabel,
125
+ FormMessage,
126
+ useFormField,
127
+ }
@@ -0,0 +1,13 @@
1
+ import type { ReactNode } from "react"
2
+
3
+ /**
4
+ * Tiny group label inside picker blocks ("Selected", "Available", "Tags")
5
+ * — the PickerGroupLabel voice: 11px, uppercase, tracked.
6
+ */
7
+ export function GroupLabel({ children }: { readonly children: ReactNode }) {
8
+ return (
9
+ <span className="text-tiny font-semibold tracking-label uppercase text-muted-foreground">
10
+ {children}
11
+ </span>
12
+ )
13
+ }
@@ -0,0 +1,35 @@
1
+ import {
2
+ Icon,
3
+ type IconSize,
4
+ type IconType,
5
+ } from "@hoardodile/ui/components/icon"
6
+ import { cn } from "@hoardodile/ui/lib/utils"
7
+
8
+ /**
9
+ * 32/40px fill icon tile used by list rows and directory rows; the 40px
10
+ * tier pairs with the lg icon.
11
+ */
12
+ export function IconTile({
13
+ icon,
14
+ size = 32,
15
+ iconSize,
16
+ className,
17
+ }: {
18
+ readonly icon: IconType
19
+ readonly size?: 32 | 40
20
+ /** Icon tier — md (16) by default, lg (20) on the 40px tile. */
21
+ readonly iconSize?: IconSize
22
+ readonly className?: string
23
+ }) {
24
+ return (
25
+ <span
26
+ className={cn(
27
+ "flex shrink-0 items-center justify-center rounded-lg bg-muted text-secondary-foreground",
28
+ size === 40 ? "size-10" : "size-8",
29
+ className,
30
+ )}
31
+ >
32
+ <Icon icon={icon} size={iconSize ?? "md"} />
33
+ </span>
34
+ )
35
+ }
@@ -0,0 +1,57 @@
1
+ import { Icon, type IconType } from "@hoardodile/ui/components/icon"
2
+ import { cn } from "@hoardodile/ui/lib/utils"
3
+
4
+ export type IconToggleOption<T extends string> = {
5
+ readonly value: T
6
+ readonly icon: IconType
7
+ /** Accessible name (also the tooltip). */
8
+ readonly label: string
9
+ readonly testId?: string
10
+ }
11
+
12
+ type IconToggleProps<T extends string> = {
13
+ readonly value: T
14
+ readonly options: readonly IconToggleOption<T>[]
15
+ readonly onChange: (value: T) => void
16
+ readonly className?: string
17
+ }
18
+
19
+ /**
20
+ * Icon toggle: a segmented control of square icon buttons (grid/masonry
21
+ * view switchers). Muted track; the active button lifts to the card fill
22
+ * and its icon takes the Bold weight.
23
+ */
24
+ export function IconToggle<T extends string>(props: IconToggleProps<T>) {
25
+ const { value, options, onChange, className } = props
26
+ return (
27
+ <span
28
+ className={cn(
29
+ "inline-flex shrink-0 items-center gap-0.5 rounded-lg bg-muted p-0.5",
30
+ className,
31
+ )}
32
+ >
33
+ {options.map((option) => {
34
+ const active = option.value === value
35
+ return (
36
+ <button
37
+ type="button"
38
+ key={option.value}
39
+ title={option.label}
40
+ aria-label={option.label}
41
+ aria-pressed={active}
42
+ data-testid={option.testId}
43
+ onClick={() => onChange(option.value)}
44
+ className={cn(
45
+ "flex size-7 cursor-pointer items-center justify-center rounded-md",
46
+ active
47
+ ? "bg-card text-foreground"
48
+ : "text-muted-foreground hover:text-secondary-foreground",
49
+ )}
50
+ >
51
+ <Icon icon={option.icon} selected={active} className="size-4" />
52
+ </button>
53
+ )
54
+ })}
55
+ </span>
56
+ )
57
+ }