@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,23 @@
1
+ import { Skeleton } from "@hoardodile/ui/components/skeleton"
2
+ import { Surface } from "@hoardodile/ui/components/surface"
3
+
4
+ export type ManagementSkeletonProps = {
5
+ readonly chipCount?: number
6
+ }
7
+
8
+ /**
9
+ * Loading placeholder for a chip-based management panel.
10
+ */
11
+ export function ManagementSkeleton(props: ManagementSkeletonProps) {
12
+ const chipCount = props.chipCount ?? 2
13
+ return (
14
+ <Surface as="section" size="compact" className="space-y-3">
15
+ <Skeleton className="h-4 w-28" />
16
+ <div className="flex flex-wrap gap-1.5">
17
+ {Array.from({ length: chipCount }).map((_, i) => (
18
+ <Skeleton key={i} className="h-8 w-24 rounded-md" />
19
+ ))}
20
+ </div>
21
+ </Surface>
22
+ )
23
+ }
@@ -0,0 +1,40 @@
1
+ import { cn } from "@hoardodile/ui/lib/utils"
2
+ import type { ReactNode } from "react"
3
+
4
+ export type MetaChipProps = {
5
+ /** muted: quiet gray chip (default); inverse: foreground on background;
6
+ bordered: outline-only state marker. */
7
+ readonly tone?: "muted" | "inverse" | "bordered"
8
+ readonly className?: string
9
+ readonly children: ReactNode
10
+ }
11
+
12
+ /**
13
+ * Meta chip — the tiny rounded-sm text-tiny state marker worn next to
14
+ * names, versions, deltas and list rows across the app.
15
+ */
16
+ export function MetaChip(props: MetaChipProps) {
17
+ return (
18
+ <span
19
+ className={cn(
20
+ "shrink-0 rounded-sm px-1.5 py-0.5 text-tiny leading-none",
21
+ chipToneClassName(props.tone),
22
+ props.className,
23
+ )}
24
+ >
25
+ {props.children}
26
+ </span>
27
+ )
28
+ }
29
+
30
+ function chipToneClassName(tone: MetaChipProps["tone"]) {
31
+ if (tone === "inverse") {
32
+ return "bg-foreground text-background"
33
+ }
34
+
35
+ if (tone === "bordered") {
36
+ return "border border-border-strong text-muted-foreground"
37
+ }
38
+
39
+ return "bg-muted text-secondary-foreground"
40
+ }
@@ -0,0 +1,52 @@
1
+ import { render, screen } from "@testing-library/react"
2
+ import { describe, expect, it } from "vitest"
3
+ import { MobileDrawer } from "./mobile-drawer"
4
+
5
+ function renderDrawer(open: boolean) {
6
+ return render(
7
+ <MobileDrawer open={open} onOpenChange={() => {}} title="Drawer">
8
+ <div data-testid="drawer-content">content</div>
9
+ </MobileDrawer>,
10
+ )
11
+ }
12
+
13
+ describe("MobileDrawer children gating", () => {
14
+ it("does not render children while closed before the first open", () => {
15
+ renderDrawer(false)
16
+ expect(screen.queryByTestId("drawer-content")).not.toBeInTheDocument()
17
+ })
18
+
19
+ it("renders children once open", () => {
20
+ renderDrawer(true)
21
+ expect(screen.getByTestId("drawer-content")).toBeInTheDocument()
22
+ })
23
+
24
+ it("keeps children mounted after closing again", () => {
25
+ const { rerender } = render(
26
+ <MobileDrawer open={false} onOpenChange={() => {}}>
27
+ <div data-testid="drawer-content">content</div>
28
+ </MobileDrawer>,
29
+ )
30
+ expect(screen.queryByTestId("drawer-content")).not.toBeInTheDocument()
31
+ rerender(
32
+ <MobileDrawer open={true} onOpenChange={() => {}}>
33
+ <div data-testid="drawer-content">content</div>
34
+ </MobileDrawer>,
35
+ )
36
+ expect(screen.getByTestId("drawer-content")).toBeInTheDocument()
37
+ rerender(
38
+ <MobileDrawer open={false} onOpenChange={() => {}}>
39
+ <div data-testid="drawer-content">content</div>
40
+ </MobileDrawer>,
41
+ )
42
+ expect(screen.getByTestId("drawer-content")).toBeInTheDocument()
43
+ })
44
+
45
+ it("sizes the body as a constrained flex column", () => {
46
+ const { container } = renderDrawer(true)
47
+ const body = container.querySelector("aside > div:last-child")
48
+ for (const token of ["flex", "min-h-0", "flex-1", "flex-col"]) {
49
+ expect(body?.classList.contains(token)).toBe(true)
50
+ }
51
+ })
52
+ })
@@ -0,0 +1,98 @@
1
+ import { useMobileBackToClose } from "@hoardodile/ui/hooks/useMobileBackToClose"
2
+ import { Cross } from "@hoardodile/ui/icons/marks"
3
+ import { cn } from "@hoardodile/ui/lib/utils"
4
+ import { type ReactNode, useEffect, useState } from "react"
5
+
6
+ export type MobileDrawerProps = {
7
+ readonly open: boolean
8
+ readonly onOpenChange: (open: boolean) => void
9
+ readonly side?: "left" | "right"
10
+ readonly width?: string
11
+ /**
12
+ * Viewport variant at and above which the drawer is hidden. Must be a
13
+ * literal class at the call site (e.g. "min-[1440px]:hidden") so
14
+ * Tailwind can scan it. Defaults to "md:hidden".
15
+ */
16
+ readonly hideAbove?: string
17
+ readonly title?: ReactNode
18
+ readonly children: ReactNode
19
+ readonly className?: string
20
+ }
21
+
22
+ /**
23
+ * Mobile-only slide-in drawer with backdrop, close header and
24
+ * {@link useMobileBackToClose} integration.
25
+ *
26
+ * Renders nothing at and above the {@link MobileDrawerProps.hideAbove}
27
+ * breakpoint ("md" by default) — the caller is expected to provide its
28
+ * own desktop layout independently.
29
+ */
30
+ export function MobileDrawer(props: MobileDrawerProps) {
31
+ const {
32
+ open,
33
+ onOpenChange,
34
+ side = "left",
35
+ width = "w-72",
36
+ hideAbove = "md:hidden",
37
+ title,
38
+ } = props
39
+ const isLeft = side === "left"
40
+ // Mount children only once the drawer has been opened, so closed drawers
41
+ // (which stay in the tree for the slide transition) do not pay for
42
+ // rendering their content. Once mounted they stay mounted so the close
43
+ // animation and any inner state survive.
44
+ const [hasOpened, setHasOpened] = useState(open)
45
+
46
+ useEffect(() => {
47
+ if (open) setHasOpened(true)
48
+ }, [open])
49
+
50
+ useMobileBackToClose(open, onOpenChange)
51
+
52
+ return (
53
+ <>
54
+ {open && (
55
+ <button
56
+ type="button"
57
+ className={cn(
58
+ "fixed inset-0 z-30 bg-background/60 backdrop-blur-sm",
59
+ hideAbove,
60
+ )}
61
+ onClick={() => onOpenChange(false)}
62
+ />
63
+ )}
64
+ <aside
65
+ className={cn(
66
+ "fixed top-0 z-40 flex h-svh flex-col bg-card transition-transform duration-200",
67
+ hideAbove,
68
+ isLeft ? "left-0 border-r" : "right-0 border-l",
69
+ width,
70
+ isLeft
71
+ ? open
72
+ ? "translate-x-0 shadow-xl"
73
+ : "-translate-x-full"
74
+ : open
75
+ ? "translate-x-0 shadow-xl"
76
+ : "translate-x-full",
77
+ props.className,
78
+ )}
79
+ >
80
+ {title !== undefined && (
81
+ <div className="flex items-center justify-between border-b px-3 py-3">
82
+ <span className="text-sm font-medium">{title}</span>
83
+ <button
84
+ type="button"
85
+ className="flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent"
86
+ onClick={() => onOpenChange(false)}
87
+ >
88
+ <Cross className="size-4" />
89
+ </button>
90
+ </div>
91
+ )}
92
+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
93
+ {hasOpened ? props.children : null}
94
+ </div>
95
+ </aside>
96
+ </>
97
+ )
98
+ }
@@ -0,0 +1,47 @@
1
+ import { cn } from "@hoardodile/ui/lib/utils"
2
+ import type { ReactNode } from "react"
3
+
4
+ type PageHeaderProps = {
5
+ readonly title?: ReactNode
6
+ /** Muted total on the title baseline (e.g. the item count). */
7
+ readonly count?: string
8
+ readonly description?: ReactNode
9
+ readonly actions?: ReactNode
10
+ readonly className?: string
11
+ }
12
+
13
+ /**
14
+ * Page header — 28px bold title with the total count muted on its baseline,
15
+ * optional muted description below, page actions on the right. The literary
16
+ * serif (font-doc) is scoped to the documents section; chrome and index
17
+ * pages speak in the app sans.
18
+ */
19
+ export function PageHeader(props: PageHeaderProps) {
20
+ const { title, count, description, actions, className } = props
21
+ return (
22
+ <header className={cn("mb-6", className)}>
23
+ <div className="flex flex-wrap items-end justify-between gap-x-6 gap-y-3">
24
+ <div className="flex min-w-0 items-baseline gap-3">
25
+ {title !== undefined ? (
26
+ <h1 className="text-doc-heading font-bold text-foreground">
27
+ {title}
28
+ </h1>
29
+ ) : null}
30
+ {count !== undefined ? (
31
+ <span className="text-ui text-muted-foreground">{count}</span>
32
+ ) : null}
33
+ </div>
34
+ {actions !== undefined ? (
35
+ // `ml-auto` keeps a wrapped actions line right-aligned on
36
+ // narrow viewports, matching the same-line justify-between.
37
+ <div className="ml-auto flex shrink-0 flex-wrap items-center gap-4 pb-1">
38
+ {actions}
39
+ </div>
40
+ ) : null}
41
+ </div>
42
+ {description !== undefined ? (
43
+ <p className="mt-2 text-ui text-muted-foreground">{description}</p>
44
+ ) : null}
45
+ </header>
46
+ )
47
+ }
@@ -0,0 +1,24 @@
1
+ import { render } from "@testing-library/react"
2
+ import { afterEach, describe, expect, it, vi } from "vitest"
3
+ import { RoutePendingFallback } from "./page-scaffold"
4
+
5
+ describe("RoutePendingFallback", () => {
6
+ afterEach(() => {
7
+ vi.restoreAllMocks()
8
+ })
9
+
10
+ it("renders the header skeletons without invalid DOM nesting", () => {
11
+ // React's dev-time validator logs "cannot be a descendant of <p>" /
12
+ // "cannot contain a nested <div>" for a block element inside the
13
+ // header's h1/p wrappers — a hydration hazard.
14
+ const errorSpy = vi
15
+ .spyOn(console, "error")
16
+ .mockImplementation(() => undefined)
17
+ render(<RoutePendingFallback />)
18
+ for (const call of errorSpy.mock.calls) {
19
+ const message = String(call[0])
20
+ expect(message).not.toContain("cannot be a descendant")
21
+ expect(message).not.toContain("cannot contain a nested")
22
+ }
23
+ })
24
+ })
@@ -0,0 +1,131 @@
1
+ import { Skeleton } from "@hoardodile/ui/components/skeleton"
2
+ import { Surface } from "@hoardodile/ui/components/surface"
3
+ import { cn } from "@hoardodile/ui/lib/utils"
4
+ import type { ReactNode } from "react"
5
+ import { PageHeader } from "./page-header"
6
+
7
+ /** Content widths (DESIGN.md — Layout): the three fixed measures
8
+ index/detail pages are held to, centered beyond them. The measure is
9
+ the content width — padding never counts toward it (DESIGN.md),
10
+ so it lives on the outer wrapper, not on the measure element. Desktop
11
+ padding is `px-10 pt-10 pb-16`; mobile
12
+ and tablet keep their own tighter gutters. */
13
+ export type PageScaffoldWidth = "reading" | "medium" | "content"
14
+
15
+ const widthClasses: Record<PageScaffoldWidth, string> = {
16
+ reading: "max-w-reading",
17
+ medium: "max-w-medium",
18
+ content: "max-w-content",
19
+ }
20
+
21
+ type PageScaffoldProps = {
22
+ readonly children: ReactNode
23
+ /** Center the page at one of the three content measures; omitted when
24
+ the page's own component owns the measure. */
25
+ readonly width?: PageScaffoldWidth
26
+ /** Drop the desktop bottom padding — pages ending in a sticky ActionBar. */
27
+ readonly bottomPad?: boolean
28
+ readonly className?: string
29
+ }
30
+
31
+ function frameClassName(props: PageScaffoldProps) {
32
+ return cn(
33
+ "pb-4 pt-2 px-3 md:px-4 md:pt-4.5 lg:px-10",
34
+ props.bottomPad !== false && "lg:pb-16",
35
+ props.className,
36
+ )
37
+ }
38
+
39
+ export function PageScaffold(props: PageScaffoldProps) {
40
+ if (props.width === undefined) {
41
+ return (
42
+ <div className={cn("flex w-full flex-col", frameClassName(props))}>
43
+ {props.children}
44
+ </div>
45
+ )
46
+ }
47
+
48
+ return (
49
+ <div className={frameClassName(props)}>
50
+ <div
51
+ className={cn(
52
+ "mx-auto flex w-full flex-col",
53
+ widthClasses[props.width],
54
+ )}
55
+ >
56
+ {props.children}
57
+ </div>
58
+ </div>
59
+ )
60
+ }
61
+
62
+ type SurfaceProps = {
63
+ readonly children: ReactNode
64
+ readonly className?: string
65
+ }
66
+
67
+ export function FlatSurface(props: SurfaceProps) {
68
+ return (
69
+ <Surface as="section" size="compact" className={props.className}>
70
+ {props.children}
71
+ </Surface>
72
+ )
73
+ }
74
+
75
+ type PillProps = {
76
+ readonly children: ReactNode
77
+ readonly tone?: "primary" | "secondary" | "accent" | "muted"
78
+ readonly className?: string
79
+ }
80
+
81
+ export function InfoPill(props: PillProps) {
82
+ return (
83
+ <span
84
+ className={cn(
85
+ "inline-flex items-center rounded-md px-2 py-1 text-xs font-medium",
86
+ pillToneClassName(props.tone),
87
+ props.className,
88
+ )}
89
+ >
90
+ {props.children}
91
+ </span>
92
+ )
93
+ }
94
+
95
+ export function RoutePendingFallback() {
96
+ return (
97
+ <PageScaffold>
98
+ <PageHeader
99
+ title={
100
+ // Span elements: the header wraps title in h1 and description
101
+ // in p, and a block div inside either is invalid HTML.
102
+ <Skeleton element="span" className="block h-8 w-64 max-w-full" />
103
+ }
104
+ description={
105
+ <Skeleton element="span" className="block h-4 w-full max-w-lg" />
106
+ }
107
+ />
108
+ <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
109
+ <Skeleton className="h-32 rounded-lg" />
110
+ <Skeleton className="h-32 rounded-lg" />
111
+ <Skeleton className="h-32 rounded-lg md:col-span-2 xl:col-span-1" />
112
+ </div>
113
+ </PageScaffold>
114
+ )
115
+ }
116
+
117
+ function pillToneClassName(tone: PillProps["tone"]) {
118
+ if (tone === "primary") {
119
+ return "bg-primary/10 text-primary"
120
+ }
121
+
122
+ if (tone === "secondary") {
123
+ return "bg-secondary text-secondary-foreground"
124
+ }
125
+
126
+ if (tone === "accent") {
127
+ return "bg-accent text-accent-foreground"
128
+ }
129
+
130
+ return "bg-muted text-muted-foreground"
131
+ }
@@ -0,0 +1,87 @@
1
+ import { fireEvent, screen } from "@testing-library/react"
2
+ import userEvent from "@testing-library/user-event"
3
+ import { beforeEach, describe, expect, it, vi } from "vitest"
4
+ import { renderWithI18n } from "../test/i18n"
5
+ import { PaginationBar } from "./pagination-bar"
6
+
7
+ let mobileMock = false
8
+
9
+ vi.mock("@hoardodile/ui/hooks/use-mobile", () => ({
10
+ useBelowMd: () => mobileMock,
11
+ }))
12
+
13
+ describe("PaginationBar", () => {
14
+ beforeEach(() => {
15
+ mobileMock = false
16
+ })
17
+
18
+ it("renders the asymmetric window with first, last and the total label", () => {
19
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
20
+ renderWithI18n(
21
+ <PaginationBar
22
+ page={6}
23
+ pageCount={68}
24
+ totalLabel="68 items"
25
+ onChangePage={() => {}}
26
+ />,
27
+ )
28
+ for (const page of ["1", "5", "6", "7", "8", "68"]) {
29
+ expect(screen.getByRole("button", { name: page })).toBeInTheDocument()
30
+ }
31
+ expect(screen.getByTestId("pagination-current")).toHaveTextContent("6")
32
+ expect(screen.getAllByText("…")).toHaveLength(2)
33
+ expect(screen.getByText("68 items")).toBeInTheDocument()
34
+ expect(errorSpy).not.toHaveBeenCalled()
35
+ errorSpy.mockRestore()
36
+ })
37
+
38
+ it("collapses to a page/total chip below the md breakpoint", () => {
39
+ mobileMock = true
40
+ renderWithI18n(
41
+ <PaginationBar
42
+ page={6}
43
+ pageCount={68}
44
+ totalLabel="68 items"
45
+ onChangePage={() => {}}
46
+ />,
47
+ )
48
+ expect(screen.getByTestId("pagination-current")).toHaveTextContent("6/68")
49
+ expect(screen.queryByRole("button", { name: "5" })).not.toBeInTheDocument()
50
+ expect(screen.queryByText("…")).not.toBeInTheDocument()
51
+ })
52
+
53
+ it("disables the prev chevron on the first page without hover", async () => {
54
+ const onChangePage = vi.fn()
55
+ const user = userEvent.setup()
56
+ renderWithI18n(
57
+ <PaginationBar
58
+ page={1}
59
+ pageCount={3}
60
+ totalLabel="3 items"
61
+ onChangePage={onChangePage}
62
+ />,
63
+ )
64
+ const prev = screen.getByRole("button", { name: "Prev" })
65
+ expect(prev).toBeDisabled()
66
+ await user.click(prev)
67
+ expect(onChangePage).not.toHaveBeenCalled()
68
+ })
69
+
70
+ it("jumps to a clamped page via the go-to field", async () => {
71
+ const onChangePage = vi.fn()
72
+ const user = userEvent.setup()
73
+ renderWithI18n(
74
+ <PaginationBar
75
+ page={3}
76
+ pageCount={10}
77
+ totalLabel="10 items"
78
+ onChangePage={onChangePage}
79
+ />,
80
+ )
81
+ const input = screen.getByRole("spinbutton")
82
+ await user.click(input)
83
+ fireEvent.change(input, { target: { value: "12" } })
84
+ fireEvent.submit(input.closest("form")!)
85
+ expect(onChangePage).toHaveBeenCalledWith(10)
86
+ })
87
+ })
@@ -0,0 +1,139 @@
1
+ import { Icon } from "@hoardodile/ui/components/icon"
2
+ import { Input } from "@hoardodile/ui/components/input"
3
+ import {
4
+ Pagination,
5
+ PaginationContent,
6
+ PaginationItem,
7
+ } from "@hoardodile/ui/components/pagination"
8
+ import { useBelowMd } from "@hoardodile/ui/hooks/use-mobile"
9
+ import { AltArrowLeft, AltArrowRight } from "@hoardodile/ui/icons/registry"
10
+ import { paginationWindow } from "@hoardodile/ui/lib/pagination"
11
+ import { cn } from "@hoardodile/ui/lib/utils"
12
+ import { useState } from "react"
13
+ import { useTranslation } from "react-i18next"
14
+
15
+ export type PaginationBarProps = {
16
+ readonly page: number
17
+ readonly pageCount: number
18
+ readonly onChangePage: (page: number) => void
19
+ /** Result-count label rendered in the left slot (e.g. "56 messages"). */
20
+ readonly totalLabel: string
21
+ }
22
+
23
+ const pageButton =
24
+ "flex h-control min-w-8 cursor-pointer items-center justify-center rounded-lg px-2 text-xs"
25
+
26
+ /**
27
+ * The paginator: the result count on the left, balanced by the page-jump
28
+ * field on the right, and the centered row of h-control page chips —
29
+ * chevrons, first and last page always visible with an asymmetric window
30
+ * around the active page (one leading, two trailing) and ellipses for the
31
+ * gap (see {@link paginationWindow}). Below the md breakpoint the window
32
+ * collapses to a compact `page/total` chip, so narrow screens still see
33
+ * how many pages exist. Shared by every paged list (resources,
34
+ * characters, comments, stats, footprints).
35
+ */
36
+ export function PaginationBar(props: PaginationBarProps) {
37
+ const { page, pageCount, onChangePage, totalLabel } = props
38
+ const { t } = useTranslation("ui", { useSuspense: false })
39
+ const [draft, setDraft] = useState("")
40
+ const isMobile = useBelowMd()
41
+
42
+ function jump(): void {
43
+ const parsed = Number.parseInt(draft, 10)
44
+ if (Number.isNaN(parsed)) return
45
+ const clamped = Math.min(Math.max(1, parsed), pageCount)
46
+ setDraft("")
47
+ if (clamped !== page) onChangePage(clamped)
48
+ }
49
+
50
+ const pages = isMobile ? [page] : paginationWindow(page, pageCount)
51
+ const chip = (active: boolean) =>
52
+ cn(
53
+ pageButton,
54
+ active
55
+ ? "bg-muted font-medium text-foreground"
56
+ : "text-muted-foreground enabled:hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50",
57
+ )
58
+
59
+ return (
60
+ <Pagination data-testid="pagination-bar">
61
+ <div className="flex w-full items-center gap-2">
62
+ <span className="w-28 shrink-0 truncate text-xs text-muted-foreground">
63
+ {totalLabel}
64
+ </span>
65
+ <PaginationContent className="flex-1 justify-center">
66
+ <PaginationItem>
67
+ <button
68
+ type="button"
69
+ className={chip(false)}
70
+ disabled={page <= 1}
71
+ aria-label={t("pagination.prev")}
72
+ onClick={() => onChangePage(page - 1)}
73
+ >
74
+ <Icon icon={AltArrowLeft} />
75
+ </button>
76
+ </PaginationItem>
77
+ {pages.map((entry, index) =>
78
+ entry === "…" ? (
79
+ <PaginationItem key={`ellipsis-${index}`}>
80
+ <span className="px-1 text-xs text-muted-foreground">
81
+ {entry}
82
+ </span>
83
+ </PaginationItem>
84
+ ) : (
85
+ <PaginationItem key={entry}>
86
+ <button
87
+ type="button"
88
+ className={chip(entry === page)}
89
+ data-testid={
90
+ entry === page ? "pagination-current" : undefined
91
+ }
92
+ onClick={() => onChangePage(entry)}
93
+ >
94
+ {isMobile ? `${page}/${pageCount}` : entry}
95
+ </button>
96
+ </PaginationItem>
97
+ ),
98
+ )}
99
+ <PaginationItem>
100
+ <button
101
+ type="button"
102
+ className={chip(false)}
103
+ disabled={page >= pageCount}
104
+ aria-label={t("pagination.next")}
105
+ onClick={() => onChangePage(page + 1)}
106
+ >
107
+ <Icon icon={AltArrowRight} />
108
+ </button>
109
+ </PaginationItem>
110
+ </PaginationContent>
111
+ <form
112
+ className="flex w-28 items-center justify-end gap-1.5 text-xs text-muted-foreground"
113
+ onSubmit={(event) => {
114
+ event.preventDefault()
115
+ jump()
116
+ }}
117
+ >
118
+ <span className="shrink-0 whitespace-nowrap">{t("pagination.goTo")}</span>
119
+ <Input
120
+ type="number"
121
+ min={1}
122
+ max={pageCount}
123
+ value={draft}
124
+ onChange={(event) => setDraft(event.target.value)}
125
+ placeholder={t("pagination.jumpToPage")}
126
+ aria-label={t("pagination.goTo")}
127
+ size="sm"
128
+ // Content-elastic width in `ch` units (the native `size`
129
+ // attribute's modern equivalent — `field-sizing` is not
130
+ // supported everywhere yet): one character per digit,
131
+ // with room for the "Page" placeholder and padding.
132
+ style={{ width: `${Math.max(4, draft.length + 2) + 4}ch` }}
133
+ className="min-w-0 text-center [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
134
+ />
135
+ </form>
136
+ </div>
137
+ </Pagination>
138
+ )
139
+ }