@object-ui/fields 17.1.0 → 17.3.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 (171) hide show
  1. package/CHANGELOG.md +786 -0
  2. package/dist/FieldEditWidget.d.ts +2 -2
  3. package/dist/__tests__/cell-truncation.test.d.ts +0 -0
  4. package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
  5. package/dist/__tests__/spec-symbol-batch7.test.d.ts +8 -0
  6. package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
  7. package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
  8. package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
  9. package/dist/__tests__/widget-props-contract.test.d.ts +8 -0
  10. package/dist/index.cjs +5 -5
  11. package/dist/index.d.ts +4 -0
  12. package/dist/index.js +2882 -2569
  13. package/dist/widgets/AddressField.d.ts +2 -2
  14. package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
  15. package/dist/widgets/AutoNumberField.d.ts +2 -2
  16. package/dist/widgets/AvatarField.d.ts +2 -2
  17. package/dist/widgets/BooleanField.d.ts +2 -2
  18. package/dist/widgets/CapabilityMultiSelectField.d.ts +2 -2
  19. package/dist/widgets/CharacterCount.d.ts +56 -0
  20. package/dist/widgets/CheckboxesField.d.ts +2 -2
  21. package/dist/widgets/CodeField.d.ts +2 -2
  22. package/dist/widgets/ColorField.d.ts +2 -2
  23. package/dist/widgets/CurrencyField.d.ts +2 -2
  24. package/dist/widgets/DateField.d.ts +2 -2
  25. package/dist/widgets/DateTimeField.d.ts +2 -2
  26. package/dist/widgets/EmailField.d.ts +2 -2
  27. package/dist/widgets/FileField.d.ts +2 -2
  28. package/dist/widgets/FilterConditionField.d.ts +2 -2
  29. package/dist/widgets/FormulaField.d.ts +2 -2
  30. package/dist/widgets/FullscreenFieldEditor.d.ts +155 -0
  31. package/dist/widgets/GeolocationField.d.ts +2 -2
  32. package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
  33. package/dist/widgets/GridField.d.ts +2 -2
  34. package/dist/widgets/ImageField.d.ts +2 -2
  35. package/dist/widgets/LocationField.d.ts +2 -2
  36. package/dist/widgets/LookupField.d.ts +2 -2
  37. package/dist/widgets/LookupField.gateHintLabel.test.d.ts +8 -0
  38. package/dist/widgets/MasterDetailField.d.ts +2 -2
  39. package/dist/widgets/MultiSelectField.d.ts +2 -2
  40. package/dist/widgets/NumberField.d.ts +2 -2
  41. package/dist/widgets/ObjectField.d.ts +2 -2
  42. package/dist/widgets/ObjectRefField.d.ts +2 -2
  43. package/dist/widgets/OptionsEmptyState.d.ts +45 -0
  44. package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
  45. package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
  46. package/dist/widgets/PasswordField.d.ts +2 -2
  47. package/dist/widgets/PercentField.d.ts +2 -2
  48. package/dist/widgets/PhoneField.d.ts +2 -2
  49. package/dist/widgets/QRCodeField.d.ts +2 -2
  50. package/dist/widgets/RadioField.d.ts +2 -2
  51. package/dist/widgets/RatingField.d.ts +2 -2
  52. package/dist/widgets/RecipientPickerField.d.ts +2 -2
  53. package/dist/widgets/RecordPickerDialog.d.ts +17 -1
  54. package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +8 -0
  55. package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +8 -0
  56. package/dist/widgets/RichTextField.d.ts +20 -2
  57. package/dist/widgets/SelectField.d.ts +2 -2
  58. package/dist/widgets/SignatureField.d.ts +2 -2
  59. package/dist/widgets/SliderField.d.ts +2 -2
  60. package/dist/widgets/SummaryField.d.ts +2 -2
  61. package/dist/widgets/TagsField.d.ts +2 -2
  62. package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
  63. package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
  64. package/dist/widgets/TextAreaField.d.ts +45 -6
  65. package/dist/widgets/TextField.d.ts +2 -2
  66. package/dist/widgets/TimeField.d.ts +2 -2
  67. package/dist/widgets/UrlField.d.ts +2 -2
  68. package/dist/widgets/UserField.d.ts +2 -2
  69. package/dist/widgets/VectorField.d.ts +2 -2
  70. package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
  71. package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
  72. package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
  73. package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
  74. package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
  75. package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
  76. package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
  77. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
  78. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
  79. package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
  80. package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
  81. package/dist/widgets/file-value.d.ts +20 -33
  82. package/dist/widgets/toDomProps.d.ts +91 -0
  83. package/dist/widgets/types.d.ts +215 -4
  84. package/dist/withFieldCarrier.d.ts +56 -0
  85. package/package.json +13 -13
  86. package/src/FieldEditWidget.tsx +6 -4
  87. package/src/__tests__/cell-truncation.test.tsx +96 -0
  88. package/src/__tests__/field-carrier-sdui.test.tsx +169 -0
  89. package/src/__tests__/spec-symbol-batch7.test.ts +126 -0
  90. package/src/__tests__/validation-feedback.test.tsx +22 -13
  91. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +265 -0
  92. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +338 -0
  93. package/src/__tests__/widget-dom-leak-e2e.test.tsx +598 -0
  94. package/src/__tests__/widget-props-contract.test.tsx +142 -0
  95. package/src/complex-widgets.test.tsx +3 -3
  96. package/src/datetime-widgets.test.tsx +2 -2
  97. package/src/index.tsx +77 -27
  98. package/src/widgets/AddressField.tsx +32 -13
  99. package/src/widgets/AddressField.uniqueIds.test.tsx +92 -0
  100. package/src/widgets/AutoNumberField.tsx +2 -2
  101. package/src/widgets/AvatarField.tsx +10 -3
  102. package/src/widgets/BooleanField.tsx +5 -5
  103. package/src/widgets/CapabilityMultiSelectField.tsx +4 -4
  104. package/src/widgets/CharacterCount.tsx +263 -0
  105. package/src/widgets/CheckboxesField.tsx +35 -18
  106. package/src/widgets/CodeField.tsx +10 -3
  107. package/src/widgets/ColorField.tsx +12 -3
  108. package/src/widgets/CurrencyField.tsx +6 -5
  109. package/src/widgets/DateField.tsx +4 -4
  110. package/src/widgets/DateTimeField.tsx +4 -4
  111. package/src/widgets/EmailField.tsx +6 -6
  112. package/src/widgets/FileField.tsx +14 -4
  113. package/src/widgets/FilterConditionField.tsx +14 -3
  114. package/src/widgets/FormulaField.tsx +3 -3
  115. package/src/widgets/FullscreenFieldEditor.tsx +271 -0
  116. package/src/widgets/GeolocationField.tsx +22 -7
  117. package/src/widgets/GeolocationField.uniqueIds.test.tsx +88 -0
  118. package/src/widgets/GridField.tsx +4 -4
  119. package/src/widgets/ImageField.tsx +10 -3
  120. package/src/widgets/LocationField.tsx +10 -3
  121. package/src/widgets/LookupField.gateHintLabel.test.tsx +135 -0
  122. package/src/widgets/LookupField.tsx +50 -15
  123. package/src/widgets/MasterDetailField.tsx +3 -3
  124. package/src/widgets/MultiSelectField.tsx +34 -18
  125. package/src/widgets/NumberField.tsx +5 -5
  126. package/src/widgets/ObjectField.tsx +18 -9
  127. package/src/widgets/ObjectRefField.tsx +14 -4
  128. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +88 -0
  129. package/src/widgets/OptionsEmptyState.test.tsx +169 -0
  130. package/src/widgets/OptionsEmptyState.tsx +83 -0
  131. package/src/widgets/PasswordField.tsx +5 -5
  132. package/src/widgets/PeoplePicker.tsx +7 -1
  133. package/src/widgets/PercentField.tsx +6 -5
  134. package/src/widgets/PhoneField.tsx +6 -6
  135. package/src/widgets/QRCodeField.tsx +10 -3
  136. package/src/widgets/RadioField.tsx +28 -18
  137. package/src/widgets/RatingField.tsx +19 -4
  138. package/src/widgets/RecipientPickerField.tsx +22 -4
  139. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +611 -0
  140. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +112 -0
  141. package/src/widgets/RecordPickerDialog.tsx +173 -8
  142. package/src/widgets/RichTextField.tsx +152 -25
  143. package/src/widgets/SelectField.tsx +53 -22
  144. package/src/widgets/SignatureField.tsx +2 -2
  145. package/src/widgets/SliderField.tsx +3 -3
  146. package/src/widgets/SummaryField.tsx +3 -3
  147. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +68 -0
  148. package/src/widgets/TagsField.placeholder.test.tsx +95 -0
  149. package/src/widgets/TagsField.tsx +25 -4
  150. package/src/widgets/TextAreaField.tsx +172 -94
  151. package/src/widgets/TextField.tsx +5 -5
  152. package/src/widgets/TimeField.tsx +4 -4
  153. package/src/widgets/UrlField.tsx +6 -6
  154. package/src/widgets/UserField.tsx +3 -3
  155. package/src/widgets/VectorField.tsx +3 -3
  156. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +248 -0
  157. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +116 -0
  158. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +244 -0
  159. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +195 -0
  160. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +417 -0
  161. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +287 -0
  162. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +160 -0
  163. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +161 -0
  164. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +346 -0
  165. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +246 -0
  166. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +175 -0
  167. package/src/widgets/file-value.ts +21 -5
  168. package/src/widgets/toDomProps.ts +175 -0
  169. package/src/widgets/types.ts +230 -7
  170. package/src/widgets/useFieldTranslation.ts +60 -0
  171. package/src/withFieldCarrier.tsx +75 -0
@@ -0,0 +1,135 @@
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
+ * A gated lookup names its controlling field by LABEL — objectstack#5407.
11
+ *
12
+ * The gate sentence (`lookup.selectFirst`) is translated in all ten packs, but
13
+ * its `{{fields}}` interpolation came straight off `depends_on`, which holds
14
+ * API names. So the rendered hint was localized around a raw identifier in
15
+ * every locale, English included:
16
+ *
17
+ * en Select crm_account first
18
+ * zh-CN 请先选择crm_account
19
+ *
20
+ * The `en` row is why this is not merely an i18n bug: an English user was shown
21
+ * an internal identifier. The form renderer knows the label (it already
22
+ * resolves the same map for the fixed-option widgets' `emptyHint`) and now
23
+ * hands it over as `dependsOnLabels`.
24
+ *
25
+ * All three gated surfaces are asserted — the trigger's visible text, the
26
+ * trigger's `title`, and the "browse all" button's `title` — because they were
27
+ * three independent copies of the same expression and a fix applied to one of
28
+ * them looks identical from the outside.
29
+ */
30
+
31
+ import { render, screen, cleanup } from '@testing-library/react';
32
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
33
+ import { LookupField } from './LookupField';
34
+
35
+ /** Mirrors the reported metadata: the parent field's API name IS an object-ish
36
+ * name (`crm_account`), which is exactly what made the leak so legible. */
37
+ const gatedField = {
38
+ name: 'contact',
39
+ label: 'Contact',
40
+ reference_to: 'crm_contact',
41
+ reference_field: 'name',
42
+ depends_on: ['crm_account'],
43
+ } as any;
44
+
45
+ const dataSource = { find: vi.fn(async () => ({ data: [], total: 0 })) } as any;
46
+
47
+ function renderGated(extra: Record<string, unknown>) {
48
+ return render(
49
+ <LookupField
50
+ field={gatedField}
51
+ value={undefined}
52
+ onChange={vi.fn()}
53
+ readonly={false}
54
+ dataSource={dataSource}
55
+ {...({ dependentValues: { crm_account: null }, ...extra } as any)}
56
+ />,
57
+ );
58
+ }
59
+
60
+ beforeEach(() => {
61
+ Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1280 });
62
+ window.matchMedia = ((query: string) => ({
63
+ matches: false,
64
+ media: query,
65
+ onchange: null,
66
+ addEventListener: () => {},
67
+ removeEventListener: () => {},
68
+ addListener: () => {},
69
+ removeListener: () => {},
70
+ dispatchEvent: () => false,
71
+ })) as any;
72
+ });
73
+
74
+ afterEach(() => cleanup());
75
+
76
+ describe('LookupField gate hint names the controlling field (objectstack#5407)', () => {
77
+ it('interpolates the label the host supplied, not the API name', () => {
78
+ renderGated({ dependsOnLabels: { crm_account: 'Account' } });
79
+
80
+ const trigger = screen.getByTestId('lookup-trigger-gated');
81
+ expect(trigger).toHaveTextContent('Select Account first');
82
+ // The identifier must be gone from the copy entirely, not merely
83
+ // accompanied by the label.
84
+ expect(trigger).not.toHaveTextContent('crm_account');
85
+ expect(trigger).toHaveAttribute('title', 'Select Account first');
86
+ });
87
+
88
+ it('labels the gated "browse all" button the same way', () => {
89
+ renderGated({ dependsOnLabels: { crm_account: 'Account' } });
90
+
91
+ const browse = screen.getByTestId('browse-all-records');
92
+ expect(browse).toBeDisabled();
93
+ expect(browse).toHaveAttribute('title', 'Select Account first');
94
+ });
95
+
96
+ it('joins several controlling fields by their labels', () => {
97
+ render(
98
+ <LookupField
99
+ field={{ ...gatedField, depends_on: ['crm_account', 'lead_source'] }}
100
+ value={undefined}
101
+ onChange={vi.fn()}
102
+ readonly={false}
103
+ dataSource={dataSource}
104
+ {...({
105
+ dependentValues: { crm_account: null, lead_source: null },
106
+ dependsOnLabels: { crm_account: 'Account', lead_source: 'Lead Source' },
107
+ } as any)}
108
+ />,
109
+ );
110
+
111
+ expect(screen.getByTestId('lookup-trigger-gated')).toHaveTextContent(
112
+ 'Select Account, Lead Source first',
113
+ );
114
+ });
115
+
116
+ it('falls back to the API name for a field the host did not map', () => {
117
+ // Standalone use (no host form) has to keep rendering what it rendered
118
+ // before — the map is an enrichment, not a new requirement. This is also
119
+ // the assertion that would go red if `dependsOnLabels` were consulted for
120
+ // its VALUES rather than keyed by field name.
121
+ renderGated({ dependsOnLabels: { some_other_field: 'Irrelevant' } });
122
+
123
+ expect(screen.getByTestId('lookup-trigger-gated')).toHaveTextContent(
124
+ 'Select crm_account first',
125
+ );
126
+ });
127
+
128
+ it('falls back to the API name when the host passes no map at all', () => {
129
+ renderGated({});
130
+
131
+ expect(screen.getByTestId('lookup-trigger-gated')).toHaveTextContent(
132
+ 'Select crm_account first',
133
+ );
134
+ });
135
+ });
@@ -10,7 +10,8 @@ import { cn,
10
10
  PopoverTrigger,
11
11
  PopoverContent, EmptyValue } from '@object-ui/components';
12
12
  import { Search, X, Loader2, AlertCircle, Plus, TableProperties } from 'lucide-react';
13
- import { FieldWidgetProps } from './types';
13
+ import { FieldWidgetComponentProps } from './types';
14
+ import { toDomProps } from './toDomProps';
14
15
  import type { DataSource, QueryParams, LookupColumnDef } from '@object-ui/types';
15
16
  import { RecordPickerDialog, lookupFiltersToRecord } from './RecordPickerDialog';
16
17
  import type { RecordPickerFilterColumn } from './RecordPickerDialog';
@@ -191,7 +192,7 @@ function mapFieldTypeToFilterType(
191
192
  * from the referenced object using `DataSource.find()`.
192
193
  * Falls back to static `options` when no DataSource is available.
193
194
  */
194
- export function LookupField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>) {
195
+ export function LookupField({ value, onChange, field, readonly, error: fieldError, ...props }: FieldWidgetComponentProps<any>) {
195
196
  const [isOpen, setIsOpen] = useState(false);
196
197
  const { t } = useFieldTranslation();
197
198
  const listboxId = React.useId();
@@ -215,7 +216,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
215
216
  const [activeIndex, setActiveIndex] = useState(-1);
216
217
  const listRef = useRef<HTMLDivElement>(null);
217
218
 
218
- const lookupField = (field || (props as any).schema) as any;
219
+ const lookupField = field as any;
219
220
 
220
221
  // When rendered via createFieldRenderer wrapper the actual objectSchema field
221
222
  // metadata (reference_to, display_field, etc.) lives at lookupField.field.
@@ -279,14 +280,31 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
279
280
  return [];
280
281
  }, [fieldMeta?.depends_on, fieldMeta?.dependsOn]);
281
282
 
283
+ /**
284
+ * The gate sentence's `{{fields}}` — the controlling fields named the way the
285
+ * user sees them on the form, not the way the metadata spells them.
286
+ *
287
+ * `depends_on` holds API names, and this used to interpolate them straight
288
+ * into the sentence, so every locale — `en` included — read "Select
289
+ * crm_account first" (objectstack#5407): an internal identifier in the UI,
290
+ * not merely an untranslated word. The host form supplies the name→label map
291
+ * (`dependsOnLabels`); a name it doesn't cover falls back to itself, so a
292
+ * standalone widget with no host renders exactly what it did before.
293
+ */
294
+ const dependsOnLabelsProp = props.dependsOnLabels;
295
+ const dependsOnFieldsText = useMemo(
296
+ () => dependsOn.map((d) => dependsOnLabelsProp?.[d.field] || d.field).join(', '),
297
+ [dependsOn, dependsOnLabelsProp],
298
+ );
299
+
282
300
  // Resolve dependent field values from explicit prop or SchemaRendererContext.data
283
- const dependentValuesProp = (props as any).dependentValues as Record<string, any> | undefined;
301
+ const dependentValuesProp = props.dependentValues;
284
302
 
285
303
  // Resolve DataSource: explicit prop > field-level > wrapper field > SchemaRendererContext > none
286
304
  const ctx = useContext(SchemaRendererContext);
287
305
  const contextDataSource = ctx?.dataSource ?? null;
288
306
  const dataSource: DataSource | null =
289
- (props as any).dataSource ?? lookupField?.dataSource ?? fieldMeta?.dataSource ?? contextDataSource;
307
+ (props.dataSource as DataSource | null | undefined) ?? lookupField?.dataSource ?? fieldMeta?.dataSource ?? contextDataSource;
290
308
 
291
309
  /** Resolve dependent values from the explicit prop (preferred), the form-data
292
310
  * context provided by @object-ui/react, or finally `ctx.data` (record scope). */
@@ -369,7 +387,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
369
387
 
370
388
  // Optional create-new callback
371
389
  const onCreateNew: ((searchQuery: string) => void) | undefined =
372
- (props as any).onCreateNew ?? lookupField?.onCreateNew;
390
+ props.onCreateNew ?? lookupField?.onCreateNew;
373
391
 
374
392
  // State for the full Record Picker dialog (Level 2)
375
393
  const [isPickerOpen, setIsPickerOpen] = useState(false);
@@ -657,7 +675,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
657
675
  // auto-fill sibling fields from it — e.g. a line-item grid copying a product's
658
676
  // unit_price/description when the item is chosen. When provided (single
659
677
  // select), it drives the update and the host owns the resulting value change.
660
- const onSelectRecord = (props as any).onSelectRecord as ((record: LookupOption) => void) | undefined;
678
+ const onSelectRecord = props.onSelectRecord;
661
679
 
662
680
  const handleSelect = useCallback(
663
681
  (option: LookupOption) => {
@@ -906,25 +924,41 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
906
924
  // Compact mode (e.g. inside a line-item grid cell): show the selected value
907
925
  // INSIDE a borderless trigger on a single line — no chip stacked above a
908
926
  // separate "Select…" button (which double-stacks and wastes the row height).
909
- const compact = !!(props as any).compact;
927
+ const compact = !!props.compact;
910
928
  const singleSelectedLabel = selectedOptions[0]?.label || selectedOptions[0]?.[displayField];
911
929
 
912
930
  // Shared field trigger — the anchor for either the inline PeoplePicker
913
931
  // (search fields) or the classic quick-select popover. No onClick: the Radix
914
932
  // trigger it is slotted into (PopoverTrigger / SheetTrigger) owns open/close.
933
+ //
934
+ // DOM pass-through onto this button — the widget's real focusable control —
935
+ // per objectui#3318. `name` is withheld: no native control here takes part
936
+ // in form submission, and a stray `name` on a button invites exactly the
937
+ // accidental-submitter semantics #3306 kept it off the SelectTrigger for.
938
+ const { name: _domName, ...triggerDomProps } = toDomProps(props);
915
939
  const triggerButton = (
916
940
  <Button
941
+ {...triggerDomProps}
917
942
  variant="outline"
918
943
  className={cn(
919
- 'min-w-0 flex-1 justify-start text-left font-normal',
944
+ // max-w-full: in a BLOCK parent (detail-section inline edit) the
945
+ // inline-flex button is content-sized — a long selected label pushed
946
+ // it past the card edge; bounding it lets the inner `truncate` span
947
+ // clip instead (objectui#3466). flex-1/min-w-0 keep handling the
948
+ // flex-parent (form row) case.
949
+ 'min-w-0 max-w-full flex-1 justify-start text-left font-normal',
920
950
  compact && 'h-8 rounded-none border-0 bg-transparent px-2 shadow-none focus-visible:ring-1 focus-visible:ring-ring/60',
921
951
  )}
922
952
  type="button"
923
- disabled={dependenciesMissing || (props as any).disabled}
924
- data-testid={dependenciesMissing ? 'lookup-trigger-gated' : (((props as any).name || lookupField?.name) ? `lookup-trigger-${(props as any).name || lookupField.name}` : 'lookup-trigger')}
953
+ disabled={dependenciesMissing || props.disabled}
954
+ data-testid={dependenciesMissing ? 'lookup-trigger-gated' : ((props.name || lookupField?.name) ? `lookup-trigger-${props.name || lookupField.name}` : 'lookup-trigger')}
925
955
  title={dependenciesMissing
926
- ? t('lookup.selectFirst', { fields: dependsOn.map(d => d.field).join(', ') })
956
+ ? t('lookup.selectFirst', { fields: dependsOnFieldsText })
927
957
  : undefined}
958
+ // AFTER the spread so this widget's own computation wins (#3222):
959
+ // `fieldError` is the published validation slot — NOT the popover's
960
+ // fetch error, which is a widget-internal state named `error` below.
961
+ aria-invalid={!!fieldError}
928
962
  >
929
963
  {hydrating ? (
930
964
  <Loader2
@@ -936,7 +970,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
936
970
  )}
937
971
  <span className={cn('truncate', compact && selectedOptions.length === 0 && 'text-muted-foreground')}>
938
972
  {dependenciesMissing
939
- ? t('lookup.selectFirst', { fields: dependsOn.map(d => d.field).join(', ') })
973
+ ? t('lookup.selectFirst', { fields: dependsOnFieldsText })
940
974
  : hydrating
941
975
  // The value EXISTS but its labels are still loading — say so
942
976
  // instead of the empty placeholder (#3108).
@@ -1214,11 +1248,11 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
1214
1248
  type="button"
1215
1249
  // Gated exactly like the main trigger (#2215) — pre-fix this button
1216
1250
  // opened the full unscoped table while the dependency was missing.
1217
- disabled={dependenciesMissing || (props as any).disabled}
1251
+ disabled={dependenciesMissing || props.disabled}
1218
1252
  onClick={() => setIsPickerOpen(true)}
1219
1253
  aria-label={t('lookup.browseAll')}
1220
1254
  title={dependenciesMissing
1221
- ? t('lookup.selectFirst', { fields: dependsOn.map(d => d.field).join(', ') })
1255
+ ? t('lookup.selectFirst', { fields: dependsOnFieldsText })
1222
1256
  : t('lookup.browseAll')}
1223
1257
  data-testid="browse-all-records"
1224
1258
  >
@@ -1248,6 +1282,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
1248
1282
  lookupFilters={lookupFilters}
1249
1283
  baseFilter={dependentFilter}
1250
1284
  cellRenderer={getCellRendererResolver()}
1285
+ fieldsMeta={refObjectSchema?.fields}
1251
1286
  filterColumns={filterColumns}
1252
1287
  />
1253
1288
  )}
@@ -1,7 +1,7 @@
1
1
  import React from 'react';
2
2
  import { Button, Badge, cn } from '@object-ui/components';
3
3
  import { Plus, X, ExternalLink } from 'lucide-react';
4
- import { FieldWidgetProps } from './types';
4
+ import { FieldWidgetComponentProps } from './types';
5
5
 
6
6
  /**
7
7
  * Master-Detail relationship data structure
@@ -23,9 +23,9 @@ export function MasterDetailField({
23
23
  readonly,
24
24
  className,
25
25
  ...props
26
- }: FieldWidgetProps<MasterDetailValue[]>) {
26
+ }: FieldWidgetComponentProps<MasterDetailValue[]>) {
27
27
  const items = value || [];
28
- const config = field || (props as any).schema;
28
+ const config = field;
29
29
 
30
30
  const handleAdd = () => {
31
31
  // This would typically open a dialog to select/create related records
@@ -1,7 +1,9 @@
1
1
  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
- import { FieldWidgetProps } from './types';
4
+ import { FieldWidgetComponentProps } from './types';
5
+ import { toDomProps } from './toDomProps';
6
+ import { OptionsEmptyState } from './OptionsEmptyState';
5
7
  import { useCascadingOptions } from './useCascadingOptions';
6
8
 
7
9
  interface Option extends OptionLike { color?: string }
@@ -26,17 +28,17 @@ export function MultiSelectField({
26
28
  field,
27
29
  readonly,
28
30
  className,
29
- schema,
30
31
  dependentValues,
31
32
  dependsOn: dependsOnProp,
32
- emptyHint: _emptyHint,
33
+ emptyHint,
33
34
  dataSource: _dataSource,
35
+ error,
34
36
  ...props
35
- }: FieldWidgetProps<string[]>) {
36
- const config = (field || schema) as any;
37
+ }: FieldWidgetComponentProps<string[]>) {
38
+ const config = field as any;
37
39
  const rawOptions: Option[] = config?.options || [];
38
40
  const selected: string[] = Array.isArray(value) ? value : value == null ? [] : [value as unknown as string];
39
- const fieldName = (props as any).name || config?.name || (props as any).id || '';
41
+ const fieldName = props.name || config?.name || props.id || '';
40
42
 
41
43
  const dependsOn = config?.dependsOn ?? dependsOnProp;
42
44
  const { options, gated, dependsOnFields } = useCascadingOptions<Option>(
@@ -71,19 +73,18 @@ export function MultiSelectField({
71
73
  }
72
74
 
73
75
  // 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.
76
+ // empty chip row: the host's `emptyHint` when it computed one, else this
77
+ // widget's own translated copy. Shared with the single select / radio /
78
+ // checkboxes so the four cannot drift again (objectui#3231).
76
79
  if (options.length === 0) {
77
- const hint = gated
78
- ? `Select ${dependsOnFields.join(' / ')} first`
79
- : 'No options available';
80
80
  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>
81
+ <OptionsEmptyState
82
+ emptyHint={emptyHint}
83
+ gated={gated}
84
+ dependsOnFields={dependsOnFields}
85
+ testId={fieldName ? `multiselect-empty-${fieldName}` : undefined}
86
+ className="min-h-9"
87
+ />
87
88
  );
88
89
  }
89
90
 
@@ -92,8 +93,22 @@ export function MultiSelectField({
92
93
  onChange(next);
93
94
  };
94
95
 
96
+ // DOM pass-through (objectui#3318): the container carries the form
97
+ // renderer's id / aria-describedby, but NOT its `aria-invalid` — a plain
98
+ // wrapper div is not where assistive tech reads the invalid state. That
99
+ // state goes onto every focusable chip button below, computed from the
100
+ // published `error` slot (#3222), so whichever chip the user tabs to
101
+ // announces the failure. `name` is withheld too: it is only DOM-legal on
102
+ // form controls, and on this div it is exactly the leak #3291 sweeps for.
103
+ const {
104
+ 'aria-invalid': _hostAriaInvalid,
105
+ name: _domName,
106
+ ...groupDomProps
107
+ } = toDomProps(props);
108
+
95
109
  return (
96
110
  <div
111
+ {...groupDomProps}
97
112
  className={cn('flex flex-wrap gap-1.5', className)}
98
113
  data-testid={fieldName ? `multiselect-${fieldName}` : undefined}
99
114
  >
@@ -107,8 +122,9 @@ export function MultiSelectField({
107
122
  type="button"
108
123
  key={value}
109
124
  onClick={() => toggle(value)}
110
- disabled={(props as any).disabled}
125
+ disabled={props.disabled}
111
126
  aria-pressed={active}
127
+ aria-invalid={!!error}
112
128
  data-testid={`multiselect-option-${opt.value}`}
113
129
  className={cn(
114
130
  'rounded-full border px-3 py-1 text-sm transition-colors',
@@ -1,18 +1,19 @@
1
1
  import React from 'react';
2
2
  import { Input, EmptyValue } from '@object-ui/components';
3
3
  import { NumberFieldMetadata } from '@object-ui/types';
4
- import { FieldWidgetProps } from './types';
4
+ import { FieldWidgetComponentProps } from './types';
5
+ import { toDomProps } from './toDomProps';
5
6
 
6
7
  /**
7
8
  * NumberField - Numeric input with optional decimal precision
8
9
  * Supports min/max/step constraints and configurable decimal precision
9
10
  */
10
- export function NumberField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<number>) {
11
+ export function NumberField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<number>) {
11
12
  if (readonly) {
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
- import { FieldWidgetProps } from './types';
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 }: FieldWidgetProps<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
  }
@@ -1,7 +1,8 @@
1
1
  import React from 'react';
2
2
  import { Combobox, EmptyValue, cn } from '@object-ui/components';
3
3
  import { SchemaRendererContext } from '@object-ui/react';
4
- import type { FieldWidgetProps } from './types';
4
+ import type { FieldWidgetComponentProps } from './types';
5
+ import { toDomProps } from './toDomProps';
5
6
  import { useFieldTranslation } from './useFieldTranslation';
6
7
 
7
8
  /**
@@ -31,12 +32,13 @@ export function ObjectRefField({
31
32
  onChange,
32
33
  readonly,
33
34
  className,
35
+ error,
34
36
  ...props
35
- }: FieldWidgetProps<string>) {
37
+ }: FieldWidgetComponentProps<string>) {
36
38
  const ctx = React.useContext(SchemaRendererContext);
37
39
  const { t } = useFieldTranslation();
38
- const dataSource: any = (props as any).dataSource ?? (ctx as any)?.dataSource ?? null;
39
- const disabled = (props as any).disabled as boolean | undefined;
40
+ const dataSource: any = props.dataSource ?? (ctx as any)?.dataSource ?? null;
41
+ const disabled = props.disabled;
40
42
 
41
43
  const [objects, setObjects] = React.useState<ObjectHeader[] | null>(null);
42
44
 
@@ -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
+ });