@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.
- package/CHANGELOG.md +6150 -0
- package/README.md +108 -8
- package/dist/FieldEditWidget.d.ts +70 -8
- package/dist/coerceToSafeValue.d.ts +54 -0
- package/dist/currency.d.ts +1 -1
- package/dist/field-type-alias.d.ts +16 -0
- package/dist/index.cjs +5 -5
- package/dist/index.css +53 -11
- package/dist/index.d.ts +653 -128
- package/dist/index.js +4275 -3171
- package/dist/widgets/AddressField.d.ts +3 -3
- package/dist/widgets/AutoNumberField.d.ts +1 -1
- package/dist/widgets/AvatarField.d.ts +20 -4
- package/dist/widgets/BooleanField.d.ts +2 -2
- package/dist/widgets/CapabilityMultiSelectField.d.ts +1 -1
- package/dist/widgets/CheckboxesField.d.ts +1 -1
- package/dist/widgets/CodeField.d.ts +1 -1
- package/dist/widgets/ColorField.d.ts +1 -1
- package/dist/widgets/CurrencyField.d.ts +1 -1
- package/dist/widgets/DateField.d.ts +2 -2
- package/dist/widgets/DateTimeField.d.ts +2 -2
- package/dist/widgets/EmailField.d.ts +1 -1
- package/dist/widgets/FileField.d.ts +18 -2
- package/dist/widgets/FilterConditionField.d.ts +43 -2
- package/dist/widgets/FormulaField.d.ts +1 -1
- package/dist/widgets/FullscreenFieldEditor.d.ts +9 -2
- package/dist/widgets/GeolocationField.d.ts +10 -4
- package/dist/widgets/GridField.d.ts +102 -86
- package/dist/widgets/ImageField.d.ts +1 -1
- package/dist/widgets/LocationField.d.ts +31 -4
- package/dist/widgets/LookupField.d.ts +9 -1
- package/dist/widgets/MasterDetailField.d.ts +1 -1
- package/dist/widgets/MultiSelectField.d.ts +1 -1
- package/dist/widgets/NumberField.d.ts +2 -2
- package/dist/widgets/ObjectField.d.ts +10 -3
- package/dist/widgets/ObjectRefField.d.ts +1 -1
- package/dist/widgets/OptionsEmptyState.d.ts +38 -8
- package/dist/widgets/PasswordField.d.ts +15 -1
- package/dist/widgets/PeoplePicker.d.ts +17 -2
- package/dist/widgets/PercentField.d.ts +3 -2
- package/dist/widgets/PersonRow.d.ts +13 -2
- package/dist/widgets/PhoneField.d.ts +1 -1
- package/dist/widgets/QRCodeField.d.ts +1 -1
- package/dist/widgets/RadioField.d.ts +1 -1
- package/dist/widgets/RatingField.d.ts +1 -1
- package/dist/widgets/RecipientPickerField.d.ts +44 -1
- package/dist/widgets/RecordPickerDialog.d.ts +33 -16
- package/dist/widgets/RichTextField.d.ts +37 -1
- package/dist/widgets/SelectField.d.ts +1 -1
- package/dist/widgets/SelectionTray.d.ts +13 -2
- package/dist/widgets/SignatureField.d.ts +32 -2
- package/dist/widgets/SliderField.d.ts +37 -2
- package/dist/widgets/SummaryField.d.ts +1 -1
- package/dist/widgets/TagsField.d.ts +1 -1
- package/dist/widgets/TextAreaField.d.ts +6 -2
- package/dist/widgets/TextField.d.ts +2 -2
- package/dist/widgets/TimeField.d.ts +2 -2
- package/dist/widgets/UrlField.d.ts +1 -1
- package/dist/widgets/UserField.d.ts +1 -1
- package/dist/widgets/VectorField.d.ts +1 -1
- package/dist/widgets/_cell-renderer-bridge.d.ts +1 -1
- package/dist/widgets/address-format.d.ts +27 -7
- package/dist/widgets/file-affordance.d.ts +33 -0
- package/dist/widgets/file-size-guard.d.ts +5 -23
- package/dist/widgets/file-value.d.ts +38 -9
- package/dist/widgets/lookupColumnDisplay.d.ts +110 -0
- package/dist/widgets/nativeDateValue.d.ts +9 -27
- package/dist/widgets/number-format.d.ts +137 -0
- package/dist/widgets/numberBadInput.d.ts +135 -0
- package/dist/widgets/personDisplay.d.ts +22 -4
- package/dist/widgets/richTextDisplay.d.ts +116 -0
- package/dist/widgets/toDomProps.d.ts +10 -1
- package/dist/widgets/toHostGroupProps.d.ts +172 -21
- package/dist/widgets/toHostProps.d.ts +105 -0
- package/dist/widgets/types.d.ts +19 -6
- package/dist/widgets/upload-error-message.d.ts +6 -0
- package/dist/widgets/uploadingScope.d.ts +142 -0
- package/dist/widgets/useCascadingOptions.d.ts +3 -2
- package/dist/widgets/useRecordQuery.d.ts +4 -2
- package/dist/widgets/useUploadingSignal.d.ts +8 -0
- package/package.json +11 -13
- package/dist/widgets/CharacterCount.d.ts +0 -56
- package/src/FieldEditWidget.test.ts +0 -133
- package/src/FieldEditWidget.tsx +0 -207
- package/src/__tests__/AddressCellRenderer.test.tsx +0 -166
- package/src/__tests__/CurrencyField.minorUnits.test.tsx +0 -191
- package/src/__tests__/CurrencyField.symbol.test.tsx +0 -198
- package/src/__tests__/DateCellRenderer.test.tsx +0 -82
- package/src/__tests__/NumberCellRenderer.grouping.test.tsx +0 -146
- package/src/__tests__/NumberCellRenderer.test.tsx +0 -60
- package/src/__tests__/PercentCellRenderer.locale.test.tsx +0 -152
- package/src/__tests__/PercentCellRenderer.test.tsx +0 -115
- package/src/__tests__/boolean-label-association-e2e.test.tsx +0 -298
- package/src/__tests__/capability-multiselect-retired.test.ts +0 -109
- package/src/__tests__/cell-truncation.test.tsx +0 -96
- package/src/__tests__/composite-group-label-e2e.test.tsx +0 -436
- package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +0 -403
- package/src/__tests__/date-formatter-residue-4272.test.ts +0 -120
- package/src/__tests__/date-locale-channel.test.tsx +0 -305
- package/src/__tests__/field-carrier-sdui.test.tsx +0 -169
- package/src/__tests__/formatCurrency.centsDigit.test.tsx +0 -158
- package/src/__tests__/formatCurrency.minorUnits.test.tsx +0 -221
- package/src/__tests__/group-labelling-declaration.test.ts +0 -148
- package/src/__tests__/lookupCellDisplayField.test.tsx +0 -107
- package/src/__tests__/lookupCellDisplayName.test.tsx +0 -119
- package/src/__tests__/lookupCellRecordLink.test.tsx +0 -171
- package/src/__tests__/lookupCellReferenceKey.test.tsx +0 -57
- package/src/__tests__/percent-cell-vs-measure-4576.test.ts +0 -115
- package/src/__tests__/percent-formatter-locale-4553.test.ts +0 -147
- package/src/__tests__/percent-tie-halfup-4590.test.ts +0 -224
- package/src/__tests__/registry-collision.test.ts +0 -91
- package/src/__tests__/resolveFieldCurrency.test.ts +0 -20
- package/src/__tests__/select-label-association-e2e.test.tsx +0 -199
- package/src/__tests__/spec-symbol-batch7.test.ts +0 -126
- package/src/__tests__/validation-feedback.test.tsx +0 -361
- package/src/__tests__/widget-aria-invalid-e2e.test.tsx +0 -265
- package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +0 -338
- package/src/__tests__/widget-dom-leak-e2e.test.tsx +0 -357
- package/src/__tests__/widget-props-contract.test.tsx +0 -142
- package/src/coerce-safe-value.test.ts +0 -34
- package/src/complex-widgets.test.tsx +0 -924
- package/src/currency.ts +0 -144
- package/src/datetime-widgets.test.tsx +0 -185
- package/src/field-type-alias.multiple.test.ts +0 -81
- package/src/field-type-alias.ts +0 -156
- package/src/field-type-coverage.test.ts +0 -67
- package/src/index.css +0 -44
- package/src/index.tsx +0 -2745
- package/src/standard-widgets.test.tsx +0 -364
- package/src/widgets/AddressField.postalCode.test.tsx +0 -257
- package/src/widgets/AddressField.tsx +0 -182
- package/src/widgets/AddressField.uniqueIds.test.tsx +0 -93
- package/src/widgets/AutoNumberField.tsx +0 -18
- package/src/widgets/AvatarField.tsx +0 -117
- package/src/widgets/BooleanField.tsx +0 -86
- package/src/widgets/CapabilityMultiSelectField.test.tsx +0 -168
- package/src/widgets/CapabilityMultiSelectField.tsx +0 -241
- package/src/widgets/CharacterCount.tsx +0 -263
- package/src/widgets/CheckboxesField.cascade.test.tsx +0 -143
- package/src/widgets/CheckboxesField.tsx +0 -169
- package/src/widgets/CodeField.tsx +0 -41
- package/src/widgets/ColorField.tsx +0 -53
- package/src/widgets/CurrencyField.tsx +0 -135
- package/src/widgets/DateField.tsx +0 -40
- package/src/widgets/DateTimeField.tsx +0 -46
- package/src/widgets/EmailField.tsx +0 -37
- package/src/widgets/FileField.tsx +0 -512
- package/src/widgets/FilterConditionField.tsx +0 -454
- package/src/widgets/FormulaField.tsx +0 -38
- package/src/widgets/FullscreenFieldEditor.tsx +0 -60
- package/src/widgets/GeolocationField.tsx +0 -193
- package/src/widgets/GeolocationField.uniqueIds.test.tsx +0 -88
- package/src/widgets/GridField.test.tsx +0 -632
- package/src/widgets/GridField.tsx +0 -1070
- package/src/widgets/ImageCropperDialog.tsx +0 -245
- package/src/widgets/ImageField.maxSize.test.tsx +0 -154
- package/src/widgets/ImageField.tsx +0 -305
- package/src/widgets/ImageLightbox.test.tsx +0 -52
- package/src/widgets/ImageLightbox.tsx +0 -108
- package/src/widgets/LocationField.tsx +0 -57
- package/src/widgets/LookupField.dependsOn.test.tsx +0 -233
- package/src/widgets/LookupField.gateHintLabel.test.tsx +0 -135
- package/src/widgets/LookupField.hydration.test.tsx +0 -136
- package/src/widgets/LookupField.idField.test.tsx +0 -61
- package/src/widgets/LookupField.tsx +0 -1295
- package/src/widgets/MarkdownContent.tsx +0 -19
- package/src/widgets/MasterDetailField.tsx +0 -131
- package/src/widgets/MultiSelectField.cascade.test.tsx +0 -143
- package/src/widgets/MultiSelectField.tsx +0 -196
- package/src/widgets/NumberField.tsx +0 -49
- package/src/widgets/ObjectField.tsx +0 -94
- package/src/widgets/ObjectRefField.tsx +0 -121
- package/src/widgets/OptionsEmptyState.no-provider.test.tsx +0 -88
- package/src/widgets/OptionsEmptyState.test.tsx +0 -169
- package/src/widgets/OptionsEmptyState.tsx +0 -99
- package/src/widgets/PasswordField.tsx +0 -49
- package/src/widgets/PeoplePicker.test.tsx +0 -360
- package/src/widgets/PeoplePicker.tsx +0 -579
- package/src/widgets/PeoplePickerComponents.test.tsx +0 -104
- package/src/widgets/PercentField.tsx +0 -95
- package/src/widgets/PersonRow.tsx +0 -117
- package/src/widgets/PhoneField.tsx +0 -37
- package/src/widgets/QRCodeField.tsx +0 -103
- package/src/widgets/RadioField.cascade.test.tsx +0 -141
- package/src/widgets/RadioField.tsx +0 -143
- package/src/widgets/RatingField.tsx +0 -94
- package/src/widgets/RecipientPickerField.test.tsx +0 -157
- package/src/widgets/RecipientPickerField.tsx +0 -188
- package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +0 -191
- package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +0 -124
- package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +0 -611
- package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +0 -112
- package/src/widgets/RecordPickerDialog.tsx +0 -1365
- package/src/widgets/RichTextField.tsx +0 -176
- package/src/widgets/SelectField.cascade.test.tsx +0 -138
- package/src/widgets/SelectField.tsx +0 -193
- package/src/widgets/SelectionTray.tsx +0 -110
- package/src/widgets/SignatureField.tsx +0 -140
- package/src/widgets/SliderField.tsx +0 -39
- package/src/widgets/SummaryField.tsx +0 -31
- package/src/widgets/TagsField.placeholder.no-provider.test.tsx +0 -68
- package/src/widgets/TagsField.placeholder.test.tsx +0 -95
- package/src/widgets/TagsField.tsx +0 -88
- package/src/widgets/TextAreaField.tsx +0 -213
- package/src/widgets/TextField.tsx +0 -45
- package/src/widgets/TimeField.tsx +0 -31
- package/src/widgets/UrlField.tsx +0 -46
- package/src/widgets/UserField.tsx +0 -59
- package/src/widgets/UserFieldPicker.test.tsx +0 -78
- package/src/widgets/VectorField.tsx +0 -29
- package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +0 -66
- package/src/widgets/__tests__/FilterConditionField.operators.test.ts +0 -166
- package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +0 -248
- package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +0 -116
- package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +0 -244
- package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +0 -195
- package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +0 -417
- package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +0 -287
- package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +0 -160
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +0 -161
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +0 -346
- package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +0 -246
- package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +0 -175
- package/src/widgets/_cell-renderer-bridge.ts +0 -19
- package/src/widgets/address-format.ts +0 -120
- package/src/widgets/deriveLookupColumns.test.ts +0 -112
- package/src/widgets/deriveLookupColumns.ts +0 -108
- package/src/widgets/file-size-guard.ts +0 -55
- package/src/widgets/file-value.test.ts +0 -230
- package/src/widgets/file-value.ts +0 -246
- package/src/widgets/nativeDateValue.test.ts +0 -115
- package/src/widgets/nativeDateValue.ts +0 -112
- package/src/widgets/openNativePicker.ts +0 -18
- package/src/widgets/optionWidgets.gatedOptions.test.tsx +0 -267
- package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +0 -150
- package/src/widgets/personDisplay.test.ts +0 -113
- package/src/widgets/personDisplay.ts +0 -92
- package/src/widgets/recentLookups.test.ts +0 -45
- package/src/widgets/recentLookups.ts +0 -47
- package/src/widgets/toDomProps.ts +0 -204
- package/src/widgets/toHostGroupProps.ts +0 -102
- package/src/widgets/types.ts +0 -242
- package/src/widgets/useCascadingOptions.ts +0 -46
- package/src/widgets/useFieldTranslation.ts +0 -132
- package/src/widgets/useRecordQuery.test.ts +0 -174
- package/src/widgets/useRecordQuery.ts +0 -291
- package/src/widgets/useUploadingSignal.test.tsx +0 -62
- package/src/widgets/useUploadingSignal.ts +0 -20
- 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
|
-
}
|