@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,498 @@
1
+ import * as React from "react";
2
+ import { Camera, ImagePlus, RotateCcw, Trash2, Upload as UploadIcon, X } from "lucide-react";
3
+
4
+ import { useTranslation } from "../../i18n/use-translation";
5
+ import { formatBytes } from "../../lib/format";
6
+ import { cn } from "../../lib/utils";
7
+ import { controlIconClass } from "../../lib/control-styles";
8
+ import { Button } from "../general/button";
9
+ import type { UploadProp } from "../../props/components/data-entry.prop";
10
+ import { UploadCropDialog } from "./upload-crop-dialog";
11
+ import {
12
+ createUploadItem,
13
+ revokePreviewUrl,
14
+ type UploadFileItem,
15
+ type UploadVariant,
16
+ } from "./upload-types";
17
+ import { useUploadDraft } from "./use-upload-draft";
18
+
19
+ export type {
20
+ UploadProp,
21
+ UploadProp as UploadProps,
22
+ UploadFileItemProp,
23
+ UploadVariantProp,
24
+ } from "../../props/components/data-entry.prop";
25
+ export type { UploadFileItem, UploadVariant, UploadCommitAction } from "./upload-types";
26
+ export { collectUploadCommitActions, createUploadItem } from "./upload-types";
27
+ export { useUploadDraft } from "./use-upload-draft";
28
+
29
+ function defaultAcceptForVariant(variant: UploadVariant): string | undefined {
30
+ if (
31
+ variant === "avatar" ||
32
+ variant === "avatar-crop" ||
33
+ variant === "picture" ||
34
+ variant === "picture-card"
35
+ ) {
36
+ return "image/*";
37
+ }
38
+ return undefined;
39
+ }
40
+
41
+ function defaultMaxCount(variant: UploadVariant): number | undefined {
42
+ if (variant === "avatar" || variant === "avatar-crop" || variant === "picture") return 1;
43
+ return undefined;
44
+ }
45
+
46
+ function fileMatchesAccept(file: File, accept?: string): boolean {
47
+ if (!accept) return true;
48
+ return accept.split(",").some((rule) => {
49
+ const trimmed = rule.trim();
50
+ if (trimmed.endsWith("/*")) return file.type.startsWith(trimmed.slice(0, -1));
51
+ return file.type === trimmed || file.name.endsWith(trimmed);
52
+ });
53
+ }
54
+
55
+ function useUploadList(
56
+ controlled: UploadFileItem[] | undefined,
57
+ defaultValue: UploadFileItem[] | undefined,
58
+ onChange: UploadProp["onChange"],
59
+ ) {
60
+ const [internal, setInternal] = React.useState<UploadFileItem[]>(defaultValue ?? []);
61
+ const items = controlled ?? internal;
62
+
63
+ const setItems = React.useCallback(
64
+ (next: UploadFileItem[] | ((prev: UploadFileItem[]) => UploadFileItem[])) => {
65
+ const resolved = typeof next === "function" ? next(items) : next;
66
+ if (controlled === undefined) setInternal(resolved);
67
+ onChange?.(resolved);
68
+ },
69
+ [controlled, items, onChange],
70
+ );
71
+
72
+ return [items, setItems] as const;
73
+ }
74
+
75
+ async function runUpload(
76
+ file: File,
77
+ item: UploadFileItem,
78
+ onUpload: NonNullable<UploadProp["onUpload"]>,
79
+ setItems: ReturnType<typeof useUploadList>[1],
80
+ ) {
81
+ const uid = item.uid;
82
+ setItems((prev) => prev.map((it) => (it.uid === uid ? { ...it, status: "uploading" } : it)));
83
+
84
+ try {
85
+ const result = await onUpload(file, item);
86
+ setItems((prev) =>
87
+ prev.map((it) =>
88
+ it.uid === uid
89
+ ? {
90
+ ...it,
91
+ status: "done",
92
+ mediaId: result.mediaId,
93
+ previewUrl: result.previewUrl ?? it.previewUrl,
94
+ file: undefined,
95
+ }
96
+ : it,
97
+ ),
98
+ );
99
+ } catch (err) {
100
+ const message = err instanceof Error ? err.message : String(err);
101
+ setItems((prev) =>
102
+ prev.map((it) => (it.uid === uid ? { ...it, status: "error", error: message } : it)),
103
+ );
104
+ }
105
+ }
106
+
107
+ export function Upload({
108
+ variant = "dropzone",
109
+ value,
110
+ defaultValue,
111
+ onChange,
112
+ accept: acceptProp,
113
+ multiple: multipleProp,
114
+ maxCount: maxCountProp,
115
+ maxSizeBytes,
116
+ disabled,
117
+ removable = true,
118
+ onUpload,
119
+ className,
120
+ children,
121
+ }: UploadProp) {
122
+ const { t } = useTranslation();
123
+ const accept = acceptProp ?? defaultAcceptForVariant(variant);
124
+ const maxCount = maxCountProp ?? defaultMaxCount(variant);
125
+ const multiple = multipleProp ?? (maxCount === 1 ? false : true);
126
+ const inputRef = React.useRef<HTMLInputElement>(null);
127
+ const [dragActive, setDragActive] = React.useState(false);
128
+ const [cropFile, setCropFile] = React.useState<File | null>(null);
129
+ const [items, setItems] = useUploadList(value, defaultValue, onChange);
130
+
131
+ const isSingleAvatar =
132
+ variant === "avatar" || variant === "avatar-crop" || (variant === "picture" && maxCount === 1);
133
+ const singleItem = isSingleAvatar ? (items[0] ?? null) : null;
134
+
135
+ const draft = useUploadDraft({
136
+ value: singleItem,
137
+ onChange: (next) => {
138
+ setItems(next ? [next] : []);
139
+ },
140
+ });
141
+
142
+ const pickFiles = (fileList: FileList | null) => {
143
+ if (!fileList?.length || disabled) return;
144
+
145
+ const slotsLeft = maxCount != null ? Math.max(0, maxCount - items.length) : fileList.length;
146
+ const candidates = Array.from(fileList).slice(0, multiple ? slotsLeft || fileList.length : 1);
147
+
148
+ for (const file of candidates) {
149
+ if (!fileMatchesAccept(file, accept)) continue;
150
+ if (maxSizeBytes != null && file.size > maxSizeBytes) continue;
151
+
152
+ if (variant === "avatar-crop") {
153
+ setCropFile(file);
154
+ return;
155
+ }
156
+
157
+ if (isSingleAvatar) {
158
+ const baseline = items[0];
159
+ const item = createUploadItem(file, {
160
+ pendingReplace: Boolean(baseline?.mediaId),
161
+ replacesMediaId: baseline?.mediaId,
162
+ });
163
+ setItems([item]);
164
+ if (onUpload) void runUpload(file, item, onUpload, setItems);
165
+ return;
166
+ }
167
+
168
+ const item = createUploadItem(file);
169
+ setItems((prev) => [...prev, item]);
170
+ if (onUpload) void runUpload(file, item, onUpload, setItems);
171
+ }
172
+ };
173
+
174
+ const removeItem = (uid: string) => {
175
+ if (isSingleAvatar) {
176
+ draft.markRemove();
177
+ return;
178
+ }
179
+ setItems((prev) => {
180
+ const target = prev.find((it) => it.uid === uid);
181
+ revokePreviewUrl(target);
182
+ return prev.filter((it) => it.uid !== uid);
183
+ });
184
+ };
185
+
186
+ const hiddenInput = (
187
+ <input
188
+ ref={inputRef}
189
+ type="file"
190
+ className="sr-only"
191
+ accept={accept}
192
+ multiple={multiple && !isSingleAvatar}
193
+ disabled={disabled}
194
+ onChange={(e) => {
195
+ pickFiles(e.target.files);
196
+ e.target.value = "";
197
+ }}
198
+ />
199
+ );
200
+
201
+ const openPicker = () => {
202
+ if (!disabled) inputRef.current?.click();
203
+ };
204
+
205
+ if (variant === "dropzone") {
206
+ return (
207
+ <div className={cn("ui-stack-sm", className)}>
208
+ {hiddenInput}
209
+ <div
210
+ role="button"
211
+ tabIndex={disabled ? -1 : 0}
212
+ aria-disabled={disabled}
213
+ aria-label={t("dataEntry.upload.dropzoneLabel")}
214
+ onClick={openPicker}
215
+ onKeyDown={(e) => {
216
+ if (e.key === "Enter" || e.key === " ") {
217
+ e.preventDefault();
218
+ openPicker();
219
+ }
220
+ }}
221
+ onDragOver={(e) => {
222
+ e.preventDefault();
223
+ setDragActive(true);
224
+ }}
225
+ onDragLeave={() => setDragActive(false)}
226
+ onDrop={(e) => {
227
+ e.preventDefault();
228
+ setDragActive(false);
229
+ pickFiles(e.dataTransfer.files);
230
+ }}
231
+ className={cn(
232
+ "cursor-pointer rounded-lg border-2 border-dashed p-10 text-center transition-colors",
233
+ dragActive ? "border-primary bg-primary/5" : "border-border hover:border-primary/50",
234
+ disabled && "pointer-events-none opacity-50",
235
+ )}
236
+ >
237
+ <UploadIcon className="text-muted-foreground mx-auto size-10" aria-hidden="true" />
238
+ <p className="mt-3 text-sm">{t("dataEntry.upload.dropzoneHint")}</p>
239
+ <p className="text-muted-foreground mt-1 text-xs">{t("dataEntry.upload.dropzoneMeta")}</p>
240
+ </div>
241
+ {items.length > 0 && (
242
+ <UploadFileList items={items} onRemove={removable ? removeItem : undefined} />
243
+ )}
244
+ </div>
245
+ );
246
+ }
247
+
248
+ if (variant === "button") {
249
+ return (
250
+ <div className={cn("ui-stack-sm", className)}>
251
+ {hiddenInput}
252
+ <Button type="button" variant="outline" disabled={disabled} onClick={openPicker}>
253
+ <UploadIcon className="mr-2 size-4" aria-hidden="true" />
254
+ {children ?? t("dataEntry.upload.buttonLabel")}
255
+ </Button>
256
+ {items.length > 0 && (
257
+ <UploadFileList items={items} onRemove={removable ? removeItem : undefined} />
258
+ )}
259
+ </div>
260
+ );
261
+ }
262
+
263
+ if (variant === "picture-card") {
264
+ const canAdd = maxCount == null || items.length < maxCount;
265
+ return (
266
+ <div className={cn("flex flex-wrap gap-3", className)}>
267
+ {hiddenInput}
268
+ {items.map((item) => (
269
+ <UploadPictureCard
270
+ key={item.uid}
271
+ item={item}
272
+ onRemove={removable ? () => removeItem(item.uid) : undefined}
273
+ />
274
+ ))}
275
+ {canAdd && (
276
+ <button
277
+ type="button"
278
+ disabled={disabled}
279
+ onClick={openPicker}
280
+ className={cn(
281
+ "flex size-24 flex-col items-center justify-center rounded-md border border-dashed",
282
+ "text-muted-foreground hover:border-primary hover:text-primary transition-colors",
283
+ disabled && "pointer-events-none opacity-50",
284
+ )}
285
+ aria-label={t("dataEntry.upload.addImage")}
286
+ >
287
+ <ImagePlus className="size-6" aria-hidden="true" />
288
+ <span className="mt-1 text-xs">{t("dataEntry.upload.addImage")}</span>
289
+ </button>
290
+ )}
291
+ </div>
292
+ );
293
+ }
294
+
295
+ if (variant === "picture") {
296
+ const item = draft.state.display;
297
+ return (
298
+ <div className={cn("ui-stack-sm max-w-xs", className)}>
299
+ {hiddenInput}
300
+ {item?.previewUrl && !item.pendingDelete ? (
301
+ <div className="relative overflow-hidden rounded-md border">
302
+ <img src={item.previewUrl} alt="" className="max-h-48 w-full object-cover" />
303
+ {item.status === "uploading" && (
304
+ <div className="bg-background/70 absolute inset-0 flex items-center justify-center text-sm">
305
+ {t("dataEntry.upload.uploading")}
306
+ </div>
307
+ )}
308
+ {item.pendingReplace && (
309
+ <div className="bg-warning text-warning-foreground absolute top-2 left-2 rounded px-2 py-0.5 text-xs">
310
+ {t("dataEntry.upload.pendingReplace")}
311
+ </div>
312
+ )}
313
+ </div>
314
+ ) : (
315
+ <button
316
+ type="button"
317
+ disabled={disabled}
318
+ onClick={openPicker}
319
+ className="text-muted-foreground hover:border-primary flex h-32 w-full flex-col items-center justify-center rounded-md border border-dashed"
320
+ >
321
+ <ImagePlus className={controlIconClass} aria-hidden="true" />
322
+ <span className="mt-2 text-sm">{t("dataEntry.upload.addImage")}</span>
323
+ </button>
324
+ )}
325
+ <UploadDraftActions draft={draft} disabled={disabled} onPick={openPicker} />
326
+ </div>
327
+ );
328
+ }
329
+
330
+ // avatar + avatar-crop
331
+ const item = draft.state.display;
332
+ const showPlaceholder = !item?.previewUrl || item.pendingDelete;
333
+
334
+ return (
335
+ <div className={cn("ui-stack-sm items-start", className)}>
336
+ {hiddenInput}
337
+ <UploadCropDialog
338
+ open={variant === "avatar-crop" && cropFile != null}
339
+ onOpenChange={(open) => !open && setCropFile(null)}
340
+ file={cropFile}
341
+ onConfirm={(cropped) => {
342
+ const baseline = items[0];
343
+ const item = createUploadItem(cropped, {
344
+ pendingReplace: Boolean(baseline?.mediaId),
345
+ replacesMediaId: baseline?.mediaId,
346
+ });
347
+ setItems([item]);
348
+ setCropFile(null);
349
+ if (onUpload) void runUpload(cropped, item, onUpload, setItems);
350
+ }}
351
+ />
352
+ <div className="relative inline-block">
353
+ <button
354
+ type="button"
355
+ disabled={disabled}
356
+ onClick={openPicker}
357
+ className={cn(
358
+ "border-border bg-muted relative size-24 overflow-hidden rounded-full border-2",
359
+ "focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none",
360
+ item?.pendingDelete && "opacity-40",
361
+ disabled && "pointer-events-none opacity-50",
362
+ )}
363
+ aria-label={t("dataEntry.upload.avatarLabel")}
364
+ >
365
+ {!showPlaceholder && item?.previewUrl ? (
366
+ <img src={item.previewUrl} alt="" className="size-full object-cover" />
367
+ ) : (
368
+ <span className="text-muted-foreground flex size-full items-center justify-center">
369
+ <Camera className={controlIconClass} aria-hidden="true" />
370
+ </span>
371
+ )}
372
+ {!disabled && (
373
+ <span className="absolute inset-x-0 bottom-0 bg-black/50 py-1 text-center text-[10px] text-white">
374
+ {t("dataEntry.upload.change")}
375
+ </span>
376
+ )}
377
+ </button>
378
+ {removable && item && !item.pendingDelete && (
379
+ <button
380
+ type="button"
381
+ disabled={disabled}
382
+ onClick={() => draft.markRemove()}
383
+ className="bg-background hover:bg-destructive hover:text-destructive-foreground absolute -top-1 -right-1 rounded-full border p-1 shadow-sm"
384
+ aria-label={t("dataEntry.upload.removeAvatar")}
385
+ >
386
+ <Trash2 className="size-3.5" aria-hidden="true" />
387
+ </button>
388
+ )}
389
+ </div>
390
+ <UploadDraftActions draft={draft} disabled={disabled} onPick={openPicker} />
391
+ </div>
392
+ );
393
+ }
394
+
395
+ function UploadDraftActions({
396
+ draft,
397
+ disabled,
398
+ onPick,
399
+ }: {
400
+ draft: ReturnType<typeof useUploadDraft>;
401
+ disabled?: boolean;
402
+ onPick: () => void;
403
+ }) {
404
+ const { t } = useTranslation();
405
+ const { state, undoRemove, undoReplace } = draft;
406
+
407
+ if (state.canUndoRemove) {
408
+ return (
409
+ <div className="border-destructive/40 bg-destructive/5 flex flex-wrap items-center gap-2 rounded-md border border-dashed px-3 py-2 text-sm">
410
+ <span className="text-destructive">{t("dataEntry.upload.markedForDelete")}</span>
411
+ <Button type="button" size="sm" variant="outline" disabled={disabled} onClick={undoRemove}>
412
+ <RotateCcw className="mr-1 size-3.5" aria-hidden="true" />
413
+ {t("dataEntry.upload.undo")}
414
+ </Button>
415
+ </div>
416
+ );
417
+ }
418
+
419
+ if (state.canUndoReplace) {
420
+ return (
421
+ <div className="text-muted-foreground flex flex-wrap items-center gap-2 text-sm">
422
+ <span>{t("dataEntry.upload.pendingReplaceHint")}</span>
423
+ <Button type="button" size="sm" variant="ghost" disabled={disabled} onClick={undoReplace}>
424
+ {t("dataEntry.upload.undo")}
425
+ </Button>
426
+ <Button type="button" size="sm" variant="outline" disabled={disabled} onClick={onPick}>
427
+ {t("dataEntry.upload.change")}
428
+ </Button>
429
+ </div>
430
+ );
431
+ }
432
+
433
+ return null;
434
+ }
435
+
436
+ function UploadPictureCard({ item, onRemove }: { item: UploadFileItem; onRemove?: () => void }) {
437
+ return (
438
+ <div className="bg-muted relative size-24 overflow-hidden rounded-md border">
439
+ {item.previewUrl ? (
440
+ <img src={item.previewUrl} alt="" className="size-full object-cover" />
441
+ ) : (
442
+ <div className="text-muted-foreground flex size-full items-center justify-center text-xs">
443
+
444
+ </div>
445
+ )}
446
+ {item.status === "uploading" && (
447
+ <div className="bg-background/70 absolute inset-0 flex items-center justify-center text-xs">
448
+
449
+ </div>
450
+ )}
451
+ {onRemove && (
452
+ <button
453
+ type="button"
454
+ onClick={onRemove}
455
+ className="bg-background/90 hover:bg-destructive hover:text-destructive-foreground absolute top-1 right-1 rounded-full p-0.5 shadow"
456
+ aria-label="Remove"
457
+ >
458
+ <X className="size-3.5" aria-hidden="true" />
459
+ </button>
460
+ )}
461
+ </div>
462
+ );
463
+ }
464
+
465
+ function UploadFileList({
466
+ items,
467
+ onRemove,
468
+ }: {
469
+ items: UploadFileItem[];
470
+ onRemove?: (uid: string) => void;
471
+ }) {
472
+ return (
473
+ <ul className="ui-stack-xs">
474
+ {items.map((item) => (
475
+ <li key={item.uid} className="flex items-center gap-3 rounded-md border px-3 py-2 text-sm">
476
+ <div className="min-w-0 flex-1">
477
+ <div className="truncate font-medium">{item.name}</div>
478
+ <div className="text-muted-foreground text-xs">
479
+ {formatBytes(item.size)}
480
+ {item.status === "uploading" && " · …"}
481
+ {item.status === "error" && item.error && (
482
+ <span className="text-destructive"> · {item.error}</span>
483
+ )}
484
+ {item.mediaId && (
485
+ <span className="text-muted-foreground"> · {item.mediaId.slice(0, 8)}…</span>
486
+ )}
487
+ </div>
488
+ </div>
489
+ {onRemove && (
490
+ <Button type="button" size="sm" variant="ghost" onClick={() => onRemove(item.uid)}>
491
+ <X className="size-4" aria-hidden="true" />
492
+ </Button>
493
+ )}
494
+ </li>
495
+ ))}
496
+ </ul>
497
+ );
498
+ }
@@ -0,0 +1,93 @@
1
+ import * as React from "react";
2
+
3
+ import {
4
+ createUploadItem,
5
+ revokePreviewUrl,
6
+ type UploadCommitAction,
7
+ type UploadFileItem,
8
+ } from "./upload-types";
9
+
10
+ export type UploadDraftState = {
11
+ /** Item rendered in UI (may reflect pending delete/replace). */
12
+ display: UploadFileItem | null;
13
+ /** Server baseline — restored on undo remove/replace. */
14
+ baseline: UploadFileItem | null;
15
+ canUndoRemove: boolean;
16
+ canUndoReplace: boolean;
17
+ };
18
+
19
+ export type UseUploadDraftOptions = {
20
+ value?: UploadFileItem | null;
21
+ onChange?: (item: UploadFileItem | null) => void;
22
+ };
23
+
24
+ /**
25
+ * Single-value draft for avatar / picture — coordinates with media-service:
26
+ * soft-delete only on form commit; undo before save (no restore API).
27
+ */
28
+ export function useUploadDraft({ value, onChange }: UseUploadDraftOptions) {
29
+ const [baseline, setBaseline] = React.useState<UploadFileItem | null>(value ?? null);
30
+
31
+ React.useEffect(() => {
32
+ if (value && !value.pendingDelete && !value.pendingReplace && value.mediaId) {
33
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- baseline mirrors the latest committed server media item for undo.
34
+ setBaseline(value);
35
+ }
36
+ }, [value]);
37
+
38
+ const markRemove = React.useCallback(() => {
39
+ if (!value) return;
40
+ onChange?.({ ...value, pendingDelete: true, pendingReplace: false, status: "removed" });
41
+ }, [onChange, value]);
42
+
43
+ const undoRemove = React.useCallback(() => {
44
+ if (!baseline) {
45
+ onChange?.(null);
46
+ return;
47
+ }
48
+ onChange?.({ ...baseline, pendingDelete: false, pendingReplace: false, status: "done" });
49
+ }, [baseline, onChange]);
50
+
51
+ const stageReplace = React.useCallback(
52
+ (file: File) => {
53
+ revokePreviewUrl(value);
54
+ const next = createUploadItem(file, {
55
+ pendingReplace: Boolean(value?.mediaId),
56
+ replacesMediaId: value?.mediaId,
57
+ status: "idle",
58
+ });
59
+ onChange?.({ ...next, pendingDelete: false });
60
+ },
61
+ [onChange, value],
62
+ );
63
+
64
+ const undoReplace = React.useCallback(() => {
65
+ revokePreviewUrl(value);
66
+ onChange?.(baseline ? { ...baseline, pendingReplace: false, pendingDelete: false } : null);
67
+ }, [baseline, onChange, value]);
68
+
69
+ const getCommitActions = React.useCallback((): UploadCommitAction => {
70
+ if (!value) return { deleteMediaIds: [], promoteMediaIds: [] };
71
+ const deleteMediaIds: string[] = [];
72
+ if (value.pendingDelete && value.mediaId) deleteMediaIds.push(value.mediaId);
73
+ if (value.pendingReplace && value.replacesMediaId) deleteMediaIds.push(value.replacesMediaId);
74
+ const promoteMediaIds =
75
+ value.mediaId && !value.pendingDelete && value.status !== "error" ? [value.mediaId] : [];
76
+ return {
77
+ deleteMediaIds: [...new Set(deleteMediaIds)],
78
+ promoteMediaIds: [...new Set(promoteMediaIds)],
79
+ };
80
+ }, [value]);
81
+
82
+ const state = React.useMemo<UploadDraftState>(
83
+ () => ({
84
+ display: value ?? null,
85
+ baseline,
86
+ canUndoRemove: Boolean(value?.pendingDelete),
87
+ canUndoReplace: Boolean(value?.pendingReplace),
88
+ }),
89
+ [baseline, value],
90
+ );
91
+
92
+ return { state, markRemove, undoRemove, stageReplace, undoReplace, getCommitActions };
93
+ }