@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.
Files changed (229) hide show
  1. package/CHANGELOG.md +714 -0
  2. package/dist/FieldEditWidget.d.ts +16 -2
  3. package/dist/field-type-alias.d.ts +16 -0
  4. package/dist/index.cjs +4 -4
  5. package/dist/index.css +27 -9
  6. package/dist/index.d.ts +425 -72
  7. package/dist/index.js +2530 -2228
  8. package/dist/widgets/AddressField.d.ts +3 -3
  9. package/dist/widgets/AutoNumberField.d.ts +1 -1
  10. package/dist/widgets/AvatarField.d.ts +1 -1
  11. package/dist/widgets/BooleanField.d.ts +1 -1
  12. package/dist/widgets/CapabilityMultiSelectField.d.ts +1 -1
  13. package/dist/widgets/CheckboxesField.d.ts +1 -1
  14. package/dist/widgets/CodeField.d.ts +1 -1
  15. package/dist/widgets/ColorField.d.ts +1 -1
  16. package/dist/widgets/CurrencyField.d.ts +1 -1
  17. package/dist/widgets/DateField.d.ts +1 -1
  18. package/dist/widgets/DateTimeField.d.ts +1 -1
  19. package/dist/widgets/EmailField.d.ts +1 -1
  20. package/dist/widgets/FileField.d.ts +18 -2
  21. package/dist/widgets/FilterConditionField.d.ts +28 -1
  22. package/dist/widgets/FormulaField.d.ts +1 -1
  23. package/dist/widgets/FullscreenFieldEditor.d.ts +9 -2
  24. package/dist/widgets/GeolocationField.d.ts +1 -1
  25. package/dist/widgets/GridField.d.ts +56 -4
  26. package/dist/widgets/ImageField.d.ts +1 -1
  27. package/dist/widgets/LocationField.d.ts +1 -1
  28. package/dist/widgets/LookupField.d.ts +1 -1
  29. package/dist/widgets/MasterDetailField.d.ts +1 -1
  30. package/dist/widgets/MultiSelectField.d.ts +1 -1
  31. package/dist/widgets/NumberField.d.ts +1 -1
  32. package/dist/widgets/ObjectField.d.ts +1 -1
  33. package/dist/widgets/ObjectRefField.d.ts +1 -1
  34. package/dist/widgets/OptionsEmptyState.d.ts +13 -7
  35. package/dist/widgets/PasswordField.d.ts +1 -1
  36. package/dist/widgets/PercentField.d.ts +1 -1
  37. package/dist/widgets/PhoneField.d.ts +1 -1
  38. package/dist/widgets/QRCodeField.d.ts +1 -1
  39. package/dist/widgets/RadioField.d.ts +1 -1
  40. package/dist/widgets/RatingField.d.ts +1 -1
  41. package/dist/widgets/RecipientPickerField.d.ts +1 -1
  42. package/dist/widgets/RecordPickerDialog.d.ts +2 -4
  43. package/dist/widgets/RichTextField.d.ts +37 -1
  44. package/dist/widgets/SelectField.d.ts +1 -1
  45. package/dist/widgets/SignatureField.d.ts +32 -2
  46. package/dist/widgets/SliderField.d.ts +37 -2
  47. package/dist/widgets/SummaryField.d.ts +1 -1
  48. package/dist/widgets/TagsField.d.ts +1 -1
  49. package/dist/widgets/TextAreaField.d.ts +6 -2
  50. package/dist/widgets/TextField.d.ts +1 -1
  51. package/dist/widgets/TimeField.d.ts +1 -1
  52. package/dist/widgets/UrlField.d.ts +1 -1
  53. package/dist/widgets/UserField.d.ts +1 -1
  54. package/dist/widgets/VectorField.d.ts +1 -1
  55. package/dist/widgets/_cell-renderer-bridge.d.ts +1 -1
  56. package/dist/widgets/address-format.d.ts +27 -7
  57. package/dist/widgets/lookupColumnDisplay.d.ts +93 -0
  58. package/dist/widgets/richTextDisplay.d.ts +80 -0
  59. package/dist/widgets/toHostGroupProps.d.ts +119 -21
  60. package/dist/widgets/types.d.ts +8 -3
  61. package/package.json +9 -10
  62. package/dist/widgets/CharacterCount.d.ts +0 -56
  63. package/src/FieldEditWidget.test.ts +0 -133
  64. package/src/FieldEditWidget.tsx +0 -207
  65. package/src/__tests__/AddressCellRenderer.test.tsx +0 -166
  66. package/src/__tests__/CurrencyField.minorUnits.test.tsx +0 -191
  67. package/src/__tests__/CurrencyField.symbol.test.tsx +0 -198
  68. package/src/__tests__/DateCellRenderer.test.tsx +0 -82
  69. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +0 -146
  70. package/src/__tests__/NumberCellRenderer.test.tsx +0 -60
  71. package/src/__tests__/PercentCellRenderer.locale.test.tsx +0 -152
  72. package/src/__tests__/PercentCellRenderer.test.tsx +0 -115
  73. package/src/__tests__/boolean-label-association-e2e.test.tsx +0 -298
  74. package/src/__tests__/capability-multiselect-retired.test.ts +0 -109
  75. package/src/__tests__/cell-truncation.test.tsx +0 -96
  76. package/src/__tests__/composite-group-label-e2e.test.tsx +0 -436
  77. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +0 -403
  78. package/src/__tests__/date-formatter-residue-4272.test.ts +0 -120
  79. package/src/__tests__/date-locale-channel.test.tsx +0 -305
  80. package/src/__tests__/field-carrier-sdui.test.tsx +0 -169
  81. package/src/__tests__/formatCurrency.centsDigit.test.tsx +0 -158
  82. package/src/__tests__/formatCurrency.minorUnits.test.tsx +0 -221
  83. package/src/__tests__/group-labelling-declaration.test.ts +0 -148
  84. package/src/__tests__/lookupCellDisplayField.test.tsx +0 -107
  85. package/src/__tests__/lookupCellDisplayName.test.tsx +0 -119
  86. package/src/__tests__/lookupCellRecordLink.test.tsx +0 -171
  87. package/src/__tests__/lookupCellReferenceKey.test.tsx +0 -57
  88. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +0 -115
  89. package/src/__tests__/percent-formatter-locale-4553.test.ts +0 -147
  90. package/src/__tests__/percent-tie-halfup-4590.test.ts +0 -224
  91. package/src/__tests__/registry-collision.test.ts +0 -91
  92. package/src/__tests__/resolveFieldCurrency.test.ts +0 -20
  93. package/src/__tests__/select-label-association-e2e.test.tsx +0 -199
  94. package/src/__tests__/spec-symbol-batch7.test.ts +0 -126
  95. package/src/__tests__/validation-feedback.test.tsx +0 -361
  96. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +0 -265
  97. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +0 -338
  98. package/src/__tests__/widget-dom-leak-e2e.test.tsx +0 -357
  99. package/src/__tests__/widget-props-contract.test.tsx +0 -142
  100. package/src/coerce-safe-value.test.ts +0 -34
  101. package/src/complex-widgets.test.tsx +0 -924
  102. package/src/currency.ts +0 -144
  103. package/src/datetime-widgets.test.tsx +0 -185
  104. package/src/field-type-alias.multiple.test.ts +0 -81
  105. package/src/field-type-alias.ts +0 -156
  106. package/src/field-type-coverage.test.ts +0 -67
  107. package/src/index.css +0 -44
  108. package/src/index.tsx +0 -2745
  109. package/src/standard-widgets.test.tsx +0 -364
  110. package/src/widgets/AddressField.postalCode.test.tsx +0 -257
  111. package/src/widgets/AddressField.tsx +0 -182
  112. package/src/widgets/AddressField.uniqueIds.test.tsx +0 -93
  113. package/src/widgets/AutoNumberField.tsx +0 -18
  114. package/src/widgets/AvatarField.tsx +0 -117
  115. package/src/widgets/BooleanField.tsx +0 -86
  116. package/src/widgets/CapabilityMultiSelectField.test.tsx +0 -168
  117. package/src/widgets/CapabilityMultiSelectField.tsx +0 -241
  118. package/src/widgets/CharacterCount.tsx +0 -263
  119. package/src/widgets/CheckboxesField.cascade.test.tsx +0 -143
  120. package/src/widgets/CheckboxesField.tsx +0 -169
  121. package/src/widgets/CodeField.tsx +0 -41
  122. package/src/widgets/ColorField.tsx +0 -53
  123. package/src/widgets/CurrencyField.tsx +0 -135
  124. package/src/widgets/DateField.tsx +0 -40
  125. package/src/widgets/DateTimeField.tsx +0 -46
  126. package/src/widgets/EmailField.tsx +0 -37
  127. package/src/widgets/FileField.tsx +0 -512
  128. package/src/widgets/FilterConditionField.tsx +0 -454
  129. package/src/widgets/FormulaField.tsx +0 -38
  130. package/src/widgets/FullscreenFieldEditor.tsx +0 -60
  131. package/src/widgets/GeolocationField.tsx +0 -193
  132. package/src/widgets/GeolocationField.uniqueIds.test.tsx +0 -88
  133. package/src/widgets/GridField.test.tsx +0 -632
  134. package/src/widgets/GridField.tsx +0 -1070
  135. package/src/widgets/ImageCropperDialog.tsx +0 -245
  136. package/src/widgets/ImageField.maxSize.test.tsx +0 -154
  137. package/src/widgets/ImageField.tsx +0 -305
  138. package/src/widgets/ImageLightbox.test.tsx +0 -52
  139. package/src/widgets/ImageLightbox.tsx +0 -108
  140. package/src/widgets/LocationField.tsx +0 -57
  141. package/src/widgets/LookupField.dependsOn.test.tsx +0 -233
  142. package/src/widgets/LookupField.gateHintLabel.test.tsx +0 -135
  143. package/src/widgets/LookupField.hydration.test.tsx +0 -136
  144. package/src/widgets/LookupField.idField.test.tsx +0 -61
  145. package/src/widgets/LookupField.tsx +0 -1295
  146. package/src/widgets/MarkdownContent.tsx +0 -19
  147. package/src/widgets/MasterDetailField.tsx +0 -131
  148. package/src/widgets/MultiSelectField.cascade.test.tsx +0 -143
  149. package/src/widgets/MultiSelectField.tsx +0 -196
  150. package/src/widgets/NumberField.tsx +0 -49
  151. package/src/widgets/ObjectField.tsx +0 -94
  152. package/src/widgets/ObjectRefField.tsx +0 -121
  153. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +0 -88
  154. package/src/widgets/OptionsEmptyState.test.tsx +0 -169
  155. package/src/widgets/OptionsEmptyState.tsx +0 -99
  156. package/src/widgets/PasswordField.tsx +0 -49
  157. package/src/widgets/PeoplePicker.test.tsx +0 -360
  158. package/src/widgets/PeoplePicker.tsx +0 -579
  159. package/src/widgets/PeoplePickerComponents.test.tsx +0 -104
  160. package/src/widgets/PercentField.tsx +0 -95
  161. package/src/widgets/PersonRow.tsx +0 -117
  162. package/src/widgets/PhoneField.tsx +0 -37
  163. package/src/widgets/QRCodeField.tsx +0 -103
  164. package/src/widgets/RadioField.cascade.test.tsx +0 -141
  165. package/src/widgets/RadioField.tsx +0 -143
  166. package/src/widgets/RatingField.tsx +0 -94
  167. package/src/widgets/RecipientPickerField.test.tsx +0 -157
  168. package/src/widgets/RecipientPickerField.tsx +0 -188
  169. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +0 -191
  170. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +0 -124
  171. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +0 -611
  172. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +0 -112
  173. package/src/widgets/RecordPickerDialog.tsx +0 -1365
  174. package/src/widgets/RichTextField.tsx +0 -176
  175. package/src/widgets/SelectField.cascade.test.tsx +0 -138
  176. package/src/widgets/SelectField.tsx +0 -193
  177. package/src/widgets/SelectionTray.tsx +0 -110
  178. package/src/widgets/SignatureField.tsx +0 -140
  179. package/src/widgets/SliderField.tsx +0 -39
  180. package/src/widgets/SummaryField.tsx +0 -31
  181. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +0 -68
  182. package/src/widgets/TagsField.placeholder.test.tsx +0 -95
  183. package/src/widgets/TagsField.tsx +0 -88
  184. package/src/widgets/TextAreaField.tsx +0 -213
  185. package/src/widgets/TextField.tsx +0 -45
  186. package/src/widgets/TimeField.tsx +0 -31
  187. package/src/widgets/UrlField.tsx +0 -46
  188. package/src/widgets/UserField.tsx +0 -59
  189. package/src/widgets/UserFieldPicker.test.tsx +0 -78
  190. package/src/widgets/VectorField.tsx +0 -29
  191. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +0 -66
  192. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +0 -166
  193. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +0 -248
  194. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +0 -116
  195. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +0 -244
  196. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +0 -195
  197. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +0 -417
  198. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +0 -287
  199. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +0 -160
  200. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +0 -161
  201. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +0 -346
  202. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +0 -246
  203. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +0 -175
  204. package/src/widgets/_cell-renderer-bridge.ts +0 -19
  205. package/src/widgets/address-format.ts +0 -120
  206. package/src/widgets/deriveLookupColumns.test.ts +0 -112
  207. package/src/widgets/deriveLookupColumns.ts +0 -108
  208. package/src/widgets/file-size-guard.ts +0 -55
  209. package/src/widgets/file-value.test.ts +0 -230
  210. package/src/widgets/file-value.ts +0 -246
  211. package/src/widgets/nativeDateValue.test.ts +0 -115
  212. package/src/widgets/nativeDateValue.ts +0 -112
  213. package/src/widgets/openNativePicker.ts +0 -18
  214. package/src/widgets/optionWidgets.gatedOptions.test.tsx +0 -267
  215. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +0 -150
  216. package/src/widgets/personDisplay.test.ts +0 -113
  217. package/src/widgets/personDisplay.ts +0 -92
  218. package/src/widgets/recentLookups.test.ts +0 -45
  219. package/src/widgets/recentLookups.ts +0 -47
  220. package/src/widgets/toDomProps.ts +0 -204
  221. package/src/widgets/toHostGroupProps.ts +0 -102
  222. package/src/widgets/types.ts +0 -242
  223. package/src/widgets/useCascadingOptions.ts +0 -46
  224. package/src/widgets/useFieldTranslation.ts +0 -132
  225. package/src/widgets/useRecordQuery.test.ts +0 -174
  226. package/src/widgets/useRecordQuery.ts +0 -291
  227. package/src/widgets/useUploadingSignal.test.tsx +0 -62
  228. package/src/widgets/useUploadingSignal.ts +0 -20
  229. 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
- }