@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,95 +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
- import { useLocalization } from '@object-ui/i18n';
6
- import { resolveFieldCurrency } from '../currency';
7
-
8
- /**
9
- * Format currency value for display. When `currency` is undefined the value
10
- * is rendered as a plain number with thousands separators (no symbol),
11
- * because silently assuming USD is misleading for non-USD businesses.
12
- */
13
- function formatAmount(value: number, currency: string | undefined, precision: number): string {
14
- if (currency) {
15
- try {
16
- return new Intl.NumberFormat('en-US', {
17
- style: 'currency',
18
- currency,
19
- minimumFractionDigits: precision,
20
- maximumFractionDigits: precision,
21
- }).format(value);
22
- } catch {
23
- return `${currency} ${value.toFixed(precision)}`;
24
- }
25
- }
26
- try {
27
- return new Intl.NumberFormat('en-US', {
28
- minimumFractionDigits: precision,
29
- maximumFractionDigits: precision,
30
- }).format(value);
31
- } catch {
32
- return value.toFixed(precision);
33
- }
34
- }
35
-
36
- const CURRENCY_SYMBOLS: Record<string, string> = {
37
- USD: '$',
38
- };
39
-
40
- export function CurrencyField({ value, onChange, field, readonly, error, className, ...props }: FieldWidgetComponentProps<number>) {
41
- const currencyField = field as any;
42
- // Shared precedence: field currency → currencyConfig → tenant default (ADR-0053).
43
- const { currency: tenantCurrency } = useLocalization();
44
- const currency: string | undefined = resolveFieldCurrency(currencyField, tenantCurrency);
45
- const precision = currencyField?.precision ?? 2;
46
-
47
- if (readonly) {
48
- if (value == null) return <EmptyValue />;
49
- return (
50
- <span className="text-sm font-medium tabular-nums">
51
- {formatAmount(Number(value), currency, precision)}
52
- </span>
53
- );
54
- }
55
-
56
- // Parse and format on blur to ensure valid currency format
57
- const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
58
- const val = parseFloat(e.target.value);
59
- if (!isNaN(val)) {
60
- onChange(parseFloat(val.toFixed(precision)));
61
- }
62
- };
63
-
64
- const symbol = currency ? (currency === 'USD' ? '$' : currency) : '';
65
-
66
- return (
67
- <div className="relative">
68
- {symbol && (
69
- <span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm text-gray-500">
70
- {symbol}
71
- </span>
72
- )}
73
- <Input
74
- {...toDomProps(props)}
75
- type="number"
76
- value={value ?? ''}
77
- onChange={(e) => {
78
- const val = e.target.value === '' ? null : parseFloat(e.target.value);
79
- onChange(val as any);
80
- }}
81
- onBlur={handleBlur}
82
- placeholder={currencyField?.placeholder || '0.00'}
83
- disabled={readonly || props.disabled}
84
- className={`${symbol ? 'pl-8' : ''} ${className || ''}`}
85
- // Surface the field's declared range (e.g. `min: 0` on a budget) so the
86
- // browser's spinner/keyboard affordances respect it (objectui#2572);
87
- // server-side validation still owns enforcement.
88
- min={typeof currencyField?.min === 'number' ? currencyField.min : undefined}
89
- max={typeof currencyField?.max === 'number' ? currencyField.max : undefined}
90
- step={Math.pow(10, -precision).toFixed(precision)}
91
- aria-invalid={!!error}
92
- />
93
- </div>
94
- );
95
- }
@@ -1,31 +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
- import { openNativePicker } from './openNativePicker';
6
-
7
- /**
8
- * DateField - Date picker input widget
9
- * Uses native date input and displays locale-formatted date in readonly mode
10
- */
11
- export function DateField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
12
- if (readonly) {
13
- return value ? <span className="text-sm">{new Date(value).toLocaleDateString()}</span> : <EmptyValue />;
14
- }
15
-
16
- const domProps = toDomProps(props);
17
-
18
- return (
19
- <Input
20
- {...domProps}
21
- type="date"
22
- value={value || ''}
23
- onChange={(e) => onChange(e.target.value)}
24
- onClick={(e) => {
25
- openNativePicker(e.currentTarget);
26
- domProps.onClick?.(e);
27
- }}
28
- disabled={readonly || domProps.disabled}
29
- />
30
- );
31
- }
@@ -1,37 +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
- import { openNativePicker } from './openNativePicker';
6
-
7
- /**
8
- * DateTimeField - Combined date and time picker widget
9
- * Displays both date and time in locale format when readonly
10
- */
11
- export function DateTimeField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
12
- if (readonly) {
13
- if (!value) return <EmptyValue />;
14
- const date = new Date(value);
15
- return (
16
- <span className="text-sm">
17
- {date.toLocaleDateString()} {date.toLocaleTimeString()}
18
- </span>
19
- );
20
- }
21
-
22
- const domProps = toDomProps(props);
23
-
24
- return (
25
- <Input
26
- {...domProps}
27
- type="datetime-local"
28
- value={value || ''}
29
- onChange={(e) => onChange(e.target.value)}
30
- onClick={(e) => {
31
- openNativePicker(e.currentTarget);
32
- domProps.onClick?.(e);
33
- }}
34
- disabled={readonly || domProps.disabled}
35
- />
36
- );
37
- }
@@ -1,37 +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
- * EmailField - Email address input with mailto link in readonly mode
8
- * Renders as a clickable mailto link when readonly, standard email input otherwise
9
- */
10
- export function EmailField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
11
- const config = field;
12
- if (readonly) {
13
- if (!value) return <EmptyValue />;
14
- return (
15
- <a
16
- href={`mailto:${value}`}
17
- className="text-sm text-blue-600 hover:text-blue-800 hover:underline"
18
- >
19
- {value}
20
- </a>
21
- );
22
- }
23
-
24
- const domProps = toDomProps(props);
25
-
26
- return (
27
- <Input
28
- {...domProps}
29
- type="email"
30
- value={value || ''}
31
- onChange={(e) => onChange(e.target.value)}
32
- placeholder={config?.placeholder || 'email@example.com'}
33
- disabled={readonly || domProps.disabled}
34
- aria-invalid={!!error}
35
- />
36
- );
37
- }
@@ -1,494 +0,0 @@
1
- import React, { useRef, useState, useCallback } 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 { Upload, X, File as FileIcon, ImageIcon, Camera, Loader2 } from 'lucide-react';
6
- import { FieldWidgetComponentProps } from './types';
7
- import { toDomProps } from './toDomProps';
8
- import { useUploadingSignal } from './useUploadingSignal';
9
- import {
10
- fileValueForSubmit,
11
- readFileValues,
12
- uploadResultView,
13
- withRecentUploads,
14
- isImageValue,
15
- type FileValueView,
16
- } from './file-value';
17
-
18
- /**
19
- * Shared upload pipeline for the file widgets: validates size, uploads through
20
- * the configured UploadProvider adapter (with progress), and merges results
21
- * into the field value — append for `multiple`, replace otherwise. Extracted so
22
- * the full-size FileField and the compact grid-cell {@link FileCell} stay
23
- * behaviourally identical (same value shape, same error handling).
24
- *
25
- * Stores the **reference form** — a bare `sys_file` id — when the upload
26
- * adapter surfaced one, and the legacy inline blob when it did not, so the same
27
- * build works against a backend that has adopted file-as-reference and one that
28
- * has not (see `file-value`). Because a bare id carries no name or URL, each
29
- * completed upload's display details are kept in `recent`, keyed by id, so the
30
- * file renders immediately instead of as a bare token until a read enriches it.
31
- */
32
- function useFileUploads(opts: {
33
- files: any[];
34
- multiple: boolean;
35
- maxSize?: number;
36
- onChange: (value: any) => void;
37
- }) {
38
- const { files, multiple, maxSize, onChange } = opts;
39
- const { upload } = useUpload();
40
- const { t } = useObjectTranslation();
41
- const [errors, setErrors] = useState<string[]>([]);
42
- const [uploadProgress, setUploadProgress] = useState<Record<string, number>>({});
43
- const [uploading, setUploading] = useState(false);
44
- const [recent, setRecent] = useState<Record<string, FileValueView>>({});
45
-
46
- const processFiles = useCallback(async (selectedFiles: File[]) => {
47
- if (selectedFiles.length === 0) return;
48
- const newErrors: string[] = [];
49
-
50
- const validFiles = selectedFiles.filter(file => {
51
- if (maxSize && file.size > maxSize) {
52
- const maxMB = (maxSize / (1024 * 1024)).toFixed(1);
53
- newErrors.push(t('fields.file.exceedsMaxSize', {
54
- defaultValue: `"${file.name}" exceeds max size (${maxMB} MB)`,
55
- name: file.name, max: maxMB,
56
- }));
57
- return false;
58
- }
59
- return true;
60
- });
61
- setErrors(newErrors);
62
-
63
- if (validFiles.length === 0) return;
64
-
65
- setUploading(true);
66
- try {
67
- const uploaded = await Promise.all(
68
- validFiles.map(async (file) => {
69
- try {
70
- const result = await upload(file, {
71
- onProgress: (ratio) =>
72
- setUploadProgress((prev) => ({ ...prev, [file.name]: ratio })),
73
- });
74
- return { result, originalName: file.name };
75
- } catch (err) {
76
- newErrors.push(t('fields.file.uploadFailed', {
77
- defaultValue: `Failed to upload "${file.name}": ${(err as Error).message}`,
78
- name: file.name, error: (err as Error).message,
79
- }));
80
- setErrors([...newErrors]);
81
- return null;
82
- }
83
- }),
84
- );
85
- const successful = uploaded.filter(Boolean) as Array<{ result: any; originalName: string }>;
86
- if (successful.length === 0) return;
87
-
88
- // Remember what each new id looks like so it can render before the next
89
- // read expands it back into `{ id, name, size, mimeType, url }`.
90
- const views: Record<string, FileValueView> = {};
91
- for (const { result, originalName } of successful) {
92
- const view = uploadResultView(result, originalName);
93
- if (view.id) views[view.id] = view;
94
- }
95
- if (Object.keys(views).length > 0) setRecent((prev) => ({ ...prev, ...views }));
96
-
97
- const nextValues = successful.map(({ result, originalName }) =>
98
- fileValueForSubmit(result, originalName),
99
- );
100
- if (multiple) {
101
- onChange([...files, ...nextValues]);
102
- } else {
103
- onChange(nextValues[0]);
104
- }
105
- } finally {
106
- setUploading(false);
107
- setUploadProgress({});
108
- }
109
- }, [files, multiple, onChange, maxSize, upload, t]);
110
-
111
- return { processFiles, errors, uploading, uploadProgress, recent };
112
- }
113
-
114
- /**
115
- * FileField - File upload widget with drag-and-drop support
116
- * Supports single and multiple file uploads with configurable accepted file types.
117
- * L2: File size validation, per-file progress indicators, error messages.
118
- */
119
- export function FileField({ value, onChange, field, readonly, onUploadingChange, error, ...props }: FieldWidgetComponentProps<any>) {
120
- const { t } = useObjectTranslation();
121
- const inputRef = useRef<HTMLInputElement>(null);
122
- const cameraRef = useRef<HTMLInputElement>(null);
123
- const fileField = field as any;
124
- const multiple = fileField?.multiple || false;
125
- const accept = fileField?.accept ? fileField.accept.join(',') : undefined;
126
- const maxSize = fileField?.maxSize as number | undefined; // bytes
127
- /**
128
- * Camera capture mode for mobile devices.
129
- * - `'environment'` (back camera): photos of receipts, documents, products
130
- * - `'user'` (front camera): selfies, profile pictures
131
- * - `false`: disable the camera button entirely
132
- * @default 'environment' when accept includes image/* on a touch device
133
- */
134
- const captureMode = (fileField?.capture ?? null) as 'environment' | 'user' | false | null;
135
- const acceptsImages = !accept || accept.split(',').some((t: string) =>
136
- t.trim().startsWith('image/') || t.trim() === 'image/*' || t.trim().startsWith('.jp') || t.trim().startsWith('.png') || t.trim().startsWith('.gif') || t.trim().startsWith('.webp'),
137
- );
138
- // Auto-enable camera button on touch devices when image upload is permitted, unless explicitly disabled.
139
- const isTouchDevice = typeof navigator !== 'undefined' && (navigator.maxTouchPoints > 0 || /Mobi|Android/i.test(navigator.userAgent));
140
- const cameraEnabled = captureMode === false ? false : (captureMode ?? (acceptsImages && isTouchDevice ? 'environment' : null));
141
- const [isDragOver, setIsDragOver] = useState(false);
142
-
143
- const files = value ? (Array.isArray(value) ? value : [value]) : [];
144
- const { processFiles, errors, uploading, uploadProgress, recent } = useFileUploads({
145
- files, multiple, maxSize, onChange,
146
- });
147
- const fallbackName = t('fields.file.fileFallback', { defaultValue: 'File' });
148
- // Normalised for display: accepts a bare reference id, the expanded
149
- // `{ id, name, size, mimeType, url }`, or a legacy inline blob.
150
- const views = withRecentUploads(readFileValues(value, fallbackName), recent);
151
- useUploadingSignal(uploading, onUploadingChange);
152
-
153
- const handleDragOver = useCallback((e: React.DragEvent) => {
154
- e.preventDefault();
155
- e.stopPropagation();
156
- setIsDragOver(true);
157
- }, []);
158
-
159
- const handleDragLeave = useCallback((e: React.DragEvent) => {
160
- e.preventDefault();
161
- e.stopPropagation();
162
- setIsDragOver(false);
163
- }, []);
164
-
165
- const handleDrop = useCallback((e: React.DragEvent) => {
166
- e.preventDefault();
167
- e.stopPropagation();
168
- setIsDragOver(false);
169
-
170
- const droppedFiles = Array.from(e.dataTransfer.files);
171
- if (accept) {
172
- const acceptedTypes = accept.split(',').map((t: string) => t.trim().toLowerCase());
173
- const filtered = droppedFiles.filter(file => {
174
- const parts = file.name.split('.');
175
- const ext = parts.length > 1 ? '.' + parts.pop()?.toLowerCase() : '';
176
- return acceptedTypes.some((t: string) =>
177
- t === file.type || (ext && t === ext) || (t.endsWith('/*') && file.type.startsWith(t.replace('/*', '/')))
178
- );
179
- });
180
- processFiles(filtered);
181
- } else {
182
- processFiles(droppedFiles);
183
- }
184
- }, [accept, processFiles]);
185
-
186
- if (readonly) {
187
- if (!value) return <EmptyValue />;
188
-
189
- return (
190
- <div className="flex flex-wrap gap-2">
191
- {views.map((file, idx) => (
192
- <span key={idx} className="text-sm truncate max-w-xs">
193
- {file.name}
194
- </span>
195
- ))}
196
- </div>
197
- );
198
- }
199
-
200
- const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
201
- processFiles(Array.from(e.target.files || []));
202
- };
203
-
204
- const handleRemove = (index: number) => {
205
- if (multiple) {
206
- const newFiles = files.filter((_: any, i: number) => i !== index);
207
- onChange(newFiles.length > 0 ? newFiles : null);
208
- } else {
209
- onChange(null);
210
- }
211
- };
212
-
213
- const { name: _domName, ...dropzoneDomProps } = toDomProps(props);
214
-
215
- return (
216
- <div className={props.className}>
217
- <input
218
- ref={inputRef}
219
- type="file"
220
- multiple={multiple}
221
- accept={accept}
222
- onChange={handleFileChange}
223
- className="hidden"
224
- />
225
- {cameraEnabled && (
226
- <input
227
- ref={cameraRef}
228
- type="file"
229
- accept="image/*"
230
- capture={cameraEnabled}
231
- onChange={handleFileChange}
232
- className="hidden"
233
- aria-label={t('fields.file.cameraCapture', { defaultValue: 'Camera capture' })}
234
- data-testid="file-field-camera-input"
235
- />
236
- )}
237
-
238
- <div className="space-y-2">
239
- {/* Drag-and-drop zone — the widget's real focusable control (it is the
240
- keyboard path to the hidden file input), so the DOM pass-through
241
- and the validation state land here (objectui#3318). `name` is
242
- withheld: only DOM-legal on form controls, and on this div it is
243
- exactly the leak #3291 sweeps for. */}
244
- <div
245
- {...dropzoneDomProps}
246
- onDragOver={handleDragOver}
247
- onDragLeave={handleDragLeave}
248
- onDrop={handleDrop}
249
- onClick={() => inputRef.current?.click()}
250
- className={`
251
- flex flex-col items-center justify-center gap-2 p-6
252
- border-2 border-dashed rounded-lg cursor-pointer
253
- transition-colors duration-200
254
- ${isDragOver
255
- ? 'border-primary bg-primary/5 text-primary'
256
- : 'border-muted-foreground/25 hover:border-primary/50 text-muted-foreground hover:text-foreground'}
257
- `}
258
- role="button"
259
- tabIndex={0}
260
- onKeyDown={(e) => {
261
- if (e.key === 'Enter' || e.key === ' ') {
262
- e.preventDefault();
263
- inputRef.current?.click();
264
- }
265
- }}
266
- // AFTER the spread so this widget's own computation wins (#3222).
267
- aria-invalid={!!error}
268
- >
269
- <Upload className={`size-8 ${isDragOver ? 'text-primary' : 'text-muted-foreground'}`} />
270
- <div className="text-center">
271
- <p className="text-sm font-medium">
272
- {isDragOver
273
- ? t('fields.file.dropFilesHere', { defaultValue: 'Drop files here' })
274
- : t('fields.file.dragDropHere', { defaultValue: 'Drag & drop files here' })}
275
- </p>
276
- <p className="text-xs text-muted-foreground mt-1">
277
- {cameraEnabled
278
- ? t('fields.file.browseHintCamera', { defaultValue: 'or click to browse • use the camera button below' })
279
- : t('fields.file.browseHint', { defaultValue: 'or click to browse' })}
280
- </p>
281
- </div>
282
- </div>
283
-
284
- {cameraEnabled && (
285
- <Button
286
- type="button"
287
- variant="outline"
288
- size="sm"
289
- className="w-full"
290
- onClick={(e) => {
291
- e.stopPropagation();
292
- cameraRef.current?.click();
293
- }}
294
- data-testid="file-field-camera-button"
295
- >
296
- <Camera className="size-4 mr-2" />
297
- {cameraEnabled === 'user'
298
- ? t('fields.file.takeSelfie', { defaultValue: 'Take selfie' })
299
- : t('fields.file.takePhoto', { defaultValue: 'Take photo' })}
300
- </Button>
301
- )}
302
-
303
- {/* Upload progress indicator */}
304
- {uploading && (
305
- <div className="flex items-center gap-2 text-xs text-muted-foreground" data-testid="file-field-uploading">
306
- <Loader2 className="size-3 animate-spin" />
307
- <span>
308
- {(() => {
309
- const keys = Object.keys(uploadProgress);
310
- if (keys.length === 0) return t('fields.file.uploading', { defaultValue: 'Uploading…' });
311
- const pct = Math.round(
312
- (Object.values(uploadProgress).reduce((s, v) => s + v, 0) / keys.length) * 100,
313
- );
314
- return t('fields.file.uploadingPct', { defaultValue: `Uploading… (${pct}%)`, pct });
315
- })()}
316
- </span>
317
- </div>
318
- )}
319
-
320
- {/* Validation errors */}
321
- {errors.length > 0 && (
322
- <div className="space-y-0.5">
323
- {errors.map((err, i) => (
324
- <p key={i} className="text-xs text-destructive">{err}</p>
325
- ))}
326
- </div>
327
- )}
328
-
329
- {/* File list */}
330
- {views.length > 0 && (
331
- <div className="space-y-1">
332
- {views.map((file, idx) => (
333
- <div
334
- key={idx}
335
- className="flex items-center justify-between gap-2 p-2 bg-muted/50 rounded-md border"
336
- >
337
- <div className="flex items-center gap-2 flex-1 min-w-0">
338
- {isImageValue(file) && file.url ? (
339
- <img src={file.url} alt={file.name} className="size-8 object-cover rounded shrink-0" />
340
- ) : isImageValue(file) ? (
341
- <ImageIcon className="size-4 text-muted-foreground shrink-0" />
342
- ) : (
343
- <FileIcon className="size-4 text-muted-foreground shrink-0" />
344
- )}
345
- <span className="text-sm truncate">
346
- {file.name}
347
- </span>
348
- {file.size && (
349
- <span className="text-xs text-muted-foreground">
350
- ({(file.size / 1024).toFixed(1)} KB)
351
- </span>
352
- )}
353
- </div>
354
- <Button
355
- type="button"
356
- variant="ghost"
357
- size="sm"
358
- onClick={(e) => {
359
- e.stopPropagation();
360
- handleRemove(idx);
361
- }}
362
- className="h-6 w-6 p-0"
363
- >
364
- <X className="size-3" />
365
- </Button>
366
- </div>
367
- ))}
368
- </div>
369
- )}
370
- </div>
371
- </div>
372
- );
373
- }
374
-
375
- /**
376
- * FileCell — compact upload control for a line-item grid cell (objectui#2360).
377
- *
378
- * Same value shape and upload pipeline as {@link FileField}, sized for a 32px
379
- * grid row: existing files render as removable chips (image thumbnail / file
380
- * icon + name) and a small button opens the native file picker. No
381
- * drag-and-drop zone — a grid cell has no room for one; the per-row expand
382
- * form still offers the full-size FileField.
383
- */
384
- export function FileCell({
385
- value,
386
- onChange,
387
- disabled,
388
- multiple,
389
- accept,
390
- maxSize,
391
- 'aria-label': ariaLabel,
392
- 'data-cell': dataCell,
393
- }: {
394
- value: any;
395
- onChange: (value: any) => void;
396
- disabled?: boolean;
397
- multiple?: boolean;
398
- /** Comma-joined accept list for the native picker (e.g. `"image/*,.pdf"`). */
399
- accept?: string;
400
- /** Max file size in bytes (oversize picks are rejected with an inline error). */
401
- maxSize?: number;
402
- 'aria-label'?: string;
403
- /** Focus-grid coordinate (see GridField keyboard navigation). */
404
- 'data-cell'?: string;
405
- }) {
406
- const { t } = useObjectTranslation();
407
- const inputRef = useRef<HTMLInputElement>(null);
408
- const files = value ? (Array.isArray(value) ? value : [value]) : [];
409
- const { processFiles, errors, uploading, recent } = useFileUploads({
410
- files, multiple: !!multiple, maxSize, onChange,
411
- });
412
- const fallbackName = t('fields.file.fileFallback', { defaultValue: 'File' });
413
- const views = withRecentUploads(readFileValues(value, fallbackName), recent);
414
-
415
- const removeAt = (index: number) => {
416
- if (multiple) {
417
- const next = files.filter((_: any, i: number) => i !== index);
418
- onChange(next.length > 0 ? next : null);
419
- } else {
420
- onChange(null);
421
- }
422
- };
423
-
424
- const showUpload = !disabled && !uploading && (multiple || files.length === 0);
425
-
426
- return (
427
- <div className="flex min-h-8 flex-wrap items-center gap-1 px-1 py-0.5">
428
- <input
429
- ref={inputRef}
430
- type="file"
431
- multiple={multiple}
432
- accept={accept}
433
- onChange={(e) => {
434
- processFiles(Array.from(e.target.files || []));
435
- e.target.value = ''; // allow re-picking the same file
436
- }}
437
- className="hidden"
438
- />
439
- {views.map((file, idx) => (
440
- <span
441
- key={idx}
442
- className="inline-flex max-w-40 items-center gap-1 rounded border bg-muted/50 px-1 py-0.5 text-xs"
443
- title={file.name}
444
- data-testid="file-cell-chip"
445
- >
446
- {isImageValue(file) && file.url ? (
447
- <img src={file.url} alt={file.name} className="size-5 shrink-0 rounded object-cover" />
448
- ) : (
449
- <FileIcon className="size-3 shrink-0 text-muted-foreground" />
450
- )}
451
- <span className="truncate">{file.name}</span>
452
- {!disabled && (
453
- <button
454
- type="button"
455
- className="shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground"
456
- aria-label={t('fields.file.remove', { defaultValue: `Remove ${file.name}`, name: file.name })}
457
- onClick={() => removeAt(idx)}
458
- >
459
- <X className="size-3" />
460
- </button>
461
- )}
462
- </span>
463
- ))}
464
- {uploading && (
465
- <span
466
- className="inline-flex items-center gap-1 text-xs text-muted-foreground"
467
- data-testid="file-cell-uploading"
468
- >
469
- <Loader2 className="size-3.5 animate-spin" />
470
- </span>
471
- )}
472
- {showUpload && (
473
- <Button
474
- type="button"
475
- variant="ghost"
476
- size="sm"
477
- className="h-7 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground"
478
- onClick={() => inputRef.current?.click()}
479
- aria-label={ariaLabel}
480
- data-cell={dataCell}
481
- disabled={disabled}
482
- >
483
- <Upload className="size-3.5" />
484
- {files.length === 0 && t('fields.file.upload', { defaultValue: 'Upload' })}
485
- </Button>
486
- )}
487
- {errors.length > 0 && (
488
- <span className="w-full truncate text-[11px] text-destructive" title={errors.join('; ')}>
489
- {errors[0]}
490
- </span>
491
- )}
492
- </div>
493
- );
494
- }