@object-ui/fields 17.2.0 → 17.5.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 (212) hide show
  1. package/CHANGELOG.md +1533 -0
  2. package/README.md +1 -12
  3. package/dist/FieldEditWidget.d.ts +7 -1
  4. package/dist/currency.d.ts +46 -0
  5. package/dist/field-type-alias.d.ts +19 -1
  6. package/dist/index.cjs +5 -5
  7. package/dist/index.css +633 -0
  8. package/dist/index.d.ts +52 -12
  9. package/dist/index.js +5167 -4759
  10. package/dist/widgets/AddressField.d.ts +4 -12
  11. package/dist/widgets/AvatarField.d.ts +1 -1
  12. package/dist/widgets/CharacterCount.d.ts +56 -0
  13. package/dist/widgets/CheckboxesField.d.ts +1 -1
  14. package/dist/widgets/CodeField.d.ts +1 -1
  15. package/dist/widgets/ColorField.d.ts +1 -1
  16. package/dist/widgets/CurrencyField.d.ts +1 -1
  17. package/dist/widgets/EmailField.d.ts +1 -1
  18. package/dist/widgets/FileField.d.ts +1 -1
  19. package/dist/widgets/FilterConditionField.d.ts +1 -1
  20. package/dist/widgets/FullscreenFieldEditor.d.ts +45 -0
  21. package/dist/widgets/GeolocationField.d.ts +1 -1
  22. package/dist/widgets/GridField.d.ts +12 -2
  23. package/dist/widgets/ImageField.d.ts +1 -1
  24. package/dist/widgets/LocationField.d.ts +1 -1
  25. package/dist/widgets/LookupField.d.ts +1 -1
  26. package/dist/widgets/MultiSelectField.d.ts +1 -1
  27. package/dist/widgets/ObjectField.d.ts +1 -1
  28. package/dist/widgets/ObjectRefField.d.ts +1 -1
  29. package/dist/widgets/OptionsEmptyState.d.ts +59 -0
  30. package/dist/widgets/PercentField.d.ts +1 -1
  31. package/dist/widgets/PhoneField.d.ts +1 -1
  32. package/dist/widgets/QRCodeField.d.ts +1 -1
  33. package/dist/widgets/RadioField.d.ts +1 -1
  34. package/dist/widgets/RatingField.d.ts +1 -1
  35. package/dist/widgets/RecipientPickerField.d.ts +1 -1
  36. package/dist/widgets/RecordPickerDialog.d.ts +49 -7
  37. package/dist/widgets/RichTextField.d.ts +19 -1
  38. package/dist/widgets/SelectField.d.ts +25 -5
  39. package/dist/widgets/TagsField.d.ts +1 -1
  40. package/dist/widgets/TextAreaField.d.ts +44 -5
  41. package/dist/widgets/UrlField.d.ts +1 -1
  42. package/dist/widgets/address-format.d.ts +68 -0
  43. package/dist/widgets/file-size-guard.d.ts +41 -0
  44. package/dist/widgets/nativeDateValue.d.ts +37 -0
  45. package/dist/widgets/toDomProps.d.ts +111 -0
  46. package/dist/widgets/toHostGroupProps.d.ts +91 -0
  47. package/dist/widgets/types.d.ts +128 -33
  48. package/dist/widgets/useRecordQuery.d.ts +7 -1
  49. package/dist/withFieldCarrier.d.ts +56 -0
  50. package/package.json +20 -16
  51. package/src/FieldEditWidget.tsx +17 -1
  52. package/src/__tests__/AddressCellRenderer.test.tsx +166 -0
  53. package/src/__tests__/CurrencyField.minorUnits.test.tsx +191 -0
  54. package/src/__tests__/CurrencyField.symbol.test.tsx +198 -0
  55. package/src/__tests__/DateCellRenderer.test.tsx +9 -0
  56. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +146 -0
  57. package/src/__tests__/PercentCellRenderer.locale.test.tsx +152 -0
  58. package/src/__tests__/PercentCellRenderer.test.tsx +115 -0
  59. package/src/__tests__/boolean-label-association-e2e.test.tsx +298 -0
  60. package/src/__tests__/capability-multiselect-retired.test.ts +109 -0
  61. package/src/__tests__/cell-truncation.test.tsx +96 -0
  62. package/src/__tests__/composite-group-label-e2e.test.tsx +436 -0
  63. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +403 -0
  64. package/src/__tests__/date-formatter-residue-4272.test.ts +120 -0
  65. package/src/__tests__/date-locale-channel.test.tsx +305 -0
  66. package/src/__tests__/field-carrier-sdui.test.tsx +169 -0
  67. package/src/__tests__/formatCurrency.centsDigit.test.tsx +158 -0
  68. package/src/__tests__/formatCurrency.minorUnits.test.tsx +221 -0
  69. package/src/__tests__/group-labelling-declaration.test.ts +148 -0
  70. package/src/__tests__/lookupCellRecordLink.test.tsx +171 -0
  71. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +115 -0
  72. package/src/__tests__/percent-formatter-locale-4553.test.ts +147 -0
  73. package/src/__tests__/percent-tie-halfup-4590.test.ts +224 -0
  74. package/src/__tests__/select-label-association-e2e.test.tsx +199 -0
  75. package/src/__tests__/spec-symbol-batch7.test.ts +29 -15
  76. package/src/__tests__/validation-feedback.test.tsx +22 -13
  77. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +265 -0
  78. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +338 -0
  79. package/src/__tests__/widget-dom-leak-e2e.test.tsx +357 -0
  80. package/src/__tests__/widget-props-contract.test.tsx +22 -9
  81. package/src/complex-widgets.test.tsx +22 -6
  82. package/src/currency.ts +130 -0
  83. package/src/datetime-widgets.test.tsx +94 -2
  84. package/src/field-type-alias.multiple.test.ts +81 -0
  85. package/src/field-type-alias.ts +58 -2
  86. package/src/index.css +44 -0
  87. package/src/index.tsx +558 -268
  88. package/src/standard-widgets.test.tsx +8 -1
  89. package/src/widgets/AddressField.postalCode.test.tsx +257 -0
  90. package/src/widgets/AddressField.tsx +107 -38
  91. package/src/widgets/AddressField.uniqueIds.test.tsx +93 -0
  92. package/src/widgets/AvatarField.tsx +9 -2
  93. package/src/widgets/BooleanField.tsx +44 -10
  94. package/src/widgets/CapabilityMultiSelectField.tsx +17 -5
  95. package/src/widgets/CharacterCount.tsx +263 -0
  96. package/src/widgets/CheckboxesField.tsx +66 -16
  97. package/src/widgets/CodeField.tsx +9 -2
  98. package/src/widgets/ColorField.tsx +11 -2
  99. package/src/widgets/CurrencyField.tsx +60 -19
  100. package/src/widgets/DateField.tsx +13 -4
  101. package/src/widgets/DateTimeField.tsx +14 -5
  102. package/src/widgets/EmailField.tsx +5 -5
  103. package/src/widgets/FileField.tsx +39 -11
  104. package/src/widgets/FilterConditionField.tsx +11 -0
  105. package/src/widgets/FormulaField.tsx +7 -2
  106. package/src/widgets/FullscreenFieldEditor.tsx +60 -0
  107. package/src/widgets/GeolocationField.tsx +49 -8
  108. package/src/widgets/GeolocationField.uniqueIds.test.tsx +88 -0
  109. package/src/widgets/GridField.test.tsx +226 -0
  110. package/src/widgets/GridField.tsx +115 -11
  111. package/src/widgets/ImageField.maxSize.test.tsx +154 -0
  112. package/src/widgets/ImageField.tsx +63 -6
  113. package/src/widgets/LocationField.tsx +9 -2
  114. package/src/widgets/LookupField.gateHintLabel.test.tsx +135 -0
  115. package/src/widgets/LookupField.hydration.test.tsx +1 -1
  116. package/src/widgets/LookupField.tsx +49 -10
  117. package/src/widgets/MasterDetailField.tsx +1 -1
  118. package/src/widgets/MultiSelectField.tsx +86 -16
  119. package/src/widgets/NumberField.tsx +3 -3
  120. package/src/widgets/ObjectField.tsx +17 -8
  121. package/src/widgets/ObjectRefField.tsx +10 -0
  122. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +88 -0
  123. package/src/widgets/OptionsEmptyState.test.tsx +169 -0
  124. package/src/widgets/OptionsEmptyState.tsx +99 -0
  125. package/src/widgets/PasswordField.tsx +3 -3
  126. package/src/widgets/PeoplePicker.tsx +7 -1
  127. package/src/widgets/PercentField.tsx +5 -4
  128. package/src/widgets/PhoneField.tsx +5 -5
  129. package/src/widgets/QRCodeField.tsx +9 -2
  130. package/src/widgets/RadioField.tsx +58 -16
  131. package/src/widgets/RatingField.tsx +32 -4
  132. package/src/widgets/RecipientPickerField.tsx +18 -0
  133. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +191 -0
  134. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +124 -0
  135. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +611 -0
  136. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +112 -0
  137. package/src/widgets/RecordPickerDialog.tsx +235 -21
  138. package/src/widgets/RichTextField.tsx +151 -24
  139. package/src/widgets/SelectField.tsx +84 -22
  140. package/src/widgets/SliderField.tsx +1 -1
  141. package/src/widgets/SummaryField.tsx +1 -1
  142. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +68 -0
  143. package/src/widgets/TagsField.placeholder.test.tsx +95 -0
  144. package/src/widgets/TagsField.tsx +23 -2
  145. package/src/widgets/TextAreaField.tsx +171 -93
  146. package/src/widgets/TextField.tsx +3 -3
  147. package/src/widgets/TimeField.tsx +2 -2
  148. package/src/widgets/UrlField.tsx +5 -5
  149. package/src/widgets/UserField.tsx +4 -3
  150. package/src/widgets/VectorField.tsx +1 -1
  151. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +25 -1
  152. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +248 -0
  153. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +116 -0
  154. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +244 -0
  155. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +195 -0
  156. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +417 -0
  157. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +287 -0
  158. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +160 -0
  159. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +161 -0
  160. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +346 -0
  161. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +246 -0
  162. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +175 -0
  163. package/src/widgets/address-format.ts +120 -0
  164. package/src/widgets/file-size-guard.ts +55 -0
  165. package/src/widgets/nativeDateValue.test.ts +115 -0
  166. package/src/widgets/nativeDateValue.ts +112 -0
  167. package/src/widgets/optionWidgets.gatedOptions.test.tsx +267 -0
  168. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +150 -0
  169. package/src/widgets/toDomProps.ts +204 -0
  170. package/src/widgets/toHostGroupProps.ts +102 -0
  171. package/src/widgets/types.ts +135 -35
  172. package/src/widgets/useFieldTranslation.ts +46 -4
  173. package/src/widgets/useRecordQuery.ts +30 -3
  174. package/src/withFieldCarrier.tsx +75 -0
  175. package/dist/FieldEditWidget.test.d.ts +0 -8
  176. package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
  177. package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
  178. package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
  179. package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
  180. package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
  181. package/dist/__tests__/registry-collision.test.d.ts +0 -0
  182. package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
  183. package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
  184. package/dist/__tests__/validation-feedback.test.d.ts +0 -0
  185. package/dist/__tests__/widget-props-contract.test.d.ts +0 -8
  186. package/dist/coerce-safe-value.test.d.ts +0 -1
  187. package/dist/complex-widgets.test.d.ts +0 -1
  188. package/dist/datetime-widgets.test.d.ts +0 -1
  189. package/dist/field-type-coverage.test.d.ts +0 -1
  190. package/dist/standard-widgets.test.d.ts +0 -0
  191. package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
  192. package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
  193. package/dist/widgets/GridField.test.d.ts +0 -1
  194. package/dist/widgets/ImageLightbox.test.d.ts +0 -1
  195. package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
  196. package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
  197. package/dist/widgets/LookupField.idField.test.d.ts +0 -1
  198. package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
  199. package/dist/widgets/PeoplePicker.test.d.ts +0 -8
  200. package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
  201. package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
  202. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  203. package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
  204. package/dist/widgets/UserFieldPicker.test.d.ts +0 -8
  205. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
  206. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
  207. package/dist/widgets/deriveLookupColumns.test.d.ts +0 -1
  208. package/dist/widgets/file-value.test.d.ts +0 -1
  209. package/dist/widgets/personDisplay.test.d.ts +0 -8
  210. package/dist/widgets/recentLookups.test.d.ts +0 -1
  211. package/dist/widgets/useRecordQuery.test.d.ts +0 -8
  212. package/dist/widgets/useUploadingSignal.test.d.ts +0 -1
@@ -1,53 +1,85 @@
1
1
  import React from 'react';
2
2
  import { Input, EmptyValue } from '@object-ui/components';
3
3
  import { FieldWidgetComponentProps } from './types';
4
- import { useLocalization } from '@object-ui/i18n';
5
- import { resolveFieldCurrency } from '../currency';
4
+ import { toDomProps } from './toDomProps';
5
+ import { useLocalization, useDisplayLocale, formatDisplayNumber } from '@object-ui/i18n';
6
+ import { resolveFieldCurrency, currencyFractionDigits, currencySymbol } from '../currency';
6
7
 
7
8
  /**
8
9
  * Format currency value for display. When `currency` is undefined the value
9
10
  * is rendered as a plain number with thousands separators (no symbol),
10
11
  * because silently assuming USD is misleading for non-USD businesses.
12
+ *
13
+ * `precision` is a display width the caller resolved — either the field's
14
+ * authored `precision` or, when it declared none, the currency's own ISO 4217
15
+ * minor-unit count (see the derivation at the call site, objectui#4361).
11
16
  */
12
- function formatAmount(value: number, currency: string | undefined, precision: number): string {
17
+ function formatAmount(
18
+ value: number,
19
+ currency: string | undefined,
20
+ precision: number,
21
+ locale?: string,
22
+ ): string {
13
23
  if (currency) {
14
24
  try {
15
- return new Intl.NumberFormat('en-US', {
16
- style: 'currency',
25
+ return formatDisplayNumber(value, {
26
+ locale,
17
27
  currency,
18
28
  minimumFractionDigits: precision,
19
29
  maximumFractionDigits: precision,
20
- }).format(value);
30
+ });
21
31
  } catch {
22
32
  return `${currency} ${value.toFixed(precision)}`;
23
33
  }
24
34
  }
25
35
  try {
26
- return new Intl.NumberFormat('en-US', {
36
+ // No `scale` passed: this is a currency widget whose `precision` is a
37
+ // display width, so a `precision: 0` amount keeps its separators rather
38
+ // than being read as an ordinal (objectui#4033).
39
+ return formatDisplayNumber(value, {
40
+ locale,
27
41
  minimumFractionDigits: precision,
28
42
  maximumFractionDigits: precision,
29
- }).format(value);
43
+ });
30
44
  } catch {
31
45
  return value.toFixed(precision);
32
46
  }
33
47
  }
34
48
 
35
- const CURRENCY_SYMBOLS: Record<string, string> = {
36
- USD: '$',
37
- };
38
-
39
- export function CurrencyField({ value, onChange, field, readonly, errorMessage, className, ...props }: FieldWidgetComponentProps<number>) {
40
- const currencyField = (field || (props as any).schema) as any;
49
+ export function CurrencyField({ value, onChange, field, readonly, error, className, ...props }: FieldWidgetComponentProps<number>) {
50
+ const currencyField = field as any;
41
51
  // Shared precedence: field currency → currencyConfig → tenant default (ADR-0053).
42
52
  const { currency: tenantCurrency } = useLocalization();
53
+ const locale = useDisplayLocale();
43
54
  const currency: string | undefined = resolveFieldCurrency(currencyField, tenantCurrency);
44
- const precision = currencyField?.precision ?? 2;
55
+ // An AUTHORED `precision` wins: it is authored metadata, and this repo's
56
+ // convention is that authored metadata keeps priority. Whether a declared
57
+ // `precision` that contradicts the currency's ISO 4217 digits (say
58
+ // `precision: 2` on a JPY field) should be REJECTED at publish time is a
59
+ // contract question, so it is filed upstream in `@objectstack/spec` rather
60
+ // than answered here by overriding the author (objectui#4361).
61
+ //
62
+ // An ABSENT `precision` derives from the currency instead of defaulting to a
63
+ // literal 2, which rendered `¥1,234.50` for a currency with no minor unit.
64
+ // "Absent" is genuinely distinguishable from "authored 2" here — MEASURED,
65
+ // not assumed: `CurrencyFieldMetadata.precision` is optional in
66
+ // `@object-ui/types` and `z.ZodOptional<z.ZodNumber>` (no `.default()`) in
67
+ // `@objectstack/spec`, so a parsed field carries no materialized 2. The only
68
+ // `.default(2)` on the currency surface is `CurrencyConfigSchema.precision`,
69
+ // a different key on the `currencyConfig` block that this widget never reads.
70
+ //
71
+ // This is the widget's ONE precision, so the derivation also reaches the edit
72
+ // affordances below. Deliberate: leaving `step`/blur-rounding at 2 would give
73
+ // a JPY field that displays whole yen while offering a 0.01 spinner step and
74
+ // rounding typed input to 1234.56 yen.
75
+ const precision =
76
+ currencyField?.precision ?? (currency ? currencyFractionDigits(currency) : 2);
45
77
 
46
78
  if (readonly) {
47
79
  if (value == null) return <EmptyValue />;
48
80
  return (
49
81
  <span className="text-sm font-medium tabular-nums">
50
- {formatAmount(Number(value), currency, precision)}
82
+ {formatAmount(Number(value), currency, precision, locale)}
51
83
  </span>
52
84
  );
53
85
  }
@@ -60,7 +92,16 @@ export function CurrencyField({ value, onChange, field, readonly, errorMessage,
60
92
  }
61
93
  };
62
94
 
63
- const symbol = currency ? (currency === 'USD' ? '$' : currency) : '';
95
+ // ONE channel for the symbol (objectui#4414). This used to be a hand-written
96
+ // `currency === 'USD' ? '$' : currency` ternary, with a dead one-entry
97
+ // `CURRENCY_SYMBOLS` map sitting two lines above it restating the same fact —
98
+ // a table that looked like the place to add a currency but that nothing read.
99
+ // Both were hand copies of knowledge `Intl` already carries, and both are
100
+ // gone: `currencySymbol` reads the `currency` part of the SAME format the
101
+ // readonly branch above renders amounts with, so the two modes of this widget
102
+ // can no longer disagree (they did: `JPY` in the adornment, `¥1,235` in the
103
+ // readonly span). USD at the display-locale default is `$` either way.
104
+ const symbol = currency ? currencySymbol(currency, locale) : '';
64
105
 
65
106
  return (
66
107
  <div className="relative">
@@ -70,7 +111,7 @@ export function CurrencyField({ value, onChange, field, readonly, errorMessage,
70
111
  </span>
71
112
  )}
72
113
  <Input
73
- {...props}
114
+ {...toDomProps(props)}
74
115
  type="number"
75
116
  value={value ?? ''}
76
117
  onChange={(e) => {
@@ -87,7 +128,7 @@ export function CurrencyField({ value, onChange, field, readonly, errorMessage,
87
128
  min={typeof currencyField?.min === 'number' ? currencyField.min : undefined}
88
129
  max={typeof currencyField?.max === 'number' ? currencyField.max : undefined}
89
130
  step={Math.pow(10, -precision).toFixed(precision)}
90
- aria-invalid={!!errorMessage}
131
+ aria-invalid={!!error}
91
132
  />
92
133
  </div>
93
134
  );
@@ -1,25 +1,34 @@
1
1
  import React from 'react';
2
2
  import { Input, EmptyValue } from '@object-ui/components';
3
+ import { useDisplayLocale } from '@object-ui/i18n';
3
4
  import { FieldWidgetComponentProps } from './types';
5
+ import { toDomProps } from './toDomProps';
4
6
  import { openNativePicker } from './openNativePicker';
7
+ import { toDateInputValue } from './nativeDateValue';
5
8
 
6
9
  /**
7
10
  * DateField - Date picker input widget
8
11
  * Uses native date input and displays locale-formatted date in readonly mode
9
12
  */
10
13
  export function DateField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
14
+ // Before the readonly early return: the hook count must not depend on a prop
15
+ // (objectui#4468). A bare `toLocaleDateString()` reads the MACHINE's locale,
16
+ // which is how a Chinese form ended up with an `8/11/2026` value in it.
17
+ const locale = useDisplayLocale();
11
18
  if (readonly) {
12
- return value ? <span className="text-sm">{new Date(value).toLocaleDateString()}</span> : <EmptyValue />;
19
+ return value ? <span className="text-sm">{new Date(value).toLocaleDateString(locale)}</span> : <EmptyValue />;
13
20
  }
14
21
 
15
- // Filter out non-DOM props
16
- const { inputType, ...domProps } = props as any;
22
+ const domProps = toDomProps(props);
17
23
 
18
24
  return (
19
25
  <Input
20
26
  {...domProps}
21
27
  type="date"
22
- value={value || ''}
28
+ // An API that hands back `2026-06-17T00:00:00.000Z` for a `date` field
29
+ // would leave this control empty too (objectui#3127). The written-back
30
+ // shape is unchanged: the control's own plain `YYYY-MM-DD`.
31
+ value={toDateInputValue(value)}
23
32
  onChange={(e) => onChange(e.target.value)}
24
33
  onClick={(e) => {
25
34
  openNativePicker(e.currentTarget);
@@ -1,32 +1,41 @@
1
1
  import React from 'react';
2
2
  import { Input, EmptyValue } from '@object-ui/components';
3
+ import { useDisplayLocale } from '@object-ui/i18n';
3
4
  import { FieldWidgetComponentProps } from './types';
5
+ import { toDomProps } from './toDomProps';
4
6
  import { openNativePicker } from './openNativePicker';
7
+ import { toDateTimeInputValue, fromDateTimeInputValue } from './nativeDateValue';
5
8
 
6
9
  /**
7
10
  * DateTimeField - Combined date and time picker widget
8
11
  * Displays both date and time in locale format when readonly
9
12
  */
10
13
  export function DateTimeField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
14
+ // Before the readonly early return — the hook count must not depend on a
15
+ // prop. See DateField for why the bare `toLocale*` calls were wrong
16
+ // (objectui#4468).
17
+ const locale = useDisplayLocale();
11
18
  if (readonly) {
12
19
  if (!value) return <EmptyValue />;
13
20
  const date = new Date(value);
14
21
  return (
15
22
  <span className="text-sm">
16
- {date.toLocaleDateString()} {date.toLocaleTimeString()}
23
+ {date.toLocaleDateString(locale)} {date.toLocaleTimeString(locale)}
17
24
  </span>
18
25
  );
19
26
  }
20
27
 
21
- // Filter out non-DOM props
22
- const { inputType, ...domProps } = props as any;
28
+ const domProps = toDomProps(props);
23
29
 
24
30
  return (
25
31
  <Input
26
32
  {...domProps}
27
33
  type="datetime-local"
28
- value={value || ''}
29
- onChange={(e) => onChange(e.target.value)}
34
+ // The record's value is ISO-8601 (`…T14:30:00.000Z`), which this control
35
+ // rejects outright — it renders empty and the user reads that as a lost
36
+ // value (objectui#3127). Convert in, and back out on the same basis.
37
+ value={toDateTimeInputValue(value)}
38
+ onChange={(e) => onChange(fromDateTimeInputValue(e.target.value))}
30
39
  onClick={(e) => {
31
40
  openNativePicker(e.currentTarget);
32
41
  domProps.onClick?.(e);
@@ -1,13 +1,14 @@
1
1
  import React from 'react';
2
2
  import { Input, EmptyValue } from '@object-ui/components';
3
3
  import { FieldWidgetComponentProps } from './types';
4
+ import { toDomProps } from './toDomProps';
4
5
 
5
6
  /**
6
7
  * EmailField - Email address input with mailto link in readonly mode
7
8
  * Renders as a clickable mailto link when readonly, standard email input otherwise
8
9
  */
9
- export function EmailField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetComponentProps<string>) {
10
- const config = field || (props as any).schema;
10
+ export function EmailField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
11
+ const config = field;
11
12
  if (readonly) {
12
13
  if (!value) return <EmptyValue />;
13
14
  return (
@@ -20,8 +21,7 @@ export function EmailField({ value, onChange, field, readonly, errorMessage, ...
20
21
  );
21
22
  }
22
23
 
23
- // Filter out non-DOM props
24
- const { inputType, ...domProps } = props as any;
24
+ const domProps = toDomProps(props);
25
25
 
26
26
  return (
27
27
  <Input
@@ -31,7 +31,7 @@ export function EmailField({ value, onChange, field, readonly, errorMessage, ...
31
31
  onChange={(e) => onChange(e.target.value)}
32
32
  placeholder={config?.placeholder || 'email@example.com'}
33
33
  disabled={readonly || domProps.disabled}
34
- aria-invalid={!!errorMessage}
34
+ aria-invalid={!!error}
35
35
  />
36
36
  );
37
37
  }
@@ -4,7 +4,10 @@ import { useUpload } from '@object-ui/providers';
4
4
  import { useObjectTranslation } from '@object-ui/i18n';
5
5
  import { Upload, X, File as FileIcon, ImageIcon, Camera, Loader2 } from 'lucide-react';
6
6
  import { FieldWidgetComponentProps } from './types';
7
+ import { toDomProps } from './toDomProps';
8
+ import { toHostGroupProps } from './toHostGroupProps';
7
9
  import { useUploadingSignal } from './useUploadingSignal';
10
+ import { maxSizeError, type TranslateFn } from './file-size-guard';
8
11
  import {
9
12
  fileValueForSubmit,
10
13
  readFileValues,
@@ -46,13 +49,11 @@ function useFileUploads(opts: {
46
49
  if (selectedFiles.length === 0) return;
47
50
  const newErrors: string[] = [];
48
51
 
52
+ // Shared with ImageField — see `file-size-guard`.
49
53
  const validFiles = selectedFiles.filter(file => {
50
- if (maxSize && file.size > maxSize) {
51
- const maxMB = (maxSize / (1024 * 1024)).toFixed(1);
52
- newErrors.push(t('fields.file.exceedsMaxSize', {
53
- defaultValue: `"${file.name}" exceeds max size (${maxMB} MB)`,
54
- name: file.name, max: maxMB,
55
- }));
54
+ const rejection = maxSizeError(t as TranslateFn, file, maxSize);
55
+ if (rejection) {
56
+ newErrors.push(rejection);
56
57
  return false;
57
58
  }
58
59
  return true;
@@ -115,11 +116,11 @@ function useFileUploads(opts: {
115
116
  * Supports single and multiple file uploads with configurable accepted file types.
116
117
  * L2: File size validation, per-file progress indicators, error messages.
117
118
  */
118
- export function FileField({ value, onChange, field, readonly, onUploadingChange, ...props }: FieldWidgetComponentProps<any>) {
119
+ export function FileField({ value, onChange, field, readonly, onUploadingChange, error, ...props }: FieldWidgetComponentProps<any>) {
119
120
  const { t } = useObjectTranslation();
120
121
  const inputRef = useRef<HTMLInputElement>(null);
121
122
  const cameraRef = useRef<HTMLInputElement>(null);
122
- const fileField = (field || (props as any).schema) as any;
123
+ const fileField = field as any;
123
124
  const multiple = fileField?.multiple || false;
124
125
  const accept = fileField?.accept ? fileField.accept.join(',') : undefined;
125
126
  const maxSize = fileField?.maxSize as number | undefined; // bytes
@@ -183,10 +184,18 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
183
184
  }, [accept, processFiles]);
184
185
 
185
186
  if (readonly) {
186
- if (!value) return <EmptyValue />;
187
+ // Readonly there is no dropzone: the field's whole rendered surface is the
188
+ // list of file names, so THAT is what the host's group label names
189
+ // (objectui#3990). The role is therefore `group` here and `button` (the
190
+ // dropzone) while editable — the same widget, two different surfaces. See
191
+ // `toHostGroupProps`; `EmptyValue`'s own `aria-label` ("No value") is
192
+ // outranked by `aria-labelledby` per accname, and on the `generic` role that
193
+ // placeholder span carries it was never exposed as a name anyway.
194
+ const hostGroupProps = toHostGroupProps(props);
195
+ if (!value) return <EmptyValue {...hostGroupProps} />;
187
196
 
188
197
  return (
189
- <div className="flex flex-wrap gap-2">
198
+ <div {...hostGroupProps} className="flex flex-wrap gap-2">
190
199
  {views.map((file, idx) => (
191
200
  <span key={idx} className="text-sm truncate max-w-xs">
192
201
  {file.name}
@@ -209,6 +218,8 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
209
218
  }
210
219
  };
211
220
 
221
+ const { name: _domName, ...dropzoneDomProps } = toDomProps(props);
222
+
212
223
  return (
213
224
  <div className={props.className}>
214
225
  <input
@@ -233,8 +244,23 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
233
244
  )}
234
245
 
235
246
  <div className="space-y-2">
236
- {/* Drag-and-drop zone */}
247
+ {/* Drag-and-drop zone — the widget's real focusable control (it is the
248
+ keyboard path to the hidden file input), so the DOM pass-through
249
+ and the validation state land here (objectui#3318). `name` is
250
+ withheld: only DOM-legal on form controls, and on this div it is
251
+ exactly the leak #3291 sweeps for.
252
+
253
+ It also carries the host's label. This widget is declared
254
+ `labelling: 'group'` (objectui#3961) NOT because it is composite — it
255
+ has exactly one control — but because that one control is a
256
+ `div[role="button"]`, which no `<label for>` can reach. So the form
257
+ renderer names it by IDREF instead, and `aria-labelledby` arrives in
258
+ the same whitelist spread (`toDomProps` forwards the whole `aria-`
259
+ family). Deliberately no extra `role="group"` wrapper: there is no
260
+ group here, and adding one would announce a container that holds a
261
+ single button. */}
237
262
  <div
263
+ {...dropzoneDomProps}
238
264
  onDragOver={handleDragOver}
239
265
  onDragLeave={handleDragLeave}
240
266
  onDrop={handleDrop}
@@ -255,6 +281,8 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
255
281
  inputRef.current?.click();
256
282
  }
257
283
  }}
284
+ // AFTER the spread so this widget's own computation wins (#3222).
285
+ aria-invalid={!!error}
258
286
  >
259
287
  <Upload className={`size-8 ${isDragOver ? 'text-primary' : 'text-muted-foreground'}`} />
260
288
  <div className="text-center">
@@ -2,6 +2,7 @@ import React from 'react';
2
2
  import { FilterBuilder, cn } from '@object-ui/components';
3
3
  import { SchemaRendererContext } from '@object-ui/react';
4
4
  import type { FieldWidgetComponentProps } from './types';
5
+ import { toDomProps } from './toDomProps';
5
6
  import { useFieldTranslation } from './useFieldTranslation';
6
7
 
7
8
  /**
@@ -297,6 +298,7 @@ export function FilterConditionField({
297
298
  onChange,
298
299
  readonly,
299
300
  className,
301
+ error,
300
302
  ...props
301
303
  }: FieldWidgetComponentProps<string | object>) {
302
304
  const ctx = React.useContext(SchemaRendererContext);
@@ -397,10 +399,19 @@ export function FilterConditionField({
397
399
  {rawMode ? (
398
400
  <>
399
401
  <textarea
402
+ // DOM pass-through onto the raw-JSON editing surface
403
+ // (objectui#3318). NOTE this widget stays on the #3318 ledger
404
+ // regardless: its dependency-gated state (no `object_name` chosen
405
+ // yet — the state a fresh form and the registry sweep render) is a
406
+ // plain hint paragraph with no focusable control.
407
+ {...toDomProps(props)}
400
408
  className="min-h-[96px] w-full rounded border bg-background px-2 py-1 font-mono text-xs"
401
409
  value={rawValue}
402
410
  placeholder='{ "type": "customer", "is_active": true }'
403
411
  onChange={(e) => onChange(e.target.value as any)}
412
+ // The form's validation slot (#3222) OR this widget's own
413
+ // unparsable-JSON state, which already renders its red message.
414
+ aria-invalid={!!error || !parsed.ok}
404
415
  />
405
416
  {!parsed.ok && (
406
417
  <span className="text-xs text-destructive">{t('fields.filterCondition.invalidJson')}</span>
@@ -1,5 +1,6 @@
1
1
  import React from 'react';
2
2
  import { EmptyValue } from '@object-ui/components';
3
+ import { useDisplayLocale } from '@object-ui/i18n';
3
4
  import { FieldWidgetComponentProps } from './types';
4
5
 
5
6
  /**
@@ -7,7 +8,11 @@ import { FieldWidgetComponentProps } from './types';
7
8
  * Values are computed on the backend and cannot be edited
8
9
  */
9
10
  export function FormulaField({ value, field, ...props }: FieldWidgetComponentProps<any>) {
10
- const formulaField = (field || (props as any).schema) as any;
11
+ // Before the empty-value early return — the hook count must not change when
12
+ // a value flips between null and set. A `date` return type used to format
13
+ // through the MACHINE's locale (objectui#4468).
14
+ const locale = useDisplayLocale();
15
+ const formulaField = field as any;
11
16
  const returnType = formulaField?.return_type || 'text';
12
17
 
13
18
  if (value == null) {
@@ -20,7 +25,7 @@ export function FormulaField({ value, field, ...props }: FieldWidgetComponentPro
20
25
  } else if (returnType === 'boolean') {
21
26
  displayValue = value ? 'Yes' : 'No';
22
27
  } else if (returnType === 'date') {
23
- displayValue = new Date(value).toLocaleDateString();
28
+ displayValue = new Date(value).toLocaleDateString(locale);
24
29
  } else {
25
30
  displayValue = String(value);
26
31
  }
@@ -0,0 +1,60 @@
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 React from 'react';
10
+ import { FullscreenEditor, type FullscreenEditorProps } from '@object-ui/components';
11
+
12
+ /**
13
+ * The fullscreen-edit affordance shared by every long-text widget in this
14
+ * package (`field:textarea` → `TextAreaField`, `field:markdown` / `field:html`
15
+ * → `RichTextField`).
16
+ *
17
+ * ## This is now a thin wrapper (objectui#3398)
18
+ *
19
+ * The affordance, the dialog, the draft/commit state machine and the
20
+ * `readOnly` / `disabled` semantics all live in `FullscreenEditor`
21
+ * (`@object-ui/components`). They used to live here AND, separately, in the
22
+ * form renderer's built-in `textarea` branch — one form-level promise
23
+ * (`ObjectFormSchema.mobile.fullscreenLongText`, projected onto every long-text
24
+ * field as `mobile_fullscreen`) answered twice, on the two render paths.
25
+ *
26
+ * Two copies of a state machine drift, and these did: objectui#3400 measured a
27
+ * read-only field editable through the built-in branch's dialog, objectui#3402
28
+ * measured the same hole for `disabled` here, and #3393 (label in the dialog
29
+ * title) and #3272 (i18n) each landed on one side before the other. The
30
+ * maintainer's ruling of 2026-08-10 was one implementation hoisted to the
31
+ * dependency-legal side — and the measured import graph allows exactly that
32
+ * direction: this package depends on `@object-ui/components`, which declares no
33
+ * dependency on `@object-ui/fields` in `dependencies` or `peerDependencies`.
34
+ *
35
+ * ## Why the wrapper survives the merge rather than being deleted
36
+ *
37
+ * It carries the one thing that is genuinely about THIS package: `readOnly` is
38
+ * not part of the contract here. Both hosts early-return a read-only DISPLAY
39
+ * before they compute the affordance (`TextAreaField.tsx`, `RichTextField.tsx`),
40
+ * so a `readOnly` reaching this component would be a prop with no producer on
41
+ * this path — the shape this package keeps deleting (objectui#3232/#3233). The
42
+ * `Omit` below is that statement in the type system, and it is why the hoist did
43
+ * not simply re-export the primitive. A future host that renders an editor for
44
+ * read-only fields must widen this type AND pass the prop together; the
45
+ * primitive already answers it (no affordance at all).
46
+ *
47
+ * Everything else — `value` / `onCommit` / `label` / `testIdPrefix` /
48
+ * `disabled` / `children` / `footer` — passes straight through, so the hosts and
49
+ * their pins are unchanged. `testIdPrefix` still namespaces this package's ids
50
+ * (`textarea-`, `richtext-`), distinct from the built-in branch's
51
+ * `form-textarea-`, so a test can still say which render path it found.
52
+ */
53
+ export type FullscreenFieldEditorProps = Omit<FullscreenEditorProps, 'readOnly'>;
54
+
55
+ export function FullscreenFieldEditor(props: FullscreenFieldEditorProps) {
56
+ // `readOnly` is deliberately not forwarded — it is not in the props type, and
57
+ // the primitive defaults it to false, which is the behaviour both hosts rely
58
+ // on: they have already returned a read-only display before reaching here.
59
+ return <FullscreenEditor {...props} />;
60
+ }
@@ -1,7 +1,9 @@
1
- import React from 'react';
1
+ import React, { useId } from 'react';
2
2
  import { Input, Button, Label, EmptyValue } from '@object-ui/components';
3
3
  import { MapPin, Crosshair } from 'lucide-react';
4
4
  import { FieldWidgetComponentProps } from './types';
5
+ import { toDomProps } from './toDomProps';
6
+ import { toHostGroupProps } from './toHostGroupProps';
5
7
 
6
8
  /**
7
9
  * Geolocation data structure
@@ -16,9 +18,37 @@ export interface GeolocationValue {
16
18
  * Geolocation field widget - provides a location picker with coordinates
17
19
  * Supports manual entry and browser geolocation API
18
20
  */
19
- export function GeolocationField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<GeolocationValue>) {
21
+ export function GeolocationField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<GeolocationValue>) {
20
22
  const [isLoading, setIsLoading] = React.useState(false);
21
23
  const location = value || {};
24
+ // DOM pass-through (objectui#3318): the whitelist spread goes onto the FIRST
25
+ // sub-input (latitude); the composite's validation state goes onto BOTH
26
+ // focusable sub-inputs via `aria-invalid={!!error}`.
27
+ //
28
+ // `id` and `aria-labelledby` are held back and land on the group CONTAINER
29
+ // instead (objectui#3961) — they address the whole field, not the latitude box.
30
+ // The host `id` never reached the DOM before: it was overwritten one line later
31
+ // by `id={subId('latitude')}` (objectui#3343), leaving the form's group label
32
+ // pointing at nothing. And `aria-labelledby` on the latitude input would
33
+ // OVERRIDE its own "Latitude" label with the field name. `aria-describedby` and
34
+ // the rest stay on the first sub-input, where focus can reach them.
35
+ const {
36
+ id: _hostId,
37
+ 'aria-labelledby': _hostLabelledBy,
38
+ ...domProps
39
+ } = toDomProps(props);
40
+ // The pair held back above, in the one spelling every group-labelled widget
41
+ // uses for it — and computed HERE, before the readonly early return below,
42
+ // because that return used to drop both keys on the floor: a published label
43
+ // id with no consumer in the document (objectui#3990). See `toHostGroupProps`.
44
+ const hostGroupProps = toHostGroupProps(props);
45
+ // Sub-input ids (objectui#3343): `useId()` prefix + sub-field name — the
46
+ // `groupId` paradigm of RadioField / CheckboxesField. Hardcoded literals
47
+ // ("latitude" / "longitude") collide as soon as a form renders two
48
+ // geolocation fields, and every label's htmlFor then resolves to the
49
+ // FIRST match.
50
+ const groupId = useId();
51
+ const subId = (name: keyof GeolocationValue) => `${groupId}-${name}`;
22
52
 
23
53
  const handleFieldChange = (fieldName: keyof GeolocationValue, fieldValue: string) => {
24
54
  onChange({
@@ -67,9 +97,13 @@ export function GeolocationField({ value, onChange, field, readonly, ...props }:
67
97
  };
68
98
 
69
99
  if (readonly) {
100
+ // Readonly the composite collapses to the formatted coordinates (plus the
101
+ // "View on map" link), so THAT row is the surface the host label names
102
+ // (objectui#3990). The `EmptyValue` placeholder sits inside it, so it needs
103
+ // nothing of its own.
70
104
  const formatted = formatLocation(location);
71
105
  return (
72
- <div className="flex items-center gap-2">
106
+ <div {...hostGroupProps} className="flex items-center gap-2">
73
107
  <MapPin className="w-4 h-4 text-muted-foreground" />
74
108
  {formatted ? <span className="text-sm">{formatted}</span> : <EmptyValue />}
75
109
  {location.latitude && location.longitude && (
@@ -88,7 +122,11 @@ export function GeolocationField({ value, onChange, field, readonly, ...props }:
88
122
  }
89
123
 
90
124
  return (
91
- <div className="space-y-3">
125
+ // `role="group"` only when a host actually named this container
126
+ // (objectui#3961); standalone rendering stays exactly as it was. That
127
+ // condition now lives in `toHostGroupProps` so this container and the
128
+ // readonly row above cannot answer differently (objectui#3990).
129
+ <div className="space-y-3" {...hostGroupProps}>
92
130
  <div className="flex items-center gap-2">
93
131
  <Button
94
132
  type="button"
@@ -115,9 +153,10 @@ export function GeolocationField({ value, onChange, field, readonly, ...props }:
115
153
 
116
154
  <div className="grid grid-cols-2 gap-3">
117
155
  <div>
118
- <Label htmlFor="latitude" className="text-xs">Latitude</Label>
156
+ <Label htmlFor={subId('latitude')} className="text-xs">Latitude</Label>
119
157
  <Input
120
- id="latitude"
158
+ {...domProps}
159
+ id={subId('latitude')}
121
160
  type="number"
122
161
  value={location.latitude ?? ''}
123
162
  onChange={(e) => handleFieldChange('latitude', e.target.value)}
@@ -125,19 +164,21 @@ export function GeolocationField({ value, onChange, field, readonly, ...props }:
125
164
  disabled={readonly || props.disabled}
126
165
  step="any"
127
166
  className={props.className}
167
+ aria-invalid={!!error}
128
168
  />
129
169
  </div>
130
170
 
131
171
  <div>
132
- <Label htmlFor="longitude" className="text-xs">Longitude</Label>
172
+ <Label htmlFor={subId('longitude')} className="text-xs">Longitude</Label>
133
173
  <Input
134
- id="longitude"
174
+ id={subId('longitude')}
135
175
  type="number"
136
176
  value={location.longitude ?? ''}
137
177
  onChange={(e) => handleFieldChange('longitude', e.target.value)}
138
178
  placeholder="-122.4194"
139
179
  disabled={readonly || props.disabled}
140
180
  step="any"
181
+ aria-invalid={!!error}
141
182
  />
142
183
  </div>
143
184
  </div>