@object-ui/fields 17.3.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 (265) hide show
  1. package/CHANGELOG.md +1655 -0
  2. package/README.md +1 -12
  3. package/dist/FieldEditWidget.d.ts +23 -3
  4. package/dist/currency.d.ts +46 -0
  5. package/dist/field-type-alias.d.ts +35 -1
  6. package/dist/index.cjs +4 -4
  7. package/dist/index.css +651 -0
  8. package/dist/index.d.ts +474 -85
  9. package/dist/index.js +5418 -5023
  10. package/dist/widgets/AddressField.d.ts +4 -12
  11. package/dist/widgets/AutoNumberField.d.ts +1 -1
  12. package/dist/widgets/AvatarField.d.ts +1 -1
  13. package/dist/widgets/BooleanField.d.ts +1 -1
  14. package/dist/widgets/CapabilityMultiSelectField.d.ts +1 -1
  15. package/dist/widgets/CheckboxesField.d.ts +1 -1
  16. package/dist/widgets/CodeField.d.ts +1 -1
  17. package/dist/widgets/ColorField.d.ts +1 -1
  18. package/dist/widgets/CurrencyField.d.ts +1 -1
  19. package/dist/widgets/DateField.d.ts +1 -1
  20. package/dist/widgets/DateTimeField.d.ts +1 -1
  21. package/dist/widgets/EmailField.d.ts +1 -1
  22. package/dist/widgets/FileField.d.ts +18 -2
  23. package/dist/widgets/FilterConditionField.d.ts +28 -1
  24. package/dist/widgets/FormulaField.d.ts +1 -1
  25. package/dist/widgets/FullscreenFieldEditor.d.ts +49 -152
  26. package/dist/widgets/GeolocationField.d.ts +1 -1
  27. package/dist/widgets/GridField.d.ts +68 -6
  28. package/dist/widgets/ImageField.d.ts +1 -1
  29. package/dist/widgets/LocationField.d.ts +1 -1
  30. package/dist/widgets/LookupField.d.ts +1 -1
  31. package/dist/widgets/MasterDetailField.d.ts +1 -1
  32. package/dist/widgets/MultiSelectField.d.ts +1 -1
  33. package/dist/widgets/NumberField.d.ts +1 -1
  34. package/dist/widgets/ObjectField.d.ts +1 -1
  35. package/dist/widgets/ObjectRefField.d.ts +1 -1
  36. package/dist/widgets/OptionsEmptyState.d.ts +21 -1
  37. package/dist/widgets/PasswordField.d.ts +1 -1
  38. package/dist/widgets/PercentField.d.ts +1 -1
  39. package/dist/widgets/PhoneField.d.ts +1 -1
  40. package/dist/widgets/QRCodeField.d.ts +1 -1
  41. package/dist/widgets/RadioField.d.ts +1 -1
  42. package/dist/widgets/RatingField.d.ts +1 -1
  43. package/dist/widgets/RecipientPickerField.d.ts +1 -1
  44. package/dist/widgets/RecordPickerDialog.d.ts +34 -10
  45. package/dist/widgets/RichTextField.d.ts +37 -1
  46. package/dist/widgets/SelectField.d.ts +26 -6
  47. package/dist/widgets/SignatureField.d.ts +32 -2
  48. package/dist/widgets/SliderField.d.ts +37 -2
  49. package/dist/widgets/SummaryField.d.ts +1 -1
  50. package/dist/widgets/TagsField.d.ts +1 -1
  51. package/dist/widgets/TextAreaField.d.ts +6 -2
  52. package/dist/widgets/TextField.d.ts +1 -1
  53. package/dist/widgets/TimeField.d.ts +1 -1
  54. package/dist/widgets/UrlField.d.ts +1 -1
  55. package/dist/widgets/UserField.d.ts +1 -1
  56. package/dist/widgets/VectorField.d.ts +1 -1
  57. package/dist/widgets/_cell-renderer-bridge.d.ts +1 -1
  58. package/dist/widgets/address-format.d.ts +88 -0
  59. package/dist/widgets/file-size-guard.d.ts +41 -0
  60. package/dist/widgets/lookupColumnDisplay.d.ts +93 -0
  61. package/dist/widgets/nativeDateValue.d.ts +37 -0
  62. package/dist/widgets/richTextDisplay.d.ts +80 -0
  63. package/dist/widgets/toDomProps.d.ts +30 -10
  64. package/dist/widgets/toHostGroupProps.d.ts +189 -0
  65. package/dist/widgets/types.d.ts +8 -3
  66. package/dist/widgets/useRecordQuery.d.ts +7 -1
  67. package/package.json +17 -14
  68. package/dist/FieldEditWidget.test.d.ts +0 -8
  69. package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
  70. package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
  71. package/dist/__tests__/cell-truncation.test.d.ts +0 -0
  72. package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
  73. package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
  74. package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
  75. package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
  76. package/dist/__tests__/registry-collision.test.d.ts +0 -0
  77. package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
  78. package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
  79. package/dist/__tests__/validation-feedback.test.d.ts +0 -0
  80. package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
  81. package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
  82. package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
  83. package/dist/__tests__/widget-props-contract.test.d.ts +0 -8
  84. package/dist/coerce-safe-value.test.d.ts +0 -1
  85. package/dist/complex-widgets.test.d.ts +0 -1
  86. package/dist/datetime-widgets.test.d.ts +0 -1
  87. package/dist/field-type-coverage.test.d.ts +0 -1
  88. package/dist/standard-widgets.test.d.ts +0 -0
  89. package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
  90. package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
  91. package/dist/widgets/CharacterCount.d.ts +0 -56
  92. package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
  93. package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
  94. package/dist/widgets/GridField.test.d.ts +0 -1
  95. package/dist/widgets/ImageLightbox.test.d.ts +0 -1
  96. package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
  97. package/dist/widgets/LookupField.gateHintLabel.test.d.ts +0 -8
  98. package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
  99. package/dist/widgets/LookupField.idField.test.d.ts +0 -1
  100. package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
  101. package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
  102. package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
  103. package/dist/widgets/PeoplePicker.test.d.ts +0 -8
  104. package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
  105. package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
  106. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  107. package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +0 -8
  108. package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +0 -8
  109. package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
  110. package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
  111. package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
  112. package/dist/widgets/UserFieldPicker.test.d.ts +0 -8
  113. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
  114. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
  115. package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
  116. package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
  117. package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
  118. package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
  119. package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
  120. package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
  121. package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
  122. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
  123. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
  124. package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
  125. package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
  126. package/dist/widgets/deriveLookupColumns.test.d.ts +0 -1
  127. package/dist/widgets/file-value.test.d.ts +0 -1
  128. package/dist/widgets/personDisplay.test.d.ts +0 -8
  129. package/dist/widgets/recentLookups.test.d.ts +0 -1
  130. package/dist/widgets/useRecordQuery.test.d.ts +0 -8
  131. package/dist/widgets/useUploadingSignal.test.d.ts +0 -1
  132. package/src/FieldEditWidget.test.ts +0 -133
  133. package/src/FieldEditWidget.tsx +0 -191
  134. package/src/__tests__/DateCellRenderer.test.tsx +0 -73
  135. package/src/__tests__/NumberCellRenderer.test.tsx +0 -60
  136. package/src/__tests__/cell-truncation.test.tsx +0 -96
  137. package/src/__tests__/field-carrier-sdui.test.tsx +0 -169
  138. package/src/__tests__/lookupCellDisplayField.test.tsx +0 -107
  139. package/src/__tests__/lookupCellDisplayName.test.tsx +0 -119
  140. package/src/__tests__/lookupCellReferenceKey.test.tsx +0 -57
  141. package/src/__tests__/registry-collision.test.ts +0 -91
  142. package/src/__tests__/resolveFieldCurrency.test.ts +0 -20
  143. package/src/__tests__/spec-symbol-batch7.test.ts +0 -126
  144. package/src/__tests__/validation-feedback.test.tsx +0 -361
  145. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +0 -265
  146. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +0 -338
  147. package/src/__tests__/widget-dom-leak-e2e.test.tsx +0 -598
  148. package/src/__tests__/widget-props-contract.test.tsx +0 -142
  149. package/src/coerce-safe-value.test.ts +0 -34
  150. package/src/complex-widgets.test.tsx +0 -908
  151. package/src/currency.ts +0 -14
  152. package/src/datetime-widgets.test.tsx +0 -93
  153. package/src/field-type-alias.ts +0 -100
  154. package/src/field-type-coverage.test.ts +0 -67
  155. package/src/index.tsx +0 -2503
  156. package/src/standard-widgets.test.tsx +0 -357
  157. package/src/widgets/AddressField.tsx +0 -132
  158. package/src/widgets/AddressField.uniqueIds.test.tsx +0 -92
  159. package/src/widgets/AutoNumberField.tsx +0 -18
  160. package/src/widgets/AvatarField.tsx +0 -117
  161. package/src/widgets/BooleanField.tsx +0 -52
  162. package/src/widgets/CapabilityMultiSelectField.test.tsx +0 -168
  163. package/src/widgets/CapabilityMultiSelectField.tsx +0 -229
  164. package/src/widgets/CharacterCount.tsx +0 -263
  165. package/src/widgets/CheckboxesField.cascade.test.tsx +0 -143
  166. package/src/widgets/CheckboxesField.tsx +0 -136
  167. package/src/widgets/CodeField.tsx +0 -41
  168. package/src/widgets/ColorField.tsx +0 -53
  169. package/src/widgets/CurrencyField.tsx +0 -95
  170. package/src/widgets/DateField.tsx +0 -31
  171. package/src/widgets/DateTimeField.tsx +0 -37
  172. package/src/widgets/EmailField.tsx +0 -37
  173. package/src/widgets/FileField.tsx +0 -494
  174. package/src/widgets/FilterConditionField.tsx +0 -454
  175. package/src/widgets/FormulaField.tsx +0 -33
  176. package/src/widgets/FullscreenFieldEditor.tsx +0 -271
  177. package/src/widgets/GeolocationField.tsx +0 -167
  178. package/src/widgets/GeolocationField.uniqueIds.test.tsx +0 -88
  179. package/src/widgets/GridField.test.tsx +0 -406
  180. package/src/widgets/GridField.tsx +0 -966
  181. package/src/widgets/ImageCropperDialog.tsx +0 -245
  182. package/src/widgets/ImageField.tsx +0 -255
  183. package/src/widgets/ImageLightbox.test.tsx +0 -52
  184. package/src/widgets/ImageLightbox.tsx +0 -108
  185. package/src/widgets/LocationField.tsx +0 -57
  186. package/src/widgets/LookupField.dependsOn.test.tsx +0 -233
  187. package/src/widgets/LookupField.gateHintLabel.test.tsx +0 -135
  188. package/src/widgets/LookupField.hydration.test.tsx +0 -136
  189. package/src/widgets/LookupField.idField.test.tsx +0 -61
  190. package/src/widgets/LookupField.tsx +0 -1291
  191. package/src/widgets/MarkdownContent.tsx +0 -19
  192. package/src/widgets/MasterDetailField.tsx +0 -131
  193. package/src/widgets/MultiSelectField.cascade.test.tsx +0 -143
  194. package/src/widgets/MultiSelectField.tsx +0 -142
  195. package/src/widgets/NumberField.tsx +0 -49
  196. package/src/widgets/ObjectField.tsx +0 -94
  197. package/src/widgets/ObjectRefField.tsx +0 -121
  198. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +0 -88
  199. package/src/widgets/OptionsEmptyState.test.tsx +0 -169
  200. package/src/widgets/OptionsEmptyState.tsx +0 -83
  201. package/src/widgets/PasswordField.tsx +0 -49
  202. package/src/widgets/PeoplePicker.test.tsx +0 -360
  203. package/src/widgets/PeoplePicker.tsx +0 -579
  204. package/src/widgets/PeoplePickerComponents.test.tsx +0 -104
  205. package/src/widgets/PercentField.tsx +0 -95
  206. package/src/widgets/PersonRow.tsx +0 -117
  207. package/src/widgets/PhoneField.tsx +0 -37
  208. package/src/widgets/QRCodeField.tsx +0 -103
  209. package/src/widgets/RadioField.cascade.test.tsx +0 -141
  210. package/src/widgets/RadioField.tsx +0 -111
  211. package/src/widgets/RatingField.tsx +0 -81
  212. package/src/widgets/RecipientPickerField.test.tsx +0 -157
  213. package/src/widgets/RecipientPickerField.tsx +0 -188
  214. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +0 -611
  215. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +0 -112
  216. package/src/widgets/RecordPickerDialog.tsx +0 -1316
  217. package/src/widgets/RichTextField.tsx +0 -176
  218. package/src/widgets/SelectField.cascade.test.tsx +0 -138
  219. package/src/widgets/SelectField.tsx +0 -162
  220. package/src/widgets/SelectionTray.tsx +0 -110
  221. package/src/widgets/SignatureField.tsx +0 -140
  222. package/src/widgets/SliderField.tsx +0 -39
  223. package/src/widgets/SummaryField.tsx +0 -31
  224. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +0 -68
  225. package/src/widgets/TagsField.placeholder.test.tsx +0 -95
  226. package/src/widgets/TagsField.tsx +0 -88
  227. package/src/widgets/TextAreaField.tsx +0 -213
  228. package/src/widgets/TextField.tsx +0 -45
  229. package/src/widgets/TimeField.tsx +0 -31
  230. package/src/widgets/UrlField.tsx +0 -46
  231. package/src/widgets/UserField.tsx +0 -58
  232. package/src/widgets/UserFieldPicker.test.tsx +0 -78
  233. package/src/widgets/VectorField.tsx +0 -29
  234. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +0 -66
  235. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +0 -142
  236. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +0 -248
  237. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +0 -116
  238. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +0 -244
  239. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +0 -195
  240. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +0 -417
  241. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +0 -287
  242. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +0 -160
  243. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +0 -161
  244. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +0 -346
  245. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +0 -246
  246. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +0 -175
  247. package/src/widgets/_cell-renderer-bridge.ts +0 -19
  248. package/src/widgets/deriveLookupColumns.test.ts +0 -112
  249. package/src/widgets/deriveLookupColumns.ts +0 -108
  250. package/src/widgets/file-value.test.ts +0 -230
  251. package/src/widgets/file-value.ts +0 -246
  252. package/src/widgets/openNativePicker.ts +0 -18
  253. package/src/widgets/personDisplay.test.ts +0 -113
  254. package/src/widgets/personDisplay.ts +0 -92
  255. package/src/widgets/recentLookups.test.ts +0 -45
  256. package/src/widgets/recentLookups.ts +0 -47
  257. package/src/widgets/toDomProps.ts +0 -175
  258. package/src/widgets/types.ts +0 -242
  259. package/src/widgets/useCascadingOptions.ts +0 -46
  260. package/src/widgets/useFieldTranslation.ts +0 -150
  261. package/src/widgets/useRecordQuery.test.ts +0 -174
  262. package/src/widgets/useRecordQuery.ts +0 -264
  263. package/src/widgets/useUploadingSignal.test.tsx +0 -62
  264. package/src/widgets/useUploadingSignal.ts +0 -20
  265. package/src/withFieldCarrier.tsx +0 -75
@@ -1,245 +0,0 @@
1
- /**
2
- * ImageCropperDialog - Lightweight crop/rotate dialog for ImageField.
3
- *
4
- * Implementation notes:
5
- * - Uses a Canvas + native pointer events (no extra dependency) so the bundle
6
- * stays small and the widget works in all SDUI contexts.
7
- * - Exposes a rectangular crop selection that can be dragged around the image
8
- * and a rotate-by-90° button.
9
- * - On confirm, returns a `Blob` of the cropped image (PNG by default) so callers
10
- * can wire it into upload providers or `URL.createObjectURL()`.
11
- */
12
- import React, { useCallback, useEffect, useRef, useState } from 'react';
13
- import {
14
- Button,
15
- Dialog,
16
- DialogContent,
17
- DialogDescription,
18
- DialogFooter,
19
- DialogHeader,
20
- DialogTitle,
21
- } from '@object-ui/components';
22
- import { RotateCw, Crop as CropIcon } from 'lucide-react';
23
-
24
- export interface ImageCropperDialogProps {
25
- open: boolean;
26
- onOpenChange: (open: boolean) => void;
27
- /** Source image URL or data URL */
28
- src: string;
29
- /** MIME type for the cropped output. Defaults to image/png. */
30
- outputType?: string;
31
- /** Suggested filename (passed back via onConfirm). Defaults to "cropped.png". */
32
- outputName?: string;
33
- /** Called when the user confirms the crop. */
34
- onConfirm: (blob: Blob, name: string) => void;
35
- }
36
-
37
- interface CropRect {
38
- x: number;
39
- y: number;
40
- width: number;
41
- height: number;
42
- }
43
-
44
- export const ImageCropperDialog: React.FC<ImageCropperDialogProps> = ({
45
- open,
46
- onOpenChange,
47
- src,
48
- outputType = 'image/png',
49
- outputName = 'cropped.png',
50
- onConfirm,
51
- }) => {
52
- const canvasRef = useRef<HTMLCanvasElement>(null);
53
- const imgRef = useRef<HTMLImageElement | null>(null);
54
- const [rotation, setRotation] = useState(0); // 0, 90, 180, 270
55
- const [crop, setCrop] = useState<CropRect>({ x: 0, y: 0, width: 100, height: 100 });
56
- const [drag, setDrag] = useState<{ ox: number; oy: number; cx: number; cy: number } | null>(null);
57
- const [imageReady, setImageReady] = useState(false);
58
-
59
- // Load the image when the dialog opens
60
- useEffect(() => {
61
- if (!open || !src) return;
62
- setImageReady(false);
63
- const img = new Image();
64
- img.crossOrigin = 'anonymous';
65
- img.onload = () => {
66
- imgRef.current = img;
67
- // Default crop = centred 80% box
68
- const w = img.naturalWidth * 0.8;
69
- const h = img.naturalHeight * 0.8;
70
- setCrop({
71
- x: (img.naturalWidth - w) / 2,
72
- y: (img.naturalHeight - h) / 2,
73
- width: w,
74
- height: h,
75
- });
76
- setRotation(0);
77
- setImageReady(true);
78
- };
79
- img.onerror = () => setImageReady(false);
80
- img.src = src;
81
- }, [open, src]);
82
-
83
- // Render image + crop overlay
84
- useEffect(() => {
85
- if (!imageReady || !imgRef.current || !canvasRef.current) return;
86
- const img = imgRef.current;
87
- const canvas = canvasRef.current;
88
- const ctx = canvas.getContext('2d');
89
- if (!ctx) return;
90
-
91
- // Scale image to fit a 480px-wide preview while preserving aspect
92
- const previewW = 480;
93
- const scale = previewW / img.naturalWidth;
94
- const previewH = img.naturalHeight * scale;
95
- canvas.width = previewW;
96
- canvas.height = previewH;
97
- ctx.clearRect(0, 0, previewW, previewH);
98
-
99
- // Apply rotation to the image (purely visual — actual rotation is baked in on confirm)
100
- ctx.save();
101
- ctx.translate(previewW / 2, previewH / 2);
102
- ctx.rotate((rotation * Math.PI) / 180);
103
- const drawW = rotation % 180 === 0 ? previewW : previewH;
104
- const drawH = rotation % 180 === 0 ? previewH : previewW;
105
- ctx.drawImage(img, -drawW / 2, -drawH / 2, drawW, drawH);
106
- ctx.restore();
107
-
108
- // Draw crop overlay (in preview coordinates)
109
- const px = crop.x * scale;
110
- const py = crop.y * scale;
111
- const pw = crop.width * scale;
112
- const ph = crop.height * scale;
113
- ctx.fillStyle = 'rgba(0,0,0,0.45)';
114
- ctx.fillRect(0, 0, previewW, py);
115
- ctx.fillRect(0, py + ph, previewW, previewH - py - ph);
116
- ctx.fillRect(0, py, px, ph);
117
- ctx.fillRect(px + pw, py, previewW - px - pw, ph);
118
- ctx.strokeStyle = '#3b82f6';
119
- ctx.lineWidth = 2;
120
- ctx.strokeRect(px, py, pw, ph);
121
- }, [imageReady, crop, rotation]);
122
-
123
- const handlePointerDown = useCallback(
124
- (e: React.PointerEvent<HTMLCanvasElement>) => {
125
- if (!imgRef.current || !canvasRef.current) return;
126
- const rect = canvasRef.current.getBoundingClientRect();
127
- const scale = imgRef.current.naturalWidth / rect.width;
128
- setDrag({
129
- ox: (e.clientX - rect.left) * scale,
130
- oy: (e.clientY - rect.top) * scale,
131
- cx: crop.x,
132
- cy: crop.y,
133
- });
134
- (e.target as HTMLCanvasElement).setPointerCapture(e.pointerId);
135
- },
136
- [crop],
137
- );
138
-
139
- const handlePointerMove = useCallback(
140
- (e: React.PointerEvent<HTMLCanvasElement>) => {
141
- if (!drag || !imgRef.current || !canvasRef.current) return;
142
- const rect = canvasRef.current.getBoundingClientRect();
143
- const scale = imgRef.current.naturalWidth / rect.width;
144
- const dx = (e.clientX - rect.left) * scale - drag.ox;
145
- const dy = (e.clientY - rect.top) * scale - drag.oy;
146
- const nextX = Math.max(0, Math.min(drag.cx + dx, imgRef.current.naturalWidth - crop.width));
147
- const nextY = Math.max(0, Math.min(drag.cy + dy, imgRef.current.naturalHeight - crop.height));
148
- setCrop((c) => ({ ...c, x: nextX, y: nextY }));
149
- },
150
- [drag, crop.width, crop.height],
151
- );
152
-
153
- const handlePointerUp = useCallback(() => setDrag(null), []);
154
-
155
- const rotate = useCallback(() => setRotation((r) => (r + 90) % 360), []);
156
-
157
- const handleConfirm = useCallback(async () => {
158
- if (!imgRef.current) return;
159
- const img = imgRef.current;
160
- const out = document.createElement('canvas');
161
- // After rotation, swap width/height when needed
162
- const cw = crop.width;
163
- const ch = crop.height;
164
- out.width = rotation % 180 === 0 ? cw : ch;
165
- out.height = rotation % 180 === 0 ? ch : cw;
166
- const ctx = out.getContext('2d');
167
- if (!ctx) return;
168
- ctx.save();
169
- ctx.translate(out.width / 2, out.height / 2);
170
- ctx.rotate((rotation * Math.PI) / 180);
171
- ctx.drawImage(img, crop.x, crop.y, cw, ch, -cw / 2, -ch / 2, cw, ch);
172
- ctx.restore();
173
- const blob: Blob | null = await new Promise((resolve) => out.toBlob(resolve, outputType));
174
- if (blob) {
175
- onConfirm(blob, outputName);
176
- onOpenChange(false);
177
- }
178
- }, [crop, rotation, outputName, outputType, onConfirm, onOpenChange]);
179
-
180
- return (
181
- <Dialog open={open} onOpenChange={onOpenChange}>
182
- <DialogContent className="max-w-xl">
183
- <DialogHeader>
184
- <DialogTitle>Crop & rotate image</DialogTitle>
185
- <DialogDescription>
186
- Drag the highlighted box to reposition the crop. Use the rotate button to spin the image
187
- in 90° increments.
188
- </DialogDescription>
189
- </DialogHeader>
190
-
191
- <div className="flex flex-col items-center gap-3">
192
- {imageReady ? (
193
- <canvas
194
- ref={canvasRef}
195
- data-testid="image-cropper-canvas"
196
- role="img"
197
- aria-label="Image crop preview"
198
- className="border rounded-md cursor-move touch-none max-w-full"
199
- onPointerDown={handlePointerDown}
200
- onPointerMove={handlePointerMove}
201
- onPointerUp={handlePointerUp}
202
- onPointerCancel={handlePointerUp}
203
- />
204
- ) : (
205
- <div className="flex h-[270px] w-full items-center justify-center text-sm text-muted-foreground">
206
- Loading image…
207
- </div>
208
- )}
209
- <div className="flex w-full items-center justify-between text-xs text-muted-foreground">
210
- <span>
211
- Crop: {Math.round(crop.width)} × {Math.round(crop.height)} px
212
- </span>
213
- <span>Rotation: {rotation}°</span>
214
- </div>
215
- </div>
216
-
217
- <DialogFooter className="flex justify-between sm:justify-between">
218
- <Button
219
- type="button"
220
- variant="outline"
221
- onClick={rotate}
222
- data-testid="image-cropper-rotate"
223
- >
224
- <RotateCw className="size-4 mr-2" />
225
- Rotate 90°
226
- </Button>
227
- <div className="flex gap-2">
228
- <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
229
- Cancel
230
- </Button>
231
- <Button
232
- type="button"
233
- onClick={handleConfirm}
234
- disabled={!imageReady}
235
- data-testid="image-cropper-confirm"
236
- >
237
- <CropIcon className="size-4 mr-2" />
238
- Apply crop
239
- </Button>
240
- </div>
241
- </DialogFooter>
242
- </DialogContent>
243
- </Dialog>
244
- );
245
- };
@@ -1,255 +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 {
11
- fileValueForSubmit,
12
- readFileValues,
13
- uploadResultView,
14
- withRecentUploads,
15
- type FileValueView,
16
- } from './file-value';
17
-
18
- // Lazy-load the cropper so the dialog (canvas + crop logic) is not in the initial
19
- // ImageField bundle. Consumers that never crop pay zero cost.
20
- const ImageCropperDialog = lazy(() =>
21
- import('./ImageCropperDialog').then((m) => ({ default: m.ImageCropperDialog })),
22
- );
23
-
24
- /**
25
- * ImageField - Image upload widget with preview thumbnails
26
- * Supports single and multiple image uploads with drag-and-drop and preview display
27
- */
28
- export function ImageField({ value, onChange, field, readonly, onUploadingChange, error, ...props }: FieldWidgetComponentProps<any>) {
29
- const inputRef = useRef<HTMLInputElement>(null);
30
- const imageField = field as any;
31
- const multiple = imageField?.multiple || false;
32
- const accept = imageField?.accept ? imageField.accept.join(',') : 'image/*';
33
- /**
34
- * Set `field.crop = false` to opt out of inline cropping. Defaults to enabled.
35
- */
36
- const cropEnabled = imageField?.crop !== false;
37
- const [cropTarget, setCropTarget] = useState<{ index: number; src: string; name: string } | null>(null);
38
- const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
39
- const { upload } = useUpload();
40
- const { t } = useObjectTranslation();
41
- const [uploading, setUploading] = useState(false);
42
- // Display details of just-uploaded images, keyed by their new `sys_file` id.
43
- // Submitting the reference form means the field value no longer carries the
44
- // URL a thumbnail needs; these keep the preview visible until the next read
45
- // returns the expanded form. See `file-value`.
46
- const [recent, setRecent] = useState<Record<string, FileValueView>>({});
47
- useUploadingSignal(uploading, onUploadingChange);
48
-
49
- // Derived value + memoized handlers must run before the readonly early return
50
- // so hook order stays stable across renders.
51
- const images = value ? (Array.isArray(value) ? value : [value]) : [];
52
- const views = withRecentUploads(readFileValues(value, 'Image'), recent);
53
-
54
- const remember = useCallback((result: any, originalName: string) => {
55
- const view = uploadResultView(result, originalName);
56
- if (view.id) setRecent((prev) => ({ ...prev, [view.id as string]: view }));
57
- }, []);
58
-
59
- const handleCropConfirm = useCallback(
60
- async (blob: Blob, name: string) => {
61
- if (!cropTarget) return;
62
- setUploading(true);
63
- try {
64
- const result = await upload(blob);
65
- remember(result, name);
66
- const next = fileValueForSubmit(result, name);
67
- if (multiple) {
68
- const updated = [...images];
69
- updated[cropTarget.index] = next;
70
- onChange(updated);
71
- } else {
72
- onChange(next);
73
- }
74
- } finally {
75
- setUploading(false);
76
- setCropTarget(null);
77
- }
78
- },
79
- [cropTarget, images, multiple, onChange, upload, remember],
80
- );
81
-
82
- const openCropper = useCallback(
83
- (index: number) => {
84
- const img = views[index];
85
- if (!img?.url) return;
86
- setCropTarget({ index, src: img.url, name: img.name || `image-${index}.png` });
87
- },
88
- [views],
89
- );
90
-
91
- const lightboxImages = views.filter((v) => v.url).map((v) => ({ url: v.url as string, name: v.name }));
92
-
93
- if (readonly) {
94
- if (!value || lightboxImages.length === 0) return <EmptyValue />;
95
-
96
- return (
97
- <>
98
- <div className="flex flex-wrap gap-2">
99
- {lightboxImages.map((img, idx) => (
100
- <button
101
- key={idx}
102
- type="button"
103
- onClick={() => setLightboxIndex(idx)}
104
- className="group relative overflow-hidden rounded-md border border-border focus:outline-none focus:ring-2 focus:ring-ring"
105
- aria-label={t('fields.image.enlarge', { name: img.name || t('fields.image.imageAlt', { index: idx + 1 }) })}
106
- >
107
- <img
108
- src={img.url}
109
- alt={img.name || t('fields.image.imageAlt', { index: idx + 1 })}
110
- className="size-20 object-cover transition-transform duration-150 group-hover:scale-105"
111
- />
112
- <span className="pointer-events-none absolute inset-0 bg-black/0 transition-colors group-hover:bg-black/10" />
113
- </button>
114
- ))}
115
- </div>
116
- {lightboxIndex !== null && (
117
- <ImageLightbox
118
- images={lightboxImages}
119
- index={lightboxIndex}
120
- open
121
- onOpenChange={(o) => !o && setLightboxIndex(null)}
122
- onIndexChange={setLightboxIndex}
123
- />
124
- )}
125
- </>
126
- );
127
- }
128
-
129
- const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
130
- const selectedFiles = Array.from(e.target.files || []);
131
- if (selectedFiles.length === 0) return;
132
-
133
- setUploading(true);
134
- try {
135
- const imageObjects = await Promise.all(
136
- selectedFiles.map(async (file) => {
137
- const result = await upload(file);
138
- remember(result, file.name);
139
- return fileValueForSubmit(result, file.name);
140
- }),
141
- );
142
-
143
- if (multiple) {
144
- onChange([...images, ...imageObjects]);
145
- } else {
146
- onChange(imageObjects[0]);
147
- }
148
- } finally {
149
- setUploading(false);
150
- // Reset input so picking the same file again still triggers change.
151
- if (inputRef.current) inputRef.current.value = '';
152
- }
153
- };
154
-
155
- const handleRemove = (index: number) => {
156
- if (multiple) {
157
- const newImages = images.filter((_: any, i: number) => i !== index);
158
- onChange(newImages.length > 0 ? newImages : null);
159
- } else {
160
- onChange(null);
161
- }
162
- };
163
-
164
- return (
165
- <div className={props.className}>
166
- <input
167
- ref={inputRef}
168
- type="file"
169
- multiple={multiple}
170
- accept={accept}
171
- onChange={handleFileChange}
172
- className="hidden"
173
- />
174
-
175
- <div className="space-y-2">
176
- {views.length > 0 && (
177
- <div className="grid grid-cols-4 gap-2">
178
- {views.map((img, idx) => (
179
- <div key={idx} className="relative group">
180
- <img
181
- src={img.url || ''}
182
- alt={img.name || t('fields.image.imageAlt', { index: idx + 1 })}
183
- className="size-20 rounded-md object-cover border border-gray-200"
184
- />
185
- <div className="absolute top-1 right-1 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
186
- {cropEnabled && (
187
- <Button
188
- type="button"
189
- variant="secondary"
190
- size="sm"
191
- onClick={() => openCropper(idx)}
192
- className="h-6 w-6 p-0"
193
- aria-label={t('fields.image.crop', { index: idx + 1 })}
194
- data-testid={`image-field-crop-${idx}`}
195
- >
196
- <CropIcon className="size-3" />
197
- </Button>
198
- )}
199
- <Button
200
- type="button"
201
- variant="destructive"
202
- size="sm"
203
- onClick={() => handleRemove(idx)}
204
- className="h-6 w-6 p-0"
205
- aria-label={t('fields.image.remove', { index: idx + 1 })}
206
- >
207
- <X className="size-3" />
208
- </Button>
209
- </div>
210
- </div>
211
- ))}
212
- </div>
213
- )}
214
-
215
- <Button
216
- // DOM pass-through onto the widget's real focusable control — the
217
- // upload button is the keyboard path to the hidden file input
218
- // (objectui#3318).
219
- {...toDomProps(props)}
220
- type="button"
221
- variant="outline"
222
- onClick={() => inputRef.current?.click()}
223
- className="w-full"
224
- disabled={uploading}
225
- data-testid="image-field-upload-button"
226
- // AFTER the spread so this widget's own computation wins (#3222).
227
- aria-invalid={!!error}
228
- >
229
- {uploading ? (
230
- <Loader2 className="size-4 mr-2 animate-spin" />
231
- ) : (
232
- <ImageIcon className="size-4 mr-2" />
233
- )}
234
- {uploading
235
- ? t('fields.image.uploading')
236
- : images.length > 0
237
- ? t('fields.image.addMore')
238
- : t('fields.image.upload')}
239
- </Button>
240
- </div>
241
-
242
- {cropEnabled && cropTarget && (
243
- <Suspense fallback={null}>
244
- <ImageCropperDialog
245
- open
246
- onOpenChange={(o) => !o && setCropTarget(null)}
247
- src={cropTarget.src}
248
- outputName={cropTarget.name}
249
- onConfirm={handleCropConfirm}
250
- />
251
- </Suspense>
252
- )}
253
- </div>
254
- );
255
- }
@@ -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
- }