@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,130 @@
1
+ import { AppDialog } from "@hoardodile/ui/components/app-dialog"
2
+ import { Button } from "@hoardodile/ui/components/button"
3
+ import { Input } from "@hoardodile/ui/components/input"
4
+ import { type ReactNode } from "react"
5
+ import { Trans, useTranslation } from "react-i18next"
6
+
7
+ export type ConfirmByTypingDialogProps = Readonly<{
8
+ open: boolean
9
+ onOpenChange(open: boolean): void
10
+ title: string
11
+ description: string
12
+ /** Exact string the user must type to enable the confirm button. */
13
+ expectedInput: string
14
+ /**
15
+ * The target name rendered in bold inside the one-line confirm prompt.
16
+ */
17
+ targetName: string
18
+ confirmLabel: string
19
+ pendingLabel: string
20
+ pending: boolean
21
+ /** When true the dialog takes the danger register (destructive header
22
+ ink) and the confirm button uses the destructive variant. */
23
+ destructive?: boolean
24
+ typed: string
25
+ onTypedChange(value: string): void
26
+ onConfirm(): void
27
+ /**
28
+ * The one-line confirm prompt (rendered below the description) —
29
+ * defaults to the shared "Type <name>…</name> to confirm" copy with
30
+ * the target name bolded.
31
+ */
32
+ prompt?: ReactNode
33
+ /** Cancel button label — defaults to the shared "Cancel" copy. */
34
+ cancelLabel?: ReactNode
35
+ inputTestId?: string
36
+ confirmTestId?: string
37
+ /** Forwarded to the dialog content (e.g. theme scope classes). */
38
+ contentClassName?: string
39
+ }>
40
+
41
+ /**
42
+ * Shared "type the name to confirm" dialog for any operation that
43
+ * requires the user to re-read a target name and type it back. The
44
+ * type-to-confirm anatomy: the consequence reads in the header
45
+ * description, the target name sits in bold inside the one-line prompt
46
+ * (`Type “harbor” to confirm`), and the confirm stays gated until the
47
+ * input matches. The prompt is rendered uniformly; only the bold target
48
+ * name varies.
49
+ */
50
+ export function ConfirmByTypingDialog(props: ConfirmByTypingDialogProps) {
51
+ const {
52
+ open,
53
+ onOpenChange,
54
+ title,
55
+ description,
56
+ expectedInput,
57
+ confirmLabel,
58
+ pendingLabel,
59
+ pending,
60
+ destructive = true,
61
+ typed,
62
+ onTypedChange,
63
+ onConfirm,
64
+ targetName,
65
+ prompt,
66
+ cancelLabel,
67
+ inputTestId,
68
+ confirmTestId,
69
+ contentClassName,
70
+ } = props
71
+ const { t } = useTranslation("ui", { useSuspense: false })
72
+ const canConfirm = !pending && typed === expectedInput
73
+ function handleOpenChange(next: boolean) {
74
+ if (pending && !next) return
75
+ onOpenChange(next)
76
+ }
77
+ return (
78
+ <AppDialog
79
+ open={open}
80
+ onOpenChange={handleOpenChange}
81
+ title={title}
82
+ description={description}
83
+ danger={destructive}
84
+ contentClassName={contentClassName}
85
+ footer={
86
+ <>
87
+ <Button
88
+ variant="secondary"
89
+ onClick={() => onOpenChange(false)}
90
+ disabled={pending}
91
+ >
92
+ {cancelLabel ?? t("dialog.cancel")}
93
+ </Button>
94
+ <Button
95
+ variant={destructive ? "destructive" : "default"}
96
+ disabled={!canConfirm}
97
+ onClick={onConfirm}
98
+ data-testid={confirmTestId}
99
+ >
100
+ {pending ? pendingLabel : confirmLabel}
101
+ </Button>
102
+ </>
103
+ }
104
+ >
105
+ <div className="flex flex-col gap-1.5">
106
+ <span className="text-xs text-muted-foreground">
107
+ {prompt ?? (
108
+ <Trans
109
+ ns="ui"
110
+ i18nKey="confirmByTyping.prompt"
111
+ values={{ name: targetName }}
112
+ components={{
113
+ name: (
114
+ <span className="font-semibold text-foreground" />
115
+ ),
116
+ }}
117
+ />
118
+ )}
119
+ </span>
120
+ <Input
121
+ autoFocus
122
+ value={typed}
123
+ onChange={(e) => onTypedChange(e.target.value)}
124
+ autoComplete="off"
125
+ data-testid={inputTestId}
126
+ />
127
+ </div>
128
+ </AppDialog>
129
+ )
130
+ }
@@ -0,0 +1,39 @@
1
+ import { screen } from "@testing-library/react"
2
+ import userEvent from "@testing-library/user-event"
3
+ import { describe, expect, it, vi } from "vitest"
4
+ import { testI18n, renderWithI18n } from "../test/i18n"
5
+ import { ConfirmDialog } from "./confirm-dialog"
6
+
7
+ function renderDialog(
8
+ overrides: Partial<Parameters<typeof ConfirmDialog>[0]> = {},
9
+ ) {
10
+ const onOpenChange = vi.fn()
11
+ const onConfirm = vi.fn()
12
+ renderWithI18n(
13
+ <ConfirmDialog
14
+ open
15
+ onOpenChange={onOpenChange}
16
+ title="Title"
17
+ confirmLabel="Confirm"
18
+ isPending={false}
19
+ onConfirm={onConfirm}
20
+ {...overrides}
21
+ />,
22
+ )
23
+ return { onOpenChange, onConfirm }
24
+ }
25
+
26
+ describe("ConfirmDialog", () => {
27
+ it("defaults the cancel button to the shared cancel copy", async () => {
28
+ const { onOpenChange } = renderDialog()
29
+ await userEvent.click(screen.getByRole("button", { name: "Cancel" }))
30
+ expect(onOpenChange).toHaveBeenCalledWith(false)
31
+ })
32
+
33
+ it("uses the localized cancel copy in another language", async () => {
34
+ await testI18n.changeLanguage("zh")
35
+ renderDialog()
36
+ expect(screen.getByRole("button", { name: "取消" })).toBeInTheDocument()
37
+ await testI18n.changeLanguage("en")
38
+ })
39
+ })
@@ -0,0 +1,98 @@
1
+ import { AppDialog } from "@hoardodile/ui/components/app-dialog"
2
+ import { Button } from "@hoardodile/ui/components/button"
3
+ import { type ReactNode } from "react"
4
+ import { useTranslation } from "react-i18next"
5
+
6
+ export type ConfirmDialogProps = Readonly<{
7
+ open: boolean
8
+ onOpenChange: (open: boolean) => void
9
+ title: ReactNode
10
+ description?: ReactNode
11
+ body?: ReactNode
12
+ confirmLabel: ReactNode
13
+ pendingLabel?: ReactNode
14
+ /** Cancel button label; defaults to the shared "Cancel" copy. */
15
+ cancelLabel?: ReactNode
16
+ isPending: boolean
17
+ destructive?: boolean
18
+ confirmDisabled?: boolean
19
+ onConfirm: () => void
20
+ confirmTestId?: string
21
+ cancelTestId?: string
22
+ contentClassName?: string
23
+ overlayClassName?: string
24
+ contentMotion?: "default" | "minimal"
25
+ suppressAutoFocus?: boolean
26
+ /** When true, closing while pending is blocked. Defaults to true. */
27
+ lockWhilePending?: boolean
28
+ }>
29
+
30
+ /**
31
+ * Cancel + primary action dialog. Disables and (by default) blocks
32
+ * dismissal while `isPending` so a user does not double-submit a
33
+ * destructive action.
34
+ */
35
+ export function ConfirmDialog(props: ConfirmDialogProps) {
36
+ const {
37
+ open,
38
+ onOpenChange,
39
+ title,
40
+ description,
41
+ body,
42
+ confirmLabel,
43
+ pendingLabel,
44
+ cancelLabel,
45
+ isPending,
46
+ destructive = false,
47
+ confirmDisabled = false,
48
+ onConfirm,
49
+ confirmTestId,
50
+ cancelTestId,
51
+ contentClassName,
52
+ overlayClassName,
53
+ contentMotion,
54
+ suppressAutoFocus,
55
+ lockWhilePending = true,
56
+ } = props
57
+ const { t } = useTranslation("ui", { useSuspense: false })
58
+ function handleOpenChange(next: boolean) {
59
+ if (lockWhilePending && isPending && !next) return
60
+ onOpenChange(next)
61
+ }
62
+ const footer = (
63
+ <>
64
+ <Button
65
+ variant="secondary"
66
+ onClick={() => handleOpenChange(false)}
67
+ disabled={isPending}
68
+ data-testid={cancelTestId}
69
+ >
70
+ {cancelLabel ?? t("dialog.cancel")}
71
+ </Button>
72
+ <Button
73
+ variant={destructive ? "destructive" : "default"}
74
+ onClick={onConfirm}
75
+ disabled={isPending || confirmDisabled}
76
+ data-testid={confirmTestId}
77
+ >
78
+ {isPending && pendingLabel !== undefined ? pendingLabel : confirmLabel}
79
+ </Button>
80
+ </>
81
+ )
82
+ return (
83
+ <AppDialog
84
+ open={open}
85
+ onOpenChange={handleOpenChange}
86
+ title={title}
87
+ description={description}
88
+ danger={destructive}
89
+ footer={footer}
90
+ contentClassName={contentClassName}
91
+ overlayClassName={overlayClassName}
92
+ contentMotion={contentMotion}
93
+ suppressAutoFocus={suppressAutoFocus}
94
+ >
95
+ {body}
96
+ </AppDialog>
97
+ )
98
+ }
@@ -0,0 +1,23 @@
1
+ import { cn } from "@hoardodile/ui/lib/utils"
2
+
3
+ export type CountBadgeProps = {
4
+ readonly count: number
5
+ readonly className?: string
6
+ }
7
+
8
+ /**
9
+ * Count badge — the inverted 16px pill worn by buttons to show how many
10
+ * filters are active, how many documents are pinned, etc.
11
+ */
12
+ export function CountBadge(props: CountBadgeProps) {
13
+ return (
14
+ <span
15
+ className={cn(
16
+ "flex size-4 items-center justify-center rounded-full bg-foreground text-tiny text-background",
17
+ props.className,
18
+ )}
19
+ >
20
+ {props.count}
21
+ </span>
22
+ )
23
+ }
@@ -0,0 +1,315 @@
1
+ import * as React from "react"
2
+ import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
3
+ import { useTranslation } from "react-i18next"
4
+
5
+ import { cn } from "@hoardodile/ui/lib/utils"
6
+ import { Button } from "@hoardodile/ui/components/button"
7
+ import { useMobileBackToClose } from "@hoardodile/ui/hooks/useMobileBackToClose"
8
+ import { Cross } from "@hoardodile/ui/icons/marks"
9
+
10
+ function Dialog({
11
+ open,
12
+ defaultOpen,
13
+ onOpenChange,
14
+ ...props
15
+ }: Omit<DialogPrimitive.Root.Props, "onOpenChange"> & {
16
+ /**
17
+ * `eventDetails` is absent for closes triggered by the mobile back
18
+ * gesture (a synthetic close with no underlying Base UI event).
19
+ */
20
+ onOpenChange?: (
21
+ open: boolean,
22
+ eventDetails?: DialogPrimitive.Root.ChangeEventDetails,
23
+ ) => void
24
+ }) {
25
+ const [uncontrolledOpen, setUncontrolledOpen] = React.useState(
26
+ defaultOpen ?? false,
27
+ )
28
+ const isControlled = open !== undefined
29
+ const currentOpen = isControlled ? open : uncontrolledOpen
30
+ function handleOpenChange(
31
+ next: boolean,
32
+ eventDetails?: DialogPrimitive.Root.ChangeEventDetails,
33
+ ) {
34
+ if (!isControlled) setUncontrolledOpen(next)
35
+ onOpenChange?.(next, eventDetails)
36
+ }
37
+ useMobileBackToClose(currentOpen, handleOpenChange)
38
+ return (
39
+ <DialogPrimitive.Root
40
+ data-slot="dialog"
41
+ open={currentOpen}
42
+ onOpenChange={handleOpenChange}
43
+ {...props}
44
+ />
45
+ )
46
+ }
47
+
48
+ function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
49
+ return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
50
+ }
51
+
52
+ function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
53
+ return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
54
+ }
55
+
56
+ function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
57
+ return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
58
+ }
59
+
60
+ function DialogOverlay({
61
+ className,
62
+ ...props
63
+ }: DialogPrimitive.Backdrop.Props) {
64
+ return (
65
+ <DialogPrimitive.Backdrop
66
+ data-slot="dialog-overlay"
67
+ className={cn(
68
+ // The dialog layer: a static whisper-of-ink scrim plus
69
+ // backdrop blur — part of the surface definition, not motion.
70
+ "fixed inset-0 isolate z-50 bg-foreground/5 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-sm",
71
+ className
72
+ )}
73
+ {...props}
74
+ />
75
+ )
76
+ }
77
+
78
+ function DialogContent({
79
+ className,
80
+ children,
81
+ showCloseButton = true,
82
+ overlayClassName,
83
+ contentMotion = "default",
84
+ ...props
85
+ }: DialogPrimitive.Popup.Props &
86
+ React.RefAttributes<HTMLDivElement> & {
87
+ showCloseButton?: boolean
88
+ /** Merged into {@link DialogOverlay}. Use to drop backdrop blur over heavy GPU layers (e.g. WebGL). */
89
+ overlayClassName?: string
90
+ /**
91
+ * `minimal` uses fade-only enter/exit (no slide/zoom) to reduce jank
92
+ * when opening over canvas/video or other expensive surfaces.
93
+ */
94
+ contentMotion?: "default" | "minimal"
95
+ }) {
96
+ const { t } = useTranslation("ui", { useSuspense: false })
97
+ // Auto-wrap "loose" body children (anything that is not a DialogHeader
98
+ // or DialogFooter) inside a DialogBody. This guarantees a single
99
+ // scroll container — historically callers relied on DialogContent
100
+ // itself scrolling and added their own nested `overflow-y-auto`
101
+ // wrappers, which produced a confusing two-scrollbar behaviour on
102
+ // touch devices. With a single inner scroller, header/footer stay
103
+ // pinned and only the middle region scrolls on every viewport.
104
+ const arranged = arrangeDialogChildren(children)
105
+ const motionClasses =
106
+ contentMotion === "minimal"
107
+ ? "data-ending-style:opacity-0 data-starting-style:opacity-0"
108
+ : cn(
109
+ "data-ending-style:translate-y-10 data-ending-style:opacity-0 data-starting-style:translate-y-10 data-starting-style:opacity-0",
110
+ // ≥ sm the popup is centred with -translate-y-1/2, so the
111
+ // starting/ending styles must keep that offset (zoom + fade
112
+ // only, no slide) instead of clobbering it with translate-y-0.
113
+ "sm:data-ending-style:-translate-y-1/2 sm:data-ending-style:scale-95 sm:data-starting-style:-translate-y-1/2 sm:data-starting-style:scale-95"
114
+ )
115
+ return (
116
+ <DialogPortal>
117
+ <DialogOverlay className={overlayClassName} />
118
+ <DialogPrimitive.Popup
119
+ data-slot="dialog-content"
120
+ className={cn(
121
+ // The floating-card anatomy: `bg-card`
122
+ // hairline-bordered surface with the system's one deep shadow.
123
+ // Mobile: bottom sheet occupying most of the viewport so edit
124
+ // forms have room to breathe. The component never scrolls
125
+ // itself; only the inner DialogBody does. Header/body/footer
126
+ // parts are held apart by the card's section gap
127
+ // (`gap-4` between the header document, the body and the
128
+ // hairline footer).
129
+ "fixed z-50 flex flex-col gap-4 bg-card text-sm text-card-foreground shadow-dialog outline-hidden transition duration-200 ease-in-out",
130
+ "inset-x-0 bottom-0 max-h-[85svh] overflow-hidden rounded-t-2xl rounded-b-none border border-b-0 border-border",
131
+ motionClasses,
132
+ // ≥ sm: centred modal, allow up to 90vh.
133
+ "sm:inset-x-auto sm:bottom-auto sm:top-1/2 sm:left-1/2 sm:w-full sm:max-w-md sm:max-h-[90vh] sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-2xl",
134
+ className
135
+ )}
136
+ {...props}
137
+ >
138
+ {arranged}
139
+ {showCloseButton && (
140
+ <DialogPrimitive.Close
141
+ data-slot="dialog-close"
142
+ className="absolute top-3 right-3 z-20 flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 hover:bg-muted hover:text-foreground"
143
+ >
144
+ <Cross className="size-3" />
145
+ <span className="sr-only">{t("aria.close")}</span>
146
+ </DialogPrimitive.Close>
147
+ )}
148
+ </DialogPrimitive.Popup>
149
+ </DialogPortal>
150
+ )
151
+ }
152
+
153
+ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
154
+ return (
155
+ <div
156
+ data-slot="dialog-header"
157
+ className={cn(
158
+ // The header document: the card's `p-5`
159
+ // rhythm — 20px top and sides, no bottom padding — with the
160
+ // eyebrow, title row and description stacked tight; the section
161
+ // gap below separates it from the body.
162
+ "flex shrink-0 flex-col bg-card px-5 pt-5",
163
+ className
164
+ )}
165
+ {...props}
166
+ />
167
+ )
168
+ }
169
+
170
+ /**
171
+ * Scrolling body region for dialogs. The dialog itself never scrolls —
172
+ * only this element does — so touch users always interact with one
173
+ * obvious scroll surface. The card's `p-5` side rhythm lives here; the
174
+ * section gaps (`gap-4`) separate it from the header and footer.
175
+ */
176
+ function DialogBody({ className, ...props }: React.ComponentProps<"div">) {
177
+ return (
178
+ <div
179
+ data-slot="dialog-body"
180
+ className={cn(
181
+ "flex-1 overflow-y-auto overscroll-contain px-5",
182
+ className
183
+ )}
184
+ {...props}
185
+ />
186
+ )
187
+ }
188
+
189
+ function DialogFooter({
190
+ className,
191
+ flush = false,
192
+ showCloseButton = false,
193
+ children,
194
+ ...props
195
+ }: React.ComponentProps<"div"> & {
196
+ flush?: boolean
197
+ showCloseButton?: boolean
198
+ }) {
199
+ return (
200
+ <div
201
+ data-slot="dialog-footer"
202
+ className={cn("flex shrink-0 flex-col bg-card", className)}
203
+ {...props}
204
+ >
205
+ {/* Inset hairline: the same side padding as the content below, so
206
+ the divider never runs edge to edge. */}
207
+ <div
208
+ className={cn(
209
+ "border-t border-border",
210
+ flush ? "mx-4" : "mx-5"
211
+ )}
212
+ />
213
+ <div
214
+ className={cn(
215
+ // The hairline action bar: 16px above the
216
+ // buttons, the card's bottom padding (20px) below.
217
+ "flex flex-row justify-end flex-wrap gap-2",
218
+ flush ? "p-4" : "px-5 pt-4 pb-5"
219
+ )}
220
+ >
221
+ {children}
222
+ {showCloseButton && (
223
+ <DialogPrimitive.Close render={<Button variant="outline" />}>
224
+ Close
225
+ </DialogPrimitive.Close>
226
+ )}
227
+ </div>
228
+ </div>
229
+ )
230
+ }
231
+
232
+ function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
233
+ return (
234
+ <DialogPrimitive.Title
235
+ data-slot="dialog-title"
236
+ className={cn("text-sm font-semibold", className)}
237
+ {...props}
238
+ />
239
+ )
240
+ }
241
+
242
+ function DialogDescription({
243
+ className,
244
+ ...props
245
+ }: DialogPrimitive.Description.Props) {
246
+ return (
247
+ <DialogPrimitive.Description
248
+ data-slot="dialog-description"
249
+ className={cn(
250
+ "text-xs leading-5 text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
251
+ className
252
+ )}
253
+ {...props}
254
+ />
255
+ )
256
+ }
257
+
258
+ /**
259
+ * Splits dialog children into header/footer/body groups so loose body
260
+ * nodes (passed by callers that pre-date `<DialogBody>`) get wrapped in
261
+ * a single scrolling region. Children already wrapped in
262
+ * `<DialogBody>` are passed through unchanged.
263
+ */
264
+ function arrangeDialogChildren(children: React.ReactNode): React.ReactNode {
265
+ const headers: React.ReactNode[] = []
266
+ const footers: React.ReactNode[] = []
267
+ const bodyNodes: React.ReactNode[] = []
268
+ let hasExplicitBody = false
269
+ React.Children.forEach(children, (child) => {
270
+ if (!React.isValidElement(child)) {
271
+ bodyNodes.push(child)
272
+ return
273
+ }
274
+ if (child.type === DialogHeader) {
275
+ headers.push(child)
276
+ return
277
+ }
278
+ if (child.type === DialogFooter) {
279
+ footers.push(child)
280
+ return
281
+ }
282
+ if (child.type === DialogBody) {
283
+ hasExplicitBody = true
284
+ bodyNodes.push(child)
285
+ return
286
+ }
287
+ bodyNodes.push(child)
288
+ })
289
+ const body = hasExplicitBody ? (
290
+ bodyNodes
291
+ ) : (
292
+ <DialogBody>{bodyNodes}</DialogBody>
293
+ )
294
+ return (
295
+ <>
296
+ {headers}
297
+ {body}
298
+ {footers}
299
+ </>
300
+ )
301
+ }
302
+
303
+ export {
304
+ Dialog,
305
+ DialogBody,
306
+ DialogClose,
307
+ DialogContent,
308
+ DialogDescription,
309
+ DialogFooter,
310
+ DialogHeader,
311
+ DialogOverlay,
312
+ DialogPortal,
313
+ DialogTitle,
314
+ DialogTrigger,
315
+ }