@object-ui/fields 17.3.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 (156) hide show
  1. package/CHANGELOG.md +941 -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 +4 -4
  7. package/dist/index.css +633 -0
  8. package/dist/index.d.ts +48 -12
  9. package/dist/index.js +5026 -4933
  10. package/dist/widgets/AddressField.d.ts +3 -11
  11. package/dist/widgets/FullscreenFieldEditor.d.ts +42 -152
  12. package/dist/widgets/GridField.d.ts +12 -2
  13. package/dist/widgets/OptionsEmptyState.d.ts +15 -1
  14. package/dist/widgets/RecordPickerDialog.d.ts +32 -6
  15. package/dist/widgets/SelectField.d.ts +25 -5
  16. package/dist/widgets/address-format.d.ts +68 -0
  17. package/dist/widgets/file-size-guard.d.ts +41 -0
  18. package/dist/widgets/nativeDateValue.d.ts +37 -0
  19. package/dist/widgets/toDomProps.d.ts +30 -10
  20. package/dist/widgets/toHostGroupProps.d.ts +91 -0
  21. package/dist/widgets/useRecordQuery.d.ts +7 -1
  22. package/package.json +17 -13
  23. package/src/FieldEditWidget.tsx +17 -1
  24. package/src/__tests__/AddressCellRenderer.test.tsx +166 -0
  25. package/src/__tests__/CurrencyField.minorUnits.test.tsx +191 -0
  26. package/src/__tests__/CurrencyField.symbol.test.tsx +198 -0
  27. package/src/__tests__/DateCellRenderer.test.tsx +9 -0
  28. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +146 -0
  29. package/src/__tests__/PercentCellRenderer.locale.test.tsx +152 -0
  30. package/src/__tests__/PercentCellRenderer.test.tsx +115 -0
  31. package/src/__tests__/boolean-label-association-e2e.test.tsx +298 -0
  32. package/src/__tests__/capability-multiselect-retired.test.ts +109 -0
  33. package/src/__tests__/composite-group-label-e2e.test.tsx +436 -0
  34. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +403 -0
  35. package/src/__tests__/date-formatter-residue-4272.test.ts +120 -0
  36. package/src/__tests__/date-locale-channel.test.tsx +305 -0
  37. package/src/__tests__/formatCurrency.centsDigit.test.tsx +158 -0
  38. package/src/__tests__/formatCurrency.minorUnits.test.tsx +221 -0
  39. package/src/__tests__/group-labelling-declaration.test.ts +148 -0
  40. package/src/__tests__/lookupCellRecordLink.test.tsx +171 -0
  41. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +115 -0
  42. package/src/__tests__/percent-formatter-locale-4553.test.ts +147 -0
  43. package/src/__tests__/percent-tie-halfup-4590.test.ts +224 -0
  44. package/src/__tests__/select-label-association-e2e.test.tsx +199 -0
  45. package/src/__tests__/widget-dom-leak-e2e.test.tsx +29 -270
  46. package/src/complex-widgets.test.tsx +22 -6
  47. package/src/currency.ts +130 -0
  48. package/src/datetime-widgets.test.tsx +94 -2
  49. package/src/field-type-alias.multiple.test.ts +81 -0
  50. package/src/field-type-alias.ts +58 -2
  51. package/src/index.css +44 -0
  52. package/src/index.tsx +489 -247
  53. package/src/standard-widgets.test.tsx +8 -1
  54. package/src/widgets/AddressField.postalCode.test.tsx +257 -0
  55. package/src/widgets/AddressField.tsx +79 -29
  56. package/src/widgets/AddressField.uniqueIds.test.tsx +2 -1
  57. package/src/widgets/BooleanField.tsx +41 -7
  58. package/src/widgets/CapabilityMultiSelectField.tsx +17 -5
  59. package/src/widgets/CheckboxesField.tsx +35 -2
  60. package/src/widgets/CurrencyField.tsx +55 -15
  61. package/src/widgets/DateField.tsx +11 -2
  62. package/src/widgets/DateTimeField.tsx +12 -3
  63. package/src/widgets/FileField.tsx +27 -9
  64. package/src/widgets/FormulaField.tsx +6 -1
  65. package/src/widgets/FullscreenFieldEditor.tsx +47 -258
  66. package/src/widgets/GeolocationField.tsx +29 -3
  67. package/src/widgets/GridField.test.tsx +226 -0
  68. package/src/widgets/GridField.tsx +114 -10
  69. package/src/widgets/ImageField.maxSize.test.tsx +154 -0
  70. package/src/widgets/ImageField.tsx +54 -4
  71. package/src/widgets/LookupField.hydration.test.tsx +1 -1
  72. package/src/widgets/LookupField.tsx +8 -4
  73. package/src/widgets/MultiSelectField.tsx +56 -2
  74. package/src/widgets/OptionsEmptyState.tsx +16 -0
  75. package/src/widgets/RadioField.tsx +35 -3
  76. package/src/widgets/RatingField.tsx +15 -2
  77. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +191 -0
  78. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +124 -0
  79. package/src/widgets/RecordPickerDialog.tsx +64 -15
  80. package/src/widgets/RichTextField.tsx +1 -1
  81. package/src/widgets/SelectField.tsx +36 -5
  82. package/src/widgets/UserField.tsx +3 -2
  83. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +25 -1
  84. package/src/widgets/address-format.ts +120 -0
  85. package/src/widgets/file-size-guard.ts +55 -0
  86. package/src/widgets/nativeDateValue.test.ts +115 -0
  87. package/src/widgets/nativeDateValue.ts +112 -0
  88. package/src/widgets/optionWidgets.gatedOptions.test.tsx +267 -0
  89. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +150 -0
  90. package/src/widgets/toDomProps.ts +48 -19
  91. package/src/widgets/toHostGroupProps.ts +102 -0
  92. package/src/widgets/useFieldTranslation.ts +15 -33
  93. package/src/widgets/useRecordQuery.ts +30 -3
  94. package/dist/FieldEditWidget.test.d.ts +0 -8
  95. package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
  96. package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
  97. package/dist/__tests__/cell-truncation.test.d.ts +0 -0
  98. package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
  99. package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
  100. package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
  101. package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
  102. package/dist/__tests__/registry-collision.test.d.ts +0 -0
  103. package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
  104. package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
  105. package/dist/__tests__/validation-feedback.test.d.ts +0 -0
  106. package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
  107. package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
  108. package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
  109. package/dist/__tests__/widget-props-contract.test.d.ts +0 -8
  110. package/dist/coerce-safe-value.test.d.ts +0 -1
  111. package/dist/complex-widgets.test.d.ts +0 -1
  112. package/dist/datetime-widgets.test.d.ts +0 -1
  113. package/dist/field-type-coverage.test.d.ts +0 -1
  114. package/dist/standard-widgets.test.d.ts +0 -0
  115. package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
  116. package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
  117. package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
  118. package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
  119. package/dist/widgets/GridField.test.d.ts +0 -1
  120. package/dist/widgets/ImageLightbox.test.d.ts +0 -1
  121. package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
  122. package/dist/widgets/LookupField.gateHintLabel.test.d.ts +0 -8
  123. package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
  124. package/dist/widgets/LookupField.idField.test.d.ts +0 -1
  125. package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
  126. package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
  127. package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
  128. package/dist/widgets/PeoplePicker.test.d.ts +0 -8
  129. package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
  130. package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
  131. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  132. package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +0 -8
  133. package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +0 -8
  134. package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
  135. package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
  136. package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
  137. package/dist/widgets/UserFieldPicker.test.d.ts +0 -8
  138. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
  139. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
  140. package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
  141. package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
  142. package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
  143. package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
  144. package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
  145. package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
  146. package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
  147. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
  148. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
  149. package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
  150. package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
  151. package/dist/widgets/deriveLookupColumns.test.d.ts +0 -1
  152. package/dist/widgets/file-value.test.d.ts +0 -1
  153. package/dist/widgets/personDisplay.test.d.ts +0 -8
  154. package/dist/widgets/recentLookups.test.d.ts +0 -1
  155. package/dist/widgets/useRecordQuery.test.d.ts +0 -8
  156. package/dist/widgets/useUploadingSignal.test.d.ts +0 -1
@@ -7,6 +7,7 @@ import { FieldWidgetComponentProps } from './types';
7
7
  import { toDomProps } from './toDomProps';
8
8
  import { ImageLightbox } from './ImageLightbox';
9
9
  import { useUploadingSignal } from './useUploadingSignal';
10
+ import { maxSizeError, type TranslateFn } from './file-size-guard';
10
11
  import {
11
12
  fileValueForSubmit,
12
13
  readFileValues,
@@ -30,6 +31,12 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
30
31
  const imageField = field as any;
31
32
  const multiple = imageField?.multiple || false;
32
33
  const accept = imageField?.accept ? imageField.accept.join(',') : 'image/*';
34
+ /**
35
+ * Max upload size in bytes. Delivered by `paramToField` for an action param
36
+ * and by the field config for a record field; enforced before any upload
37
+ * starts, exactly as FileField does (objectui#4141).
38
+ */
39
+ const maxSize = imageField?.maxSize as number | undefined;
33
40
  /**
34
41
  * Set `field.crop = false` to opt out of inline cropping. Defaults to enabled.
35
42
  */
@@ -39,6 +46,8 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
39
46
  const { upload } = useUpload();
40
47
  const { t } = useObjectTranslation();
41
48
  const [uploading, setUploading] = useState(false);
49
+ /** Client-side rejections (oversize picks), cleared on the next attempt. */
50
+ const [errors, setErrors] = useState<string[]>([]);
42
51
  // Display details of just-uploaded images, keyed by their new `sys_file` id.
43
52
  // Submitting the reference form means the field value no longer carries the
44
53
  // URL a thumbnail needs; these keep the preview visible until the next read
@@ -59,6 +68,18 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
59
68
  const handleCropConfirm = useCallback(
60
69
  async (blob: Blob, name: string) => {
61
70
  if (!cropTarget) return;
71
+ // The size that matters here is the CROP's, not the source image's: the
72
+ // cropper re-encodes to PNG, so cropping an in-limit JPEG can produce a
73
+ // blob over the limit. Checked before `setUploading` so a rejected crop
74
+ // never flashes the spinner, and the dialog closes so the message lands
75
+ // in the field's error row rather than behind the open dialog.
76
+ const rejection = maxSizeError(t as TranslateFn, { name, size: blob.size }, maxSize);
77
+ if (rejection) {
78
+ setErrors([rejection]);
79
+ setCropTarget(null);
80
+ return;
81
+ }
82
+ setErrors([]);
62
83
  setUploading(true);
63
84
  try {
64
85
  const result = await upload(blob);
@@ -76,7 +97,7 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
76
97
  setCropTarget(null);
77
98
  }
78
99
  },
79
- [cropTarget, images, multiple, onChange, upload, remember],
100
+ [cropTarget, images, multiple, onChange, upload, remember, maxSize, t],
80
101
  );
81
102
 
82
103
  const openCropper = useCallback(
@@ -128,12 +149,32 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
128
149
 
129
150
  const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
130
151
  const selectedFiles = Array.from(e.target.files || []);
152
+ // Reset the input up front rather than in `finally`: a pick rejected below
153
+ // returns before the upload runs, and leaving the old value in place would
154
+ // stop the user re-picking the same name after shrinking the image.
155
+ // `selectedFiles` already holds the File objects, so clearing is safe.
156
+ if (inputRef.current) inputRef.current.value = '';
131
157
  if (selectedFiles.length === 0) return;
132
158
 
159
+ // Enforce the declared limit before anything touches the network. Partial
160
+ // acceptance matches FileField: the in-limit picks of a multi-select still
161
+ // upload, and only the oversize ones are reported.
162
+ const rejections: string[] = [];
163
+ const validFiles = selectedFiles.filter((file) => {
164
+ const rejection = maxSizeError(t as TranslateFn, file, maxSize);
165
+ if (rejection) {
166
+ rejections.push(rejection);
167
+ return false;
168
+ }
169
+ return true;
170
+ });
171
+ setErrors(rejections);
172
+ if (validFiles.length === 0) return;
173
+
133
174
  setUploading(true);
134
175
  try {
135
176
  const imageObjects = await Promise.all(
136
- selectedFiles.map(async (file) => {
177
+ validFiles.map(async (file) => {
137
178
  const result = await upload(file);
138
179
  remember(result, file.name);
139
180
  return fileValueForSubmit(result, file.name);
@@ -147,8 +188,6 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
147
188
  }
148
189
  } finally {
149
190
  setUploading(false);
150
- // Reset input so picking the same file again still triggers change.
151
- if (inputRef.current) inputRef.current.value = '';
152
191
  }
153
192
  };
154
193
 
@@ -237,6 +276,17 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
237
276
  ? t('fields.image.addMore')
238
277
  : t('fields.image.upload')}
239
278
  </Button>
279
+
280
+ {/* Client-side rejections (oversize picks). Same presentation as
281
+ FileField's error row — this widget previously had no surface for
282
+ them at all, because it never rejected anything (objectui#4141). */}
283
+ {errors.length > 0 && (
284
+ <div className="space-y-0.5">
285
+ {errors.map((err, i) => (
286
+ <p key={i} className="text-xs text-destructive">{err}</p>
287
+ ))}
288
+ </div>
289
+ )}
240
290
  </div>
241
291
 
242
292
  {cropEnabled && cropTarget && (
@@ -71,7 +71,7 @@ describe('LookupField — multi-value hydration batches and shows loading (#3108
71
71
 
72
72
  // In-flight: loading indicator + raw committed count, no "Select…".
73
73
  await waitFor(() => expect(screen.getByTestId('lookup-hydrating')).toBeTruthy());
74
- expect(screen.queryByText('Select...')).toBeNull();
74
+ expect(screen.queryByText('Select…')).toBeNull();
75
75
  expect(screen.getByText(/3/)).toBeTruthy();
76
76
 
77
77
  resolveFind({ data: USERS });
@@ -218,9 +218,13 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro
218
218
 
219
219
  const lookupField = field as any;
220
220
 
221
- // When rendered via createFieldRenderer wrapper the actual objectSchema field
222
- // metadata (reference_to, display_field, etc.) lives at lookupField.field.
223
- // Unwrap it so lookup-specific properties resolve correctly.
221
+ // The form renderer passes `field: field.field || field` — `.field` is the
222
+ // declared metadata slot (objectui#3090) — so the actual objectSchema field
223
+ // metadata (reference_to, display_field, etc.) can arrive nested at
224
+ // `lookupField.field`. Unwrap it so lookup-specific properties resolve
225
+ // correctly. (This used to credit the docs-demo `createFieldRenderer` wrapper,
226
+ // which never produced the nesting and was removed in objectui#3910; the form
227
+ // path is the real producer.)
224
228
  // ObjectStack convention uses `reference` while the types use `reference_to`,
225
229
  // so we check for both property names.
226
230
  const innerField = lookupField?.field;
@@ -1078,7 +1082,7 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro
1078
1082
  <div className="relative">
1079
1083
  <Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
1080
1084
  <Input
1081
- placeholder={t('common.search') + '...'}
1085
+ placeholder={t('table.search')}
1082
1086
  value={searchQuery}
1083
1087
  onChange={(e) => handleSearchChange(e.target.value)}
1084
1088
  onKeyDown={handleSearchKeyDown}
@@ -3,6 +3,7 @@ import { Badge, EmptyValue, cn } from '@object-ui/components';
3
3
  import type { OptionLike } from '@object-ui/core';
4
4
  import { FieldWidgetComponentProps } from './types';
5
5
  import { toDomProps } from './toDomProps';
6
+ import { toHostGroupProps } from './toHostGroupProps';
6
7
  import { OptionsEmptyState } from './OptionsEmptyState';
7
8
  import { useCascadingOptions } from './useCascadingOptions';
8
9
 
@@ -52,16 +53,56 @@ export function MultiSelectField({
52
53
  // the scalar case we prune per-element rather than clearing the whole field.
53
54
  useEffect(() => {
54
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.
55
83
  if (selected.length === 0) return;
56
84
  const stillOffered = selected.filter((v) => options.some((o) => o.value === v));
57
85
  if (stillOffered.length !== selected.length) onChange(stillOffered);
58
86
  // eslint-disable-next-line react-hooks/exhaustive-deps
59
87
  }, [options, gated]);
60
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
+
61
96
  if (readonly) {
62
- 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} />;
63
104
  return (
64
- <div className="flex flex-wrap gap-1">
105
+ <div {...hostGroupProps} className="flex flex-wrap gap-1">
65
106
  {selected.map((v) => {
66
107
  // Label from the raw set so a stored value hidden by `visibleWhen`
67
108
  // still renders its label rather than a bare id.
@@ -84,6 +125,9 @@ export function MultiSelectField({
84
125
  dependsOnFields={dependsOnFields}
85
126
  testId={fieldName ? `multiselect-empty-${fieldName}` : undefined}
86
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}
87
131
  />
88
132
  );
89
133
  }
@@ -105,10 +149,20 @@ export function MultiSelectField({
105
149
  name: _domName,
106
150
  ...groupDomProps
107
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;
108
161
 
109
162
  return (
110
163
  <div
111
164
  {...groupDomProps}
165
+ role={isLabelledGroup ? 'group' : undefined}
112
166
  className={cn('flex flex-wrap gap-1.5', className)}
113
167
  data-testid={fieldName ? `multiselect-${fieldName}` : undefined}
114
168
  >
@@ -9,6 +9,7 @@
9
9
  import React from 'react';
10
10
  import { cn } from '@object-ui/components';
11
11
  import { useFieldTranslation } from './useFieldTranslation';
12
+ import type { HostGroupProps } from './toHostGroupProps';
12
13
 
13
14
  /**
14
15
  * The "this option list cannot be filled" state shared by every fixed-option
@@ -52,6 +53,19 @@ export interface OptionsEmptyStateProps {
52
53
  testId?: string;
53
54
  /** Widget-specific sizing (the dropdown is a fixed `h-9`, lists grow). */
54
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;
55
69
  }
56
70
 
57
71
  export function OptionsEmptyState({
@@ -60,6 +74,7 @@ export function OptionsEmptyState({
60
74
  dependsOnFields,
61
75
  testId,
62
76
  className,
77
+ hostGroupProps,
63
78
  }: OptionsEmptyStateProps) {
64
79
  const { t } = useFieldTranslation();
65
80
  // The host's hint when it computed one; otherwise this widget's own copy,
@@ -71,6 +86,7 @@ export function OptionsEmptyState({
71
86
  : t('fields.options.empty'));
72
87
  return (
73
88
  <div
89
+ {...hostGroupProps}
74
90
  data-testid={testId}
75
91
  className={cn(
76
92
  'flex w-full items-center rounded-md border border-input bg-muted/30 px-3 py-2 text-sm text-muted-foreground',
@@ -3,6 +3,7 @@ import { RadioGroup, RadioGroupItem, Label, EmptyValue } from '@object-ui/compon
3
3
  import { isValueStillOffered, type OptionLike } from '@object-ui/core';
4
4
  import { FieldWidgetComponentProps } from './types';
5
5
  import { toDomProps } from './toDomProps';
6
+ import { toHostGroupProps } from './toHostGroupProps';
6
7
  import { OptionsEmptyState } from './OptionsEmptyState';
7
8
  import { useCascadingOptions } from './useCascadingOptions';
8
9
 
@@ -49,17 +50,37 @@ export function RadioField({
49
50
  // (parent changed / predicate flipped), drop it so no stale pair persists.
50
51
  useEffect(() => {
51
52
  if (readonly) return;
53
+ // Never configured → nothing to prune against; see `MultiSelectField`'s
54
+ // copy of this guard for the measured failure (objectui#4220).
55
+ if (rawOptions.length === 0) return;
56
+ // Gated → the authored list is withheld until the `dependsOn` parent is
57
+ // chosen, so the empty offered set is missing information, not a verdict on
58
+ // the stored value; clearing here fired on mount (objectui#4247). Same
59
+ // reasoning, at length, in `MultiSelectField`.
60
+ if (gated) return;
52
61
  if (value === undefined || value === null || (value as unknown) === '') return;
53
62
  if (!isValueStillOffered(value, options)) onChange?.(undefined as unknown as string);
54
63
  // eslint-disable-next-line react-hooks/exhaustive-deps
55
64
  }, [options, gated]);
56
65
 
66
+ // The host's group label has to be consumed on EVERY surface this widget can
67
+ // render, not only the `RadioGroup` (objectui#3990): both branches below return
68
+ // before that spread, which is how a field-level `readonly: true` and a
69
+ // zero-option list ended up with a published label id and no consumer at all.
70
+ // See `toHostGroupProps` — note the role it emits is `group`, not the
71
+ // `radiogroup` the editable branch keeps: there is not one radio left in
72
+ // either of these surfaces.
73
+ const hostGroupProps = toHostGroupProps(props);
74
+
57
75
  if (readonly) {
58
- if (value == null || value === '') return <EmptyValue />;
76
+ // `EmptyValue`'s own `aria-label` ("No value") is outranked by
77
+ // `aria-labelledby` per accname, and on the `generic` role that placeholder
78
+ // span carries it was never exposed as a name anyway.
79
+ if (value == null || value === '') return <EmptyValue {...hostGroupProps} />;
59
80
  // Label from the raw set so a stored value hidden by `visibleWhen` still
60
81
  // renders its label rather than a bare id.
61
82
  const opt = rawOptions.find((o) => o.value === value);
62
- return <span className="text-sm">{opt?.label || String(value)}</span>;
83
+ return <span {...hostGroupProps} className="text-sm">{opt?.label || String(value)}</span>;
63
84
  }
64
85
 
65
86
  // No offered options is unfillable — surface a legible state instead of an
@@ -74,12 +95,23 @@ export function RadioField({
74
95
  dependsOnFields={dependsOnFields}
75
96
  testId={fieldName ? `radio-empty-${fieldName}` : undefined}
76
97
  className="min-h-9"
98
+ // This box IS the field in this state, so it is what the host label
99
+ // names (objectui#3990).
100
+ hostGroupProps={hostGroupProps}
77
101
  />
78
102
  );
79
103
  }
80
104
 
81
105
  return (
82
- // DOM pass-through onto the radiogroup (objectui#3318). Unlike Radix
106
+ // DOM pass-through onto the radiogroup (objectui#3318). It also carries the
107
+ // host's group label: when the form renderer associates its `<FormLabel>` by
108
+ // IDREF (`aria-labelledby`, objectui#3961) that key rides in this same
109
+ // whitelist spread — `toDomProps` forwards the whole `aria-` family — so this
110
+ // widget needs no branch of its own. `radiogroup` is already a member of the
111
+ // group role family, so nothing here overrides it with `role="group"`: it is
112
+ // the more specific role AND the correct carrier of `aria-invalid` below.
113
+ //
114
+ // Unlike Radix
83
115
  // `Select.Root` (#3306) this Root IS a real DOM element — a
84
116
  // `<div role="radiogroup">` — and `radiogroup` is exactly the role
85
117
  // WAI-ARIA designates to carry `aria-invalid` for a set of radios
@@ -3,6 +3,7 @@ import { Star } from 'lucide-react';
3
3
  import { cn } from '@object-ui/components';
4
4
  import { FieldWidgetComponentProps } from './types';
5
5
  import { toDomProps } from './toDomProps';
6
+ import { toHostGroupProps } from './toHostGroupProps';
6
7
 
7
8
  /**
8
9
  * Rating field widget - provides a star rating input
@@ -19,8 +20,11 @@ export function RatingField({ value, onChange, field, readonly, className, error
19
20
  const displayValue = hoverValue !== null ? hoverValue : currentValue;
20
21
 
21
22
  if (readonly) {
23
+ // The readonly star row is the field's whole rendered surface, so it — not
24
+ // only the editable row below — consumes the host's group label
25
+ // (objectui#3990). See `toHostGroupProps`.
22
26
  return (
23
- <div className={cn("flex items-center gap-1", className)}>
27
+ <div {...toHostGroupProps(props)} className={cn("flex items-center gap-1", className)}>
24
28
  {Array.from({ length: max }, (_, i) => (
25
29
  <Star
26
30
  key={i}
@@ -50,9 +54,18 @@ export function RatingField({ value, onChange, field, readonly, className, error
50
54
  name: _domName,
51
55
  ...groupDomProps
52
56
  } = toDomProps(props);
57
+ // When the host named this container by IDREF (`aria-labelledby`,
58
+ // objectui#3961) it IS the labelled group of stars, so it answers with the
59
+ // matching role; a `label for` pointing at this `div` was inert. Standalone
60
+ // (no host label) the markup is unchanged.
61
+ const isLabelledGroup = groupDomProps['aria-labelledby'] != null;
53
62
 
54
63
  return (
55
- <div {...groupDomProps} className={cn("flex items-center gap-1", className)}>
64
+ <div
65
+ {...groupDomProps}
66
+ role={isLabelledGroup ? 'group' : undefined}
67
+ className={cn("flex items-center gap-1", className)}
68
+ >
56
69
  {Array.from({ length: max }, (_, i) => (
57
70
  <button
58
71
  key={i}
@@ -0,0 +1,191 @@
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
+ * `RecordPickerDialog.baseFilter` accepts TWO shapes — objectui#3831.
11
+ *
12
+ * The slot was declared `Record< string, any >` and merged by object spread,
13
+ * which serves the dependent-lookup chain (#2215) exactly right and cannot
14
+ * carry a spec `ViewFilterRule[]` at all: TypeScript accepts an array there
15
+ * (an array satisfies a string index of `any`), the spread flattens it to
16
+ * `{"0": rule, "1": rule}`, and the query then filters on columns literally
17
+ * named `0`/`1` — green types, wrong query, no diagnostic. That is the slot
18
+ * `record:related_list.add.picker.filter` has to reach.
19
+ *
20
+ * So the merge now discriminates on shape, and both directions are pinned here:
21
+ *
22
+ * - **record form** keeps KEY-OVERWRITE precedence, byte for byte. A cascaded
23
+ * parent value must REPLACE a stale same-field `lookupFilters` entry, not
24
+ * intersect with it — `account = 'stale' AND account = 'a1'` returns nothing.
25
+ * (`LookupField.dependsOn.test.tsx` owns the #2215 behaviour end-to-end and
26
+ * is deliberately left untouched; this file pins the same precedence at the
27
+ * dialog's own boundary, so a future merge change cannot pass by only
28
+ * satisfying the array path.)
29
+ * - **rule array** lowers through `mergeFilterNodes`, the repo's single filter
30
+ * sink, so all 19 `VIEW_FILTER_OPERATORS` survive — including the four
31
+ * (`before`, `after`, `is_empty`, `is_not_empty`) the MongoDB-style record
32
+ * form has no `$op` for and which a hand-rolled second lowering would have
33
+ * had to drop or throw on.
34
+ */
35
+
36
+ import { render, waitFor } from '@testing-library/react';
37
+ import { describe, it, expect, vi } from 'vitest';
38
+ import { RecordPickerDialog } from './RecordPickerDialog';
39
+
40
+ const records = [{ id: 'r1', name: 'One' }];
41
+
42
+ function makeDataSource() {
43
+ const find = vi.fn(async () => ({ data: records, total: records.length }));
44
+ return { find } as any;
45
+ }
46
+
47
+ /** The `$filter` of the most recent query. */
48
+ function lastFilter(ds: any) {
49
+ const calls = ds.find.mock.calls;
50
+ return calls[calls.length - 1][1]?.$filter;
51
+ }
52
+
53
+ describe('RecordPickerDialog baseFilter — record form (#2215 precedence)', () => {
54
+ it('spreads a record baseFilter last, overwriting lookupFilters on the same field', async () => {
55
+ const ds = makeDataSource();
56
+ render(
57
+ <RecordPickerDialog
58
+ open
59
+ onOpenChange={vi.fn()}
60
+ dataSource={ds}
61
+ objectName="contacts"
62
+ onSelect={vi.fn()}
63
+ lookupFilters={[{ field: 'account', operator: 'eq', value: 'stale' }]}
64
+ baseFilter={{ account: 'a1' }}
65
+ />,
66
+ );
67
+
68
+ await waitFor(() => expect(ds.find).toHaveBeenCalled());
69
+ // Still the plain record form — NOT lowered to an AST, and emphatically not
70
+ // an `and` of the stale value with the cascaded one.
71
+ expect(lastFilter(ds)).toEqual({ account: 'a1' });
72
+ expect(Array.isArray(lastFilter(ds))).toBe(false);
73
+ });
74
+
75
+ it('sends no $filter at all when nothing is constrained', async () => {
76
+ const ds = makeDataSource();
77
+ render(
78
+ <RecordPickerDialog
79
+ open
80
+ onOpenChange={vi.fn()}
81
+ dataSource={ds}
82
+ objectName="contacts"
83
+ onSelect={vi.fn()}
84
+ />,
85
+ );
86
+
87
+ await waitFor(() => expect(ds.find).toHaveBeenCalled());
88
+ expect(lastFilter(ds)).toBeUndefined();
89
+ });
90
+ });
91
+
92
+ describe('RecordPickerDialog baseFilter — spec ViewFilterRule[] (#3831)', () => {
93
+ it('lowers a rule array to AST nodes instead of spreading it into index keys', async () => {
94
+ const ds = makeDataSource();
95
+ render(
96
+ <RecordPickerDialog
97
+ open
98
+ onOpenChange={vi.fn()}
99
+ dataSource={ds}
100
+ objectName="sys_position"
101
+ onSelect={vi.fn()}
102
+ baseFilter={[{ field: 'is_active', operator: 'equals', value: true }]}
103
+ />,
104
+ );
105
+
106
+ await waitFor(() => expect(ds.find).toHaveBeenCalled());
107
+ expect(lastFilter(ds)).toEqual([['is_active', 'equals', true]]);
108
+ // The old object-spread failure mode, pinned by structure rather than by
109
+ // key: the spread produced a PLAIN OBJECT whose values were still rule
110
+ // objects (`{"0": {field, operator, value}}`), so the query asked for a
111
+ // column named `0`. Asserting on `Object.keys` cannot tell the two apart —
112
+ // an array's keys are its indices too — so what is pinned is that each
113
+ // element is an AST TRIPLE and no longer carries a `field` member.
114
+ const filter = lastFilter(ds) as unknown[];
115
+ expect(Array.isArray(filter)).toBe(true);
116
+ expect(Array.isArray(filter[0])).toBe(true);
117
+ expect(filter[0]).not.toHaveProperty('field');
118
+ });
119
+
120
+ it('preserves the four operators the record form cannot spell', async () => {
121
+ const ds = makeDataSource();
122
+ render(
123
+ <RecordPickerDialog
124
+ open
125
+ onOpenChange={vi.fn()}
126
+ dataSource={ds}
127
+ objectName="sys_license"
128
+ onSelect={vi.fn()}
129
+ baseFilter={[
130
+ { field: 'starts_at', operator: 'before', value: '2026-01-01' },
131
+ { field: 'expires_at', operator: 'after', value: '2026-01-01' },
132
+ { field: 'revoked_at', operator: 'is_empty' },
133
+ { field: 'assigned_to', operator: 'is_not_empty' },
134
+ ]}
135
+ />,
136
+ );
137
+
138
+ await waitFor(() => expect(ds.find).toHaveBeenCalled());
139
+ expect(lastFilter(ds)).toEqual([
140
+ ['starts_at', 'before', '2026-01-01'],
141
+ ['expires_at', 'after', '2026-01-01'],
142
+ // A rule with no `value` stays two-element: inventing `null` here would be
143
+ // a real `{revoked_at: null}` predicate, i.e. a different question.
144
+ ['revoked_at', 'is_empty'],
145
+ ['assigned_to', 'is_not_empty'],
146
+ ]);
147
+ });
148
+
149
+ it('keeps a record baseFilter and a rule array as separate `and` children', async () => {
150
+ const ds = makeDataSource();
151
+ render(
152
+ <RecordPickerDialog
153
+ open
154
+ onOpenChange={vi.fn()}
155
+ dataSource={ds}
156
+ objectName="sys_position"
157
+ onSelect={vi.fn()}
158
+ // A rule array is the author's restriction; the record-form base still
159
+ // arrives via `lookupFilters` here, and the two must BOTH apply.
160
+ lookupFilters={[{ field: 'company', operator: 'eq', value: 'c1' }]}
161
+ baseFilter={[{ field: 'is_active', operator: 'equals', value: true }]}
162
+ />,
163
+ );
164
+
165
+ await waitFor(() => expect(ds.find).toHaveBeenCalled());
166
+ expect(lastFilter(ds)).toEqual([
167
+ 'and',
168
+ ['company', '=', 'c1'],
169
+ [['is_active', 'equals', true]],
170
+ ]);
171
+ });
172
+
173
+ it('sends no $filter for an empty rule array', async () => {
174
+ const ds = makeDataSource();
175
+ render(
176
+ <RecordPickerDialog
177
+ open
178
+ onOpenChange={vi.fn()}
179
+ dataSource={ds}
180
+ objectName="sys_position"
181
+ onSelect={vi.fn()}
182
+ baseFilter={[]}
183
+ />,
184
+ );
185
+
186
+ await waitFor(() => expect(ds.find).toHaveBeenCalled());
187
+ // An empty restriction is no restriction — not an empty `$filter` the wire
188
+ // has to interpret.
189
+ expect(lastFilter(ds)).toBeUndefined();
190
+ });
191
+ });