@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,144 @@
1
+ import { fireEvent, render, waitFor } from "@testing-library/react"
2
+ import { beforeEach, describe, expect, it, vi } from "vitest"
3
+ import { type CroppedImage, ImageCropper } from "./image-cropper"
4
+
5
+ beforeEach(() => {
6
+ const context = {
7
+ drawImage: vi.fn(),
8
+ imageSmoothingQuality: "",
9
+ }
10
+ HTMLCanvasElement.prototype.getContext = vi.fn(() => context) as never
11
+ HTMLCanvasElement.prototype.toDataURL = vi.fn(
12
+ () => "data:image/png;base64,",
13
+ ) as never
14
+ HTMLCanvasElement.prototype.toBlob = vi.fn((callback) => {
15
+ callback?.(new Blob([""], { type: "image/png" }))
16
+ }) as never
17
+ })
18
+
19
+ const TALL_IMAGE_SRC =
20
+ "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"
21
+ const SQUARE_IMAGE_SRC =
22
+ "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='804' height='804'%3E%3Crect width='100%25' height='100%25' fill='red'/%3E%3C/svg%3E"
23
+
24
+ describe("ImageCropper", () => {
25
+ it("fills a tall image with a square aspect crop", async () => {
26
+ let renderCrop: (() => Promise<CroppedImage>) | undefined
27
+
28
+ render(
29
+ <ImageCropper
30
+ src={TALL_IMAGE_SRC}
31
+ aspect={1}
32
+ onCropReady={(render) => {
33
+ renderCrop = render
34
+ }}
35
+ />,
36
+ )
37
+
38
+ const img = await waitFor(() => {
39
+ const el = document.querySelector("img")
40
+ expect(el).toBeInTheDocument()
41
+ return el as HTMLImageElement
42
+ })
43
+
44
+ // Natural size is 1000x2000, but the image is displayed at 200x400.
45
+ // The output must still be a 1000x1000 square based on natural pixels.
46
+ setImageSize(img, {
47
+ naturalWidth: 1000,
48
+ naturalHeight: 2000,
49
+ width: 200,
50
+ height: 400,
51
+ })
52
+ fireEvent.load(img)
53
+
54
+ await waitFor(() => expect(renderCrop).toBeDefined())
55
+ const cropped = await renderCrop!()
56
+
57
+ expect(cropped.width).toBe(1000)
58
+ expect(cropped.height).toBe(1000)
59
+ })
60
+
61
+ it("keeps a square image square with a 1:1 aspect crop", async () => {
62
+ let renderCrop: (() => Promise<CroppedImage>) | undefined
63
+
64
+ render(
65
+ <ImageCropper
66
+ src={SQUARE_IMAGE_SRC}
67
+ aspect={1}
68
+ onCropReady={(render) => {
69
+ renderCrop = render
70
+ }}
71
+ />,
72
+ )
73
+
74
+ const img = await waitFor(() => {
75
+ const el = document.querySelector("img")
76
+ expect(el).toBeInTheDocument()
77
+ return el as HTMLImageElement
78
+ })
79
+
80
+ // Natural size is 804x804, displayed much smaller (e.g. 200x200).
81
+ // The output must remain exactly 804x804.
82
+ setImageSize(img, {
83
+ naturalWidth: 804,
84
+ naturalHeight: 804,
85
+ width: 200,
86
+ height: 200,
87
+ })
88
+ fireEvent.load(img)
89
+
90
+ await waitFor(() => expect(renderCrop).toBeDefined())
91
+ const cropped = await renderCrop!()
92
+
93
+ expect(cropped.width).toBe(804)
94
+ expect(cropped.height).toBe(804)
95
+ })
96
+
97
+ it("uses the full image when no aspect is provided", async () => {
98
+ let renderCrop: (() => Promise<CroppedImage>) | undefined
99
+
100
+ render(
101
+ <ImageCropper
102
+ src={TALL_IMAGE_SRC}
103
+ onCropReady={(render) => {
104
+ renderCrop = render
105
+ }}
106
+ />,
107
+ )
108
+
109
+ const img = await waitFor(() => {
110
+ const el = document.querySelector("img")
111
+ expect(el).toBeInTheDocument()
112
+ return el as HTMLImageElement
113
+ })
114
+
115
+ setImageSize(img, {
116
+ naturalWidth: 1000,
117
+ naturalHeight: 2000,
118
+ width: 200,
119
+ height: 400,
120
+ })
121
+ fireEvent.load(img)
122
+
123
+ await waitFor(() => expect(renderCrop).toBeDefined())
124
+ const cropped = await renderCrop!()
125
+
126
+ expect(cropped.width).toBe(1000)
127
+ expect(cropped.height).toBe(2000)
128
+ })
129
+ })
130
+
131
+ function setImageSize(
132
+ img: HTMLImageElement,
133
+ size: {
134
+ naturalWidth: number
135
+ naturalHeight: number
136
+ width: number
137
+ height: number
138
+ },
139
+ ): void {
140
+ Object.defineProperty(img, "naturalWidth", { value: size.naturalWidth })
141
+ Object.defineProperty(img, "naturalHeight", { value: size.naturalHeight })
142
+ Object.defineProperty(img, "width", { value: size.width })
143
+ Object.defineProperty(img, "height", { value: size.height })
144
+ }
@@ -0,0 +1,265 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react"
2
+ import ReactCrop, { type Crop } from "react-image-crop"
3
+ import "react-image-crop/dist/ReactCrop.css"
4
+
5
+ /**
6
+ * Output of {@link ImageCropper} once the user releases the selection
7
+ * box. Contains the cropped pixel data ready to upload (PNG by default)
8
+ * plus its dimensions.
9
+ */
10
+ export type CroppedImage = {
11
+ readonly blob: Blob
12
+ readonly width: number
13
+ readonly height: number
14
+ readonly mimeType: string
15
+ }
16
+
17
+ export type ImageCropperProps = {
18
+ /** The source image as a data URL or object URL. */
19
+ readonly src: string
20
+ /**
21
+ * Optional fixed aspect ratio (width / height). When omitted, the
22
+ * user can crop freely.
23
+ */
24
+ readonly aspect?: number
25
+ /**
26
+ * Output mime type. Defaults to `image/png`. PNG preserves quality;
27
+ * JPEG produces smaller files for photographic content.
28
+ */
29
+ readonly mimeType?: "image/png" | "image/jpeg" | "image/webp"
30
+ /** JPEG/WebP quality (0..1). Ignored for PNG. */
31
+ readonly quality?: number
32
+ /**
33
+ * Called whenever the cropped pixel rectangle changes and is non-empty.
34
+ * The callback receives a function that, when invoked, produces the
35
+ * final {@link CroppedImage}. The render is deferred to avoid running
36
+ * the canvas pipeline on every drag tick.
37
+ */
38
+ readonly onCropReady: (render: () => Promise<CroppedImage>) => void
39
+ /**
40
+ * Called synchronously with a PNG `data:` URL whenever the crop
41
+ * selection changes. PNG preserves alpha for the live preview (JPEG
42
+ * would flatten transparency to black). Unlike blob URLs, `data:`
43
+ * URLs never appear in the browser network tab and setting them as
44
+ * image `src` cannot cause an infinite re-render loop.
45
+ */
46
+ readonly onPreviewChange?: (dataUrl: string) => void
47
+ /**
48
+ * Max CSS width for the source image in the crop stage. When omitted the
49
+ * image relies on `max-w-full` to fill the parent width.
50
+ */
51
+ readonly displayMaxWidth?: number | string
52
+ /**
53
+ * Max CSS height for the source image in the crop stage (e.g. `80vh` for
54
+ * large canvas captures). Defaults to `70vh`.
55
+ */
56
+ readonly displayMaxHeight?: number | string
57
+ }
58
+
59
+ /**
60
+ * Free-aspect (or fixed-aspect when {@link ImageCropperProps.aspect} is
61
+ * provided) image cropper. Wraps `react-image-crop` and exposes a
62
+ * deferred `render()` callback so callers can rasterise the selection on
63
+ * demand (typically when the user clicks "save").
64
+ */
65
+ export function ImageCropper(props: ImageCropperProps) {
66
+ const {
67
+ src,
68
+ aspect,
69
+ mimeType = "image/png",
70
+ quality = 0.92,
71
+ displayMaxWidth,
72
+ displayMaxHeight = "70vh",
73
+ } = props
74
+
75
+ const imgRef = useRef<HTMLImageElement | null>(null)
76
+ const [crop, setCrop] = useState<Crop | undefined>(undefined)
77
+ const [completed, setCompleted] = useState<Crop | undefined>(undefined)
78
+
79
+ const handleImageLoad = useCallback(
80
+ (e: React.SyntheticEvent<HTMLImageElement>) => {
81
+ const { naturalWidth, naturalHeight } = e.currentTarget
82
+ const initial = buildInitialCrop(naturalWidth, naturalHeight, aspect)
83
+ setCrop(initial)
84
+ setCompleted(initial)
85
+ },
86
+ [aspect],
87
+ )
88
+
89
+ const onCropReady = props.onCropReady
90
+ const onPreviewChange = props.onPreviewChange
91
+
92
+ useEffect(() => {
93
+ const img = imgRef.current
94
+ if (img === null || completed === undefined || completed.width === 0) {
95
+ return
96
+ }
97
+ onCropReady(() => renderCroppedImage(img, completed, mimeType, quality))
98
+ if (onPreviewChange !== undefined) {
99
+ onPreviewChange(renderPreviewDataUrl(img, completed))
100
+ }
101
+ }, [completed, mimeType, quality, onCropReady, onPreviewChange])
102
+
103
+ return (
104
+ <div className="react-crop-theme flex min-h-0 w-full max-w-full flex-col items-center gap-2 overflow-hidden">
105
+ <ReactCrop
106
+ className="max-w-full"
107
+ crop={crop}
108
+ onChange={(_, percentCrop) => setCrop(percentCrop)}
109
+ onComplete={(_, percentCrop) => setCompleted(percentCrop)}
110
+ aspect={aspect}
111
+ keepSelection
112
+ ruleOfThirds
113
+ >
114
+ {/* biome-ignore lint/a11y/useAltText: cropper preview lacks user-supplied alt */}
115
+ <img
116
+ ref={imgRef}
117
+ src={src}
118
+ onLoad={handleImageLoad}
119
+ className="h-auto max-w-full"
120
+ style={{
121
+ maxWidth: displayMaxWidth,
122
+ maxHeight: displayMaxHeight,
123
+ }}
124
+ />
125
+ </ReactCrop>
126
+ </div>
127
+ )
128
+ }
129
+
130
+ function buildInitialCrop(
131
+ imgWidth: number,
132
+ imgHeight: number,
133
+ aspect: number | undefined,
134
+ ): Crop {
135
+ if (aspect === undefined) {
136
+ return { unit: "%" as const, x: 0, y: 0, width: 100, height: 100 }
137
+ }
138
+
139
+ const imgAspect = imgWidth / imgHeight
140
+ let widthPct: number
141
+ let heightPct: number
142
+
143
+ if (imgAspect >= aspect) {
144
+ // Image is wider (or equal) than the target aspect: fit to height,
145
+ // crop the sides.
146
+ heightPct = 100
147
+ widthPct = (100 * aspect) / imgAspect
148
+ } else {
149
+ // Image is taller than the target aspect: fit to width, crop top/bottom.
150
+ widthPct = 100
151
+ heightPct = (100 * imgAspect) / aspect
152
+ }
153
+
154
+ return {
155
+ unit: "%" as const,
156
+ x: (100 - widthPct) / 2,
157
+ y: (100 - heightPct) / 2,
158
+ width: widthPct,
159
+ height: heightPct,
160
+ }
161
+ }
162
+
163
+ class CroppedImageRenderError extends Error {
164
+ constructor(message: string) {
165
+ super(message)
166
+ this.name = "CroppedImageRenderError"
167
+ }
168
+ }
169
+
170
+ /**
171
+ * @throws CroppedImageRenderError when the canvas context is unavailable
172
+ * or the browser refuses to encode the selected blob.
173
+ */
174
+ async function renderCroppedImage(
175
+ image: HTMLImageElement,
176
+ crop: Crop,
177
+ mimeType: string,
178
+ quality: number,
179
+ ): Promise<CroppedImage> {
180
+ const sourceRect = cropToNaturalRect(image, crop)
181
+
182
+ const canvas = document.createElement("canvas")
183
+ canvas.width = sourceRect.sw
184
+ canvas.height = sourceRect.sh
185
+ const ctx = canvas.getContext("2d")
186
+ if (ctx === null) {
187
+ throw new CroppedImageRenderError("2d context is unavailable")
188
+ }
189
+ ctx.imageSmoothingQuality = "high"
190
+ ctx.drawImage(
191
+ image,
192
+ sourceRect.sx,
193
+ sourceRect.sy,
194
+ sourceRect.sw,
195
+ sourceRect.sh,
196
+ 0,
197
+ 0,
198
+ sourceRect.sw,
199
+ sourceRect.sh,
200
+ )
201
+
202
+ const blob = await new Promise<Blob | null>((resolve) => {
203
+ canvas.toBlob(resolve, mimeType, quality)
204
+ })
205
+ if (blob === null) {
206
+ throw new CroppedImageRenderError("toBlob returned null")
207
+ }
208
+ return { blob, width: sourceRect.sw, height: sourceRect.sh, mimeType }
209
+ }
210
+
211
+ /**
212
+ * Render the current crop to a PNG `data:` URL synchronously.
213
+ *
214
+ * Unlike {@link renderCroppedImage}, this function does NOT create a
215
+ * `blob:` URL and therefore does not appear in the browser network tab.
216
+ * Preview uses PNG (not JPEG) so transparent regions stay transparent.
217
+ */
218
+ function renderPreviewDataUrl(image: HTMLImageElement, crop: Crop): string {
219
+ const sourceRect = cropToNaturalRect(image, crop)
220
+
221
+ const canvas = document.createElement("canvas")
222
+ canvas.width = sourceRect.sw
223
+ canvas.height = sourceRect.sh
224
+ const ctx = canvas.getContext("2d", { alpha: true })
225
+ if (ctx === null) return ""
226
+ ctx.imageSmoothingQuality = "high"
227
+ ctx.drawImage(
228
+ image,
229
+ sourceRect.sx,
230
+ sourceRect.sy,
231
+ sourceRect.sw,
232
+ sourceRect.sh,
233
+ 0,
234
+ 0,
235
+ sourceRect.sw,
236
+ sourceRect.sh,
237
+ )
238
+ return canvas.toDataURL("image/png")
239
+ }
240
+
241
+ function cropToNaturalRect(
242
+ image: HTMLImageElement,
243
+ crop: Crop,
244
+ ): { sx: number; sy: number; sw: number; sh: number } {
245
+ const naturalWidth = image.naturalWidth
246
+ const naturalHeight = image.naturalHeight
247
+
248
+ if (crop.unit === "px") {
249
+ return {
250
+ sx: crop.x,
251
+ sy: crop.y,
252
+ sw: Math.max(1, Math.round(crop.width)),
253
+ sh: Math.max(1, Math.round(crop.height)),
254
+ }
255
+ }
256
+
257
+ const sx = (crop.x / 100) * naturalWidth
258
+ const sy = (crop.y / 100) * naturalHeight
259
+ const swRaw = (crop.width / 100) * naturalWidth
260
+ const shRaw = (crop.height / 100) * naturalHeight
261
+ const sw = Math.max(1, Math.round(swRaw))
262
+ const sh = Math.max(1, Math.round(shRaw))
263
+
264
+ return { sx, sy, sw, sh }
265
+ }
@@ -0,0 +1,156 @@
1
+ import * as React from "react"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+
4
+ import { cn } from "@hoardodile/ui/lib/utils"
5
+ import { Button } from "@hoardodile/ui/components/button"
6
+ import { Input } from "@hoardodile/ui/components/input"
7
+ import { Textarea } from "@hoardodile/ui/components/textarea"
8
+
9
+ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
10
+ return (
11
+ <div
12
+ data-slot="input-group"
13
+ role="group"
14
+ className={cn(
15
+ "group/input-group relative flex h-9 w-full min-w-0 items-center rounded-md border border-input shadow-xs transition-[color,box-shadow] outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
16
+ className
17
+ )}
18
+ {...props}
19
+ />
20
+ )
21
+ }
22
+
23
+ const inputGroupAddonVariants = cva(
24
+ "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
25
+ {
26
+ variants: {
27
+ align: {
28
+ "inline-start":
29
+ "order-first pl-2 has-[>button]:-ml-1 has-[>kbd]:ml-[-0.15rem]",
30
+ "inline-end":
31
+ "order-last pr-2 has-[>button]:-mr-1 has-[>kbd]:mr-[-0.15rem]",
32
+ "block-start":
33
+ "order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
34
+ "block-end":
35
+ "order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
36
+ },
37
+ },
38
+ defaultVariants: {
39
+ align: "inline-start",
40
+ },
41
+ }
42
+ )
43
+
44
+ function InputGroupAddon({
45
+ className,
46
+ align = "inline-start",
47
+ ...props
48
+ }: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
49
+ return (
50
+ <div
51
+ role="group"
52
+ data-slot="input-group-addon"
53
+ data-align={align}
54
+ className={cn(inputGroupAddonVariants({ align }), className)}
55
+ onClick={(e) => {
56
+ if ((e.target as HTMLElement).closest("button")) {
57
+ return
58
+ }
59
+ e.currentTarget.parentElement?.querySelector("input")?.focus()
60
+ }}
61
+ {...props}
62
+ />
63
+ )
64
+ }
65
+
66
+ const inputGroupButtonVariants = cva(
67
+ "flex items-center gap-2 text-sm shadow-none",
68
+ {
69
+ variants: {
70
+ size: {
71
+ xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
72
+ sm: "",
73
+ "icon-xs":
74
+ "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
75
+ "icon-sm": "size-8 p-0 has-[>svg]:p-0",
76
+ },
77
+ },
78
+ defaultVariants: {
79
+ size: "xs",
80
+ },
81
+ }
82
+ )
83
+
84
+ function InputGroupButton({
85
+ className,
86
+ type = "button",
87
+ variant = "ghost",
88
+ size = "xs",
89
+ ...props
90
+ }: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
91
+ VariantProps<typeof inputGroupButtonVariants> & {
92
+ type?: "button" | "submit" | "reset"
93
+ }) {
94
+ return (
95
+ <Button
96
+ type={type}
97
+ data-size={size}
98
+ variant={variant}
99
+ className={cn(inputGroupButtonVariants({ size }), className)}
100
+ {...props}
101
+ />
102
+ )
103
+ }
104
+
105
+ function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
106
+ return (
107
+ <span
108
+ className={cn(
109
+ "flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
110
+ className
111
+ )}
112
+ {...props}
113
+ />
114
+ )
115
+ }
116
+
117
+ function InputGroupInput({
118
+ className,
119
+ ...props
120
+ }: Omit<React.ComponentProps<"input">, "size">) {
121
+ return (
122
+ <Input
123
+ data-slot="input-group-control"
124
+ className={cn(
125
+ "flex-1 rounded-none bg-transparent aria-invalid:ring-0 dark:bg-transparent",
126
+ className
127
+ )}
128
+ {...props}
129
+ />
130
+ )
131
+ }
132
+
133
+ function InputGroupTextarea({
134
+ className,
135
+ ...props
136
+ }: React.ComponentProps<"textarea">) {
137
+ return (
138
+ <Textarea
139
+ data-slot="input-group-control"
140
+ className={cn(
141
+ "flex-1 resize-none rounded-none bg-transparent py-2 aria-invalid:ring-0 dark:bg-transparent",
142
+ className
143
+ )}
144
+ {...props}
145
+ />
146
+ )
147
+ }
148
+
149
+ export {
150
+ InputGroup,
151
+ InputGroupAddon,
152
+ InputGroupButton,
153
+ InputGroupText,
154
+ InputGroupInput,
155
+ InputGroupTextarea,
156
+ }
@@ -0,0 +1,45 @@
1
+ import { Input as InputPrimitive } from "@base-ui/react/input"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+
4
+ import { cn } from "@hoardodile/ui/lib/utils"
5
+
6
+ /**
7
+ * Muted-fill control (the ControlStub / forms anatomy): fill background,
8
+ * `rounded-lg`, no border — the same anatomy as the search field. Three
9
+ * height tiers ride the control tokens: `sm` h-chip, `md` h-control,
10
+ * `lg` h-10.
11
+ */
12
+ const inputVariants = cva(
13
+ "w-full min-w-0 rounded-lg bg-muted text-ui text-foreground outline-none placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-[3px] aria-invalid:ring-destructive/20",
14
+ {
15
+ variants: {
16
+ size: {
17
+ sm: "h-chip px-2.5 text-xs",
18
+ md: "h-control px-3",
19
+ lg: "h-10 px-3.5",
20
+ },
21
+ },
22
+ defaultVariants: {
23
+ size: "md",
24
+ },
25
+ }
26
+ )
27
+
28
+ function Input({
29
+ className,
30
+ size,
31
+ type,
32
+ ...props
33
+ }: Omit<React.ComponentProps<"input">, "size"> &
34
+ VariantProps<typeof inputVariants>) {
35
+ return (
36
+ <InputPrimitive
37
+ type={type}
38
+ data-slot="input"
39
+ className={cn(inputVariants({ size }), className)}
40
+ {...props}
41
+ />
42
+ )
43
+ }
44
+
45
+ export { Input, inputVariants }
@@ -0,0 +1,20 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+
5
+ import { cn } from "@hoardodile/ui/lib/utils"
6
+
7
+ function Label({ className, ...props }: React.ComponentProps<"label">) {
8
+ return (
9
+ <label
10
+ data-slot="label"
11
+ className={cn(
12
+ "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
13
+ className
14
+ )}
15
+ {...props}
16
+ />
17
+ )
18
+ }
19
+
20
+ export { Label }
@@ -0,0 +1,27 @@
1
+ import { cn } from "@hoardodile/ui/lib/utils"
2
+ import type { ReactNode } from "react"
3
+
4
+ export type ListEmptyRowProps = {
5
+ readonly testId?: string
6
+ readonly className?: string
7
+ readonly children: ReactNode
8
+ }
9
+
10
+ /**
11
+ * Dashed empty-list row — a quiet centered slot that reads as an empty
12
+ * list item rather than a gap. Shared by comment, character, resource
13
+ * and tag-rule lists.
14
+ */
15
+ export function ListEmptyRow(props: ListEmptyRowProps) {
16
+ return (
17
+ <div
18
+ className={cn(
19
+ "flex min-h-9 items-center justify-center rounded-lg border border-dashed border-border-strong px-3 py-2 text-xs text-muted-foreground",
20
+ props.className,
21
+ )}
22
+ data-testid={props.testId}
23
+ >
24
+ {props.children}
25
+ </div>
26
+ )
27
+ }
@@ -0,0 +1,25 @@
1
+ import { cn } from "@hoardodile/ui/lib/utils"
2
+ import type { ReactNode } from "react"
3
+
4
+ export type ManagementEmptyProps = {
5
+ readonly testId?: string
6
+ readonly className?: string
7
+ readonly children: ReactNode
8
+ }
9
+
10
+ /**
11
+ * Dashed empty-state placeholder shared by entity-management panels.
12
+ */
13
+ export function ManagementEmpty(props: ManagementEmptyProps) {
14
+ return (
15
+ <div
16
+ className={cn(
17
+ "w-full rounded-lg border border-dashed bg-muted/20 px-4 py-3 text-sm text-muted-foreground",
18
+ props.className,
19
+ )}
20
+ data-testid={props.testId}
21
+ >
22
+ {props.children}
23
+ </div>
24
+ )
25
+ }