@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,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
- }