@object-ui/fields 17.5.0 → 17.7.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 (249) hide show
  1. package/CHANGELOG.md +6150 -0
  2. package/README.md +108 -8
  3. package/dist/FieldEditWidget.d.ts +70 -8
  4. package/dist/coerceToSafeValue.d.ts +54 -0
  5. package/dist/currency.d.ts +1 -1
  6. package/dist/field-type-alias.d.ts +16 -0
  7. package/dist/index.cjs +5 -5
  8. package/dist/index.css +53 -11
  9. package/dist/index.d.ts +653 -128
  10. package/dist/index.js +4275 -3171
  11. package/dist/widgets/AddressField.d.ts +3 -3
  12. package/dist/widgets/AutoNumberField.d.ts +1 -1
  13. package/dist/widgets/AvatarField.d.ts +20 -4
  14. package/dist/widgets/BooleanField.d.ts +2 -2
  15. package/dist/widgets/CapabilityMultiSelectField.d.ts +1 -1
  16. package/dist/widgets/CheckboxesField.d.ts +1 -1
  17. package/dist/widgets/CodeField.d.ts +1 -1
  18. package/dist/widgets/ColorField.d.ts +1 -1
  19. package/dist/widgets/CurrencyField.d.ts +1 -1
  20. package/dist/widgets/DateField.d.ts +2 -2
  21. package/dist/widgets/DateTimeField.d.ts +2 -2
  22. package/dist/widgets/EmailField.d.ts +1 -1
  23. package/dist/widgets/FileField.d.ts +18 -2
  24. package/dist/widgets/FilterConditionField.d.ts +43 -2
  25. package/dist/widgets/FormulaField.d.ts +1 -1
  26. package/dist/widgets/FullscreenFieldEditor.d.ts +9 -2
  27. package/dist/widgets/GeolocationField.d.ts +10 -4
  28. package/dist/widgets/GridField.d.ts +102 -86
  29. package/dist/widgets/ImageField.d.ts +1 -1
  30. package/dist/widgets/LocationField.d.ts +31 -4
  31. package/dist/widgets/LookupField.d.ts +9 -1
  32. package/dist/widgets/MasterDetailField.d.ts +1 -1
  33. package/dist/widgets/MultiSelectField.d.ts +1 -1
  34. package/dist/widgets/NumberField.d.ts +2 -2
  35. package/dist/widgets/ObjectField.d.ts +10 -3
  36. package/dist/widgets/ObjectRefField.d.ts +1 -1
  37. package/dist/widgets/OptionsEmptyState.d.ts +38 -8
  38. package/dist/widgets/PasswordField.d.ts +15 -1
  39. package/dist/widgets/PeoplePicker.d.ts +17 -2
  40. package/dist/widgets/PercentField.d.ts +3 -2
  41. package/dist/widgets/PersonRow.d.ts +13 -2
  42. package/dist/widgets/PhoneField.d.ts +1 -1
  43. package/dist/widgets/QRCodeField.d.ts +1 -1
  44. package/dist/widgets/RadioField.d.ts +1 -1
  45. package/dist/widgets/RatingField.d.ts +1 -1
  46. package/dist/widgets/RecipientPickerField.d.ts +44 -1
  47. package/dist/widgets/RecordPickerDialog.d.ts +33 -16
  48. package/dist/widgets/RichTextField.d.ts +37 -1
  49. package/dist/widgets/SelectField.d.ts +1 -1
  50. package/dist/widgets/SelectionTray.d.ts +13 -2
  51. package/dist/widgets/SignatureField.d.ts +32 -2
  52. package/dist/widgets/SliderField.d.ts +37 -2
  53. package/dist/widgets/SummaryField.d.ts +1 -1
  54. package/dist/widgets/TagsField.d.ts +1 -1
  55. package/dist/widgets/TextAreaField.d.ts +6 -2
  56. package/dist/widgets/TextField.d.ts +2 -2
  57. package/dist/widgets/TimeField.d.ts +2 -2
  58. package/dist/widgets/UrlField.d.ts +1 -1
  59. package/dist/widgets/UserField.d.ts +1 -1
  60. package/dist/widgets/VectorField.d.ts +1 -1
  61. package/dist/widgets/_cell-renderer-bridge.d.ts +1 -1
  62. package/dist/widgets/address-format.d.ts +27 -7
  63. package/dist/widgets/file-affordance.d.ts +33 -0
  64. package/dist/widgets/file-size-guard.d.ts +5 -23
  65. package/dist/widgets/file-value.d.ts +38 -9
  66. package/dist/widgets/lookupColumnDisplay.d.ts +110 -0
  67. package/dist/widgets/nativeDateValue.d.ts +9 -27
  68. package/dist/widgets/number-format.d.ts +137 -0
  69. package/dist/widgets/numberBadInput.d.ts +135 -0
  70. package/dist/widgets/personDisplay.d.ts +22 -4
  71. package/dist/widgets/richTextDisplay.d.ts +116 -0
  72. package/dist/widgets/toDomProps.d.ts +10 -1
  73. package/dist/widgets/toHostGroupProps.d.ts +172 -21
  74. package/dist/widgets/toHostProps.d.ts +105 -0
  75. package/dist/widgets/types.d.ts +19 -6
  76. package/dist/widgets/upload-error-message.d.ts +6 -0
  77. package/dist/widgets/uploadingScope.d.ts +142 -0
  78. package/dist/widgets/useCascadingOptions.d.ts +3 -2
  79. package/dist/widgets/useRecordQuery.d.ts +4 -2
  80. package/dist/widgets/useUploadingSignal.d.ts +8 -0
  81. package/package.json +11 -13
  82. package/dist/widgets/CharacterCount.d.ts +0 -56
  83. package/src/FieldEditWidget.test.ts +0 -133
  84. package/src/FieldEditWidget.tsx +0 -207
  85. package/src/__tests__/AddressCellRenderer.test.tsx +0 -166
  86. package/src/__tests__/CurrencyField.minorUnits.test.tsx +0 -191
  87. package/src/__tests__/CurrencyField.symbol.test.tsx +0 -198
  88. package/src/__tests__/DateCellRenderer.test.tsx +0 -82
  89. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +0 -146
  90. package/src/__tests__/NumberCellRenderer.test.tsx +0 -60
  91. package/src/__tests__/PercentCellRenderer.locale.test.tsx +0 -152
  92. package/src/__tests__/PercentCellRenderer.test.tsx +0 -115
  93. package/src/__tests__/boolean-label-association-e2e.test.tsx +0 -298
  94. package/src/__tests__/capability-multiselect-retired.test.ts +0 -109
  95. package/src/__tests__/cell-truncation.test.tsx +0 -96
  96. package/src/__tests__/composite-group-label-e2e.test.tsx +0 -436
  97. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +0 -403
  98. package/src/__tests__/date-formatter-residue-4272.test.ts +0 -120
  99. package/src/__tests__/date-locale-channel.test.tsx +0 -305
  100. package/src/__tests__/field-carrier-sdui.test.tsx +0 -169
  101. package/src/__tests__/formatCurrency.centsDigit.test.tsx +0 -158
  102. package/src/__tests__/formatCurrency.minorUnits.test.tsx +0 -221
  103. package/src/__tests__/group-labelling-declaration.test.ts +0 -148
  104. package/src/__tests__/lookupCellDisplayField.test.tsx +0 -107
  105. package/src/__tests__/lookupCellDisplayName.test.tsx +0 -119
  106. package/src/__tests__/lookupCellRecordLink.test.tsx +0 -171
  107. package/src/__tests__/lookupCellReferenceKey.test.tsx +0 -57
  108. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +0 -115
  109. package/src/__tests__/percent-formatter-locale-4553.test.ts +0 -147
  110. package/src/__tests__/percent-tie-halfup-4590.test.ts +0 -224
  111. package/src/__tests__/registry-collision.test.ts +0 -91
  112. package/src/__tests__/resolveFieldCurrency.test.ts +0 -20
  113. package/src/__tests__/select-label-association-e2e.test.tsx +0 -199
  114. package/src/__tests__/spec-symbol-batch7.test.ts +0 -126
  115. package/src/__tests__/validation-feedback.test.tsx +0 -361
  116. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +0 -265
  117. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +0 -338
  118. package/src/__tests__/widget-dom-leak-e2e.test.tsx +0 -357
  119. package/src/__tests__/widget-props-contract.test.tsx +0 -142
  120. package/src/coerce-safe-value.test.ts +0 -34
  121. package/src/complex-widgets.test.tsx +0 -924
  122. package/src/currency.ts +0 -144
  123. package/src/datetime-widgets.test.tsx +0 -185
  124. package/src/field-type-alias.multiple.test.ts +0 -81
  125. package/src/field-type-alias.ts +0 -156
  126. package/src/field-type-coverage.test.ts +0 -67
  127. package/src/index.css +0 -44
  128. package/src/index.tsx +0 -2745
  129. package/src/standard-widgets.test.tsx +0 -364
  130. package/src/widgets/AddressField.postalCode.test.tsx +0 -257
  131. package/src/widgets/AddressField.tsx +0 -182
  132. package/src/widgets/AddressField.uniqueIds.test.tsx +0 -93
  133. package/src/widgets/AutoNumberField.tsx +0 -18
  134. package/src/widgets/AvatarField.tsx +0 -117
  135. package/src/widgets/BooleanField.tsx +0 -86
  136. package/src/widgets/CapabilityMultiSelectField.test.tsx +0 -168
  137. package/src/widgets/CapabilityMultiSelectField.tsx +0 -241
  138. package/src/widgets/CharacterCount.tsx +0 -263
  139. package/src/widgets/CheckboxesField.cascade.test.tsx +0 -143
  140. package/src/widgets/CheckboxesField.tsx +0 -169
  141. package/src/widgets/CodeField.tsx +0 -41
  142. package/src/widgets/ColorField.tsx +0 -53
  143. package/src/widgets/CurrencyField.tsx +0 -135
  144. package/src/widgets/DateField.tsx +0 -40
  145. package/src/widgets/DateTimeField.tsx +0 -46
  146. package/src/widgets/EmailField.tsx +0 -37
  147. package/src/widgets/FileField.tsx +0 -512
  148. package/src/widgets/FilterConditionField.tsx +0 -454
  149. package/src/widgets/FormulaField.tsx +0 -38
  150. package/src/widgets/FullscreenFieldEditor.tsx +0 -60
  151. package/src/widgets/GeolocationField.tsx +0 -193
  152. package/src/widgets/GeolocationField.uniqueIds.test.tsx +0 -88
  153. package/src/widgets/GridField.test.tsx +0 -632
  154. package/src/widgets/GridField.tsx +0 -1070
  155. package/src/widgets/ImageCropperDialog.tsx +0 -245
  156. package/src/widgets/ImageField.maxSize.test.tsx +0 -154
  157. package/src/widgets/ImageField.tsx +0 -305
  158. package/src/widgets/ImageLightbox.test.tsx +0 -52
  159. package/src/widgets/ImageLightbox.tsx +0 -108
  160. package/src/widgets/LocationField.tsx +0 -57
  161. package/src/widgets/LookupField.dependsOn.test.tsx +0 -233
  162. package/src/widgets/LookupField.gateHintLabel.test.tsx +0 -135
  163. package/src/widgets/LookupField.hydration.test.tsx +0 -136
  164. package/src/widgets/LookupField.idField.test.tsx +0 -61
  165. package/src/widgets/LookupField.tsx +0 -1295
  166. package/src/widgets/MarkdownContent.tsx +0 -19
  167. package/src/widgets/MasterDetailField.tsx +0 -131
  168. package/src/widgets/MultiSelectField.cascade.test.tsx +0 -143
  169. package/src/widgets/MultiSelectField.tsx +0 -196
  170. package/src/widgets/NumberField.tsx +0 -49
  171. package/src/widgets/ObjectField.tsx +0 -94
  172. package/src/widgets/ObjectRefField.tsx +0 -121
  173. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +0 -88
  174. package/src/widgets/OptionsEmptyState.test.tsx +0 -169
  175. package/src/widgets/OptionsEmptyState.tsx +0 -99
  176. package/src/widgets/PasswordField.tsx +0 -49
  177. package/src/widgets/PeoplePicker.test.tsx +0 -360
  178. package/src/widgets/PeoplePicker.tsx +0 -579
  179. package/src/widgets/PeoplePickerComponents.test.tsx +0 -104
  180. package/src/widgets/PercentField.tsx +0 -95
  181. package/src/widgets/PersonRow.tsx +0 -117
  182. package/src/widgets/PhoneField.tsx +0 -37
  183. package/src/widgets/QRCodeField.tsx +0 -103
  184. package/src/widgets/RadioField.cascade.test.tsx +0 -141
  185. package/src/widgets/RadioField.tsx +0 -143
  186. package/src/widgets/RatingField.tsx +0 -94
  187. package/src/widgets/RecipientPickerField.test.tsx +0 -157
  188. package/src/widgets/RecipientPickerField.tsx +0 -188
  189. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +0 -191
  190. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +0 -124
  191. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +0 -611
  192. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +0 -112
  193. package/src/widgets/RecordPickerDialog.tsx +0 -1365
  194. package/src/widgets/RichTextField.tsx +0 -176
  195. package/src/widgets/SelectField.cascade.test.tsx +0 -138
  196. package/src/widgets/SelectField.tsx +0 -193
  197. package/src/widgets/SelectionTray.tsx +0 -110
  198. package/src/widgets/SignatureField.tsx +0 -140
  199. package/src/widgets/SliderField.tsx +0 -39
  200. package/src/widgets/SummaryField.tsx +0 -31
  201. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +0 -68
  202. package/src/widgets/TagsField.placeholder.test.tsx +0 -95
  203. package/src/widgets/TagsField.tsx +0 -88
  204. package/src/widgets/TextAreaField.tsx +0 -213
  205. package/src/widgets/TextField.tsx +0 -45
  206. package/src/widgets/TimeField.tsx +0 -31
  207. package/src/widgets/UrlField.tsx +0 -46
  208. package/src/widgets/UserField.tsx +0 -59
  209. package/src/widgets/UserFieldPicker.test.tsx +0 -78
  210. package/src/widgets/VectorField.tsx +0 -29
  211. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +0 -66
  212. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +0 -166
  213. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +0 -248
  214. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +0 -116
  215. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +0 -244
  216. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +0 -195
  217. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +0 -417
  218. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +0 -287
  219. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +0 -160
  220. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +0 -161
  221. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +0 -346
  222. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +0 -246
  223. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +0 -175
  224. package/src/widgets/_cell-renderer-bridge.ts +0 -19
  225. package/src/widgets/address-format.ts +0 -120
  226. package/src/widgets/deriveLookupColumns.test.ts +0 -112
  227. package/src/widgets/deriveLookupColumns.ts +0 -108
  228. package/src/widgets/file-size-guard.ts +0 -55
  229. package/src/widgets/file-value.test.ts +0 -230
  230. package/src/widgets/file-value.ts +0 -246
  231. package/src/widgets/nativeDateValue.test.ts +0 -115
  232. package/src/widgets/nativeDateValue.ts +0 -112
  233. package/src/widgets/openNativePicker.ts +0 -18
  234. package/src/widgets/optionWidgets.gatedOptions.test.tsx +0 -267
  235. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +0 -150
  236. package/src/widgets/personDisplay.test.ts +0 -113
  237. package/src/widgets/personDisplay.ts +0 -92
  238. package/src/widgets/recentLookups.test.ts +0 -45
  239. package/src/widgets/recentLookups.ts +0 -47
  240. package/src/widgets/toDomProps.ts +0 -204
  241. package/src/widgets/toHostGroupProps.ts +0 -102
  242. package/src/widgets/types.ts +0 -242
  243. package/src/widgets/useCascadingOptions.ts +0 -46
  244. package/src/widgets/useFieldTranslation.ts +0 -132
  245. package/src/widgets/useRecordQuery.test.ts +0 -174
  246. package/src/widgets/useRecordQuery.ts +0 -291
  247. package/src/widgets/useUploadingSignal.test.tsx +0 -62
  248. package/src/widgets/useUploadingSignal.ts +0 -20
  249. package/src/withFieldCarrier.tsx +0 -75
@@ -1,512 +0,0 @@
1
- import React, { useRef, useState, useCallback } from 'react';
2
- import { Button, EmptyValue } from '@object-ui/components';
3
- import { useUpload } from '@object-ui/providers';
4
- import { useObjectTranslation } from '@object-ui/i18n';
5
- import { Upload, X, File as FileIcon, ImageIcon, Camera, Loader2 } from 'lucide-react';
6
- import { FieldWidgetComponentProps } from './types';
7
- import { toDomProps } from './toDomProps';
8
- import { toHostGroupProps } from './toHostGroupProps';
9
- import { useUploadingSignal } from './useUploadingSignal';
10
- import { maxSizeError, type TranslateFn } from './file-size-guard';
11
- import {
12
- fileValueForSubmit,
13
- readFileValues,
14
- uploadResultView,
15
- withRecentUploads,
16
- isImageValue,
17
- type FileValueView,
18
- } from './file-value';
19
-
20
- /**
21
- * Shared upload pipeline for the file widgets: validates size, uploads through
22
- * the configured UploadProvider adapter (with progress), and merges results
23
- * into the field value — append for `multiple`, replace otherwise. Extracted so
24
- * the full-size FileField and the compact grid-cell {@link FileCell} stay
25
- * behaviourally identical (same value shape, same error handling).
26
- *
27
- * Stores the **reference form** — a bare `sys_file` id — when the upload
28
- * adapter surfaced one, and the legacy inline blob when it did not, so the same
29
- * build works against a backend that has adopted file-as-reference and one that
30
- * has not (see `file-value`). Because a bare id carries no name or URL, each
31
- * completed upload's display details are kept in `recent`, keyed by id, so the
32
- * file renders immediately instead of as a bare token until a read enriches it.
33
- */
34
- function useFileUploads(opts: {
35
- files: any[];
36
- multiple: boolean;
37
- maxSize?: number;
38
- onChange: (value: any) => void;
39
- }) {
40
- const { files, multiple, maxSize, onChange } = opts;
41
- const { upload } = useUpload();
42
- const { t } = useObjectTranslation();
43
- const [errors, setErrors] = useState<string[]>([]);
44
- const [uploadProgress, setUploadProgress] = useState<Record<string, number>>({});
45
- const [uploading, setUploading] = useState(false);
46
- const [recent, setRecent] = useState<Record<string, FileValueView>>({});
47
-
48
- const processFiles = useCallback(async (selectedFiles: File[]) => {
49
- if (selectedFiles.length === 0) return;
50
- const newErrors: string[] = [];
51
-
52
- // Shared with ImageField — see `file-size-guard`.
53
- const validFiles = selectedFiles.filter(file => {
54
- const rejection = maxSizeError(t as TranslateFn, file, maxSize);
55
- if (rejection) {
56
- newErrors.push(rejection);
57
- return false;
58
- }
59
- return true;
60
- });
61
- setErrors(newErrors);
62
-
63
- if (validFiles.length === 0) return;
64
-
65
- setUploading(true);
66
- try {
67
- const uploaded = await Promise.all(
68
- validFiles.map(async (file) => {
69
- try {
70
- const result = await upload(file, {
71
- onProgress: (ratio) =>
72
- setUploadProgress((prev) => ({ ...prev, [file.name]: ratio })),
73
- });
74
- return { result, originalName: file.name };
75
- } catch (err) {
76
- newErrors.push(t('fields.file.uploadFailed', {
77
- defaultValue: `Failed to upload "${file.name}": ${(err as Error).message}`,
78
- name: file.name, error: (err as Error).message,
79
- }));
80
- setErrors([...newErrors]);
81
- return null;
82
- }
83
- }),
84
- );
85
- const successful = uploaded.filter(Boolean) as Array<{ result: any; originalName: string }>;
86
- if (successful.length === 0) return;
87
-
88
- // Remember what each new id looks like so it can render before the next
89
- // read expands it back into `{ id, name, size, mimeType, url }`.
90
- const views: Record<string, FileValueView> = {};
91
- for (const { result, originalName } of successful) {
92
- const view = uploadResultView(result, originalName);
93
- if (view.id) views[view.id] = view;
94
- }
95
- if (Object.keys(views).length > 0) setRecent((prev) => ({ ...prev, ...views }));
96
-
97
- const nextValues = successful.map(({ result, originalName }) =>
98
- fileValueForSubmit(result, originalName),
99
- );
100
- if (multiple) {
101
- onChange([...files, ...nextValues]);
102
- } else {
103
- onChange(nextValues[0]);
104
- }
105
- } finally {
106
- setUploading(false);
107
- setUploadProgress({});
108
- }
109
- }, [files, multiple, onChange, maxSize, upload, t]);
110
-
111
- return { processFiles, errors, uploading, uploadProgress, recent };
112
- }
113
-
114
- /**
115
- * FileField - File upload widget with drag-and-drop support
116
- * Supports single and multiple file uploads with configurable accepted file types.
117
- * L2: File size validation, per-file progress indicators, error messages.
118
- */
119
- export function FileField({ value, onChange, field, readonly, onUploadingChange, error, ...props }: FieldWidgetComponentProps<any>) {
120
- const { t } = useObjectTranslation();
121
- const inputRef = useRef<HTMLInputElement>(null);
122
- const cameraRef = useRef<HTMLInputElement>(null);
123
- const fileField = field as any;
124
- const multiple = fileField?.multiple || false;
125
- const accept = fileField?.accept ? fileField.accept.join(',') : undefined;
126
- const maxSize = fileField?.maxSize as number | undefined; // bytes
127
- /**
128
- * Camera capture mode for mobile devices.
129
- * - `'environment'` (back camera): photos of receipts, documents, products
130
- * - `'user'` (front camera): selfies, profile pictures
131
- * - `false`: disable the camera button entirely
132
- * @default 'environment' when accept includes image/* on a touch device
133
- */
134
- const captureMode = (fileField?.capture ?? null) as 'environment' | 'user' | false | null;
135
- const acceptsImages = !accept || accept.split(',').some((t: string) =>
136
- t.trim().startsWith('image/') || t.trim() === 'image/*' || t.trim().startsWith('.jp') || t.trim().startsWith('.png') || t.trim().startsWith('.gif') || t.trim().startsWith('.webp'),
137
- );
138
- // Auto-enable camera button on touch devices when image upload is permitted, unless explicitly disabled.
139
- const isTouchDevice = typeof navigator !== 'undefined' && (navigator.maxTouchPoints > 0 || /Mobi|Android/i.test(navigator.userAgent));
140
- const cameraEnabled = captureMode === false ? false : (captureMode ?? (acceptsImages && isTouchDevice ? 'environment' : null));
141
- const [isDragOver, setIsDragOver] = useState(false);
142
-
143
- const files = value ? (Array.isArray(value) ? value : [value]) : [];
144
- const { processFiles, errors, uploading, uploadProgress, recent } = useFileUploads({
145
- files, multiple, maxSize, onChange,
146
- });
147
- const fallbackName = t('fields.file.fileFallback', { defaultValue: 'File' });
148
- // Normalised for display: accepts a bare reference id, the expanded
149
- // `{ id, name, size, mimeType, url }`, or a legacy inline blob.
150
- const views = withRecentUploads(readFileValues(value, fallbackName), recent);
151
- useUploadingSignal(uploading, onUploadingChange);
152
-
153
- const handleDragOver = useCallback((e: React.DragEvent) => {
154
- e.preventDefault();
155
- e.stopPropagation();
156
- setIsDragOver(true);
157
- }, []);
158
-
159
- const handleDragLeave = useCallback((e: React.DragEvent) => {
160
- e.preventDefault();
161
- e.stopPropagation();
162
- setIsDragOver(false);
163
- }, []);
164
-
165
- const handleDrop = useCallback((e: React.DragEvent) => {
166
- e.preventDefault();
167
- e.stopPropagation();
168
- setIsDragOver(false);
169
-
170
- const droppedFiles = Array.from(e.dataTransfer.files);
171
- if (accept) {
172
- const acceptedTypes = accept.split(',').map((t: string) => t.trim().toLowerCase());
173
- const filtered = droppedFiles.filter(file => {
174
- const parts = file.name.split('.');
175
- const ext = parts.length > 1 ? '.' + parts.pop()?.toLowerCase() : '';
176
- return acceptedTypes.some((t: string) =>
177
- t === file.type || (ext && t === ext) || (t.endsWith('/*') && file.type.startsWith(t.replace('/*', '/')))
178
- );
179
- });
180
- processFiles(filtered);
181
- } else {
182
- processFiles(droppedFiles);
183
- }
184
- }, [accept, processFiles]);
185
-
186
- if (readonly) {
187
- // Readonly there is no dropzone: the field's whole rendered surface is the
188
- // list of file names, so THAT is what the host's group label names
189
- // (objectui#3990). The role is therefore `group` here and `button` (the
190
- // dropzone) while editable — the same widget, two different surfaces. See
191
- // `toHostGroupProps`; `EmptyValue`'s own `aria-label` ("No value") is
192
- // outranked by `aria-labelledby` per accname, and on the `generic` role that
193
- // placeholder span carries it was never exposed as a name anyway.
194
- const hostGroupProps = toHostGroupProps(props);
195
- if (!value) return <EmptyValue {...hostGroupProps} />;
196
-
197
- return (
198
- <div {...hostGroupProps} className="flex flex-wrap gap-2">
199
- {views.map((file, idx) => (
200
- <span key={idx} className="text-sm truncate max-w-xs">
201
- {file.name}
202
- </span>
203
- ))}
204
- </div>
205
- );
206
- }
207
-
208
- const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
209
- processFiles(Array.from(e.target.files || []));
210
- };
211
-
212
- const handleRemove = (index: number) => {
213
- if (multiple) {
214
- const newFiles = files.filter((_: any, i: number) => i !== index);
215
- onChange(newFiles.length > 0 ? newFiles : null);
216
- } else {
217
- onChange(null);
218
- }
219
- };
220
-
221
- const { name: _domName, ...dropzoneDomProps } = toDomProps(props);
222
-
223
- return (
224
- <div className={props.className}>
225
- <input
226
- ref={inputRef}
227
- type="file"
228
- multiple={multiple}
229
- accept={accept}
230
- onChange={handleFileChange}
231
- className="hidden"
232
- />
233
- {cameraEnabled && (
234
- <input
235
- ref={cameraRef}
236
- type="file"
237
- accept="image/*"
238
- capture={cameraEnabled}
239
- onChange={handleFileChange}
240
- className="hidden"
241
- aria-label={t('fields.file.cameraCapture', { defaultValue: 'Camera capture' })}
242
- data-testid="file-field-camera-input"
243
- />
244
- )}
245
-
246
- <div className="space-y-2">
247
- {/* Drag-and-drop zone — the widget's real focusable control (it is the
248
- keyboard path to the hidden file input), so the DOM pass-through
249
- and the validation state land here (objectui#3318). `name` is
250
- withheld: only DOM-legal on form controls, and on this div it is
251
- exactly the leak #3291 sweeps for.
252
-
253
- It also carries the host's label. This widget is declared
254
- `labelling: 'group'` (objectui#3961) NOT because it is composite — it
255
- has exactly one control — but because that one control is a
256
- `div[role="button"]`, which no `<label for>` can reach. So the form
257
- renderer names it by IDREF instead, and `aria-labelledby` arrives in
258
- the same whitelist spread (`toDomProps` forwards the whole `aria-`
259
- family). Deliberately no extra `role="group"` wrapper: there is no
260
- group here, and adding one would announce a container that holds a
261
- single button. */}
262
- <div
263
- {...dropzoneDomProps}
264
- onDragOver={handleDragOver}
265
- onDragLeave={handleDragLeave}
266
- onDrop={handleDrop}
267
- onClick={() => inputRef.current?.click()}
268
- className={`
269
- flex flex-col items-center justify-center gap-2 p-6
270
- border-2 border-dashed rounded-lg cursor-pointer
271
- transition-colors duration-200
272
- ${isDragOver
273
- ? 'border-primary bg-primary/5 text-primary'
274
- : 'border-muted-foreground/25 hover:border-primary/50 text-muted-foreground hover:text-foreground'}
275
- `}
276
- role="button"
277
- tabIndex={0}
278
- onKeyDown={(e) => {
279
- if (e.key === 'Enter' || e.key === ' ') {
280
- e.preventDefault();
281
- inputRef.current?.click();
282
- }
283
- }}
284
- // AFTER the spread so this widget's own computation wins (#3222).
285
- aria-invalid={!!error}
286
- >
287
- <Upload className={`size-8 ${isDragOver ? 'text-primary' : 'text-muted-foreground'}`} />
288
- <div className="text-center">
289
- <p className="text-sm font-medium">
290
- {isDragOver
291
- ? t('fields.file.dropFilesHere', { defaultValue: 'Drop files here' })
292
- : t('fields.file.dragDropHere', { defaultValue: 'Drag & drop files here' })}
293
- </p>
294
- <p className="text-xs text-muted-foreground mt-1">
295
- {cameraEnabled
296
- ? t('fields.file.browseHintCamera', { defaultValue: 'or click to browse • use the camera button below' })
297
- : t('fields.file.browseHint', { defaultValue: 'or click to browse' })}
298
- </p>
299
- </div>
300
- </div>
301
-
302
- {cameraEnabled && (
303
- <Button
304
- type="button"
305
- variant="outline"
306
- size="sm"
307
- className="w-full"
308
- onClick={(e) => {
309
- e.stopPropagation();
310
- cameraRef.current?.click();
311
- }}
312
- data-testid="file-field-camera-button"
313
- >
314
- <Camera className="size-4 mr-2" />
315
- {cameraEnabled === 'user'
316
- ? t('fields.file.takeSelfie', { defaultValue: 'Take selfie' })
317
- : t('fields.file.takePhoto', { defaultValue: 'Take photo' })}
318
- </Button>
319
- )}
320
-
321
- {/* Upload progress indicator */}
322
- {uploading && (
323
- <div className="flex items-center gap-2 text-xs text-muted-foreground" data-testid="file-field-uploading">
324
- <Loader2 className="size-3 animate-spin" />
325
- <span>
326
- {(() => {
327
- const keys = Object.keys(uploadProgress);
328
- if (keys.length === 0) return t('fields.file.uploading', { defaultValue: 'Uploading…' });
329
- const pct = Math.round(
330
- (Object.values(uploadProgress).reduce((s, v) => s + v, 0) / keys.length) * 100,
331
- );
332
- return t('fields.file.uploadingPct', { defaultValue: `Uploading… (${pct}%)`, pct });
333
- })()}
334
- </span>
335
- </div>
336
- )}
337
-
338
- {/* Validation errors */}
339
- {errors.length > 0 && (
340
- <div className="space-y-0.5">
341
- {errors.map((err, i) => (
342
- <p key={i} className="text-xs text-destructive">{err}</p>
343
- ))}
344
- </div>
345
- )}
346
-
347
- {/* File list */}
348
- {views.length > 0 && (
349
- <div className="space-y-1">
350
- {views.map((file, idx) => (
351
- <div
352
- key={idx}
353
- className="flex items-center justify-between gap-2 p-2 bg-muted/50 rounded-md border"
354
- >
355
- <div className="flex items-center gap-2 flex-1 min-w-0">
356
- {isImageValue(file) && file.url ? (
357
- <img src={file.url} alt={file.name} className="size-8 object-cover rounded shrink-0" />
358
- ) : isImageValue(file) ? (
359
- <ImageIcon className="size-4 text-muted-foreground shrink-0" />
360
- ) : (
361
- <FileIcon className="size-4 text-muted-foreground shrink-0" />
362
- )}
363
- <span className="text-sm truncate">
364
- {file.name}
365
- </span>
366
- {file.size && (
367
- <span className="text-xs text-muted-foreground">
368
- ({(file.size / 1024).toFixed(1)} KB)
369
- </span>
370
- )}
371
- </div>
372
- <Button
373
- type="button"
374
- variant="ghost"
375
- size="sm"
376
- onClick={(e) => {
377
- e.stopPropagation();
378
- handleRemove(idx);
379
- }}
380
- className="h-6 w-6 p-0"
381
- >
382
- <X className="size-3" />
383
- </Button>
384
- </div>
385
- ))}
386
- </div>
387
- )}
388
- </div>
389
- </div>
390
- );
391
- }
392
-
393
- /**
394
- * FileCell — compact upload control for a line-item grid cell (objectui#2360).
395
- *
396
- * Same value shape and upload pipeline as {@link FileField}, sized for a 32px
397
- * grid row: existing files render as removable chips (image thumbnail / file
398
- * icon + name) and a small button opens the native file picker. No
399
- * drag-and-drop zone — a grid cell has no room for one; the per-row expand
400
- * form still offers the full-size FileField.
401
- */
402
- export function FileCell({
403
- value,
404
- onChange,
405
- disabled,
406
- multiple,
407
- accept,
408
- maxSize,
409
- 'aria-label': ariaLabel,
410
- 'data-cell': dataCell,
411
- }: {
412
- value: any;
413
- onChange: (value: any) => void;
414
- disabled?: boolean;
415
- multiple?: boolean;
416
- /** Comma-joined accept list for the native picker (e.g. `"image/*,.pdf"`). */
417
- accept?: string;
418
- /** Max file size in bytes (oversize picks are rejected with an inline error). */
419
- maxSize?: number;
420
- 'aria-label'?: string;
421
- /** Focus-grid coordinate (see GridField keyboard navigation). */
422
- 'data-cell'?: string;
423
- }) {
424
- const { t } = useObjectTranslation();
425
- const inputRef = useRef<HTMLInputElement>(null);
426
- const files = value ? (Array.isArray(value) ? value : [value]) : [];
427
- const { processFiles, errors, uploading, recent } = useFileUploads({
428
- files, multiple: !!multiple, maxSize, onChange,
429
- });
430
- const fallbackName = t('fields.file.fileFallback', { defaultValue: 'File' });
431
- const views = withRecentUploads(readFileValues(value, fallbackName), recent);
432
-
433
- const removeAt = (index: number) => {
434
- if (multiple) {
435
- const next = files.filter((_: any, i: number) => i !== index);
436
- onChange(next.length > 0 ? next : null);
437
- } else {
438
- onChange(null);
439
- }
440
- };
441
-
442
- const showUpload = !disabled && !uploading && (multiple || files.length === 0);
443
-
444
- return (
445
- <div className="flex min-h-8 flex-wrap items-center gap-1 px-1 py-0.5">
446
- <input
447
- ref={inputRef}
448
- type="file"
449
- multiple={multiple}
450
- accept={accept}
451
- onChange={(e) => {
452
- processFiles(Array.from(e.target.files || []));
453
- e.target.value = ''; // allow re-picking the same file
454
- }}
455
- className="hidden"
456
- />
457
- {views.map((file, idx) => (
458
- <span
459
- key={idx}
460
- className="inline-flex max-w-40 items-center gap-1 rounded border bg-muted/50 px-1 py-0.5 text-xs"
461
- title={file.name}
462
- data-testid="file-cell-chip"
463
- >
464
- {isImageValue(file) && file.url ? (
465
- <img src={file.url} alt={file.name} className="size-5 shrink-0 rounded object-cover" />
466
- ) : (
467
- <FileIcon className="size-3 shrink-0 text-muted-foreground" />
468
- )}
469
- <span className="truncate">{file.name}</span>
470
- {!disabled && (
471
- <button
472
- type="button"
473
- className="shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground"
474
- aria-label={t('fields.file.remove', { defaultValue: `Remove ${file.name}`, name: file.name })}
475
- onClick={() => removeAt(idx)}
476
- >
477
- <X className="size-3" />
478
- </button>
479
- )}
480
- </span>
481
- ))}
482
- {uploading && (
483
- <span
484
- className="inline-flex items-center gap-1 text-xs text-muted-foreground"
485
- data-testid="file-cell-uploading"
486
- >
487
- <Loader2 className="size-3.5 animate-spin" />
488
- </span>
489
- )}
490
- {showUpload && (
491
- <Button
492
- type="button"
493
- variant="ghost"
494
- size="sm"
495
- className="h-7 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground"
496
- onClick={() => inputRef.current?.click()}
497
- aria-label={ariaLabel}
498
- data-cell={dataCell}
499
- disabled={disabled}
500
- >
501
- <Upload className="size-3.5" />
502
- {files.length === 0 && t('fields.file.upload', { defaultValue: 'Upload' })}
503
- </Button>
504
- )}
505
- {errors.length > 0 && (
506
- <span className="w-full truncate text-[11px] text-destructive" title={errors.join('; ')}>
507
- {errors[0]}
508
- </span>
509
- )}
510
- </div>
511
- );
512
- }