@object-ui/fields 17.2.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 (142) hide show
  1. package/CHANGELOG.md +592 -0
  2. package/dist/__tests__/cell-truncation.test.d.ts +0 -0
  3. package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
  4. package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
  5. package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
  6. package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
  7. package/dist/index.cjs +5 -5
  8. package/dist/index.d.ts +4 -0
  9. package/dist/index.js +2881 -2566
  10. package/dist/widgets/AddressField.d.ts +1 -1
  11. package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
  12. package/dist/widgets/AvatarField.d.ts +1 -1
  13. package/dist/widgets/CharacterCount.d.ts +56 -0
  14. package/dist/widgets/CheckboxesField.d.ts +1 -1
  15. package/dist/widgets/CodeField.d.ts +1 -1
  16. package/dist/widgets/ColorField.d.ts +1 -1
  17. package/dist/widgets/CurrencyField.d.ts +1 -1
  18. package/dist/widgets/EmailField.d.ts +1 -1
  19. package/dist/widgets/FileField.d.ts +1 -1
  20. package/dist/widgets/FilterConditionField.d.ts +1 -1
  21. package/dist/widgets/FullscreenFieldEditor.d.ts +155 -0
  22. package/dist/widgets/GeolocationField.d.ts +1 -1
  23. package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
  24. package/dist/widgets/ImageField.d.ts +1 -1
  25. package/dist/widgets/LocationField.d.ts +1 -1
  26. package/dist/widgets/LookupField.d.ts +1 -1
  27. package/dist/widgets/LookupField.gateHintLabel.test.d.ts +8 -0
  28. package/dist/widgets/MultiSelectField.d.ts +1 -1
  29. package/dist/widgets/ObjectField.d.ts +1 -1
  30. package/dist/widgets/ObjectRefField.d.ts +1 -1
  31. package/dist/widgets/OptionsEmptyState.d.ts +45 -0
  32. package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
  33. package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
  34. package/dist/widgets/PercentField.d.ts +1 -1
  35. package/dist/widgets/PhoneField.d.ts +1 -1
  36. package/dist/widgets/QRCodeField.d.ts +1 -1
  37. package/dist/widgets/RadioField.d.ts +1 -1
  38. package/dist/widgets/RatingField.d.ts +1 -1
  39. package/dist/widgets/RecipientPickerField.d.ts +1 -1
  40. package/dist/widgets/RecordPickerDialog.d.ts +17 -1
  41. package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +8 -0
  42. package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +8 -0
  43. package/dist/widgets/RichTextField.d.ts +19 -1
  44. package/dist/widgets/TagsField.d.ts +1 -1
  45. package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
  46. package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
  47. package/dist/widgets/TextAreaField.d.ts +44 -5
  48. package/dist/widgets/UrlField.d.ts +1 -1
  49. package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
  50. package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
  51. package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
  52. package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
  53. package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
  54. package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
  55. package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
  56. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
  57. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
  58. package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
  59. package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
  60. package/dist/widgets/toDomProps.d.ts +91 -0
  61. package/dist/widgets/types.d.ts +128 -33
  62. package/dist/withFieldCarrier.d.ts +56 -0
  63. package/package.json +13 -13
  64. package/src/__tests__/cell-truncation.test.tsx +96 -0
  65. package/src/__tests__/field-carrier-sdui.test.tsx +169 -0
  66. package/src/__tests__/spec-symbol-batch7.test.ts +29 -15
  67. package/src/__tests__/validation-feedback.test.tsx +22 -13
  68. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +265 -0
  69. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +338 -0
  70. package/src/__tests__/widget-dom-leak-e2e.test.tsx +598 -0
  71. package/src/__tests__/widget-props-contract.test.tsx +22 -9
  72. package/src/index.tsx +74 -26
  73. package/src/widgets/AddressField.tsx +31 -12
  74. package/src/widgets/AddressField.uniqueIds.test.tsx +92 -0
  75. package/src/widgets/AvatarField.tsx +9 -2
  76. package/src/widgets/BooleanField.tsx +3 -3
  77. package/src/widgets/CharacterCount.tsx +263 -0
  78. package/src/widgets/CheckboxesField.tsx +31 -14
  79. package/src/widgets/CodeField.tsx +9 -2
  80. package/src/widgets/ColorField.tsx +11 -2
  81. package/src/widgets/CurrencyField.tsx +5 -4
  82. package/src/widgets/DateField.tsx +2 -2
  83. package/src/widgets/DateTimeField.tsx +2 -2
  84. package/src/widgets/EmailField.tsx +5 -5
  85. package/src/widgets/FileField.tsx +13 -3
  86. package/src/widgets/FilterConditionField.tsx +11 -0
  87. package/src/widgets/FormulaField.tsx +1 -1
  88. package/src/widgets/FullscreenFieldEditor.tsx +271 -0
  89. package/src/widgets/GeolocationField.tsx +21 -6
  90. package/src/widgets/GeolocationField.uniqueIds.test.tsx +88 -0
  91. package/src/widgets/GridField.tsx +1 -1
  92. package/src/widgets/ImageField.tsx +9 -2
  93. package/src/widgets/LocationField.tsx +9 -2
  94. package/src/widgets/LookupField.gateHintLabel.test.tsx +135 -0
  95. package/src/widgets/LookupField.tsx +41 -6
  96. package/src/widgets/MasterDetailField.tsx +1 -1
  97. package/src/widgets/MultiSelectField.tsx +30 -14
  98. package/src/widgets/NumberField.tsx +3 -3
  99. package/src/widgets/ObjectField.tsx +17 -8
  100. package/src/widgets/ObjectRefField.tsx +10 -0
  101. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +88 -0
  102. package/src/widgets/OptionsEmptyState.test.tsx +169 -0
  103. package/src/widgets/OptionsEmptyState.tsx +83 -0
  104. package/src/widgets/PasswordField.tsx +3 -3
  105. package/src/widgets/PeoplePicker.tsx +7 -1
  106. package/src/widgets/PercentField.tsx +5 -4
  107. package/src/widgets/PhoneField.tsx +5 -5
  108. package/src/widgets/QRCodeField.tsx +9 -2
  109. package/src/widgets/RadioField.tsx +24 -14
  110. package/src/widgets/RatingField.tsx +18 -3
  111. package/src/widgets/RecipientPickerField.tsx +18 -0
  112. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +611 -0
  113. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +112 -0
  114. package/src/widgets/RecordPickerDialog.tsx +173 -8
  115. package/src/widgets/RichTextField.tsx +151 -24
  116. package/src/widgets/SelectField.tsx +48 -17
  117. package/src/widgets/SliderField.tsx +1 -1
  118. package/src/widgets/SummaryField.tsx +1 -1
  119. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +68 -0
  120. package/src/widgets/TagsField.placeholder.test.tsx +95 -0
  121. package/src/widgets/TagsField.tsx +23 -2
  122. package/src/widgets/TextAreaField.tsx +171 -93
  123. package/src/widgets/TextField.tsx +3 -3
  124. package/src/widgets/TimeField.tsx +2 -2
  125. package/src/widgets/UrlField.tsx +5 -5
  126. package/src/widgets/UserField.tsx +1 -1
  127. package/src/widgets/VectorField.tsx +1 -1
  128. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +248 -0
  129. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +116 -0
  130. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +244 -0
  131. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +195 -0
  132. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +417 -0
  133. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +287 -0
  134. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +160 -0
  135. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +161 -0
  136. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +346 -0
  137. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +246 -0
  138. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +175 -0
  139. package/src/widgets/toDomProps.ts +175 -0
  140. package/src/widgets/types.ts +135 -35
  141. package/src/widgets/useFieldTranslation.ts +60 -0
  142. package/src/withFieldCarrier.tsx +75 -0
@@ -11,6 +11,7 @@ import { cn,
11
11
  PopoverContent, EmptyValue } from '@object-ui/components';
12
12
  import { Search, X, Loader2, AlertCircle, Plus, TableProperties } from 'lucide-react';
13
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 }: FieldWidgetComponentProps<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,6 +280,23 @@ 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
301
  const dependentValuesProp = props.dependentValues;
284
302
 
@@ -912,19 +930,35 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
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
953
  disabled={dependenciesMissing || props.disabled}
924
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).
@@ -1218,7 +1252,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
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
  )}
@@ -25,7 +25,7 @@ export function MasterDetailField({
25
25
  ...props
26
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
@@ -2,6 +2,8 @@ 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 { OptionsEmptyState } from './OptionsEmptyState';
5
7
  import { useCascadingOptions } from './useCascadingOptions';
6
8
 
7
9
  interface Option extends OptionLike { color?: string }
@@ -26,14 +28,14 @@ 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
37
  }: FieldWidgetComponentProps<string[]>) {
36
- const config = (field || schema) as any;
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
41
  const fieldName = props.name || config?.name || props.id || '';
@@ -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
  >
@@ -109,6 +124,7 @@ export function MultiSelectField({
109
124
  onClick={() => toggle(value)}
110
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',
@@ -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
+