@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,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,154 +0,0 @@
1
- /**
2
- * ImageField — a declared `maxSize` is enforced BEFORE any upload starts
3
- * (objectui#4141).
4
- *
5
- * The defect this pins: `paramToField` copies `maxSize` onto the field config
6
- * for every param type, so the image widget RECEIVES the constraint — it just
7
- * never read it. An oversized pick therefore fired the whole
8
- * `presigned → PUT → complete` chain and rendered a preview, while the sibling
9
- * file param rejected the same pick with zero uploads.
10
- *
11
- * So the assertion that matters is **zero upload attempts**, not "no preview":
12
- * a widget that uploads the bytes and then hides the thumbnail has still
13
- * stored an over-limit file. The spy sits on the `UploadProvider` adapter —
14
- * the layer that performs the presigned request — so a call there IS the
15
- * network attempt.
16
- *
17
- * Two upload entry points exist on this widget and both are covered here:
18
- * 1. the native picker (`handleFileChange`, single and `multiple`);
19
- * 2. the crop dialog's confirm (`handleCropConfirm`), which uploads a
20
- * freshly re-encoded PNG blob — canvas re-encoding can push a crop of an
21
- * in-limit JPEG *over* the limit, so it is a real second door.
22
- */
23
- import { describe, it, expect, vi } from 'vitest';
24
- import { render, screen, fireEvent, waitFor } from '@testing-library/react';
25
- import { UploadProvider, type UploadAdapter } from '@object-ui/providers';
26
- import { ImageField } from './ImageField';
27
- // Imported at module scope (not awaited in a hook) so the widget's
28
- // `React.lazy(() => import('./ImageCropperDialog'))` resolves from the ESM
29
- // cache instead of racing RTL's 1000ms `findBy` budget — AGENTS.md §测试纪律.
30
- import './ImageCropperDialog';
31
-
32
- const MB = 1024 * 1024;
33
-
34
- /** A File whose reported `size` is the declared one (constructor bytes are not). */
35
- function imageOfSize(name: string, bytes: number): File {
36
- const file = new File(['x'], name, { type: 'image/png' });
37
- Object.defineProperty(file, 'size', { value: bytes });
38
- return file;
39
- }
40
-
41
- function blobOfSize(bytes: number): Blob {
42
- const blob = new Blob(['x'], { type: 'image/png' });
43
- Object.defineProperty(blob, 'size', { value: bytes });
44
- return blob;
45
- }
46
-
47
- function renderField(field: Record<string, unknown>, value: unknown = undefined) {
48
- const upload = vi.fn(async (f: File | Blob) => ({
49
- url: 'https://cdn.example/uploaded.png',
50
- name: (f as File).name ?? 'cropped.png',
51
- size: f.size,
52
- mimeType: f.type || 'image/png',
53
- }));
54
- const adapter: UploadAdapter = { name: 'spy', upload };
55
- const onChange = vi.fn();
56
- render(
57
- <UploadProvider adapter={adapter}>
58
- <ImageField field={field as any} value={value} onChange={onChange} />
59
- </UploadProvider>,
60
- );
61
- return { upload, onChange, input: document.querySelector('input[type="file"]') as HTMLInputElement };
62
- }
63
-
64
- describe('ImageField — client-side maxSize guard (#4141)', () => {
65
- it('never reaches the upload layer for a pick over the declared maxSize', async () => {
66
- const { upload, onChange, input } = renderField({ name: 'p_cover', maxSize: 5 * MB });
67
-
68
- fireEvent.change(input, { target: { files: [imageOfSize('huge.png', 6.3 * MB)] } });
69
-
70
- // The message is the proof the guard ran; the spy is the proof nothing was sent.
71
- await screen.findByText(/"huge\.png" exceeds max size \(5\.0 MB\)/);
72
- expect(upload).not.toHaveBeenCalled();
73
- expect(onChange).not.toHaveBeenCalled();
74
- });
75
-
76
- it('still uploads a pick inside the declared maxSize', async () => {
77
- const { upload, onChange, input } = renderField({ name: 'p_cover', maxSize: 5 * MB });
78
-
79
- fireEvent.change(input, { target: { files: [imageOfSize('ok.png', 1 * MB)] } });
80
-
81
- await waitFor(() => expect(onChange).toHaveBeenCalled());
82
- expect(upload).toHaveBeenCalledTimes(1);
83
- expect(screen.queryByText(/exceeds max size/)).toBeNull();
84
- });
85
-
86
- it('uploads unrestricted when the param declares no maxSize', async () => {
87
- const { upload, onChange, input } = renderField({ name: 'p_cover' });
88
-
89
- fireEvent.change(input, { target: { files: [imageOfSize('enormous.png', 500 * MB)] } });
90
-
91
- await waitFor(() => expect(onChange).toHaveBeenCalled());
92
- expect(upload).toHaveBeenCalledTimes(1);
93
- expect(screen.queryByText(/exceeds max size/)).toBeNull();
94
- });
95
-
96
- it('rejects only the oversized picks of a multi-select and uploads the rest', async () => {
97
- const { upload, onChange, input } = renderField({ name: 'p_gallery', multiple: true, maxSize: 5 * MB });
98
-
99
- fireEvent.change(input, {
100
- target: { files: [imageOfSize('small.png', 1 * MB), imageOfSize('huge.png', 6.3 * MB)] },
101
- });
102
-
103
- await waitFor(() => expect(onChange).toHaveBeenCalled());
104
- expect(upload).toHaveBeenCalledTimes(1);
105
- expect((upload.mock.calls[0][0] as File).name).toBe('small.png');
106
- expect(screen.getByText(/"huge\.png" exceeds max size \(5\.0 MB\)/)).toBeTruthy();
107
- });
108
-
109
- it('guards the crop dialog too — an oversized re-encode is never uploaded', async () => {
110
- const existing = { name: 'cover.png', url: 'https://cdn.example/cover.png', mime_type: 'image/png' };
111
- const { upload, onChange } = renderField({ name: 'p_cover', maxSize: 5 * MB, crop: true }, existing);
112
-
113
- fireEvent.click(screen.getByTestId('image-field-crop-0'));
114
- const confirm = await screen.findByTestId('fake-crop-confirm');
115
- fireEvent.click(confirm);
116
-
117
- await screen.findByText(/exceeds max size \(5\.0 MB\)/);
118
- expect(upload).not.toHaveBeenCalled();
119
- expect(onChange).not.toHaveBeenCalled();
120
- });
121
-
122
- it('still uploads a crop that fits inside the declared maxSize', async () => {
123
- const existing = { name: 'cover.png', url: 'https://cdn.example/cover.png', mime_type: 'image/png' };
124
- const { upload, onChange } = renderField({ name: 'p_cover', maxSize: 5 * MB, crop: true }, existing);
125
-
126
- fireEvent.click(screen.getByTestId('image-field-crop-0'));
127
- fireEvent.click(await screen.findByTestId('fake-crop-confirm-small'));
128
-
129
- await waitFor(() => expect(onChange).toHaveBeenCalled());
130
- expect(upload).toHaveBeenCalledTimes(1);
131
- });
132
- });
133
-
134
- /**
135
- * The real cropper is a canvas + `toBlob` pipeline that happy-dom cannot run;
136
- * this stand-in drives the ONE contract ImageField has with it — `onConfirm`
137
- * hands back a blob and a name — with a blob size on either side of the limit.
138
- */
139
- vi.mock('./ImageCropperDialog', () => ({
140
- ImageCropperDialog: ({ onConfirm }: { onConfirm: (blob: Blob, name: string) => void }) => (
141
- <div>
142
- <button type="button" data-testid="fake-crop-confirm" onClick={() => onConfirm(blobOfSize(6.3 * MB), 'cover.png')}>
143
- confirm big
144
- </button>
145
- <button
146
- type="button"
147
- data-testid="fake-crop-confirm-small"
148
- onClick={() => onConfirm(blobOfSize(0.5 * MB), 'cover.png')}
149
- >
150
- confirm small
151
- </button>
152
- </div>
153
- ),
154
- }));