@object-ui/fields 17.5.0 → 17.6.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 +714 -0
- package/dist/FieldEditWidget.d.ts +16 -2
- package/dist/field-type-alias.d.ts +16 -0
- package/dist/index.cjs +4 -4
- package/dist/index.css +27 -9
- package/dist/index.d.ts +425 -72
- package/dist/index.js +2530 -2228
- package/dist/widgets/AddressField.d.ts +3 -3
- package/dist/widgets/AutoNumberField.d.ts +1 -1
- package/dist/widgets/AvatarField.d.ts +1 -1
- package/dist/widgets/BooleanField.d.ts +1 -1
- 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 +1 -1
- package/dist/widgets/DateTimeField.d.ts +1 -1
- package/dist/widgets/EmailField.d.ts +1 -1
- package/dist/widgets/FileField.d.ts +18 -2
- package/dist/widgets/FilterConditionField.d.ts +28 -1
- package/dist/widgets/FormulaField.d.ts +1 -1
- package/dist/widgets/FullscreenFieldEditor.d.ts +9 -2
- package/dist/widgets/GeolocationField.d.ts +1 -1
- package/dist/widgets/GridField.d.ts +56 -4
- package/dist/widgets/ImageField.d.ts +1 -1
- package/dist/widgets/LocationField.d.ts +1 -1
- package/dist/widgets/LookupField.d.ts +1 -1
- package/dist/widgets/MasterDetailField.d.ts +1 -1
- package/dist/widgets/MultiSelectField.d.ts +1 -1
- package/dist/widgets/NumberField.d.ts +1 -1
- package/dist/widgets/ObjectField.d.ts +1 -1
- package/dist/widgets/ObjectRefField.d.ts +1 -1
- package/dist/widgets/OptionsEmptyState.d.ts +13 -7
- package/dist/widgets/PasswordField.d.ts +1 -1
- package/dist/widgets/PercentField.d.ts +1 -1
- 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 +1 -1
- package/dist/widgets/RecordPickerDialog.d.ts +2 -4
- package/dist/widgets/RichTextField.d.ts +37 -1
- package/dist/widgets/SelectField.d.ts +1 -1
- 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 +1 -1
- package/dist/widgets/TimeField.d.ts +1 -1
- 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/lookupColumnDisplay.d.ts +93 -0
- package/dist/widgets/richTextDisplay.d.ts +80 -0
- package/dist/widgets/toHostGroupProps.d.ts +119 -21
- package/dist/widgets/types.d.ts +8 -3
- package/package.json +9 -10
- 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,305 +0,0 @@
|
|
|
1
|
-
import React, { useRef, useState, useCallback, lazy, Suspense } 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 { X, Image as ImageIcon, Crop as CropIcon, Loader2 } from 'lucide-react';
|
|
6
|
-
import { FieldWidgetComponentProps } from './types';
|
|
7
|
-
import { toDomProps } from './toDomProps';
|
|
8
|
-
import { ImageLightbox } from './ImageLightbox';
|
|
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
|
-
type FileValueView,
|
|
17
|
-
} from './file-value';
|
|
18
|
-
|
|
19
|
-
// Lazy-load the cropper so the dialog (canvas + crop logic) is not in the initial
|
|
20
|
-
// ImageField bundle. Consumers that never crop pay zero cost.
|
|
21
|
-
const ImageCropperDialog = lazy(() =>
|
|
22
|
-
import('./ImageCropperDialog').then((m) => ({ default: m.ImageCropperDialog })),
|
|
23
|
-
);
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* ImageField - Image upload widget with preview thumbnails
|
|
27
|
-
* Supports single and multiple image uploads with drag-and-drop and preview display
|
|
28
|
-
*/
|
|
29
|
-
export function ImageField({ value, onChange, field, readonly, onUploadingChange, error, ...props }: FieldWidgetComponentProps<any>) {
|
|
30
|
-
const inputRef = useRef<HTMLInputElement>(null);
|
|
31
|
-
const imageField = field as any;
|
|
32
|
-
const multiple = imageField?.multiple || false;
|
|
33
|
-
const accept = imageField?.accept ? imageField.accept.join(',') : 'image/*';
|
|
34
|
-
/**
|
|
35
|
-
* Max upload size in bytes. Delivered by `paramToField` for an action param
|
|
36
|
-
* and by the field config for a record field; enforced before any upload
|
|
37
|
-
* starts, exactly as FileField does (objectui#4141).
|
|
38
|
-
*/
|
|
39
|
-
const maxSize = imageField?.maxSize as number | undefined;
|
|
40
|
-
/**
|
|
41
|
-
* Set `field.crop = false` to opt out of inline cropping. Defaults to enabled.
|
|
42
|
-
*/
|
|
43
|
-
const cropEnabled = imageField?.crop !== false;
|
|
44
|
-
const [cropTarget, setCropTarget] = useState<{ index: number; src: string; name: string } | null>(null);
|
|
45
|
-
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
|
46
|
-
const { upload } = useUpload();
|
|
47
|
-
const { t } = useObjectTranslation();
|
|
48
|
-
const [uploading, setUploading] = useState(false);
|
|
49
|
-
/** Client-side rejections (oversize picks), cleared on the next attempt. */
|
|
50
|
-
const [errors, setErrors] = useState<string[]>([]);
|
|
51
|
-
// Display details of just-uploaded images, keyed by their new `sys_file` id.
|
|
52
|
-
// Submitting the reference form means the field value no longer carries the
|
|
53
|
-
// URL a thumbnail needs; these keep the preview visible until the next read
|
|
54
|
-
// returns the expanded form. See `file-value`.
|
|
55
|
-
const [recent, setRecent] = useState<Record<string, FileValueView>>({});
|
|
56
|
-
useUploadingSignal(uploading, onUploadingChange);
|
|
57
|
-
|
|
58
|
-
// Derived value + memoized handlers must run before the readonly early return
|
|
59
|
-
// so hook order stays stable across renders.
|
|
60
|
-
const images = value ? (Array.isArray(value) ? value : [value]) : [];
|
|
61
|
-
const views = withRecentUploads(readFileValues(value, 'Image'), recent);
|
|
62
|
-
|
|
63
|
-
const remember = useCallback((result: any, originalName: string) => {
|
|
64
|
-
const view = uploadResultView(result, originalName);
|
|
65
|
-
if (view.id) setRecent((prev) => ({ ...prev, [view.id as string]: view }));
|
|
66
|
-
}, []);
|
|
67
|
-
|
|
68
|
-
const handleCropConfirm = useCallback(
|
|
69
|
-
async (blob: Blob, name: string) => {
|
|
70
|
-
if (!cropTarget) return;
|
|
71
|
-
// The size that matters here is the CROP's, not the source image's: the
|
|
72
|
-
// cropper re-encodes to PNG, so cropping an in-limit JPEG can produce a
|
|
73
|
-
// blob over the limit. Checked before `setUploading` so a rejected crop
|
|
74
|
-
// never flashes the spinner, and the dialog closes so the message lands
|
|
75
|
-
// in the field's error row rather than behind the open dialog.
|
|
76
|
-
const rejection = maxSizeError(t as TranslateFn, { name, size: blob.size }, maxSize);
|
|
77
|
-
if (rejection) {
|
|
78
|
-
setErrors([rejection]);
|
|
79
|
-
setCropTarget(null);
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
setErrors([]);
|
|
83
|
-
setUploading(true);
|
|
84
|
-
try {
|
|
85
|
-
const result = await upload(blob);
|
|
86
|
-
remember(result, name);
|
|
87
|
-
const next = fileValueForSubmit(result, name);
|
|
88
|
-
if (multiple) {
|
|
89
|
-
const updated = [...images];
|
|
90
|
-
updated[cropTarget.index] = next;
|
|
91
|
-
onChange(updated);
|
|
92
|
-
} else {
|
|
93
|
-
onChange(next);
|
|
94
|
-
}
|
|
95
|
-
} finally {
|
|
96
|
-
setUploading(false);
|
|
97
|
-
setCropTarget(null);
|
|
98
|
-
}
|
|
99
|
-
},
|
|
100
|
-
[cropTarget, images, multiple, onChange, upload, remember, maxSize, t],
|
|
101
|
-
);
|
|
102
|
-
|
|
103
|
-
const openCropper = useCallback(
|
|
104
|
-
(index: number) => {
|
|
105
|
-
const img = views[index];
|
|
106
|
-
if (!img?.url) return;
|
|
107
|
-
setCropTarget({ index, src: img.url, name: img.name || `image-${index}.png` });
|
|
108
|
-
},
|
|
109
|
-
[views],
|
|
110
|
-
);
|
|
111
|
-
|
|
112
|
-
const lightboxImages = views.filter((v) => v.url).map((v) => ({ url: v.url as string, name: v.name }));
|
|
113
|
-
|
|
114
|
-
if (readonly) {
|
|
115
|
-
if (!value || lightboxImages.length === 0) return <EmptyValue />;
|
|
116
|
-
|
|
117
|
-
return (
|
|
118
|
-
<>
|
|
119
|
-
<div className="flex flex-wrap gap-2">
|
|
120
|
-
{lightboxImages.map((img, idx) => (
|
|
121
|
-
<button
|
|
122
|
-
key={idx}
|
|
123
|
-
type="button"
|
|
124
|
-
onClick={() => setLightboxIndex(idx)}
|
|
125
|
-
className="group relative overflow-hidden rounded-md border border-border focus:outline-none focus:ring-2 focus:ring-ring"
|
|
126
|
-
aria-label={t('fields.image.enlarge', { name: img.name || t('fields.image.imageAlt', { index: idx + 1 }) })}
|
|
127
|
-
>
|
|
128
|
-
<img
|
|
129
|
-
src={img.url}
|
|
130
|
-
alt={img.name || t('fields.image.imageAlt', { index: idx + 1 })}
|
|
131
|
-
className="size-20 object-cover transition-transform duration-150 group-hover:scale-105"
|
|
132
|
-
/>
|
|
133
|
-
<span className="pointer-events-none absolute inset-0 bg-black/0 transition-colors group-hover:bg-black/10" />
|
|
134
|
-
</button>
|
|
135
|
-
))}
|
|
136
|
-
</div>
|
|
137
|
-
{lightboxIndex !== null && (
|
|
138
|
-
<ImageLightbox
|
|
139
|
-
images={lightboxImages}
|
|
140
|
-
index={lightboxIndex}
|
|
141
|
-
open
|
|
142
|
-
onOpenChange={(o) => !o && setLightboxIndex(null)}
|
|
143
|
-
onIndexChange={setLightboxIndex}
|
|
144
|
-
/>
|
|
145
|
-
)}
|
|
146
|
-
</>
|
|
147
|
-
);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
151
|
-
const selectedFiles = Array.from(e.target.files || []);
|
|
152
|
-
// Reset the input up front rather than in `finally`: a pick rejected below
|
|
153
|
-
// returns before the upload runs, and leaving the old value in place would
|
|
154
|
-
// stop the user re-picking the same name after shrinking the image.
|
|
155
|
-
// `selectedFiles` already holds the File objects, so clearing is safe.
|
|
156
|
-
if (inputRef.current) inputRef.current.value = '';
|
|
157
|
-
if (selectedFiles.length === 0) return;
|
|
158
|
-
|
|
159
|
-
// Enforce the declared limit before anything touches the network. Partial
|
|
160
|
-
// acceptance matches FileField: the in-limit picks of a multi-select still
|
|
161
|
-
// upload, and only the oversize ones are reported.
|
|
162
|
-
const rejections: string[] = [];
|
|
163
|
-
const validFiles = selectedFiles.filter((file) => {
|
|
164
|
-
const rejection = maxSizeError(t as TranslateFn, file, maxSize);
|
|
165
|
-
if (rejection) {
|
|
166
|
-
rejections.push(rejection);
|
|
167
|
-
return false;
|
|
168
|
-
}
|
|
169
|
-
return true;
|
|
170
|
-
});
|
|
171
|
-
setErrors(rejections);
|
|
172
|
-
if (validFiles.length === 0) return;
|
|
173
|
-
|
|
174
|
-
setUploading(true);
|
|
175
|
-
try {
|
|
176
|
-
const imageObjects = await Promise.all(
|
|
177
|
-
validFiles.map(async (file) => {
|
|
178
|
-
const result = await upload(file);
|
|
179
|
-
remember(result, file.name);
|
|
180
|
-
return fileValueForSubmit(result, file.name);
|
|
181
|
-
}),
|
|
182
|
-
);
|
|
183
|
-
|
|
184
|
-
if (multiple) {
|
|
185
|
-
onChange([...images, ...imageObjects]);
|
|
186
|
-
} else {
|
|
187
|
-
onChange(imageObjects[0]);
|
|
188
|
-
}
|
|
189
|
-
} finally {
|
|
190
|
-
setUploading(false);
|
|
191
|
-
}
|
|
192
|
-
};
|
|
193
|
-
|
|
194
|
-
const handleRemove = (index: number) => {
|
|
195
|
-
if (multiple) {
|
|
196
|
-
const newImages = images.filter((_: any, i: number) => i !== index);
|
|
197
|
-
onChange(newImages.length > 0 ? newImages : null);
|
|
198
|
-
} else {
|
|
199
|
-
onChange(null);
|
|
200
|
-
}
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
return (
|
|
204
|
-
<div className={props.className}>
|
|
205
|
-
<input
|
|
206
|
-
ref={inputRef}
|
|
207
|
-
type="file"
|
|
208
|
-
multiple={multiple}
|
|
209
|
-
accept={accept}
|
|
210
|
-
onChange={handleFileChange}
|
|
211
|
-
className="hidden"
|
|
212
|
-
/>
|
|
213
|
-
|
|
214
|
-
<div className="space-y-2">
|
|
215
|
-
{views.length > 0 && (
|
|
216
|
-
<div className="grid grid-cols-4 gap-2">
|
|
217
|
-
{views.map((img, idx) => (
|
|
218
|
-
<div key={idx} className="relative group">
|
|
219
|
-
<img
|
|
220
|
-
src={img.url || ''}
|
|
221
|
-
alt={img.name || t('fields.image.imageAlt', { index: idx + 1 })}
|
|
222
|
-
className="size-20 rounded-md object-cover border border-gray-200"
|
|
223
|
-
/>
|
|
224
|
-
<div className="absolute top-1 right-1 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
225
|
-
{cropEnabled && (
|
|
226
|
-
<Button
|
|
227
|
-
type="button"
|
|
228
|
-
variant="secondary"
|
|
229
|
-
size="sm"
|
|
230
|
-
onClick={() => openCropper(idx)}
|
|
231
|
-
className="h-6 w-6 p-0"
|
|
232
|
-
aria-label={t('fields.image.crop', { index: idx + 1 })}
|
|
233
|
-
data-testid={`image-field-crop-${idx}`}
|
|
234
|
-
>
|
|
235
|
-
<CropIcon className="size-3" />
|
|
236
|
-
</Button>
|
|
237
|
-
)}
|
|
238
|
-
<Button
|
|
239
|
-
type="button"
|
|
240
|
-
variant="destructive"
|
|
241
|
-
size="sm"
|
|
242
|
-
onClick={() => handleRemove(idx)}
|
|
243
|
-
className="h-6 w-6 p-0"
|
|
244
|
-
aria-label={t('fields.image.remove', { index: idx + 1 })}
|
|
245
|
-
>
|
|
246
|
-
<X className="size-3" />
|
|
247
|
-
</Button>
|
|
248
|
-
</div>
|
|
249
|
-
</div>
|
|
250
|
-
))}
|
|
251
|
-
</div>
|
|
252
|
-
)}
|
|
253
|
-
|
|
254
|
-
<Button
|
|
255
|
-
// DOM pass-through onto the widget's real focusable control — the
|
|
256
|
-
// upload button is the keyboard path to the hidden file input
|
|
257
|
-
// (objectui#3318).
|
|
258
|
-
{...toDomProps(props)}
|
|
259
|
-
type="button"
|
|
260
|
-
variant="outline"
|
|
261
|
-
onClick={() => inputRef.current?.click()}
|
|
262
|
-
className="w-full"
|
|
263
|
-
disabled={uploading}
|
|
264
|
-
data-testid="image-field-upload-button"
|
|
265
|
-
// AFTER the spread so this widget's own computation wins (#3222).
|
|
266
|
-
aria-invalid={!!error}
|
|
267
|
-
>
|
|
268
|
-
{uploading ? (
|
|
269
|
-
<Loader2 className="size-4 mr-2 animate-spin" />
|
|
270
|
-
) : (
|
|
271
|
-
<ImageIcon className="size-4 mr-2" />
|
|
272
|
-
)}
|
|
273
|
-
{uploading
|
|
274
|
-
? t('fields.image.uploading')
|
|
275
|
-
: images.length > 0
|
|
276
|
-
? t('fields.image.addMore')
|
|
277
|
-
: t('fields.image.upload')}
|
|
278
|
-
</Button>
|
|
279
|
-
|
|
280
|
-
{/* Client-side rejections (oversize picks). Same presentation as
|
|
281
|
-
FileField's error row — this widget previously had no surface for
|
|
282
|
-
them at all, because it never rejected anything (objectui#4141). */}
|
|
283
|
-
{errors.length > 0 && (
|
|
284
|
-
<div className="space-y-0.5">
|
|
285
|
-
{errors.map((err, i) => (
|
|
286
|
-
<p key={i} className="text-xs text-destructive">{err}</p>
|
|
287
|
-
))}
|
|
288
|
-
</div>
|
|
289
|
-
)}
|
|
290
|
-
</div>
|
|
291
|
-
|
|
292
|
-
{cropEnabled && cropTarget && (
|
|
293
|
-
<Suspense fallback={null}>
|
|
294
|
-
<ImageCropperDialog
|
|
295
|
-
open
|
|
296
|
-
onOpenChange={(o) => !o && setCropTarget(null)}
|
|
297
|
-
src={cropTarget.src}
|
|
298
|
-
outputName={cropTarget.name}
|
|
299
|
-
onConfirm={handleCropConfirm}
|
|
300
|
-
/>
|
|
301
|
-
</Suspense>
|
|
302
|
-
)}
|
|
303
|
-
</div>
|
|
304
|
-
);
|
|
305
|
-
}
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import { render, screen, fireEvent } from '@testing-library/react';
|
|
2
|
-
import { describe, it, expect } from 'vitest';
|
|
3
|
-
import { I18nProvider } from '@object-ui/i18n';
|
|
4
|
-
import { ImageLightbox, type ImageLightboxProps } from './ImageLightbox';
|
|
5
|
-
|
|
6
|
-
const imgs = [
|
|
7
|
-
{ url: '/api/v1/storage/files/a', name: 'a.png' },
|
|
8
|
-
{ url: '/api/v1/storage/files/b', name: 'b.png' },
|
|
9
|
-
{ url: '/api/v1/storage/files/c', name: 'c.png' },
|
|
10
|
-
];
|
|
11
|
-
|
|
12
|
-
const renderLightbox = (props: ImageLightboxProps) =>
|
|
13
|
-
render(
|
|
14
|
-
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>
|
|
15
|
-
<ImageLightbox {...props} />
|
|
16
|
-
</I18nProvider>,
|
|
17
|
-
);
|
|
18
|
-
|
|
19
|
-
describe('ImageLightbox', () => {
|
|
20
|
-
it('renders the image at the requested index', () => {
|
|
21
|
-
renderLightbox({ images: imgs, index: 1, open: true, onOpenChange: () => {} });
|
|
22
|
-
const img = screen.getByRole('img') as HTMLImageElement;
|
|
23
|
-
expect(img.src).toContain('/api/v1/storage/files/b');
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it('shows navigation and a position counter for multiple images', () => {
|
|
27
|
-
renderLightbox({ images: imgs, index: 0, open: true, onOpenChange: () => {} });
|
|
28
|
-
expect(screen.getByText('1 / 3')).toBeInTheDocument();
|
|
29
|
-
expect(screen.getByLabelText('Next image')).toBeInTheDocument();
|
|
30
|
-
expect(screen.getByLabelText('Previous image')).toBeInTheDocument();
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it('omits navigation for a single image', () => {
|
|
34
|
-
renderLightbox({ images: [imgs[0]], index: 0, open: true, onOpenChange: () => {} });
|
|
35
|
-
expect(screen.queryByLabelText('Next image')).not.toBeInTheDocument();
|
|
36
|
-
expect(screen.queryByText('1 / 1')).not.toBeInTheDocument();
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it('advances to the next image and wraps around', () => {
|
|
40
|
-
const seen: number[] = [];
|
|
41
|
-
renderLightbox({ images: imgs, index: 2, open: true, onOpenChange: () => {}, onIndexChange: (i) => seen.push(i) });
|
|
42
|
-
fireEvent.click(screen.getByLabelText('Next image'));
|
|
43
|
-
expect(seen).toEqual([0]); // wrapped 2 -> 0
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it('steps to the previous image and wraps around', () => {
|
|
47
|
-
const seen: number[] = [];
|
|
48
|
-
renderLightbox({ images: imgs, index: 0, open: true, onOpenChange: () => {}, onIndexChange: (i) => seen.push(i) });
|
|
49
|
-
fireEvent.click(screen.getByLabelText('Previous image'));
|
|
50
|
-
expect(seen).toEqual([2]); // wrapped 0 -> 2
|
|
51
|
-
});
|
|
52
|
-
});
|
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectUI
|
|
3
|
-
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
-
*
|
|
5
|
-
* This source code is licensed under the MIT license found in the
|
|
6
|
-
* LICENSE file in the root directory of this source tree.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import * as React from 'react';
|
|
10
|
-
import { Dialog, DialogContent, DialogTitle } from '@object-ui/components';
|
|
11
|
-
import { useObjectTranslation } from '@object-ui/i18n';
|
|
12
|
-
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
|
13
|
-
|
|
14
|
-
export interface LightboxImage {
|
|
15
|
-
url: string;
|
|
16
|
-
name?: string;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface ImageLightboxProps {
|
|
20
|
-
images: LightboxImage[];
|
|
21
|
-
/** Index of the image shown when the lightbox opens. */
|
|
22
|
-
index: number;
|
|
23
|
-
open: boolean;
|
|
24
|
-
onOpenChange: (open: boolean) => void;
|
|
25
|
-
/** Called when the user navigates to another image (prev/next, arrow keys). */
|
|
26
|
-
onIndexChange?: (index: number) => void;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Full-screen image preview with prev/next navigation for a set of images.
|
|
31
|
-
*
|
|
32
|
-
* Controlled: the caller owns `open` and the current `index`. Built on the
|
|
33
|
-
* shared `Dialog`, so backdrop-click and Escape close it for free; this adds
|
|
34
|
-
* left/right arrow-key navigation and a position counter for the multi-image
|
|
35
|
-
* case. A single image simply omits the arrows and counter.
|
|
36
|
-
*/
|
|
37
|
-
export function ImageLightbox({ images, index, open, onOpenChange, onIndexChange }: ImageLightboxProps) {
|
|
38
|
-
const { t } = useObjectTranslation();
|
|
39
|
-
const count = images.length;
|
|
40
|
-
const current = images[index];
|
|
41
|
-
|
|
42
|
-
const go = React.useCallback(
|
|
43
|
-
(delta: number) => {
|
|
44
|
-
if (count <= 1) return;
|
|
45
|
-
const next = (index + delta + count) % count;
|
|
46
|
-
onIndexChange?.(next);
|
|
47
|
-
},
|
|
48
|
-
[count, index, onIndexChange],
|
|
49
|
-
);
|
|
50
|
-
|
|
51
|
-
const handleKeyDown = React.useCallback(
|
|
52
|
-
(e: React.KeyboardEvent) => {
|
|
53
|
-
if (e.key === 'ArrowRight') {
|
|
54
|
-
e.preventDefault();
|
|
55
|
-
go(1);
|
|
56
|
-
} else if (e.key === 'ArrowLeft') {
|
|
57
|
-
e.preventDefault();
|
|
58
|
-
go(-1);
|
|
59
|
-
}
|
|
60
|
-
},
|
|
61
|
-
[go],
|
|
62
|
-
);
|
|
63
|
-
|
|
64
|
-
if (!current) return null;
|
|
65
|
-
|
|
66
|
-
return (
|
|
67
|
-
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
68
|
-
<DialogContent
|
|
69
|
-
className="max-w-[92vw] sm:max-w-4xl p-0 bg-transparent border-0 shadow-none"
|
|
70
|
-
onKeyDown={handleKeyDown}
|
|
71
|
-
>
|
|
72
|
-
{/* Title is required for a labelled dialog but visually redundant here. */}
|
|
73
|
-
<DialogTitle className="sr-only">{current.name || t('fields.image.preview')}</DialogTitle>
|
|
74
|
-
<div className="relative flex items-center justify-center">
|
|
75
|
-
<img
|
|
76
|
-
src={current.url}
|
|
77
|
-
alt={current.name || t('fields.image.imageAlt', { index: index + 1 })}
|
|
78
|
-
className="max-h-[85vh] max-w-full rounded-md object-contain"
|
|
79
|
-
/>
|
|
80
|
-
|
|
81
|
-
{count > 1 && (
|
|
82
|
-
<>
|
|
83
|
-
<button
|
|
84
|
-
type="button"
|
|
85
|
-
onClick={() => go(-1)}
|
|
86
|
-
aria-label={t('fields.image.previous')}
|
|
87
|
-
className="absolute left-2 top-1/2 -translate-y-1/2 flex size-9 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-colors"
|
|
88
|
-
>
|
|
89
|
-
<ChevronLeft className="size-5" />
|
|
90
|
-
</button>
|
|
91
|
-
<button
|
|
92
|
-
type="button"
|
|
93
|
-
onClick={() => go(1)}
|
|
94
|
-
aria-label={t('fields.image.next')}
|
|
95
|
-
className="absolute right-2 top-1/2 -translate-y-1/2 flex size-9 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-colors"
|
|
96
|
-
>
|
|
97
|
-
<ChevronRight className="size-5" />
|
|
98
|
-
</button>
|
|
99
|
-
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-black/50 px-2.5 py-1 text-xs font-medium text-white">
|
|
100
|
-
{t('fields.image.counter', { current: index + 1, total: count })}
|
|
101
|
-
</div>
|
|
102
|
-
</>
|
|
103
|
-
)}
|
|
104
|
-
</div>
|
|
105
|
-
</DialogContent>
|
|
106
|
-
</Dialog>
|
|
107
|
-
);
|
|
108
|
-
}
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import { Input, EmptyValue } from '@object-ui/components';
|
|
3
|
-
import { FieldWidgetComponentProps } from './types';
|
|
4
|
-
import { toDomProps } from './toDomProps';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* LocationField - Geographic coordinate input for latitude and longitude
|
|
8
|
-
* Stores location as { latitude, longitude } object and displays as comma-separated pair
|
|
9
|
-
*/
|
|
10
|
-
export function LocationField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<any>) {
|
|
11
|
-
const config = field;
|
|
12
|
-
// Location is stored as { latitude, longitude } object
|
|
13
|
-
// For display, convert to "latitude, longitude" string format
|
|
14
|
-
const displayValue = value && typeof value === 'object'
|
|
15
|
-
? `${value.latitude || 0}, ${value.longitude || 0}`
|
|
16
|
-
: '';
|
|
17
|
-
|
|
18
|
-
if (readonly) {
|
|
19
|
-
return <span className="text-sm">{displayValue || <EmptyValue />}</span>;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
23
|
-
const val = e.target.value;
|
|
24
|
-
if (!val.trim()) {
|
|
25
|
-
onChange(null);
|
|
26
|
-
return;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// Parse as coordinates (latitude, longitude)
|
|
30
|
-
const parts = val.split(',').map(p => p.trim());
|
|
31
|
-
if (parts.length === 2) {
|
|
32
|
-
const lat = parseFloat(parts[0]);
|
|
33
|
-
const lng = parseFloat(parts[1]);
|
|
34
|
-
if (!isNaN(lat) && !isNaN(lng)) {
|
|
35
|
-
onChange({ latitude: lat, longitude: lng });
|
|
36
|
-
}
|
|
37
|
-
// If invalid, don't update the value
|
|
38
|
-
}
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
return (
|
|
42
|
-
<Input
|
|
43
|
-
// DOM pass-through onto the real focusable control (objectui#3318).
|
|
44
|
-
{...toDomProps(props)}
|
|
45
|
-
type="text"
|
|
46
|
-
value={displayValue}
|
|
47
|
-
onChange={handleChange}
|
|
48
|
-
placeholder={config?.placeholder || 'latitude, longitude'}
|
|
49
|
-
disabled={readonly || props.disabled}
|
|
50
|
-
className={props.className}
|
|
51
|
-
// AFTER the spread so this widget's own computation wins: `error` is
|
|
52
|
-
// the published validation slot (#3222), `!!undefined` → explicit
|
|
53
|
-
// "false".
|
|
54
|
-
aria-invalid={!!error}
|
|
55
|
-
/>
|
|
56
|
-
);
|
|
57
|
-
}
|