@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
@@ -2,6 +2,9 @@ import React, { useEffect } from 'react';
2
2
  import { Badge, EmptyValue, cn } from '@object-ui/components';
3
3
  import type { OptionLike } from '@object-ui/core';
4
4
  import { FieldWidgetComponentProps } from './types';
5
+ import { toDomProps } from './toDomProps';
6
+ import { toHostGroupProps } from './toHostGroupProps';
7
+ import { OptionsEmptyState } from './OptionsEmptyState';
5
8
  import { useCascadingOptions } from './useCascadingOptions';
6
9
 
7
10
  interface Option extends OptionLike { color?: string }
@@ -26,14 +29,14 @@ export function MultiSelectField({
26
29
  field,
27
30
  readonly,
28
31
  className,
29
- schema,
30
32
  dependentValues,
31
33
  dependsOn: dependsOnProp,
32
- emptyHint: _emptyHint,
34
+ emptyHint,
33
35
  dataSource: _dataSource,
36
+ error,
34
37
  ...props
35
38
  }: FieldWidgetComponentProps<string[]>) {
36
- const config = (field || schema) as any;
39
+ const config = field as any;
37
40
  const rawOptions: Option[] = config?.options || [];
38
41
  const selected: string[] = Array.isArray(value) ? value : value == null ? [] : [value as unknown as string];
39
42
  const fieldName = props.name || config?.name || props.id || '';
@@ -50,16 +53,56 @@ export function MultiSelectField({
50
53
  // the scalar case we prune per-element rather than clearing the whole field.
51
54
  useEffect(() => {
52
55
  if (readonly) return;
56
+ // An empty offered set has THREE very different causes, and only one of them
57
+ // is a decision to prune against. This is the canonical copy of the guards;
58
+ // the other three option widgets carry the same pair.
59
+ //
60
+ // 1. Nothing was ever CONFIGURED (objectui#4220) — a field authored with no
61
+ // `options` at all. No decision was made anywhere, and the widget renders
62
+ // its "unfillable" state below. Pruning here is not a cascade, it is
63
+ // deleting the stored value of a field the user was only ever shown a
64
+ // hint for — measured on the detail page's inline editor, which stages
65
+ // that empty array into the record draft the moment the row enters edit
66
+ // mode, and on the grid's inline cell editor, which has always taken this
67
+ // path.
68
+ if (rawOptions.length === 0) return;
69
+ // 2. The list is GATED (objectui#4247) — an authored list withheld because a
70
+ // declared `dependsOn` parent is still empty, so `resolveCascadingOptions`
71
+ // returns nothing at all. Gated means UNKNOWN, not invalid: the widget has
72
+ // no information about which stored values are valid, which is exactly
73
+ // what the `OptionsEmptyState` below tells the user ("select Country
74
+ // first"). Clearing here fired on MOUNT with no interaction — a record
75
+ // that merely ARRIVES with the parent empty (a later-cleared parent, an
76
+ // import, a partially-migrated row) had its picklist silently staged for
77
+ // deletion. Read from the resolver's own `gated` flag rather than
78
+ // re-derived from `options.length === 0`, which would collide with case 1.
79
+ if (gated) return;
80
+ // 3. The list RESOLVED and genuinely excludes the value — a real cascade
81
+ // (the parent changed, a predicate flipped). That is the ADR-0058
82
+ // contract and it still prunes, below.
53
83
  if (selected.length === 0) return;
54
84
  const stillOffered = selected.filter((v) => options.some((o) => o.value === v));
55
85
  if (stillOffered.length !== selected.length) onChange(stillOffered);
56
86
  // eslint-disable-next-line react-hooks/exhaustive-deps
57
87
  }, [options, gated]);
58
88
 
89
+ // The host's group label has to be consumed on EVERY surface this widget can
90
+ // render, not only the editable one (objectui#3990): both branches below
91
+ // return before the editable container's `groupDomProps` spread, which is how
92
+ // a field-level `readonly: true` and a zero-option list ended up with a
93
+ // published label id and no consumer at all. See `toHostGroupProps`.
94
+ const hostGroupProps = toHostGroupProps(props);
95
+
59
96
  if (readonly) {
60
- if (selected.length === 0) return <EmptyValue />;
97
+ // A readonly set of values is still a set, so it takes the same `group`
98
+ // answer the editable chip row gives. `EmptyValue` is that surface with
99
+ // nothing in it: it publishes an `aria-label` ("No value") of its own, and
100
+ // `aria-labelledby` outranks that per accname — correctly, because on the
101
+ // `generic` role that placeholder span carries, an author name is prohibited
102
+ // and never exposed, so the real choice is the field's name or no name.
103
+ if (selected.length === 0) return <EmptyValue {...hostGroupProps} />;
61
104
  return (
62
- <div className="flex flex-wrap gap-1">
105
+ <div {...hostGroupProps} className="flex flex-wrap gap-1">
63
106
  {selected.map((v) => {
64
107
  // Label from the raw set so a stored value hidden by `visibleWhen`
65
108
  // still renders its label rather than a bare id.
@@ -71,19 +114,21 @@ export function MultiSelectField({
71
114
  }
72
115
 
73
116
  // No offered options is unfillable — surface a legible state instead of an
74
- // empty chip row: a dependency-gated list prompts for its controlling field;
75
- // an unconfigured / fully-filtered list says so. Mirrors the single select.
117
+ // empty chip row: the host's `emptyHint` when it computed one, else this
118
+ // widget's own translated copy. Shared with the single select / radio /
119
+ // checkboxes so the four cannot drift again (objectui#3231).
76
120
  if (options.length === 0) {
77
- const hint = gated
78
- ? `Select ${dependsOnFields.join(' / ')} first`
79
- : 'No options available';
80
121
  return (
81
- <div
82
- data-testid={fieldName ? `multiselect-empty-${fieldName}` : undefined}
83
- className="flex min-h-9 w-full items-center rounded-md border border-input bg-muted/30 px-3 py-2 text-sm text-muted-foreground"
84
- >
85
- {hint}
86
- </div>
122
+ <OptionsEmptyState
123
+ emptyHint={emptyHint}
124
+ gated={gated}
125
+ dependsOnFields={dependsOnFields}
126
+ testId={fieldName ? `multiselect-empty-${fieldName}` : undefined}
127
+ className="min-h-9"
128
+ // This box IS the field in this state, so it is what the host label
129
+ // names (objectui#3990).
130
+ hostGroupProps={hostGroupProps}
131
+ />
87
132
  );
88
133
  }
89
134
 
@@ -92,8 +137,32 @@ export function MultiSelectField({
92
137
  onChange(next);
93
138
  };
94
139
 
140
+ // DOM pass-through (objectui#3318): the container carries the form
141
+ // renderer's id / aria-describedby, but NOT its `aria-invalid` — a plain
142
+ // wrapper div is not where assistive tech reads the invalid state. That
143
+ // state goes onto every focusable chip button below, computed from the
144
+ // published `error` slot (#3222), so whichever chip the user tabs to
145
+ // announces the failure. `name` is withheld too: it is only DOM-legal on
146
+ // form controls, and on this div it is exactly the leak #3291 sweeps for.
147
+ const {
148
+ 'aria-invalid': _hostAriaInvalid,
149
+ name: _domName,
150
+ ...groupDomProps
151
+ } = toDomProps(props);
152
+ // When the host associated its visible label with this container by IDREF
153
+ // (`aria-labelledby`, objectui#3961 / #3975) the container IS the labelled
154
+ // group, so it answers with the matching role — without one, the name sits on
155
+ // a generic `div` and contributes nothing: `label for` pointing here was
156
+ // already inert (`HTMLLabelElement.control` is null for a div), which is the
157
+ // whole defect. Each chip keeps its own accessible name from its text content,
158
+ // which the group name does not override. Absent (standalone: the inline grid
159
+ // editor, a bare SDUI node) nothing is emitted and the markup is unchanged.
160
+ const isLabelledGroup = groupDomProps['aria-labelledby'] != null;
161
+
95
162
  return (
96
163
  <div
164
+ {...groupDomProps}
165
+ role={isLabelledGroup ? 'group' : undefined}
97
166
  className={cn('flex flex-wrap gap-1.5', className)}
98
167
  data-testid={fieldName ? `multiselect-${fieldName}` : undefined}
99
168
  >
@@ -109,6 +178,7 @@ export function MultiSelectField({
109
178
  onClick={() => toggle(value)}
110
179
  disabled={props.disabled}
111
180
  aria-pressed={active}
181
+ aria-invalid={!!error}
112
182
  data-testid={`multiselect-option-${opt.value}`}
113
183
  className={cn(
114
184
  'rounded-full border px-3 py-1 text-sm transition-colors',
@@ -2,6 +2,7 @@ import React from 'react';
2
2
  import { Input, EmptyValue } from '@object-ui/components';
3
3
  import { NumberFieldMetadata } from '@object-ui/types';
4
4
  import { FieldWidgetComponentProps } from './types';
5
+ import { toDomProps } from './toDomProps';
5
6
 
6
7
  /**
7
8
  * NumberField - Numeric input with optional decimal precision
@@ -12,7 +13,7 @@ export function NumberField({ value, onChange, field, readonly, ...props }: Fiel
12
13
  return value == null ? <EmptyValue /> : <span className="text-sm">{value}</span>;
13
14
  }
14
15
 
15
- const numberField = (field || (props as any).schema) as NumberFieldMetadata;
16
+ const numberField = field as NumberFieldMetadata;
16
17
  // Step follows `scale` (decimal places), not `precision` (total digit count):
17
18
  // a decimal(10, 0) column has 0 decimal places, so the input should step by 1
18
19
  // (`scale: 0` is a valid declaration — hence the typeof check, not truthiness).
@@ -25,8 +26,7 @@ export function NumberField({ value, onChange, field, readonly, ...props }: Fiel
25
26
  ? Math.pow(10, -scale)
26
27
  : 'any';
27
28
 
28
- // Filter out non-DOM props
29
- const { inputType, ...domProps } = props as any;
29
+ const domProps = toDomProps(props);
30
30
 
31
31
  return (
32
32
  <Input
@@ -1,13 +1,14 @@
1
1
  import React, { useState, useEffect } from 'react';
2
2
  import { Textarea, cn, EmptyValue } from '@object-ui/components';
3
3
  import { FieldWidgetComponentProps } from './types';
4
+ import { toDomProps } from './toDomProps';
4
5
 
5
6
  /**
6
7
  * ObjectField - JSON object editor
7
8
  * Allows editing structured JSON data
8
9
  */
9
- export function ObjectField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<any>) {
10
- const config = field || (props as any).schema;
10
+ export function ObjectField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<any>) {
11
+ const config = field;
11
12
 
12
13
  // Initialize string state based on value
13
14
  const getInitialJsonString = () => {
@@ -16,7 +17,9 @@ export function ObjectField({ value, onChange, field, readonly, ...props }: Fiel
16
17
  };
17
18
 
18
19
  const [jsonString, setJsonString] = useState(getInitialJsonString);
19
- const [error, setError] = useState<string | null>(null);
20
+ // Named `parseError`, NOT `error`: `error` is the published validation slot
21
+ // on the widget contract (#3222) and is destructured above.
22
+ const [parseError, setParseError] = useState<string | null>(null);
20
23
 
21
24
  // Sync internal string state when value changes externally
22
25
  // This is a controlled component pattern where we need to sync external changes
@@ -53,33 +56,39 @@ export function ObjectField({ value, onChange, field, readonly, ...props }: Fiel
53
56
  const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
54
57
  const str = e.target.value;
55
58
  setJsonString(str);
56
- setError(null);
59
+ setParseError(null);
57
60
 
58
61
  if (!str.trim()) {
59
62
  onChange(null);
60
63
  return;
61
64
  }
62
-
65
+
63
66
  try {
64
67
  const parsed = JSON.parse(str);
65
68
  onChange(parsed);
66
69
  } catch (e) {
67
70
  // Invalid JSON - don't propagate change to parent, but keep local state
68
- setError("Invalid JSON");
71
+ setParseError("Invalid JSON");
69
72
  }
70
73
  };
71
74
 
72
75
  return (
73
76
  <div className="space-y-1">
74
77
  <Textarea
78
+ // DOM pass-through onto the real focusable control (objectui#3318).
79
+ {...toDomProps(props)}
75
80
  value={jsonString}
76
81
  onChange={handleChange}
77
82
  placeholder={config?.placeholder || '{\n "key": "value"\n}'}
78
83
  disabled={readonly || props.disabled}
79
- className={cn("font-mono text-xs", error ? "border-red-500 focus-visible:ring-red-500" : "", props.className)}
84
+ className={cn("font-mono text-xs", parseError ? "border-red-500 focus-visible:ring-red-500" : "", props.className)}
80
85
  rows={6}
86
+ // AFTER the spread so this widget's own computation wins (#3222).
87
+ // Invalid = the form's validation slot OR this widget's own unparsable
88
+ // draft (which already draws the red border above).
89
+ aria-invalid={!!error || !!parseError}
81
90
  />
82
- {error && <p className="text-xs text-red-500">{error}</p>}
91
+ {parseError && <p className="text-xs text-red-500">{parseError}</p>}
83
92
  </div>
84
93
  );
85
94
  }
@@ -2,6 +2,7 @@ import React from 'react';
2
2
  import { Combobox, EmptyValue, 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
  /**
@@ -31,6 +32,7 @@ export function ObjectRefField({
31
32
  onChange,
32
33
  readonly,
33
34
  className,
35
+ error,
34
36
  ...props
35
37
  }: FieldWidgetComponentProps<string>) {
36
38
  const ctx = React.useContext(SchemaRendererContext);
@@ -91,8 +93,14 @@ export function ObjectRefField({
91
93
  return <span className={className}>{label}</span>;
92
94
  }
93
95
 
96
+ // DOM pass-through onto the combobox trigger — the widget's real focusable
97
+ // control (objectui#3318). `name` is withheld: the trigger is a button, not
98
+ // a submission control (same reasoning as #3306's SelectTrigger).
99
+ const { name: _domName, ...triggerDomProps } = toDomProps(props);
100
+
94
101
  return (
95
102
  <Combobox
103
+ {...triggerDomProps}
96
104
  options={options}
97
105
  value={value ?? ''}
98
106
  onValueChange={(v) => onChange(v as any)}
@@ -104,6 +112,8 @@ export function ObjectRefField({
104
112
  // `w-[200px]` is a component default that left this control stranded at
105
113
  // a third of the row while `名称` beside it ran full width.
106
114
  className={cn('w-full', className)}
115
+ // AFTER the spread so this widget's own computation wins (#3222).
116
+ aria-invalid={!!error}
107
117
  />
108
118
  );
109
119
  }
@@ -0,0 +1,88 @@
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
+ /**
10
+ * The option widgets' empty/gate copy with NO `I18nProvider` mounted
11
+ * (objectui#3231) — standalone/embedded usage, and every test in the repo that
12
+ * renders a field widget bare.
13
+ *
14
+ * Routing the copy through `useFieldTranslation()` must not turn it into a raw
15
+ * i18n key when nothing is configured: `createSafeTranslation` probes the
16
+ * instance and falls back to its English defaults map. That is the behaviour
17
+ * the removed literals used to provide unconditionally, so it gets its own pin.
18
+ *
19
+ * Why a separate FILE rather than another case in `OptionsEmptyState.test.tsx`:
20
+ * mounting `I18nProvider` calls `initReactI18next`, which installs that
21
+ * instance as react-i18next's GLOBAL default. Once any sibling test has done
22
+ * so there is no "no provider" state left to observe in that module graph, and
23
+ * the assertion would silently read the last locale mounted instead. Vitest's
24
+ * per-file isolation (`isolate: true` on the `dom` project) is what makes this
25
+ * file's observation honest.
26
+ */
27
+ import { describe, it, expect, vi } from 'vitest';
28
+ import React from 'react';
29
+ import { render, screen } from '@testing-library/react';
30
+ import '@testing-library/jest-dom';
31
+ import { SelectField } from './SelectField';
32
+ import { MultiSelectField } from './MultiSelectField';
33
+ import { RadioField } from './RadioField';
34
+ import { CheckboxesField } from './CheckboxesField';
35
+
36
+ const WIDGETS = [
37
+ { label: 'SelectField', Widget: SelectField, type: 'select', testId: 'select-empty-province' },
38
+ {
39
+ label: 'MultiSelectField',
40
+ Widget: MultiSelectField,
41
+ type: 'multiselect',
42
+ testId: 'multiselect-empty-province',
43
+ },
44
+ { label: 'RadioField', Widget: RadioField, type: 'radio', testId: 'radio-empty-province' },
45
+ {
46
+ label: 'CheckboxesField',
47
+ Widget: CheckboxesField,
48
+ type: 'checkboxes',
49
+ testId: 'checkboxes-empty-province',
50
+ },
51
+ ] as const;
52
+
53
+ const gatedField = (type: string) =>
54
+ ({
55
+ name: 'province',
56
+ type,
57
+ dependsOn: 'country',
58
+ options: [{ label: 'Zhejiang', value: 'zj', visibleWhen: "record.country == 'cn'" }],
59
+ }) as any;
60
+
61
+ const unconfiguredField = (type: string) => ({ name: 'province', type, options: [] }) as any;
62
+
63
+ function renderWidget(Widget: React.ComponentType<any>, field: any) {
64
+ return render(
65
+ <Widget
66
+ value={undefined}
67
+ onChange={vi.fn()}
68
+ field={field}
69
+ {...({ name: 'province', dependentValues: {} } as any)}
70
+ />,
71
+ );
72
+ }
73
+
74
+ describe('option widgets — English fallback survives with no i18n configured', () => {
75
+ it.each(WIDGETS)('$label renders the gate sentence, not a raw key', ({ Widget, type, testId }) => {
76
+ renderWidget(Widget, gatedField(type));
77
+ const box = screen.getByTestId(testId);
78
+ expect(box).toHaveTextContent('Select country first');
79
+ expect(box.textContent).not.toContain('fields.options');
80
+ });
81
+
82
+ it.each(WIDGETS)('$label renders the empty sentence, not a raw key', ({ Widget, type, testId }) => {
83
+ renderWidget(Widget, unconfiguredField(type));
84
+ const box = screen.getByTestId(testId);
85
+ expect(box).toHaveTextContent('No options available');
86
+ expect(box.textContent).not.toContain('fields.options');
87
+ });
88
+ });
@@ -0,0 +1,169 @@
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
+ /**
10
+ * The empty / dependency-gated state of the fixed-option widgets (objectui#3231).
11
+ *
12
+ * Two invariants, pinned for ALL FOUR widgets — they are what regressed:
13
+ *
14
+ * 1. **declared ⇒ delivered.** `emptyHint` is declared on
15
+ * `FieldWidgetComponentProps` and computed by the form renderer. Every one
16
+ * of the four widgets used to destructure it into `_emptyHint` and drop it
17
+ * on the floor, so the host's hint could never reach a user once the field
18
+ * went through the `field:` registry (i.e. always, in the real console).
19
+ * 2. **the fallback is translated.** With no host hint the widget renders its
20
+ * own copy — which used to be a hardcoded English literal
21
+ * (`'No options available'`, `` `Select ${…} first` ``) even though
22
+ * `useFieldTranslation()` was already imported in one of the same files.
23
+ *
24
+ * All four are covered deliberately: they were four independent copies of the
25
+ * same block, which is exactly why they drifted together and why one widget's
26
+ * test would have proven nothing about the other three. They now share
27
+ * `OptionsEmptyState`; these tests keep that true from the outside, so the
28
+ * guarantee survives someone re-inlining the block.
29
+ */
30
+ import { describe, it, expect, vi } from 'vitest';
31
+ import React from 'react';
32
+ import { render, screen } from '@testing-library/react';
33
+ import '@testing-library/jest-dom';
34
+ import { I18nProvider } from '@object-ui/i18n';
35
+ import { SelectField } from './SelectField';
36
+ import { MultiSelectField } from './MultiSelectField';
37
+ import { RadioField } from './RadioField';
38
+ import { CheckboxesField } from './CheckboxesField';
39
+
40
+ interface WidgetCase {
41
+ label: string;
42
+ Widget: React.ComponentType<any>;
43
+ type: string;
44
+ testId: string;
45
+ }
46
+
47
+ const WIDGETS: WidgetCase[] = [
48
+ { label: 'SelectField', Widget: SelectField, type: 'select', testId: 'select-empty-province' },
49
+ {
50
+ label: 'MultiSelectField',
51
+ Widget: MultiSelectField,
52
+ type: 'multiselect',
53
+ testId: 'multiselect-empty-province',
54
+ },
55
+ { label: 'RadioField', Widget: RadioField, type: 'radio', testId: 'radio-empty-province' },
56
+ {
57
+ label: 'CheckboxesField',
58
+ Widget: CheckboxesField,
59
+ type: 'checkboxes',
60
+ testId: 'checkboxes-empty-province',
61
+ },
62
+ ];
63
+
64
+ /** Gated: `country` is empty, so no option is offered yet (#2284). */
65
+ const gatedField = (type: string) =>
66
+ ({
67
+ name: 'province',
68
+ type,
69
+ dependsOn: 'country',
70
+ options: [
71
+ { label: 'Zhejiang', value: 'zj', visibleWhen: "record.country == 'cn'" },
72
+ { label: 'California', value: 'ca', visibleWhen: "record.country == 'us'" },
73
+ ],
74
+ }) as any;
75
+
76
+ /** Not gated, simply nothing to offer — the other way a list ends up empty. */
77
+ const unconfiguredField = (type: string) => ({ name: 'province', type, options: [] }) as any;
78
+
79
+ function renderWidget(
80
+ { Widget }: WidgetCase,
81
+ field: any,
82
+ extra: Record<string, unknown> = {},
83
+ language?: string,
84
+ ) {
85
+ const element = (
86
+ <Widget
87
+ value={undefined}
88
+ onChange={vi.fn()}
89
+ field={field}
90
+ {...({ name: 'province', dependentValues: {}, ...extra } as any)}
91
+ />
92
+ );
93
+ return render(
94
+ language ? (
95
+ <I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
96
+ {element}
97
+ </I18nProvider>
98
+ ) : (
99
+ element
100
+ ),
101
+ );
102
+ }
103
+
104
+ describe('option widgets — the host-supplied `emptyHint` reaches the user (objectui#3231)', () => {
105
+ it.each(WIDGETS)(
106
+ '$label renders the host hint instead of its own gate copy',
107
+ (widget) => {
108
+ // What the form renderer actually computes: a sentence built from the
109
+ // controlling field's human LABEL, which the widget cannot know (its
110
+ // metadata only carries the name `country`).
111
+ renderWidget(widget, gatedField(widget.type), {
112
+ emptyHint: 'Choose the Country field first',
113
+ });
114
+
115
+ const box = screen.getByTestId(widget.testId);
116
+ expect(box).toHaveTextContent('Choose the Country field first');
117
+ // …and NOT the widget's own copy, which is what used to render.
118
+ expect(box.textContent).not.toMatch(/select country first/i);
119
+ },
120
+ );
121
+
122
+ it.each(WIDGETS)(
123
+ '$label renders the host hint for an unconfigured (ungated) list too',
124
+ (widget) => {
125
+ // `emptyHint` is a declared prop of the widget contract, not a private
126
+ // channel of one form-renderer branch: supplied ⇒ used, always.
127
+ renderWidget(widget, unconfiguredField(widget.type), {
128
+ emptyHint: 'Options come from the price book',
129
+ });
130
+
131
+ expect(screen.getByTestId(widget.testId)).toHaveTextContent(
132
+ 'Options come from the price book',
133
+ );
134
+ },
135
+ );
136
+
137
+ it.each(WIDGETS)('$label prefers a host hint over its translated fallback', (widget) => {
138
+ // Even under a non-English locale the HOST wins — it is the single source.
139
+ renderWidget(widget, gatedField(widget.type), { emptyHint: 'Pick a Country first' }, 'zh');
140
+
141
+ const box = screen.getByTestId(widget.testId);
142
+ expect(box).toHaveTextContent('Pick a Country first');
143
+ expect(box.textContent).not.toContain('请先选择');
144
+ });
145
+ });
146
+
147
+ describe('option widgets — the fallback copy is translated, never a hardcoded literal', () => {
148
+ it.each(WIDGETS)('$label translates the gate copy when no host hint is given', (widget) => {
149
+ renderWidget(widget, gatedField(widget.type), {}, 'zh');
150
+
151
+ const box = screen.getByTestId(widget.testId);
152
+ expect(box).toHaveTextContent('请先选择country');
153
+ expect(box.textContent).not.toMatch(/select .* first/i);
154
+ });
155
+
156
+ it.each(WIDGETS)('$label translates the empty copy when no host hint is given', (widget) => {
157
+ renderWidget(widget, unconfiguredField(widget.type), {}, 'zh');
158
+
159
+ const box = screen.getByTestId(widget.testId);
160
+ expect(box).toHaveTextContent('暂无可选项');
161
+ expect(box.textContent).not.toMatch(/no options available/i);
162
+ });
163
+ });
164
+
165
+ // The no-provider fallback lives in `OptionsEmptyState.no-provider.test.tsx`:
166
+ // mounting `I18nProvider` here installs its instance as react-i18next's global
167
+ // default (`initReactI18next`), so after any test above there is no longer a
168
+ // "no provider" state to observe within this module graph.
169
+
@@ -0,0 +1,99 @@
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 { cn } from '@object-ui/components';
11
+ import { useFieldTranslation } from './useFieldTranslation';
12
+ import type { HostGroupProps } from './toHostGroupProps';
13
+
14
+ /**
15
+ * The "this option list cannot be filled" state shared by every fixed-option
16
+ * widget (`SelectField` single, `MultiSelectField`, `RadioField`,
17
+ * `CheckboxesField`) — objectui#3231.
18
+ *
19
+ * ## One producer, one consumer
20
+ *
21
+ * `emptyHint` is a declared prop on `FieldWidgetComponentProps`, computed by
22
+ * the form renderer (`form.tsx`, the `#2284` dependency gate) and forwarded to
23
+ * these widgets. **When the host supplies it, it wins**: the host resolves the
24
+ * controlling fields to their human LABELS ("Select Country first"), where a
25
+ * widget on its own only knows the raw metadata names ("Select country first").
26
+ * Absent a host hint — a standalone widget, the inline grid editor, an action
27
+ * param dialog — the widget falls back to its own copy.
28
+ *
29
+ * Each of the four widgets used to inline this box, destructure `emptyHint`
30
+ * into `_emptyHint` and throw it away, then render a hardcoded English literal.
31
+ * Four independent copies is precisely why they drifted together, so the box
32
+ * AND the copy live here only: adding a fifth option widget cannot re-introduce
33
+ * the gap without deliberately re-implementing it.
34
+ *
35
+ * ## Fallback copy is translated
36
+ *
37
+ * The fallback goes through `useFieldTranslation()` (`fields.options.*`,
38
+ * present in all ten locale packs) rather than an English string literal — the
39
+ * gate sentence is the SAME i18n key the form renderer uses, so the two can
40
+ * never say different things in the same locale.
41
+ */
42
+ export interface OptionsEmptyStateProps {
43
+ /**
44
+ * Host-computed hint. Wins over the widget's own copy whenever it is a
45
+ * non-empty string — this is the whole point of the prop.
46
+ */
47
+ emptyHint?: string;
48
+ /** The list is waiting on a `dependsOn` controlling field (vs. unconfigured). */
49
+ gated: boolean;
50
+ /** Controlling field names, used by the fallback gate sentence. */
51
+ dependsOnFields: readonly string[];
52
+ /** Widget-specific stable locator, e.g. `select-empty-${fieldName}`. */
53
+ testId?: string;
54
+ /** Widget-specific sizing (the dropdown is a fixed `h-9`, lists grow). */
55
+ className?: string;
56
+ /**
57
+ * The host label's IDREF plumbing, when this box is the whole rendered surface
58
+ * of a group-labelled field (objectui#3990) — a `checkboxes` / `radio` /
59
+ * `multiselect` whose option list came back empty renders NOTHING but this
60
+ * box, so the visible label named zero elements until it landed here.
61
+ *
62
+ * A closed three-key type rather than an open props tail: only `id`,
63
+ * `aria-labelledby` and the `role` that makes them meaningful may reach this
64
+ * element, and reopening a spread is what objectui#3291 exists to prevent.
65
+ * Absent for the single `SelectField` — it is not group-labelled, its label
66
+ * still uses a plain `for`, and it must keep emitting no such attributes.
67
+ */
68
+ hostGroupProps?: HostGroupProps;
69
+ }
70
+
71
+ export function OptionsEmptyState({
72
+ emptyHint,
73
+ gated,
74
+ dependsOnFields,
75
+ testId,
76
+ className,
77
+ hostGroupProps,
78
+ }: OptionsEmptyStateProps) {
79
+ const { t } = useFieldTranslation();
80
+ // The host's hint when it computed one; otherwise this widget's own copy,
81
+ // translated. Never an English literal — that was the reported defect.
82
+ const hint =
83
+ emptyHint ||
84
+ (gated
85
+ ? t('fields.options.selectFirst', { fields: dependsOnFields.join(' / ') })
86
+ : t('fields.options.empty'));
87
+ return (
88
+ <div
89
+ {...hostGroupProps}
90
+ data-testid={testId}
91
+ className={cn(
92
+ 'flex w-full items-center rounded-md border border-input bg-muted/30 px-3 py-2 text-sm text-muted-foreground',
93
+ className,
94
+ )}
95
+ >
96
+ {hint}
97
+ </div>
98
+ );
99
+ }