@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,99 @@
1
+ import { AppDialog } from "@hoardodile/ui/components/app-dialog"
2
+ import { Button } from "@hoardodile/ui/components/button"
3
+ import { Checkbox } from "@hoardodile/ui/components/checkbox"
4
+ import { useEffect, useState } from "react"
5
+ import { useTranslation } from "react-i18next"
6
+
7
+ export type CloseConfirmDialogProps = {
8
+ readonly open: boolean
9
+ readonly onOpenChange: (open: boolean) => void
10
+ readonly onDecide: (action: "tray" | "quit", remember: boolean) => void
11
+ }
12
+
13
+ /**
14
+ * The shared close confirmation: hide to tray (primary), quit, or cancel,
15
+ * plus a "remember my choice" checkbox. Copy comes from the shared `ui`
16
+ * catalog namespace, so the SPA, the shell's static pages and the
17
+ * workbench all render the same strings for the current language (the
18
+ * native dialog in the Electron main process reads the same catalog
19
+ * directly). The three-button footer follows DESIGN.md: quit at the left
20
+ * edge, cancel and the primary action right-aligned.
21
+ */
22
+ export function CloseConfirmDialog(props: CloseConfirmDialogProps) {
23
+ const { open, onOpenChange, onDecide } = props
24
+ const { t } = useTranslation("ui", { useSuspense: false })
25
+ const [remember, setRemember] = useState(false)
26
+
27
+ // One-shot choice: the checkbox applies only to the close it was
28
+ // checked on. Resetting it whenever the dialog closes stops a stale
29
+ // check from re-persisting the old action (and undoing the Settings →
30
+ // App preference) the next time the dialog opens.
31
+ useEffect(() => {
32
+ if (!open) setRemember(false)
33
+ }, [open])
34
+
35
+ const footer = (
36
+ <>
37
+ {/* Three-button footer (DESIGN.md — Overlays): the secondary
38
+ function key (quit) sits at the left edge; cancel and the
39
+ primary action (hide to tray) stay right-aligned. */}
40
+ <Button
41
+ variant="secondary"
42
+ className="mr-auto"
43
+ onClick={() => {
44
+ onDecide("quit", remember)
45
+ }}
46
+ data-testid="close-confirm-quit"
47
+ >
48
+ {t("closeConfirm.quit")}
49
+ </Button>
50
+ <Button
51
+ variant="secondary"
52
+ onClick={() => {
53
+ onOpenChange(false)
54
+ }}
55
+ data-testid="close-confirm-cancel"
56
+ >
57
+ {t("closeConfirm.cancel")}
58
+ </Button>
59
+ <Button
60
+ variant="default"
61
+ onClick={() => {
62
+ onDecide("tray", remember)
63
+ }}
64
+ data-testid="close-confirm-tray"
65
+ >
66
+ {t("closeConfirm.tray")}
67
+ </Button>
68
+ </>
69
+ )
70
+
71
+ return (
72
+ <AppDialog
73
+ open={open}
74
+ onOpenChange={onOpenChange}
75
+ title={t("closeConfirm.title")}
76
+ description={t("closeConfirm.description")}
77
+ footer={footer}
78
+ >
79
+ {/* The checkbox is the only toggle (no label row); `py-2` keeps
80
+ it from sitting flush against the description and the
81
+ footer hairline. */}
82
+ <div
83
+ className="flex items-center gap-2 py-2 select-none"
84
+ data-testid="close-confirm-remember"
85
+ >
86
+ <Checkbox
87
+ checked={remember}
88
+ onCheckedChange={(checked) => {
89
+ setRemember(checked === true)
90
+ }}
91
+ aria-label={t("closeConfirm.remember")}
92
+ />
93
+ <span className="text-xs text-muted-foreground">
94
+ {t("closeConfirm.remember")}
95
+ </span>
96
+ </div>
97
+ </AppDialog>
98
+ )
99
+ }
@@ -0,0 +1,186 @@
1
+ import { Cross } from "@hoardodile/ui/icons/marks"
2
+ import { Bookmark, Palette } from "@hoardodile/ui/icons/registry"
3
+ import { cn } from "@hoardodile/ui/lib/utils"
4
+ import { type ReactNode } from "react"
5
+ import { useTranslation } from "react-i18next"
6
+
7
+ export type ColorPickerProps = {
8
+ readonly value: string
9
+ readonly onChange: (color: string) => void
10
+ /** Default preset colors — one dot each. */
11
+ readonly presets?: readonly string[]
12
+ /** User presets appended after the defaults; their dots carry a
13
+ hover-revealed remove. */
14
+ readonly userPresets?: readonly string[]
15
+ /** Pins the current custom value to the user presets. */
16
+ readonly onAddPreset?: () => void
17
+ readonly onRemovePreset?: (index: number) => void
18
+ /** Special-style ids rendered as chips by the injected renderer
19
+ (the consumer owns the surface, e.g. gradient chips). */
20
+ readonly specialStyles?: readonly string[]
21
+ readonly renderSpecialStyle?: (
22
+ style: string,
23
+ active: boolean,
24
+ onPick: () => void,
25
+ ) => ReactNode
26
+ readonly placeholder?: string
27
+ readonly testId?: string
28
+ }
29
+
30
+ /**
31
+ * Color field: the current value leads as a removable pill (a dot + hex
32
+ * in a muted pill, or the special-style chip), the default and user
33
+ * presets merge into one row of dots — whitespace parts the registers —
34
+ * and the special styles ride below as their own chips. Custom colors
35
+ * enter through the dashed swatch at the row's end (a real color input
36
+ * underneath — the OS picker opens on click).
37
+ */
38
+ export function ColorPicker(props: ColorPickerProps) {
39
+ const {
40
+ value,
41
+ onChange,
42
+ presets = [],
43
+ userPresets = [],
44
+ onAddPreset,
45
+ onRemovePreset,
46
+ specialStyles = [],
47
+ renderSpecialStyle,
48
+ placeholder,
49
+ testId,
50
+ } = props
51
+ const { t } = useTranslation("ui", { useSuspense: false })
52
+
53
+ const hasColor = value !== ""
54
+ const isSpecial = specialStyles.includes(value)
55
+ const isCustom =
56
+ hasColor &&
57
+ !isSpecial &&
58
+ ![...presets, ...userPresets]
59
+ .map((c) => c.toLowerCase())
60
+ .includes(value.toLowerCase())
61
+
62
+ const inputValue = hasColor && !isSpecial ? value : "#9D9D9D"
63
+
64
+ return (
65
+ <div className="flex flex-col gap-2.5" data-testid={testId}>
66
+ <span className="flex items-center gap-1.5">
67
+ {hasColor && (
68
+ <span
69
+ className={cn(
70
+ "inline-flex h-chip w-fit items-center gap-1 rounded-md pr-1.5 text-xs text-foreground",
71
+ !isSpecial && "bg-muted pl-2",
72
+ )}
73
+ >
74
+ {isSpecial && renderSpecialStyle !== undefined ? (
75
+ <span className="pointer-events-none">
76
+ {renderSpecialStyle(value, true, () => {})}
77
+ </span>
78
+ ) : (
79
+ <>
80
+ <span
81
+ className="size-2 shrink-0 rounded-full"
82
+ style={{ backgroundColor: value }}
83
+ />
84
+ <span className="-translate-y-px">{value}</span>
85
+ </>
86
+ )}
87
+ <button
88
+ type="button"
89
+ onClick={() => onChange("")}
90
+ aria-label={t("colorPicker.clear")}
91
+ className="flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 text-muted-foreground hover:text-foreground"
92
+ data-testid={testId !== undefined ? `${testId}-clear` : undefined}
93
+ >
94
+ <Cross className="size-3" />
95
+ </button>
96
+ </span>
97
+ )}
98
+ {isCustom && onAddPreset !== undefined && (
99
+ <button
100
+ type="button"
101
+ onClick={onAddPreset}
102
+ aria-label={t("colorPicker.addPreset")}
103
+ className="flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 text-muted-foreground hover:bg-muted hover:text-foreground"
104
+ data-testid={
105
+ testId !== undefined ? `${testId}-add-preset` : undefined
106
+ }
107
+ >
108
+ <Bookmark className="size-3" />
109
+ </button>
110
+ )}
111
+ </span>
112
+
113
+ {/* One row of dots: the default presets, then the user's own —
114
+ parted by whitespace, not labels. A my-preset dot's remove
115
+ appears on hover (pinned on the last one). */}
116
+ <span className="flex flex-wrap items-center gap-1.5">
117
+ {presets.map((color) => (
118
+ <button
119
+ type="button"
120
+ key={color}
121
+ onClick={() => onChange(color)}
122
+ className="size-5 shrink-0 cursor-pointer rounded-full border-0 p-0"
123
+ style={{ backgroundColor: color }}
124
+ aria-label={color}
125
+ />
126
+ ))}
127
+ {userPresets.map((color, index) => (
128
+ <span key={`${color}-${index}`} className="group/color relative ml-2">
129
+ <button
130
+ type="button"
131
+ onClick={() => onChange(color)}
132
+ className="block size-5 cursor-pointer rounded-full border-0 p-0"
133
+ style={{ backgroundColor: color }}
134
+ aria-label={color}
135
+ />
136
+ {onRemovePreset !== undefined && (
137
+ <button
138
+ type="button"
139
+ onClick={() => onRemovePreset(index)}
140
+ aria-label={t("colorPicker.removePresetAria", { color })}
141
+ className={cn(
142
+ "absolute -top-1 -right-1 flex size-3.5 cursor-pointer items-center justify-center rounded-full border border-border bg-background p-0 text-muted-foreground hover:text-foreground",
143
+ index === userPresets.length - 1
144
+ ? "opacity-100"
145
+ : "opacity-0 transition-opacity group-hover/color:opacity-100",
146
+ )}
147
+ >
148
+ <Cross className="size-2" />
149
+ </button>
150
+ )}
151
+ </span>
152
+ ))}
153
+ {/* The custom swatch — dashed until a custom color is set, then
154
+ filled; the real color input underneath opens the OS
155
+ picker. */}
156
+ <span
157
+ title={t("colorPicker.customSwatch")}
158
+ className={cn(
159
+ "relative ml-2 flex size-5 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-full",
160
+ isCustom ? "" : "border border-dashed border-border-strong",
161
+ )}
162
+ style={isCustom ? { backgroundColor: value } : undefined}
163
+ >
164
+ {!isCustom && <Palette className="size-3 text-muted-foreground" />}
165
+ <input
166
+ type="color"
167
+ value={inputValue}
168
+ onChange={(e) => onChange(e.target.value)}
169
+ className="absolute inset-0 cursor-pointer opacity-0"
170
+ data-testid={testId !== undefined ? `${testId}-input` : undefined}
171
+ title={placeholder}
172
+ aria-label={t("colorPicker.customSwatch")}
173
+ />
174
+ </span>
175
+ </span>
176
+
177
+ {specialStyles.length > 0 && renderSpecialStyle !== undefined ? (
178
+ <span className="flex flex-wrap gap-1.5">
179
+ {specialStyles.map((style) =>
180
+ renderSpecialStyle(style, value === style, () => onChange(style)),
181
+ )}
182
+ </span>
183
+ ) : null}
184
+ </div>
185
+ )
186
+ }
@@ -0,0 +1,335 @@
1
+ "use client"
2
+
3
+
4
+ import * as React from "react"
5
+ import { Combobox as ComboboxPrimitive } from "@base-ui/react"
6
+
7
+ import { cn } from "@hoardodile/ui/lib/utils"
8
+ import { useMobileBackToClose } from "@hoardodile/ui/hooks/useMobileBackToClose"
9
+ import { Button } from "@hoardodile/ui/components/button"
10
+ import {
11
+ InputGroup,
12
+ InputGroupAddon,
13
+ InputGroupButton,
14
+ InputGroupInput,
15
+ } from "@hoardodile/ui/components/input-group"
16
+ import { AltArrowDown } from "@hoardodile/ui/icons/registry"
17
+ import { Check, Cross } from "@hoardodile/ui/icons/marks"
18
+
19
+ function Combobox<Value, Multiple extends boolean | undefined = false>({
20
+ open,
21
+ defaultOpen,
22
+ onOpenChange,
23
+ ...props
24
+ }: Omit<ComboboxPrimitive.Root.Props<Value, Multiple>, "onOpenChange"> & {
25
+ /**
26
+ * `eventDetails` is absent for closes triggered by the mobile back
27
+ * gesture (a synthetic close with no underlying Base UI event).
28
+ */
29
+ onOpenChange?: (
30
+ open: boolean,
31
+ eventDetails?: ComboboxPrimitive.Root.ChangeEventDetails,
32
+ ) => void
33
+ }) {
34
+ const [uncontrolledOpen, setUncontrolledOpen] = React.useState(
35
+ defaultOpen ?? false,
36
+ )
37
+ const isControlled = open !== undefined
38
+ const currentOpen = isControlled ? open : uncontrolledOpen
39
+ function handleOpenChange(
40
+ next: boolean,
41
+ eventDetails?: ComboboxPrimitive.Root.ChangeEventDetails,
42
+ ) {
43
+ if (!isControlled) setUncontrolledOpen(next)
44
+ onOpenChange?.(next, eventDetails)
45
+ }
46
+ useMobileBackToClose(currentOpen, handleOpenChange)
47
+ return (
48
+ <ComboboxPrimitive.Root<Value, Multiple>
49
+ open={currentOpen}
50
+ onOpenChange={handleOpenChange}
51
+ {...props}
52
+ />
53
+ )
54
+ }
55
+
56
+ function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
57
+ return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
58
+ }
59
+
60
+ function ComboboxTrigger({
61
+ className,
62
+ children,
63
+ ...props
64
+ }: ComboboxPrimitive.Trigger.Props) {
65
+ return (
66
+ <ComboboxPrimitive.Trigger
67
+ data-slot="combobox-trigger"
68
+ className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
69
+ {...props}
70
+ >
71
+ {children}
72
+ <AltArrowDown className="pointer-events-none size-4 text-muted-foreground" />
73
+ </ComboboxPrimitive.Trigger>
74
+ )
75
+ }
76
+
77
+ function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
78
+ return (
79
+ <ComboboxPrimitive.Clear
80
+ data-slot="combobox-clear"
81
+ render={<InputGroupButton variant="ghost" size="icon-xs" />}
82
+ className={cn(className)}
83
+ {...props}
84
+ >
85
+ <Cross className="pointer-events-none" />
86
+ </ComboboxPrimitive.Clear>
87
+ )
88
+ }
89
+
90
+ function ComboboxInput({
91
+ className,
92
+ children,
93
+ disabled = false,
94
+ showTrigger = true,
95
+ showClear = false,
96
+ ...props
97
+ }: ComboboxPrimitive.Input.Props & {
98
+ showTrigger?: boolean
99
+ showClear?: boolean
100
+ }) {
101
+ return (
102
+ <InputGroup className={cn("w-auto", className)}>
103
+ <ComboboxPrimitive.Input
104
+ render={<InputGroupInput disabled={disabled} />}
105
+ {...props}
106
+ />
107
+ <InputGroupAddon align="inline-end">
108
+ {showTrigger && (
109
+ <InputGroupButton
110
+ size="icon-xs"
111
+ variant="ghost"
112
+ render={<ComboboxTrigger />}
113
+ data-slot="input-group-button"
114
+ className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
115
+ disabled={disabled}
116
+ />
117
+ )}
118
+ {showClear && <ComboboxClear disabled={disabled} />}
119
+ </InputGroupAddon>
120
+ {children}
121
+ </InputGroup>
122
+ )
123
+ }
124
+
125
+ function ComboboxContent({
126
+ className,
127
+ side = "bottom",
128
+ sideOffset = 6,
129
+ align = "start",
130
+ alignOffset = 0,
131
+ anchor,
132
+ ...props
133
+ }: ComboboxPrimitive.Popup.Props &
134
+ Pick<
135
+ ComboboxPrimitive.Positioner.Props,
136
+ "side" | "align" | "sideOffset" | "alignOffset" | "anchor"
137
+ >) {
138
+ return (
139
+ <ComboboxPrimitive.Portal>
140
+ <ComboboxPrimitive.Positioner
141
+ side={side}
142
+ sideOffset={sideOffset}
143
+ align={align}
144
+ alignOffset={alignOffset}
145
+ anchor={anchor}
146
+ className="isolate z-50"
147
+ >
148
+ <ComboboxPrimitive.Popup
149
+ data-slot="combobox-content"
150
+ data-chips={!!anchor}
151
+ className={cn("group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-md bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
152
+ {...props}
153
+ />
154
+ </ComboboxPrimitive.Positioner>
155
+ </ComboboxPrimitive.Portal>
156
+ )
157
+ }
158
+
159
+ function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
160
+ return (
161
+ <ComboboxPrimitive.List
162
+ data-slot="combobox-list"
163
+ className={cn(
164
+ "no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
165
+ className
166
+ )}
167
+ {...props}
168
+ />
169
+ )
170
+ }
171
+
172
+ function ComboboxItem({
173
+ className,
174
+ children,
175
+ ...props
176
+ }: ComboboxPrimitive.Item.Props) {
177
+ return (
178
+ <ComboboxPrimitive.Item
179
+ data-slot="combobox-item"
180
+ className={cn(
181
+ "relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
182
+ className
183
+ )}
184
+ {...props}
185
+ >
186
+ {children}
187
+ <ComboboxPrimitive.ItemIndicator
188
+ render={
189
+ <span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
190
+ }
191
+ >
192
+ <Check className="pointer-events-none" />
193
+ </ComboboxPrimitive.ItemIndicator>
194
+ </ComboboxPrimitive.Item>
195
+ )
196
+ }
197
+
198
+ function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
199
+ return (
200
+ <ComboboxPrimitive.Group
201
+ data-slot="combobox-group"
202
+ className={cn(className)}
203
+ {...props}
204
+ />
205
+ )
206
+ }
207
+
208
+ function ComboboxLabel({
209
+ className,
210
+ ...props
211
+ }: ComboboxPrimitive.GroupLabel.Props) {
212
+ return (
213
+ <ComboboxPrimitive.GroupLabel
214
+ data-slot="combobox-label"
215
+ className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
216
+ {...props}
217
+ />
218
+ )
219
+ }
220
+
221
+ function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
222
+ return (
223
+ <ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
224
+ )
225
+ }
226
+
227
+ function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
228
+ return (
229
+ <ComboboxPrimitive.Empty
230
+ data-slot="combobox-empty"
231
+ className={cn(
232
+ "hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex",
233
+ className
234
+ )}
235
+ {...props}
236
+ />
237
+ )
238
+ }
239
+
240
+ function ComboboxSeparator({
241
+ className,
242
+ ...props
243
+ }: ComboboxPrimitive.Separator.Props) {
244
+ return (
245
+ <ComboboxPrimitive.Separator
246
+ data-slot="combobox-separator"
247
+ className={cn("-mx-1 my-1 h-px bg-border", className)}
248
+ {...props}
249
+ />
250
+ )
251
+ }
252
+
253
+ function ComboboxChips({
254
+ className,
255
+ ...props
256
+ }: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
257
+ ComboboxPrimitive.Chips.Props) {
258
+ return (
259
+ <ComboboxPrimitive.Chips
260
+ data-slot="combobox-chips"
261
+ className={cn(
262
+ "flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border border-input bg-transparent bg-clip-padding px-2.5 py-1.5 text-sm shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1.5 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
263
+ className
264
+ )}
265
+ {...props}
266
+ />
267
+ )
268
+ }
269
+
270
+ function ComboboxChip({
271
+ className,
272
+ children,
273
+ showRemove = true,
274
+ ...props
275
+ }: ComboboxPrimitive.Chip.Props & {
276
+ showRemove?: boolean
277
+ }) {
278
+ return (
279
+ <ComboboxPrimitive.Chip
280
+ data-slot="combobox-chip"
281
+ className={cn(
282
+ "flex h-[calc(--spacing(5.5))] w-fit items-center justify-center gap-1 rounded-sm bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
283
+ className
284
+ )}
285
+ {...props}
286
+ >
287
+ {children}
288
+ {showRemove && (
289
+ <ComboboxPrimitive.ChipRemove
290
+ render={<Button variant="ghost" size="icon-xs" />}
291
+ className="-ml-1 opacity-50 hover:opacity-100"
292
+ data-slot="combobox-chip-remove"
293
+ >
294
+ <Cross className="pointer-events-none" />
295
+ </ComboboxPrimitive.ChipRemove>
296
+ )}
297
+ </ComboboxPrimitive.Chip>
298
+ )
299
+ }
300
+
301
+ function ComboboxChipsInput({
302
+ className,
303
+ ...props
304
+ }: ComboboxPrimitive.Input.Props) {
305
+ return (
306
+ <ComboboxPrimitive.Input
307
+ data-slot="combobox-chip-input"
308
+ className={cn("min-w-16 flex-1 outline-none", className)}
309
+ {...props}
310
+ />
311
+ )
312
+ }
313
+
314
+ function useComboboxAnchor() {
315
+ return React.useRef<HTMLDivElement | null>(null)
316
+ }
317
+
318
+ export {
319
+ Combobox,
320
+ ComboboxInput,
321
+ ComboboxContent,
322
+ ComboboxList,
323
+ ComboboxItem,
324
+ ComboboxGroup,
325
+ ComboboxLabel,
326
+ ComboboxCollection,
327
+ ComboboxEmpty,
328
+ ComboboxSeparator,
329
+ ComboboxChips,
330
+ ComboboxChip,
331
+ ComboboxChipsInput,
332
+ ComboboxTrigger,
333
+ ComboboxValue,
334
+ useComboboxAnchor,
335
+ }