@godxjp/ui 5.0.1 → 6.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 (298) hide show
  1. package/README.md +101 -142
  2. package/package.json +124 -128
  3. package/scripts/ui-audit.mjs +179 -0
  4. package/src/app/__tests__/app-provider.test.tsx +232 -0
  5. package/src/app/__tests__/date-format-labels.test.ts +36 -0
  6. package/src/app/__tests__/date-formats.test.ts +44 -0
  7. package/src/app/__tests__/timezones.test.ts +65 -0
  8. package/src/app/app-provider.tsx +227 -0
  9. package/src/app/date-format-labels.ts +21 -0
  10. package/src/app/date-formats.ts +30 -0
  11. package/src/app/index.ts +40 -0
  12. package/src/app/locales.ts +32 -0
  13. package/src/app/request-headers.ts +31 -0
  14. package/src/app/storage.ts +44 -0
  15. package/src/app/time-format-labels.ts +19 -0
  16. package/src/app/time-formats.ts +15 -0
  17. package/src/app/timezones.ts +208 -0
  18. package/src/app/types.ts +39 -0
  19. package/src/app/use-formatting.ts +47 -0
  20. package/src/components/__tests__/accessibility-primitives.test.tsx +65 -0
  21. package/src/components/__tests__/docs-parity.test.ts +41 -0
  22. package/src/components/__tests__/shadcn-release-guardrails.test.ts +71 -0
  23. package/src/components/__tests__/theme-axes-integration.test.tsx +242 -0
  24. package/src/components/admin/index.ts +76 -0
  25. package/src/components/data-display/__tests__/card-table.test.tsx +328 -0
  26. package/src/components/data-display/__tests__/data-display.test.tsx +73 -0
  27. package/src/components/data-display/__tests__/data-table.test.tsx +84 -0
  28. package/src/components/data-display/__tests__/popover.test.tsx +92 -0
  29. package/src/components/data-display/__tests__/scroll-area-collapsible.test.tsx +66 -0
  30. package/src/components/data-display/badge.tsx +27 -0
  31. package/src/components/data-display/card.tsx +194 -0
  32. package/src/components/data-display/code-badge.tsx +28 -0
  33. package/src/components/data-display/collapsible.tsx +5 -0
  34. package/src/components/data-display/data-table.tsx +476 -0
  35. package/src/components/data-display/empty-state.tsx +22 -0
  36. package/src/components/data-display/index.ts +41 -0
  37. package/src/components/data-display/key-value-grid.tsx +46 -0
  38. package/src/components/data-display/popover.tsx +62 -0
  39. package/src/components/data-display/progress-meter.tsx +20 -0
  40. package/src/components/data-display/scan-panel.tsx +16 -0
  41. package/src/components/data-display/scroll-area.tsx +42 -0
  42. package/src/components/data-display/status-badge.tsx +83 -0
  43. package/src/components/data-display/table.tsx +59 -0
  44. package/src/components/data-display/timeline.tsx +42 -0
  45. package/src/components/data-display/tree-list.tsx +42 -0
  46. package/src/components/data-entry/__fixtures__/tree-options.ts +80 -0
  47. package/src/components/data-entry/__tests__/cascader-tree-transfer.test.tsx +417 -0
  48. package/src/components/data-entry/__tests__/checkbox-group.test.tsx +40 -0
  49. package/src/components/data-entry/__tests__/checkbox.test.tsx +20 -0
  50. package/src/components/data-entry/__tests__/date-autocomplete.test.tsx +94 -0
  51. package/src/components/data-entry/__tests__/form-field.test.tsx +49 -0
  52. package/src/components/data-entry/__tests__/input-textarea.test.tsx +38 -0
  53. package/src/components/data-entry/__tests__/label-select.test.tsx +62 -0
  54. package/src/components/data-entry/__tests__/pickers.test.tsx +74 -0
  55. package/src/components/data-entry/__tests__/radio.test.tsx +46 -0
  56. package/src/components/data-entry/__tests__/search-input.test.tsx +32 -0
  57. package/src/components/data-entry/__tests__/switch-field.test.tsx +52 -0
  58. package/src/components/data-entry/__tests__/upload.test.tsx +125 -0
  59. package/src/components/data-entry/autocomplete.tsx +91 -0
  60. package/src/components/data-entry/calendar.tsx +90 -0
  61. package/src/components/data-entry/cascader.tsx +305 -0
  62. package/src/components/data-entry/checkbox-group.tsx +90 -0
  63. package/src/components/data-entry/checkbox.tsx +30 -0
  64. package/src/components/data-entry/choice-field.tsx +27 -0
  65. package/src/components/data-entry/choice-option.ts +20 -0
  66. package/src/components/data-entry/color-picker.tsx +75 -0
  67. package/src/components/data-entry/command.tsx +56 -0
  68. package/src/components/data-entry/country-select.tsx +88 -0
  69. package/src/components/data-entry/date-picker.tsx +69 -0
  70. package/src/components/data-entry/date-range-picker.tsx +75 -0
  71. package/src/components/data-entry/form-field.tsx +59 -0
  72. package/src/components/data-entry/index.ts +62 -0
  73. package/src/components/data-entry/input.tsx +26 -0
  74. package/src/components/data-entry/label.tsx +25 -0
  75. package/src/components/data-entry/radio.tsx +109 -0
  76. package/src/components/data-entry/search-input.tsx +103 -0
  77. package/src/components/data-entry/select.tsx +149 -0
  78. package/src/components/data-entry/slider.tsx +38 -0
  79. package/src/components/data-entry/switch-field.tsx +91 -0
  80. package/src/components/data-entry/switch.tsx +24 -0
  81. package/src/components/data-entry/textarea.tsx +12 -0
  82. package/src/components/data-entry/time-picker.tsx +214 -0
  83. package/src/components/data-entry/transfer.tsx +231 -0
  84. package/src/components/data-entry/tree-select-strategy.ts +6 -0
  85. package/src/components/data-entry/tree-select.tsx +279 -0
  86. package/src/components/data-entry/tree-utils.ts +221 -0
  87. package/src/components/data-entry/upload-crop-dialog.tsx +109 -0
  88. package/src/components/data-entry/upload-types.ts +86 -0
  89. package/src/components/data-entry/upload.tsx +498 -0
  90. package/src/components/data-entry/use-upload-draft.ts +93 -0
  91. package/src/components/feedback/__tests__/alert.test.tsx +127 -0
  92. package/src/components/feedback/__tests__/dialog.test.tsx +290 -0
  93. package/src/components/feedback/__tests__/sheet.test.tsx +94 -0
  94. package/src/components/feedback/__tests__/skeleton.test.tsx +25 -0
  95. package/src/components/feedback/__tests__/toast.test.tsx +52 -0
  96. package/src/components/feedback/alert.tsx +167 -0
  97. package/src/components/feedback/dialog.tsx +325 -0
  98. package/src/components/feedback/index.ts +53 -0
  99. package/src/components/feedback/sheet.tsx +130 -0
  100. package/src/components/feedback/skeleton.tsx +95 -0
  101. package/src/components/feedback/sonner.tsx +54 -0
  102. package/src/components/feedback/toaster.tsx +1 -0
  103. package/src/components/feedback/use-toast.ts +62 -0
  104. package/src/components/general/__tests__/button.test.tsx +71 -0
  105. package/src/components/general/button.tsx +61 -0
  106. package/src/components/general/index.ts +2 -0
  107. package/src/components/layout/__tests__/page-container.test.tsx +69 -0
  108. package/src/components/layout/__tests__/page-inset.test.tsx +14 -0
  109. package/src/components/layout/__tests__/stack-inline.test.tsx +39 -0
  110. package/src/components/layout/app-shell.tsx +42 -0
  111. package/src/components/layout/breadcrumb.tsx +35 -0
  112. package/src/components/layout/index.ts +31 -0
  113. package/src/components/layout/inline.tsx +13 -0
  114. package/src/components/layout/menu.tsx +34 -0
  115. package/src/components/layout/mobile-frame.tsx +57 -0
  116. package/src/components/layout/page-container.tsx +81 -0
  117. package/src/components/layout/page-inset.tsx +16 -0
  118. package/src/components/layout/responsive-grid.tsx +14 -0
  119. package/src/components/layout/shell-app.tsx +30 -0
  120. package/src/components/layout/sidebar.tsx +98 -0
  121. package/src/components/layout/split-pane.tsx +16 -0
  122. package/src/components/layout/stack.tsx +13 -0
  123. package/src/components/layout/topbar.tsx +108 -0
  124. package/src/components/navigation/__tests__/app-pickers.test.tsx +118 -0
  125. package/src/components/navigation/__tests__/dropdown-menu.test.tsx +104 -0
  126. package/src/components/navigation/__tests__/navigation.test.tsx +61 -0
  127. package/src/components/navigation/__tests__/pagination-steps-tabs.test.tsx +76 -0
  128. package/src/components/navigation/date-format-picker.tsx +55 -0
  129. package/src/components/navigation/dropdown-menu.tsx +190 -0
  130. package/src/components/navigation/filter-bar.tsx +38 -0
  131. package/src/components/navigation/index.ts +28 -0
  132. package/src/components/navigation/locale-picker.tsx +49 -0
  133. package/src/components/navigation/page-header.tsx +50 -0
  134. package/src/components/navigation/pagination-utils.ts +35 -0
  135. package/src/components/navigation/pagination.tsx +168 -0
  136. package/src/components/navigation/steps.tsx +163 -0
  137. package/src/components/navigation/tabs-items.tsx +69 -0
  138. package/src/components/navigation/tabs.tsx +67 -0
  139. package/src/components/navigation/time-format-picker.tsx +55 -0
  140. package/src/components/navigation/timezone-picker.tsx +63 -0
  141. package/src/components/query/__tests__/data-state.test.tsx +214 -0
  142. package/src/components/query/__tests__/infinite-prefetch.test.tsx +105 -0
  143. package/src/components/query/__tests__/query-helpers.test.tsx +61 -0
  144. package/src/components/query/data-state.tsx +58 -0
  145. package/src/components/query/index.ts +10 -0
  146. package/src/components/query/infinite-query-state.tsx +99 -0
  147. package/src/components/query/mutation-feedback.tsx +31 -0
  148. package/src/components/query/prefetch-link.tsx +45 -0
  149. package/src/components/query/query-refetch-button.tsx +41 -0
  150. package/src/components/ui/alert-dialog.tsx +1 -0
  151. package/src/components/ui/alert.tsx +1 -0
  152. package/src/components/ui/autocomplete.tsx +1 -0
  153. package/src/components/ui/badge.tsx +1 -0
  154. package/src/components/ui/button.tsx +1 -0
  155. package/src/components/ui/calendar.tsx +1 -0
  156. package/src/components/ui/card.tsx +1 -0
  157. package/src/components/ui/checkbox.tsx +1 -0
  158. package/src/components/ui/color-picker.tsx +1 -0
  159. package/src/components/ui/command.tsx +1 -0
  160. package/src/components/ui/date-picker.tsx +1 -0
  161. package/src/components/ui/date-range-picker.tsx +1 -0
  162. package/src/components/ui/dialog.tsx +1 -0
  163. package/src/components/ui/dropdown-menu.tsx +1 -0
  164. package/src/components/ui/index.tsx +31 -0
  165. package/src/components/ui/input.tsx +1 -0
  166. package/src/components/ui/label.tsx +1 -0
  167. package/src/components/ui/pagination.tsx +1 -0
  168. package/src/components/ui/popover.tsx +1 -0
  169. package/src/components/ui/radio.tsx +1 -0
  170. package/src/components/ui/scroll-area.tsx +1 -0
  171. package/src/components/ui/select.tsx +1 -0
  172. package/src/components/ui/sheet.tsx +1 -0
  173. package/src/components/ui/slider.tsx +1 -0
  174. package/src/components/ui/sonner.tsx +1 -0
  175. package/src/components/ui/switch.tsx +1 -0
  176. package/src/components/ui/table.tsx +1 -0
  177. package/src/components/ui/tabs-items.tsx +1 -0
  178. package/src/components/ui/tabs.tsx +1 -0
  179. package/src/components/ui/textarea.tsx +1 -0
  180. package/src/components/ui/time-picker.tsx +1 -0
  181. package/src/components/ui/upload.tsx +1 -0
  182. package/src/form/__tests__/use-zod-form.test.tsx +97 -0
  183. package/src/form/form-field-control.tsx +44 -0
  184. package/src/form/form-root.tsx +29 -0
  185. package/src/form/index.ts +7 -0
  186. package/src/form/use-zod-form.ts +29 -0
  187. package/src/i18n/__tests__/translate.test.ts +23 -0
  188. package/src/i18n/index.ts +9 -0
  189. package/src/i18n/messages/en.json +171 -0
  190. package/src/i18n/messages/ja.json +171 -0
  191. package/src/i18n/messages/vi.json +171 -0
  192. package/src/i18n/translate.ts +74 -0
  193. package/src/i18n/use-translation.ts +53 -0
  194. package/src/index.ts +3 -0
  195. package/src/lib/__tests__/control-styles.test.ts +78 -0
  196. package/src/lib/__tests__/datetime.test.ts +77 -0
  197. package/src/lib/__tests__/format-date.test.ts +97 -0
  198. package/src/lib/__tests__/format.test.ts +62 -0
  199. package/src/lib/__tests__/theme-tokens-audit.test.ts +176 -0
  200. package/src/lib/__tests__/theme-tokens-css.test.ts +118 -0
  201. package/src/lib/__tests__/token-governance.test.ts +191 -0
  202. package/src/lib/__tests__/variants.test.ts +18 -0
  203. package/src/lib/control-styles.ts +33 -0
  204. package/src/lib/datetime/detect.ts +25 -0
  205. package/src/lib/datetime/format-date.ts +100 -0
  206. package/src/lib/datetime/format.ts +140 -0
  207. package/src/lib/datetime/index.ts +25 -0
  208. package/src/lib/datetime/parse.ts +51 -0
  209. package/src/lib/datetime/sync.ts +48 -0
  210. package/src/lib/format.ts +114 -0
  211. package/src/lib/hooks.ts +54 -0
  212. package/src/lib/utils.ts +6 -0
  213. package/src/lib/variants.ts +40 -0
  214. package/src/props/components/app.prop.ts +99 -0
  215. package/src/props/components/data-display.prop.ts +73 -0
  216. package/src/props/components/data-entry.prop.ts +334 -0
  217. package/src/props/components/feedback.prop.ts +80 -0
  218. package/src/props/components/form.prop.ts +46 -0
  219. package/src/props/components/general.prop.ts +18 -0
  220. package/src/props/components/index.ts +99 -0
  221. package/src/props/components/layout.prop.ts +130 -0
  222. package/src/props/components/navigation.prop.ts +88 -0
  223. package/src/props/components/query.prop.ts +94 -0
  224. package/src/props/index.ts +17 -0
  225. package/src/props/registry.ts +603 -0
  226. package/src/props/vocabulary/content.prop.ts +35 -0
  227. package/src/props/vocabulary/data.prop.ts +46 -0
  228. package/src/props/vocabulary/index.ts +73 -0
  229. package/src/props/vocabulary/interaction.prop.ts +42 -0
  230. package/src/props/vocabulary/layout.prop.ts +25 -0
  231. package/src/props/vocabulary/navigation.prop.ts +19 -0
  232. package/src/props/vocabulary/shared.prop.ts +59 -0
  233. package/src/styles/alert-layout.css +191 -0
  234. package/src/styles/badge-layout.css +22 -0
  235. package/src/styles/card-layout.css +373 -0
  236. package/src/styles/control.css +504 -0
  237. package/src/styles/data-display-layout.css +246 -0
  238. package/src/styles/density.css +43 -0
  239. package/src/styles/dialog-layout.css +84 -0
  240. package/src/styles/index.css +105 -0
  241. package/src/styles/layout.css +479 -0
  242. package/src/styles/shell-layout.css +604 -0
  243. package/src/styles/table-layout.css +109 -0
  244. package/src/test/__tests__/render-loop-guard.test.tsx +38 -0
  245. package/src/test/jest-dom.d.ts +4 -0
  246. package/src/test/render-loop-guard.tsx +50 -0
  247. package/src/test/render.tsx +29 -0
  248. package/src/test/theme-globals.test.ts +77 -0
  249. package/src/test/theme-globals.ts +134 -0
  250. package/src/test/theme-test-utils.tsx +67 -0
  251. package/src/theme/example.service.css +37 -0
  252. package/src/tokens/base.css +13 -0
  253. package/src/tokens/foundation.css +151 -0
  254. package/src/tokens/primitives/badge.css +13 -0
  255. package/src/tokens/primitives/card.css +29 -0
  256. package/src/tokens/primitives/control.css +55 -0
  257. package/src/tokens/primitives/feedback.css +17 -0
  258. package/src/tokens/primitives/layout.css +20 -0
  259. package/src/tokens/primitives/navigation.css +13 -0
  260. package/src/tokens/primitives/table.css +10 -0
  261. package/BRAND.md +0 -296
  262. package/CHANGELOG.md +0 -650
  263. package/config/eslint.js +0 -54
  264. package/config/prettier.cjs +0 -20
  265. package/config/tsconfig.base.json +0 -22
  266. package/config/vitest.base.ts +0 -26
  267. package/dist/MiniMonth-YAmPGEpC.d.ts +0 -143
  268. package/dist/Table.types-BbsxoIYE.d.ts +0 -352
  269. package/dist/color-DO0qqUAb.d.ts +0 -38
  270. package/dist/components/composites.d.ts +0 -963
  271. package/dist/components/composites.js +0 -7340
  272. package/dist/components/composites.js.map +0 -1
  273. package/dist/components/primitives.d.ts +0 -2736
  274. package/dist/components/primitives.js +0 -7353
  275. package/dist/components/primitives.js.map +0 -1
  276. package/dist/components/shell.d.ts +0 -182
  277. package/dist/components/shell.js +0 -774
  278. package/dist/components/shell.js.map +0 -1
  279. package/dist/hooks.d.ts +0 -100
  280. package/dist/hooks.js +0 -558
  281. package/dist/hooks.js.map +0 -1
  282. package/dist/i18n.d.ts +0 -61
  283. package/dist/i18n.js +0 -860
  284. package/dist/i18n.js.map +0 -1
  285. package/dist/index.d.ts +0 -33
  286. package/dist/index.js +0 -13059
  287. package/dist/index.js.map +0 -1
  288. package/dist/padding-DY0JV5Ja.d.ts +0 -16
  289. package/dist/preferences.d.ts +0 -132
  290. package/dist/preferences.js +0 -262
  291. package/dist/preferences.js.map +0 -1
  292. package/dist/props.d.ts +0 -86
  293. package/dist/props.js +0 -16
  294. package/dist/props.js.map +0 -1
  295. package/dist/size-CQwNvOWd.d.ts +0 -19
  296. package/dist/types-LTj-2bl-.d.ts +0 -30
  297. package/dist/useTableViews-D5NIAJ7h.d.ts +0 -154
  298. package/src/tokens/tailwind.css +0 -158
@@ -0,0 +1,279 @@
1
+ import * as React from "react";
2
+ import { ChevronDown, ChevronRight, ChevronsUpDown, X } from "lucide-react";
3
+
4
+ import { useTranslation } from "../../i18n/use-translation";
5
+ import { cn } from "../../lib/utils";
6
+ import { Button } from "../general/button";
7
+ import { Popover, PopoverContent, PopoverTrigger } from "../data-display/popover";
8
+ import { ScrollArea } from "../data-display/scroll-area";
9
+ import { Checkbox } from "./checkbox";
10
+ import { Command, CommandInput } from "./command";
11
+ import {
12
+ collectAllExpandableKeys,
13
+ filterVisibleTree,
14
+ findNodeByValue,
15
+ flattenVisibleTree,
16
+ getDescendantValues,
17
+ normalizeTreeOptions,
18
+ reactNodeText,
19
+ type NormalizedTreeOption,
20
+ } from "./tree-utils";
21
+ import type { TreeSelectProp } from "../../props/components/data-entry.prop";
22
+
23
+ export type {
24
+ TreeSelectProp,
25
+ TreeSelectProp as TreeSelectProps,
26
+ } from "../../props/components/data-entry.prop";
27
+ export { SHOW_CHILD, SHOW_PARENT, SHOW_ALL } from "./tree-select-strategy";
28
+
29
+ import { SHOW_CHILD, SHOW_PARENT, SHOW_ALL } from "./tree-select-strategy";
30
+
31
+ function toArray(value: string | string[] | undefined): string[] {
32
+ if (value == null) return [];
33
+ return Array.isArray(value) ? value : [value];
34
+ }
35
+
36
+ function displayValues(
37
+ values: string[],
38
+ options: NormalizedTreeOption[],
39
+ showCheckedStrategy: TreeSelectProp["showCheckedStrategy"],
40
+ treeCheckStrictly?: boolean,
41
+ ): string[] {
42
+ if (treeCheckStrictly || showCheckedStrategy === SHOW_ALL) return values;
43
+ if (showCheckedStrategy === SHOW_PARENT) {
44
+ const filtered = values.filter((v) => {
45
+ const node = findNodeByValue(options, v);
46
+ if (!node?.children?.length) return true;
47
+ const desc = getDescendantValues(node).slice(1);
48
+ return !desc.every((d) => values.includes(d));
49
+ });
50
+ return filtered.length ? filtered : values;
51
+ }
52
+ // SHOW_CHILD — hide parent when all children selected
53
+ return values.filter((v) => {
54
+ const node = findNodeByValue(options, v);
55
+ return !node?.children?.length;
56
+ });
57
+ }
58
+
59
+ function TreeSelectRoot({
60
+ treeData: treeDataProp,
61
+ value,
62
+ defaultValue,
63
+ onChange,
64
+ multiple,
65
+ treeCheckable,
66
+ treeCheckStrictly,
67
+ showSearch,
68
+ showCheckedStrategy = SHOW_CHILD,
69
+ treeDefaultExpandAll,
70
+ placeholder,
71
+ disabled,
72
+ allowClear = true,
73
+ className,
74
+ id,
75
+ fieldNames,
76
+ }: TreeSelectProp) {
77
+ const { t } = useTranslation();
78
+ const options = React.useMemo(
79
+ () => normalizeTreeOptions(treeDataProp as Record<string, unknown>[], fieldNames),
80
+ [treeDataProp, fieldNames],
81
+ );
82
+
83
+ const checkable = treeCheckable ?? multiple;
84
+ const [open, setOpen] = React.useState(false);
85
+ const [search, setSearch] = React.useState("");
86
+ const [expandedKeys, setExpandedKeys] = React.useState<Set<string>>(
87
+ () => new Set(treeDefaultExpandAll ? collectAllExpandableKeys(options) : []),
88
+ );
89
+
90
+ const isControlled = value !== undefined;
91
+ const [internal, setInternal] = React.useState<string[]>(() => toArray(defaultValue));
92
+
93
+ const selected = isControlled ? toArray(value) : internal;
94
+
95
+ const resolvedPlaceholder = placeholder ?? t("dataEntry.treeSelect.placeholder");
96
+
97
+ const visible = React.useMemo(() => {
98
+ if (showSearch && search.trim()) return filterVisibleTree(options, search);
99
+ return flattenVisibleTree(options, expandedKeys);
100
+ }, [options, expandedKeys, search, showSearch]);
101
+
102
+ const commit = (next: string[]) => {
103
+ if (!isControlled) setInternal(next);
104
+ onChange?.(checkable || multiple ? next : (next[0] ?? undefined));
105
+ };
106
+
107
+ const toggleExpand = (key: string) => {
108
+ setExpandedKeys((prev) => {
109
+ const next = new Set(prev);
110
+ if (next.has(key)) next.delete(key);
111
+ else next.add(key);
112
+ return next;
113
+ });
114
+ };
115
+
116
+ const toggleSelect = (node: NormalizedTreeOption) => {
117
+ if (node.disabled) return;
118
+ const key = node.value;
119
+
120
+ if (!checkable && !multiple) {
121
+ commit([key]);
122
+ setOpen(false);
123
+ return;
124
+ }
125
+
126
+ const isOn = selected.includes(key);
127
+ let next: string[];
128
+
129
+ if (treeCheckStrictly) {
130
+ next = isOn ? selected.filter((v) => v !== key) : [...selected, key];
131
+ } else {
132
+ const related = getDescendantValues(node);
133
+ next = isOn
134
+ ? selected.filter((v) => !related.includes(v))
135
+ : [...new Set([...selected, ...related])];
136
+ }
137
+
138
+ commit(next);
139
+ };
140
+
141
+ const displayKeys = displayValues(selected, options, showCheckedStrategy, treeCheckStrictly);
142
+ const displayLabel = displayKeys
143
+ .map((v) => {
144
+ const label = findNodeByValue(options, v)?.label;
145
+ return label ? reactNodeText(label) : v;
146
+ })
147
+ .join(", ");
148
+
149
+ const clearValue = (e: React.MouseEvent) => {
150
+ e.stopPropagation();
151
+ commit([]);
152
+ };
153
+
154
+ return (
155
+ <Popover open={open} onOpenChange={setOpen}>
156
+ <PopoverTrigger asChild>
157
+ <Button
158
+ id={id}
159
+ type="button"
160
+ variant="outline"
161
+ role="combobox"
162
+ aria-expanded={open}
163
+ disabled={disabled}
164
+ className={cn(
165
+ "w-full justify-between font-normal",
166
+ !displayKeys.length && "text-muted-foreground",
167
+ className,
168
+ )}
169
+ >
170
+ <span className="truncate">
171
+ {displayKeys.length ? displayLabel : resolvedPlaceholder}
172
+ </span>
173
+ <span className="ml-2 flex shrink-0 items-center gap-1">
174
+ {allowClear && displayKeys.length > 0 && !disabled && (
175
+ <X
176
+ className="size-4 opacity-50 hover:opacity-100"
177
+ aria-hidden="true"
178
+ onClick={clearValue}
179
+ />
180
+ )}
181
+ <ChevronsUpDown className="size-4 opacity-50" aria-hidden="true" />
182
+ </span>
183
+ </Button>
184
+ </PopoverTrigger>
185
+ <PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
186
+ {showSearch && (
187
+ <div className="border-b p-2">
188
+ <Command shouldFilter={false}>
189
+ <CommandInput
190
+ placeholder={t("dataEntry.treeSelect.searchPlaceholder")}
191
+ value={search}
192
+ onValueChange={setSearch}
193
+ />
194
+ </Command>
195
+ </div>
196
+ )}
197
+ <ScrollArea className="max-h-[min(300px,50vh)]">
198
+ <div
199
+ role="tree"
200
+ aria-multiselectable={Boolean(checkable) || Boolean(multiple)}
201
+ className="p-1"
202
+ >
203
+ {visible.length === 0 ? (
204
+ <p className="text-muted-foreground py-6 text-center text-sm">
205
+ {t("dataEntry.treeSelect.empty")}
206
+ </p>
207
+ ) : (
208
+ visible.map(({ node, depth, hasChildren }) => {
209
+ const expanded = expandedKeys.has(node.value);
210
+ const isSelected = selected.includes(node.value);
211
+ return (
212
+ <div
213
+ key={node.value}
214
+ role="treeitem"
215
+ aria-expanded={hasChildren ? expanded : undefined}
216
+ aria-selected={isSelected}
217
+ className={cn(
218
+ "flex items-center rounded-sm py-1.5 pr-2 text-sm outline-none",
219
+ "hover:bg-accent hover:text-accent-foreground",
220
+ isSelected && "bg-accent/60",
221
+ node.disabled && "pointer-events-none opacity-50",
222
+ )}
223
+ style={{ paddingLeft: `${depth * 1.25 + 0.5}rem` }}
224
+ >
225
+ <button
226
+ type="button"
227
+ tabIndex={-1}
228
+ aria-label={
229
+ expanded
230
+ ? t("dataEntry.treeSelect.collapse")
231
+ : t("dataEntry.treeSelect.expand")
232
+ }
233
+ className={cn(
234
+ "mr-1 flex size-5 shrink-0 items-center justify-center rounded-sm",
235
+ !hasChildren && "invisible",
236
+ )}
237
+ onClick={() => toggleExpand(node.value)}
238
+ >
239
+ {expanded ? (
240
+ <ChevronDown className="size-4" aria-hidden="true" />
241
+ ) : (
242
+ <ChevronRight className="size-4" aria-hidden="true" />
243
+ )}
244
+ </button>
245
+ {checkable ? (
246
+ <label className="flex flex-1 cursor-pointer items-center gap-2">
247
+ <Checkbox
248
+ checked={isSelected}
249
+ disabled={Boolean(node.disabled) || Boolean(node.disableCheckbox)}
250
+ onCheckedChange={() => toggleSelect(node)}
251
+ />
252
+ <span className="truncate">{node.label}</span>
253
+ </label>
254
+ ) : (
255
+ <button
256
+ type="button"
257
+ className="flex-1 truncate text-left"
258
+ disabled={node.disabled}
259
+ onClick={() => toggleSelect(node)}
260
+ >
261
+ {node.label}
262
+ </button>
263
+ )}
264
+ </div>
265
+ );
266
+ })
267
+ )}
268
+ </div>
269
+ </ScrollArea>
270
+ </PopoverContent>
271
+ </Popover>
272
+ );
273
+ }
274
+
275
+ export const TreeSelect = Object.assign(TreeSelectRoot, {
276
+ SHOW_CHILD,
277
+ SHOW_PARENT,
278
+ SHOW_ALL,
279
+ });
@@ -0,0 +1,221 @@
1
+ import type * as React from "react";
2
+
3
+ /** Normalized tree node — Ant Design `treeData` / Cascader `options`. */
4
+ export type TreeOption = {
5
+ value: string;
6
+ label: React.ReactNode;
7
+ disabled?: boolean;
8
+ disableCheckbox?: boolean;
9
+ /** When false with `loadData`, shows expand affordance */
10
+ isLeaf?: boolean;
11
+ children?: TreeOption[];
12
+ };
13
+
14
+ export type TreeFieldNames = {
15
+ label?: string;
16
+ value?: string;
17
+ children?: string;
18
+ };
19
+
20
+ export type NormalizedTreeOption = TreeOption & { children?: NormalizedTreeOption[] };
21
+
22
+ type RawTreeNode = Record<string, unknown>;
23
+
24
+ export function reactNodeText(value: React.ReactNode): string {
25
+ if (value == null || typeof value === "boolean") return "";
26
+ if (typeof value === "string" || typeof value === "number" || typeof value === "bigint") {
27
+ return String(value);
28
+ }
29
+ if (Array.isArray(value)) {
30
+ return (value as React.ReactNode[]).map((item) => reactNodeText(item)).join("");
31
+ }
32
+ return "";
33
+ }
34
+
35
+ function unknownText(value: unknown): string {
36
+ if (typeof value === "string" || typeof value === "number" || typeof value === "bigint") {
37
+ return String(value);
38
+ }
39
+ return "";
40
+ }
41
+
42
+ export function normalizeTreeOptions(
43
+ nodes: RawTreeNode[] | undefined,
44
+ fieldNames?: TreeFieldNames,
45
+ ): NormalizedTreeOption[] {
46
+ if (!nodes?.length) return [];
47
+ const labelKey = fieldNames?.label ?? "label";
48
+ const valueKey = fieldNames?.value ?? "value";
49
+ const childrenKey = fieldNames?.children ?? "children";
50
+
51
+ return nodes.map((node) => {
52
+ const children = node[childrenKey];
53
+ const value = unknownText(node[valueKey]);
54
+ const label = node[labelKey] as React.ReactNode;
55
+ return {
56
+ value,
57
+ label: label ?? value,
58
+ disabled: Boolean(node.disabled),
59
+ disableCheckbox: Boolean(node.disableCheckbox),
60
+ isLeaf: node.isLeaf as boolean | undefined,
61
+ children: Array.isArray(children)
62
+ ? normalizeTreeOptions(children as RawTreeNode[], fieldNames)
63
+ : undefined,
64
+ };
65
+ });
66
+ }
67
+
68
+ export function getNodeByPath(
69
+ options: NormalizedTreeOption[],
70
+ path: string[],
71
+ ): NormalizedTreeOption[] {
72
+ const chain: NormalizedTreeOption[] = [];
73
+ let level = options;
74
+ for (const segment of path) {
75
+ const found = level.find((n) => n.value === segment);
76
+ if (!found) break;
77
+ chain.push(found);
78
+ level = found.children ?? [];
79
+ }
80
+ return chain;
81
+ }
82
+
83
+ export function getOptionsAtPath(
84
+ options: NormalizedTreeOption[],
85
+ path: string[],
86
+ ): NormalizedTreeOption[] {
87
+ if (!path.length) return options;
88
+ const chain = getNodeByPath(options, path);
89
+ return chain.at(-1)?.children ?? [];
90
+ }
91
+
92
+ export function formatPathLabels(chain: NormalizedTreeOption[], separator = " / "): string {
93
+ return chain.map((n) => reactNodeText(n.label)).join(separator);
94
+ }
95
+
96
+ export type TreePath = { path: string[]; labels: string[] };
97
+
98
+ export function collectLeafPaths(
99
+ options: NormalizedTreeOption[],
100
+ prefix: string[] = [],
101
+ root: NormalizedTreeOption[] = options,
102
+ ): TreePath[] {
103
+ const out: TreePath[] = [];
104
+ for (const node of options) {
105
+ const path = [...prefix, node.value];
106
+ const hasChildren = (node.children?.length ?? 0) > 0;
107
+ if (!hasChildren || node.isLeaf === true) {
108
+ out.push({ path, labels: getNodeByPath(root, path).map((n) => reactNodeText(n.label)) });
109
+ }
110
+ if (hasChildren) out.push(...collectLeafPaths(node.children!, path, root));
111
+ }
112
+ return out;
113
+ }
114
+
115
+ export function collectAllPaths(
116
+ options: NormalizedTreeOption[],
117
+ prefix: string[] = [],
118
+ root: NormalizedTreeOption[] = options,
119
+ ): TreePath[] {
120
+ const out: TreePath[] = [];
121
+ for (const node of options) {
122
+ const path = [...prefix, node.value];
123
+ out.push({ path, labels: getNodeByPath(root, path).map((n) => reactNodeText(n.label)) });
124
+ if (node.children?.length) out.push(...collectAllPaths(node.children, path, root));
125
+ }
126
+ return out;
127
+ }
128
+
129
+ export function pathKey(path: string[]): string {
130
+ return path.join("\0");
131
+ }
132
+
133
+ export function pathsEqual(a: string[], b: string[]): boolean {
134
+ return a.length === b.length && a.every((v, i) => v === b[i]);
135
+ }
136
+
137
+ export function filterTreeOptions(
138
+ options: NormalizedTreeOption[],
139
+ query: string,
140
+ filter?: (query: string, path: NormalizedTreeOption[]) => boolean,
141
+ ): TreePath[] {
142
+ const q = query.trim().toLowerCase();
143
+ if (!q) return [];
144
+ const paths = collectLeafPaths(options);
145
+ return paths.filter(({ path }) => {
146
+ const chain = getNodeByPath(options, path);
147
+ if (filter) return filter(query, chain);
148
+ return chain.some((n) => reactNodeText(n.label).toLowerCase().includes(q));
149
+ });
150
+ }
151
+
152
+ export function getDescendantValues(node: NormalizedTreeOption): string[] {
153
+ const values: string[] = [node.value];
154
+ for (const child of node.children ?? []) values.push(...getDescendantValues(child));
155
+ return values;
156
+ }
157
+
158
+ export function flattenVisibleTree(
159
+ options: NormalizedTreeOption[],
160
+ expandedKeys: Set<string>,
161
+ depth = 0,
162
+ ): { node: NormalizedTreeOption; depth: number; hasChildren: boolean }[] {
163
+ const out: { node: NormalizedTreeOption; depth: number; hasChildren: boolean }[] = [];
164
+ for (const node of options) {
165
+ const hasChildren = (node.children?.length ?? 0) > 0 && node.isLeaf !== true;
166
+ out.push({ node, depth, hasChildren });
167
+ if (hasChildren && expandedKeys.has(node.value)) {
168
+ out.push(...flattenVisibleTree(node.children!, expandedKeys, depth + 1));
169
+ }
170
+ }
171
+ return out;
172
+ }
173
+
174
+ export function filterVisibleTree(
175
+ options: NormalizedTreeOption[],
176
+ query: string,
177
+ ): { node: NormalizedTreeOption; depth: number; hasChildren: boolean }[] {
178
+ const q = query.trim().toLowerCase();
179
+ if (!q) return flattenVisibleTree(options, new Set(collectAllExpandableKeys(options)));
180
+
181
+ const expanded = new Set<string>();
182
+
183
+ function walk(nodes: NormalizedTreeOption[], depth: number): boolean {
184
+ let branchMatch = false;
185
+ for (const node of nodes) {
186
+ const selfMatch = reactNodeText(node.label).toLowerCase().includes(q);
187
+ const childMatch = node.children?.length ? walk(node.children, depth + 1) : false;
188
+ if (selfMatch || childMatch) {
189
+ branchMatch = true;
190
+ if (childMatch) expanded.add(node.value);
191
+ }
192
+ }
193
+ return branchMatch;
194
+ }
195
+
196
+ walk(options, 0);
197
+ return flattenVisibleTree(options, expanded);
198
+ }
199
+
200
+ export function collectAllExpandableKeys(options: NormalizedTreeOption[]): string[] {
201
+ const keys: string[] = [];
202
+ for (const node of options) {
203
+ if ((node.children?.length ?? 0) > 0 && node.isLeaf !== true) {
204
+ keys.push(node.value);
205
+ keys.push(...collectAllExpandableKeys(node.children!));
206
+ }
207
+ }
208
+ return keys;
209
+ }
210
+
211
+ export function findNodeByValue(
212
+ options: NormalizedTreeOption[],
213
+ value: string,
214
+ ): NormalizedTreeOption | undefined {
215
+ for (const node of options) {
216
+ if (node.value === value) return node;
217
+ const nested = node.children ? findNodeByValue(node.children, value) : undefined;
218
+ if (nested) return nested;
219
+ }
220
+ return undefined;
221
+ }
@@ -0,0 +1,109 @@
1
+ import * as React from "react";
2
+
3
+ import { useTranslation } from "../../i18n/use-translation";
4
+ import { cn } from "../../lib/utils";
5
+ import { Button } from "../general/button";
6
+ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "../feedback/dialog";
7
+ import { Slider } from "./slider";
8
+
9
+ interface UploadCropDialogProps {
10
+ open: boolean;
11
+ onOpenChange: (open: boolean) => void;
12
+ file: File | null;
13
+ onConfirm: (cropped: File) => void;
14
+ }
15
+
16
+ /** Simple 1:1 avatar crop — canvas export, no extra deps. */
17
+ export function UploadCropDialog({ open, onOpenChange, file, onConfirm }: UploadCropDialogProps) {
18
+ const { t } = useTranslation();
19
+ const [scale, setScale] = React.useState(1.2);
20
+ const [src, setSrc] = React.useState<string | null>(null);
21
+ const imgRef = React.useRef<HTMLImageElement>(null);
22
+
23
+ React.useEffect(() => {
24
+ if (!file) {
25
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- clear object URL state when the selected file is removed.
26
+ setSrc(null);
27
+ return;
28
+ }
29
+ const url = URL.createObjectURL(file);
30
+ setSrc(url);
31
+ return () => URL.revokeObjectURL(url);
32
+ }, [file]);
33
+
34
+ const handleConfirm = async () => {
35
+ const img = imgRef.current;
36
+ if (!img || !file) return;
37
+
38
+ const size = 256;
39
+ const canvas = document.createElement("canvas");
40
+ canvas.width = size;
41
+ canvas.height = size;
42
+ const ctx = canvas.getContext("2d");
43
+ if (!ctx) return;
44
+
45
+ const iw = img.naturalWidth;
46
+ const ih = img.naturalHeight;
47
+ const side = Math.min(iw, ih) / scale;
48
+ const sx = (iw - side) / 2;
49
+ const sy = (ih - side) / 2;
50
+
51
+ ctx.drawImage(img, sx, sy, side, side, 0, 0, size, size);
52
+
53
+ const blob = await new Promise<Blob | null>((resolve) =>
54
+ canvas.toBlob(resolve, "image/jpeg", 0.92),
55
+ );
56
+ if (!blob) return;
57
+
58
+ const cropped = new File([blob], file.name.replace(/\.\w+$/, "") + ".jpg", {
59
+ type: "image/jpeg",
60
+ });
61
+ onConfirm(cropped);
62
+ onOpenChange(false);
63
+ };
64
+
65
+ return (
66
+ <Dialog open={open} onOpenChange={onOpenChange}>
67
+ <DialogContent className="max-w-sm">
68
+ <DialogHeader>
69
+ <DialogTitle>{t("dataEntry.upload.cropTitle")}</DialogTitle>
70
+ </DialogHeader>
71
+ <div className="ui-stack-sm">
72
+ <div
73
+ className={cn(
74
+ "bg-muted relative mx-auto size-48 overflow-hidden rounded-full border",
75
+ "flex items-center justify-center",
76
+ )}
77
+ >
78
+ {src ? (
79
+ <img
80
+ ref={imgRef}
81
+ src={src}
82
+ alt=""
83
+ className="size-full object-cover transition-transform duration-150"
84
+ style={{ transform: `scale(${scale})` }}
85
+ />
86
+ ) : null}
87
+ </div>
88
+ <div className="ui-stack-xs px-2">
89
+ <span className="text-muted-foreground text-xs">{t("dataEntry.upload.cropZoom")}</span>
90
+ <Slider
91
+ min={1}
92
+ max={2.5}
93
+ step={0.05}
94
+ value={[scale]}
95
+ onValueChange={(v) => setScale(v[0] ?? 1)}
96
+ aria-label={t("dataEntry.upload.cropZoom")}
97
+ />
98
+ </div>
99
+ </div>
100
+ <DialogFooter>
101
+ <Button variant="ghost" onClick={() => onOpenChange(false)}>
102
+ {t("common.cancel")}
103
+ </Button>
104
+ <Button onClick={() => void handleConfirm()}>{t("dataEntry.upload.cropConfirm")}</Button>
105
+ </DialogFooter>
106
+ </DialogContent>
107
+ </Dialog>
108
+ );
109
+ }
@@ -0,0 +1,86 @@
1
+ /** Upload item — maps to media-service after `onUpload` resolves `mediaId`. */
2
+ export type UploadFileStatus = "idle" | "uploading" | "done" | "error" | "removed";
3
+
4
+ export type UploadFileItem = {
5
+ /** Stable key for list reconciliation */
6
+ uid: string;
7
+ name: string;
8
+ size: number;
9
+ mimeType?: string;
10
+ /** Blob URL or media-service download URL */
11
+ previewUrl?: string;
12
+ /** Set after issue → PUT → complete */
13
+ mediaId?: string;
14
+ status: UploadFileStatus;
15
+ error?: string;
16
+ /** Local file before / during upload */
17
+ file?: File;
18
+ /**
19
+ * Draft-only: marked for soft-delete on form commit.
20
+ * Undo clears this before save — media-service has no restore API.
21
+ */
22
+ pendingDelete?: boolean;
23
+ /**
24
+ * Draft-only: replacement staged locally; baseline kept for undo.
25
+ */
26
+ pendingReplace?: boolean;
27
+ /** mediaId to soft-delete on commit when `pendingReplace` */
28
+ replacesMediaId?: string;
29
+ };
30
+
31
+ export type UploadVariant =
32
+ | "dropzone"
33
+ | "button"
34
+ | "picture-card"
35
+ | "picture"
36
+ | "avatar"
37
+ | "avatar-crop";
38
+
39
+ /** Actions parent runs on form submit (media-service). */
40
+ export type UploadCommitAction = {
41
+ /** Soft-delete on save — no restore API */
42
+ deleteMediaIds: string[];
43
+ /** Temp uploads already completed via `onUpload` — promote on save */
44
+ promoteMediaIds: string[];
45
+ };
46
+
47
+ export function createUploadItem(file: File, partial?: Partial<UploadFileItem>): UploadFileItem {
48
+ const previewUrl = file.type.startsWith("image/") ? URL.createObjectURL(file) : undefined;
49
+ return {
50
+ uid: partial?.uid ?? crypto.randomUUID(),
51
+ name: file.name,
52
+ size: file.size,
53
+ mimeType: file.type,
54
+ previewUrl,
55
+ status: "idle",
56
+ file,
57
+ ...partial,
58
+ };
59
+ }
60
+
61
+ export function revokePreviewUrl(item: UploadFileItem | null | undefined) {
62
+ if (item?.previewUrl?.startsWith("blob:")) URL.revokeObjectURL(item.previewUrl);
63
+ }
64
+
65
+ export function collectUploadCommitActions(items: UploadFileItem[]): UploadCommitAction {
66
+ const deleteMediaIds: string[] = [];
67
+ const promoteMediaIds: string[] = [];
68
+
69
+ for (const item of items) {
70
+ if (item.pendingDelete && item.mediaId) {
71
+ deleteMediaIds.push(item.mediaId);
72
+ continue;
73
+ }
74
+ if (item.pendingReplace && item.replacesMediaId) {
75
+ deleteMediaIds.push(item.replacesMediaId);
76
+ }
77
+ if (item.mediaId && !item.pendingDelete && item.status !== "error") {
78
+ promoteMediaIds.push(item.mediaId);
79
+ }
80
+ }
81
+
82
+ return {
83
+ deleteMediaIds: [...new Set(deleteMediaIds)],
84
+ promoteMediaIds: [...new Set(promoteMediaIds)],
85
+ };
86
+ }