@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,66 @@
1
+ import type { ComponentType } from "react"
2
+ import { cn } from "../lib/utils.ts"
3
+ import type { IconMode } from "../icons/icon-style.ts"
4
+
5
+ /** Anything renderable through `Icon`: a Solar icon (imported from the
6
+ central registry on the web side) or a local utility mark (marks.tsx) —
7
+ both take `className`; registry icons take `mode` (marks ignore it). */
8
+ export type IconType = ComponentType<{ className?: string; mode?: IconMode }>
9
+
10
+ /** The three sanctioned icon sizes: `sm` 12 inside pills and meta lines,
11
+ `md` 16 by default, `lg` 20 for primary toolbar actions. */
12
+ export type IconSize = "sm" | "md" | "lg"
13
+
14
+ const sizeClasses = {
15
+ sm: "size-3",
16
+ md: "size-4",
17
+ lg: "size-5",
18
+ } as const satisfies Record<IconSize, string>
19
+
20
+ /**
21
+ * Solar icons with the design system's defaults baked in: callers pass
22
+ * the registry icon — by default it renders the boldDuotone weight,
23
+ * whose second tone takes the palette's `--icon-tone` hue (theme.css
24
+ * recolors the duotone accent via `--solar-secondary-color` under the
25
+ * `hd-icon` hook), so colored palettes feel alive while mono stays
26
+ * neutral.
27
+ *
28
+ * Mode is one channel: `mode` picks the weight directly; `selected` maps
29
+ * to `"bold"` (the filled, single-color weight — the only sanctioned use
30
+ * of Bold); otherwise the active icon style preference decides.
31
+ *
32
+ * Sizing is class-based, three tiers only — `sm`/`md`/`lg` map to
33
+ * `size-3/4/5` (12/16/20). Escape the tiers with
34
+ * `className="size-[18px]"` — twMerge lets the later class win.
35
+ *
36
+ * ✓/×/+ semantics never use Solar's Circle/Square composites — the
37
+ * container is full-bleed while the informative mark is barely a third
38
+ * of the box, so it goes muddy at small sizes. The plain marks (Check,
39
+ * Cross, Plus from marks.tsx) cover these at every size; `sm` otherwise
40
+ * belongs to simple single-stroke glyphs (arrows, dots, grips).
41
+ */
42
+ export function Icon({
43
+ icon: IconComponent,
44
+ mode,
45
+ size = "md",
46
+ selected,
47
+ className,
48
+ ...rest
49
+ }: {
50
+ icon: IconType
51
+ /** Explicit weight override; `selected` and the style preference lose
52
+ to it. */
53
+ mode?: IconMode
54
+ size?: IconSize
55
+ /** Selected state — renders the icon's `"bold"` weight. */
56
+ selected?: boolean
57
+ className?: string
58
+ } & Record<string, unknown>) {
59
+ return (
60
+ <IconComponent
61
+ mode={mode ?? (selected === true ? "bold" : undefined)}
62
+ className={cn("hd-icon", sizeClasses[size], className)}
63
+ {...rest}
64
+ />
65
+ )
66
+ }
@@ -0,0 +1,167 @@
1
+ import { screen, waitFor, within } from "@testing-library/react"
2
+ import { describe, expect, it, vi } from "vitest"
3
+ import { renderWithI18n } from "../test/i18n"
4
+ import { AppDialog } from "./app-dialog"
5
+ import { ImageCropPanel } from "./image-crop-panel"
6
+
7
+ const TALL_IMAGE_SRC =
8
+ "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1000' height='2000'%3E%3Crect width='100%25' height='100%25' fill='red'/%3E%3C/svg%3E"
9
+
10
+ describe("ImageCropPanel", () => {
11
+ it("constrains the image within the provided crop stage bounds", async () => {
12
+ renderWithI18n(
13
+ <ImageCropPanel
14
+ initialSrc={TALL_IMAGE_SRC}
15
+ cropStageWidth={260}
16
+ cropStageHeight={500}
17
+ hideActionButton
18
+ onSave={vi.fn()}
19
+ />,
20
+ )
21
+
22
+ const img = await screen.findByRole("img")
23
+ expect(img).toHaveStyle({
24
+ "max-width": "260px",
25
+ "max-height": "500px",
26
+ })
27
+ })
28
+
29
+ it("uses default bounds when crop stage dimensions are omitted", async () => {
30
+ renderWithI18n(
31
+ <ImageCropPanel
32
+ initialSrc={TALL_IMAGE_SRC}
33
+ hideActionButton
34
+ onSave={vi.fn()}
35
+ />,
36
+ )
37
+
38
+ const img = await screen.findByRole("img")
39
+ expect(img).toHaveStyle({
40
+ "max-width": "320px",
41
+ "max-height": "240px",
42
+ })
43
+ })
44
+
45
+ it("keeps the crop stage wrapper within the effective bounds", async () => {
46
+ const { container } = renderWithI18n(
47
+ <ImageCropPanel
48
+ initialSrc={TALL_IMAGE_SRC}
49
+ cropStageWidth={200}
50
+ cropStageHeight={200}
51
+ hideActionButton
52
+ onSave={vi.fn()}
53
+ />,
54
+ )
55
+
56
+ const stage = container.querySelector("[data-testid='image-crop-frame']")
57
+ expect(stage).toBeNull()
58
+
59
+ const wrapper = container.querySelector(
60
+ ".overflow-hidden.rounded-md.border",
61
+ )
62
+ await waitFor(() => expect(wrapper).toBeInTheDocument())
63
+ expect(wrapper).toHaveStyle({
64
+ width: "200px",
65
+ height: "200px",
66
+ "max-width": "200px",
67
+ "max-height": "200px",
68
+ })
69
+ })
70
+
71
+ it("does not render the live preview when hidden", async () => {
72
+ renderWithI18n(
73
+ <ImageCropPanel
74
+ initialSrc={TALL_IMAGE_SRC}
75
+ hideActionButton
76
+ hidePreview
77
+ onSave={vi.fn()}
78
+ />,
79
+ )
80
+
81
+ await screen.findByRole("img")
82
+ expect(screen.queryByTestId("image-crop-preview")).not.toBeInTheDocument()
83
+ })
84
+
85
+ it("stretches the stage to fill its parent width when filling", async () => {
86
+ const { container } = renderWithI18n(
87
+ <ImageCropPanel
88
+ initialSrc={TALL_IMAGE_SRC}
89
+ cropStageWidth={13}
90
+ cropStageHeight={25}
91
+ hideActionButton
92
+ fillWidth
93
+ onSave={vi.fn()}
94
+ />,
95
+ )
96
+
97
+ const img = await screen.findByRole("img")
98
+ expect(img).toHaveStyle({ "max-width": "100%" })
99
+
100
+ const wrapper = container.querySelector(
101
+ ".overflow-hidden.rounded-md.border",
102
+ )
103
+ await waitFor(() => expect(wrapper).toBeInTheDocument())
104
+ expect(wrapper).toHaveStyle({ "aspect-ratio": "13 / 25" })
105
+ expect(wrapper).not.toHaveStyle({ width: "13px" })
106
+ expect(wrapper?.parentElement).toHaveClass("w-full")
107
+ })
108
+
109
+ it("renders the save button on the same footer row as the dialog's cancel", async () => {
110
+ renderWithI18n(
111
+ <AppDialog
112
+ open
113
+ title="Cover"
114
+ onOpenChange={() => {}}
115
+ footer={
116
+ <button type="button" data-testid="dialog-cancel">
117
+ Cancel
118
+ </button>
119
+ }
120
+ >
121
+ <ImageCropPanel
122
+ initialSrc={TALL_IMAGE_SRC}
123
+ onSave={vi.fn()}
124
+ />
125
+ </AppDialog>,
126
+ )
127
+
128
+ await screen.findByRole("img")
129
+ // The dialog renders into a portal, so the footer lives on the
130
+ // document rather than the render container.
131
+ const footer = document.querySelector('[data-slot="dialog-footer"]')
132
+ expect(footer).not.toBeNull()
133
+ // The panel contributes its action through DialogFooterActions, so
134
+ // save and cancel share the dialog's footer row.
135
+ expect(
136
+ within(footer as HTMLElement).getByTestId("image-crop-save"),
137
+ ).toHaveTextContent("Save")
138
+ expect(
139
+ within(footer as HTMLElement).getByTestId("dialog-cancel"),
140
+ ).toBeInTheDocument()
141
+ })
142
+
143
+ it("renders the action button as an inline panel row outside a dialog", () => {
144
+ const { container } = renderWithI18n(
145
+ <ImageCropPanel onClear={vi.fn()} onSave={vi.fn()} />,
146
+ )
147
+
148
+ expect(screen.getByTestId("image-crop-frame")).toBeInTheDocument()
149
+ // No image selected: the action button becomes "Remove".
150
+ const remove = screen.getByTestId("image-crop-save")
151
+ expect(remove).toHaveTextContent("Remove")
152
+ expect(container.querySelector('[data-slot="dialog-footer"]')).toBeNull()
153
+ })
154
+
155
+ it("hides the action button when hideActionButton is set", async () => {
156
+ renderWithI18n(
157
+ <ImageCropPanel
158
+ initialSrc={TALL_IMAGE_SRC}
159
+ hideActionButton
160
+ onSave={vi.fn()}
161
+ />,
162
+ )
163
+
164
+ await screen.findByRole("img")
165
+ expect(screen.queryByTestId("image-crop-save")).not.toBeInTheDocument()
166
+ })
167
+ })
@@ -0,0 +1,465 @@
1
+ import {
2
+ DialogFooterActions,
3
+ useDialogFooterActions,
4
+ } from "@hoardodile/ui/components/app-dialog"
5
+ import { Button } from "@hoardodile/ui/components/button"
6
+ import { Label } from "@hoardodile/ui/components/label"
7
+ import { Switch } from "@hoardodile/ui/components/switch"
8
+ import { toast } from "@hoardodile/ui/components/toast"
9
+ import { Gallery, RefreshCircle, Upload } from "@hoardodile/ui/icons/registry"
10
+ import { cn } from "@hoardodile/ui/lib/utils"
11
+ import { useCallback, useEffect, useId, useRef, useState } from "react"
12
+ import { useTranslation } from "react-i18next"
13
+ import type { CroppedImage } from "./image-cropper"
14
+ import { ImageCropper } from "./image-cropper"
15
+
16
+ export type ImageCropPanelProps = {
17
+ readonly aspect?: number
18
+ readonly mimeType?: "image/png" | "image/jpeg" | "image/webp"
19
+ readonly accept?: string
20
+ readonly previewShape?: "square" | "circle"
21
+ readonly previewWidth?: number
22
+ readonly previewHeight?: number
23
+ /**
24
+ * Called when the user clicks "save". Receives the cropped image
25
+ * payload. Should return a promise that resolves once the upload is
26
+ * complete; the panel then notifies via {@link onSaved}.
27
+ *
28
+ * @throws The implementation may throw to surface an error toast.
29
+ */
30
+ readonly onSave: (cropped: CroppedImage) => Promise<unknown>
31
+ readonly onSaved?: () => void
32
+ /**
33
+ * Called when the user clicks the action button but no image has been
34
+ * selected. The parent (e.g. {@link ImageEditPanel}) can treat this as a
35
+ * request to remove the existing image.
36
+ */
37
+ readonly onClear?: () => void
38
+ /**
39
+ * When set (e.g. canvas snapshot object URL), skips the empty picker and
40
+ * shows the cropper immediately.
41
+ */
42
+ readonly initialSrc?: string
43
+ /**
44
+ * When false, hides file pick / drag-drop and "reselect" (preset image only).
45
+ */
46
+ readonly allowChangeSource?: boolean
47
+ /** Notifies parent when an async save starts / finishes (for dialog guards). */
48
+ readonly onSavingChange?: (saving: boolean) => void
49
+ /** Exact size (px) of the empty picker frame / crop stage. Overrides the default 240×240. */
50
+ readonly cropStageWidth?: number
51
+ readonly cropStageHeight?: number
52
+ /**
53
+ * When true, the right-hand live preview is hidden until the user turns
54
+ * the switch on (saves work over large captures / WebGL snapshots).
55
+ */
56
+ readonly showPreviewSwitch?: boolean
57
+ /**
58
+ * When true, the live preview pane is never rendered — the crop stage is
59
+ * the only surface. Used by creation forms whose panel column has no
60
+ * room for a side-by-side preview.
61
+ */
62
+ readonly hidePreview?: boolean
63
+ /**
64
+ * When true, the crop stage fills its parent width and keeps the aspect
65
+ * ratio implied by {@link cropStageWidth} / {@link cropStageHeight}
66
+ * (full-bleed `aspect-square w-full` panels). Ignored when only
67
+ * one stage dimension is given.
68
+ */
69
+ readonly fillWidth?: boolean
70
+ /** Extra classes on the root panel wrapper. */
71
+ readonly panelClassName?: string
72
+ /** Extra classes on the row that holds the crop stage + preview. */
73
+ readonly containerClassName?: string
74
+ /** Extra classes on the column wrapping the crop stage (e.g. flex-1 min-w-0). */
75
+ readonly cropColumnClassName?: string
76
+ /** Extra classes on the bordered crop stage box (merged with defaults via tailwind-merge). */
77
+ readonly cropStageClassName?: string
78
+ /** When true, the bottom save/remove action button is hidden. */
79
+ readonly hideActionButton?: boolean
80
+ /**
81
+ * When true, automatically calls {@link onSave} after the user finishes
82
+ * adjusting the crop (debounced by 600 ms). Useful when the action button
83
+ * is hidden and the parent only needs the final crop at form-submit time.
84
+ */
85
+ readonly autoSaveOnCrop?: boolean
86
+ }
87
+
88
+ /**
89
+ * Inline image picker + cropper with live preview.
90
+ */
91
+ export function ImageCropPanel(props: ImageCropPanelProps) {
92
+ const {
93
+ aspect,
94
+ mimeType = "image/png",
95
+ accept = "image/*",
96
+ previewShape = "square",
97
+ previewWidth: previewWidthProp,
98
+ previewHeight: previewHeightProp,
99
+ onSave,
100
+ onSaved,
101
+ onClear,
102
+ initialSrc,
103
+ allowChangeSource = true,
104
+ onSavingChange,
105
+ cropStageWidth,
106
+ cropStageHeight,
107
+ showPreviewSwitch = false,
108
+ hidePreview = false,
109
+ fillWidth = false,
110
+ panelClassName,
111
+ containerClassName,
112
+ cropColumnClassName,
113
+ cropStageClassName,
114
+ hideActionButton = false,
115
+ autoSaveOnCrop = false,
116
+ } = props
117
+ const { t } = useTranslation("ui", { useSuspense: false })
118
+ const footerSlot = useDialogFooterActions()
119
+ const previewWidth = previewWidthProp ?? cropStageWidth ?? 200
120
+ const previewHeight = previewHeightProp ?? cropStageHeight ?? 200
121
+ const effectiveCropWidth = cropStageWidth ?? 320
122
+ const effectiveCropHeight = cropStageHeight ?? 240
123
+ // Full-bleed stage: the aspect ratio of the requested stage dimensions,
124
+ // stretched to whatever width the parent column gives us.
125
+ const stageAspectRatio =
126
+ fillWidth && cropStageWidth !== undefined && cropStageHeight !== undefined
127
+ ? `${cropStageWidth} / ${cropStageHeight}`
128
+ : undefined
129
+ const previewToggleId = useId()
130
+ const fileInputRef = useRef<HTMLInputElement | null>(null)
131
+ const [imageSrc, setImageSrc] = useState<string | undefined>(initialSrc)
132
+ const renderRef = useRef<(() => Promise<CroppedImage>) | undefined>(undefined)
133
+ const [previewDataUrl, setPreviewDataUrl] = useState<string | undefined>(
134
+ undefined,
135
+ )
136
+ const [previewPaneOn, setPreviewPaneOn] = useState(false)
137
+ const [dragOver, setDragOver] = useState(false)
138
+ const [saving, setSaving] = useState(false)
139
+ const previewVisible = !hidePreview && (!showPreviewSwitch || previewPaneOn)
140
+ const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
141
+ undefined,
142
+ )
143
+ const callbacksRef = useRef({ onSave, onSaved, onSavingChange, t })
144
+ callbacksRef.current = { onSave, onSaved, onSavingChange, t }
145
+
146
+ useEffect(
147
+ function syncInitialSrc() {
148
+ if (initialSrc !== undefined) setImageSrc(initialSrc)
149
+ },
150
+ [initialSrc],
151
+ )
152
+
153
+ const handleCropReady = useCallback(
154
+ (render: () => Promise<CroppedImage>) => {
155
+ renderRef.current = render
156
+ if (autoSaveOnCrop) {
157
+ if (autoSaveTimerRef.current) {
158
+ clearTimeout(autoSaveTimerRef.current)
159
+ }
160
+ autoSaveTimerRef.current = setTimeout(() => {
161
+ render()
162
+ .then(async (cropped) => {
163
+ const { onSave, onSaved, onSavingChange, t } =
164
+ callbacksRef.current
165
+ try {
166
+ setSaving(true)
167
+ onSavingChange?.(true)
168
+ await onSave(cropped)
169
+ onSaved?.()
170
+ } catch (err) {
171
+ const msg = err instanceof Error ? err.message : String(err)
172
+ toast.add({
173
+ title: msg || t("imageCrop.saveFailed"),
174
+ type: "error",
175
+ })
176
+ } finally {
177
+ setSaving(false)
178
+ onSavingChange?.(false)
179
+ }
180
+ })
181
+ .catch((err) => {
182
+ const { t } = callbacksRef.current
183
+ const msg = err instanceof Error ? err.message : String(err)
184
+ toast.add({
185
+ title: msg || t("imageCrop.saveFailed"),
186
+ type: "error",
187
+ })
188
+ })
189
+ }, 600)
190
+ }
191
+ },
192
+ [autoSaveOnCrop],
193
+ )
194
+
195
+ const handlePreviewChange = useCallback((dataUrl: string) => {
196
+ setPreviewDataUrl(dataUrl)
197
+ }, [])
198
+
199
+ function handlePreviewToggle(on: boolean) {
200
+ setPreviewPaneOn(on)
201
+ if (!on) setPreviewDataUrl(undefined)
202
+ }
203
+
204
+ function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
205
+ const file = e.target.files?.[0]
206
+ if (file === undefined) return
207
+ readFileAsDataUrl(file)
208
+ }
209
+
210
+ function handleDrop(e: React.DragEvent<HTMLElement>) {
211
+ e.preventDefault()
212
+ setDragOver(false)
213
+ const file = e.dataTransfer.files?.[0]
214
+ if (file !== undefined) readFileAsDataUrl(file)
215
+ }
216
+
217
+ function readFileAsDataUrl(file: File) {
218
+ const reader = new FileReader()
219
+ reader.addEventListener("load", () => {
220
+ const result = reader.result
221
+ if (typeof result === "string") setImageSrc(result)
222
+ })
223
+ reader.readAsDataURL(file)
224
+ }
225
+
226
+ function handleReselect() {
227
+ if (autoSaveTimerRef.current) {
228
+ clearTimeout(autoSaveTimerRef.current)
229
+ autoSaveTimerRef.current = undefined
230
+ }
231
+ setImageSrc(undefined)
232
+ renderRef.current = undefined
233
+ setPreviewDataUrl(undefined)
234
+ setPreviewPaneOn(false)
235
+ fileInputRef.current?.click()
236
+ }
237
+
238
+ async function handleSave() {
239
+ const render = renderRef.current
240
+ if (render === undefined) {
241
+ onClear?.()
242
+ return
243
+ }
244
+ setSaving(true)
245
+ onSavingChange?.(true)
246
+ try {
247
+ const cropped = await render()
248
+ await onSave(cropped)
249
+ onSaved?.()
250
+ } catch (err) {
251
+ const msg = err instanceof Error ? err.message : String(err)
252
+ toast.add({
253
+ title: msg || t("imageCrop.saveFailed"),
254
+ type: "error",
255
+ })
256
+ } finally {
257
+ setSaving(false)
258
+ onSavingChange?.(false)
259
+ }
260
+ }
261
+
262
+ const previewRadiusClass =
263
+ previewShape === "circle" ? "rounded-full" : "rounded"
264
+
265
+ const actionButton = (
266
+ <Button
267
+ type="button"
268
+ onClick={handleSave}
269
+ disabled={saving}
270
+ data-testid="image-crop-save"
271
+ >
272
+ {saving
273
+ ? t("imageCrop.saving")
274
+ : imageSrc === undefined
275
+ ? t("imageCrop.remove")
276
+ : t("imageCrop.save")}
277
+ </Button>
278
+ )
279
+
280
+ return (
281
+ <div className={cn("flex flex-col gap-4", panelClassName)}>
282
+ {allowChangeSource ? (
283
+ <input
284
+ ref={fileInputRef}
285
+ type="file"
286
+ accept={accept}
287
+ className="hidden"
288
+ onChange={handleFileChange}
289
+ data-testid="image-crop-file-input"
290
+ />
291
+ ) : null}
292
+
293
+ <div
294
+ className={cn(
295
+ "flex flex-col items-center gap-6 py-2 sm:flex-row sm:items-center sm:justify-center",
296
+ containerClassName,
297
+ )}
298
+ >
299
+ <div
300
+ className={cn(
301
+ "flex flex-col items-center gap-2",
302
+ stageAspectRatio !== undefined && "w-full",
303
+ cropColumnClassName,
304
+ )}
305
+ >
306
+ {imageSrc === undefined ? (
307
+ allowChangeSource ? (
308
+ <button
309
+ type="button"
310
+ onClick={() => fileInputRef.current?.click()}
311
+ onDragOver={(e) => {
312
+ e.preventDefault()
313
+ setDragOver(true)
314
+ }}
315
+ onDragLeave={() => setDragOver(false)}
316
+ onDrop={handleDrop}
317
+ className={cn(
318
+ "flex select-none flex-col items-center justify-center gap-2 rounded-md border border-border bg-muted text-muted-foreground transition-colors duration-300 hover:bg-accent hover:text-accent-foreground",
319
+ dragOver && "bg-accent text-accent-foreground",
320
+ stageAspectRatio !== undefined && "w-full",
321
+ )}
322
+ data-testid="image-crop-frame"
323
+ style={
324
+ stageAspectRatio !== undefined
325
+ ? { aspectRatio: stageAspectRatio }
326
+ : {
327
+ width: cropStageWidth ?? 240,
328
+ height: cropStageHeight ?? 240,
329
+ }
330
+ }
331
+ >
332
+ <Upload className="size-5" />
333
+ <p className="text-sm">{t("imageCrop.pickHint")}</p>
334
+ </button>
335
+ ) : (
336
+ <div
337
+ className={cn(
338
+ "flex items-center justify-center rounded-md border border-dashed border-border text-sm text-muted-foreground",
339
+ stageAspectRatio !== undefined && "w-full",
340
+ )}
341
+ style={
342
+ stageAspectRatio !== undefined
343
+ ? { aspectRatio: stageAspectRatio }
344
+ : {
345
+ width: cropStageWidth ?? 240,
346
+ height: cropStageHeight ?? 240,
347
+ }
348
+ }
349
+ >
350
+ {t("imageCrop.pickHint")}
351
+ </div>
352
+ )
353
+ ) : (
354
+ <div
355
+ className={cn(
356
+ "flex min-h-0 items-center justify-center overflow-hidden rounded-md border border-border bg-card",
357
+ stageAspectRatio === undefined && "max-w-90",
358
+ cropStageClassName,
359
+ stageAspectRatio !== undefined && "w-full",
360
+ )}
361
+ style={
362
+ stageAspectRatio !== undefined
363
+ ? { aspectRatio: stageAspectRatio }
364
+ : {
365
+ minHeight: `${cropStageHeight}px`,
366
+ minWidth: `${cropStageWidth}px`,
367
+ width: effectiveCropWidth,
368
+ height: effectiveCropHeight,
369
+ maxWidth: effectiveCropWidth,
370
+ maxHeight: effectiveCropHeight,
371
+ }
372
+ }
373
+ >
374
+ <ImageCropper
375
+ src={imageSrc}
376
+ aspect={aspect}
377
+ mimeType={mimeType}
378
+ displayMaxWidth={
379
+ stageAspectRatio !== undefined ? "100%" : effectiveCropWidth
380
+ }
381
+ displayMaxHeight={
382
+ stageAspectRatio !== undefined
383
+ ? undefined
384
+ : effectiveCropHeight
385
+ }
386
+ onCropReady={handleCropReady}
387
+ onPreviewChange={
388
+ previewVisible ? handlePreviewChange : undefined
389
+ }
390
+ />
391
+ </div>
392
+ )}
393
+ {imageSrc !== undefined && allowChangeSource ? (
394
+ <Button
395
+ type="button"
396
+ size="sm"
397
+ variant="ghost"
398
+ onClick={handleReselect}
399
+ className="text-muted-foreground text-xs"
400
+ >
401
+ <RefreshCircle className="mr-1 h-3.5 w-3.5" />
402
+ {t("imageCrop.reselect")}
403
+ </Button>
404
+ ) : null}
405
+ </div>
406
+
407
+ {previewVisible ? (
408
+ <>
409
+ <div className={cn("hidden w-px bg-border sm:block h-40")} />
410
+ <div className={cn("block bg-border sm:hidden h-px w-32")} />
411
+
412
+ <div className={cn("flex shrink-0 flex-col items-center gap-1.5")}>
413
+ <div
414
+ style={{ width: previewWidth, height: previewHeight }}
415
+ className={cn(
416
+ "flex items-center justify-center overflow-hidden border border-border bg-muted text-muted-foreground",
417
+ previewRadiusClass,
418
+ )}
419
+ data-testid="image-crop-preview"
420
+ title={t("imageCrop.previewLabel")}
421
+ >
422
+ {previewDataUrl !== undefined ? (
423
+ <img
424
+ src={previewDataUrl}
425
+ alt={t("imageCrop.previewAlt")}
426
+ className="max-h-full max-w-full object-cover"
427
+ />
428
+ ) : (
429
+ <Gallery className="h-10 w-10 opacity-60" />
430
+ )}
431
+ </div>
432
+ </div>
433
+ </>
434
+ ) : null}
435
+ </div>
436
+
437
+ {showPreviewSwitch && imageSrc !== undefined ? (
438
+ <div className={cn("flex items-center justify-center gap-2")}>
439
+ <Switch
440
+ id={previewToggleId}
441
+ checked={previewPaneOn}
442
+ onCheckedChange={handlePreviewToggle}
443
+ data-testid="image-crop-preview-toggle"
444
+ />
445
+ <Label
446
+ htmlFor={previewToggleId}
447
+ className="text-muted-foreground cursor-pointer text-sm font-normal"
448
+ >
449
+ {t("imageCrop.showPreview")}
450
+ </Label>
451
+ </div>
452
+ ) : null}
453
+
454
+ {/* The save/remove action lands on the surrounding dialog's
455
+ footer row — side by side with Cancel. Outside a dialog it
456
+ falls back to an inline row at the panel's bottom. */}
457
+ {!hideActionButton &&
458
+ (footerSlot === null ? (
459
+ <div className={cn("flex justify-end pt-2")}>{actionButton}</div>
460
+ ) : (
461
+ <DialogFooterActions>{actionButton}</DialogFooterActions>
462
+ ))}
463
+ </div>
464
+ )
465
+ }