@object-ui/fields 17.2.0 → 17.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/CHANGELOG.md +1533 -0
  2. package/README.md +1 -12
  3. package/dist/FieldEditWidget.d.ts +7 -1
  4. package/dist/currency.d.ts +46 -0
  5. package/dist/field-type-alias.d.ts +19 -1
  6. package/dist/index.cjs +5 -5
  7. package/dist/index.css +633 -0
  8. package/dist/index.d.ts +52 -12
  9. package/dist/index.js +5167 -4759
  10. package/dist/widgets/AddressField.d.ts +4 -12
  11. package/dist/widgets/AvatarField.d.ts +1 -1
  12. package/dist/widgets/CharacterCount.d.ts +56 -0
  13. package/dist/widgets/CheckboxesField.d.ts +1 -1
  14. package/dist/widgets/CodeField.d.ts +1 -1
  15. package/dist/widgets/ColorField.d.ts +1 -1
  16. package/dist/widgets/CurrencyField.d.ts +1 -1
  17. package/dist/widgets/EmailField.d.ts +1 -1
  18. package/dist/widgets/FileField.d.ts +1 -1
  19. package/dist/widgets/FilterConditionField.d.ts +1 -1
  20. package/dist/widgets/FullscreenFieldEditor.d.ts +45 -0
  21. package/dist/widgets/GeolocationField.d.ts +1 -1
  22. package/dist/widgets/GridField.d.ts +12 -2
  23. package/dist/widgets/ImageField.d.ts +1 -1
  24. package/dist/widgets/LocationField.d.ts +1 -1
  25. package/dist/widgets/LookupField.d.ts +1 -1
  26. package/dist/widgets/MultiSelectField.d.ts +1 -1
  27. package/dist/widgets/ObjectField.d.ts +1 -1
  28. package/dist/widgets/ObjectRefField.d.ts +1 -1
  29. package/dist/widgets/OptionsEmptyState.d.ts +59 -0
  30. package/dist/widgets/PercentField.d.ts +1 -1
  31. package/dist/widgets/PhoneField.d.ts +1 -1
  32. package/dist/widgets/QRCodeField.d.ts +1 -1
  33. package/dist/widgets/RadioField.d.ts +1 -1
  34. package/dist/widgets/RatingField.d.ts +1 -1
  35. package/dist/widgets/RecipientPickerField.d.ts +1 -1
  36. package/dist/widgets/RecordPickerDialog.d.ts +49 -7
  37. package/dist/widgets/RichTextField.d.ts +19 -1
  38. package/dist/widgets/SelectField.d.ts +25 -5
  39. package/dist/widgets/TagsField.d.ts +1 -1
  40. package/dist/widgets/TextAreaField.d.ts +44 -5
  41. package/dist/widgets/UrlField.d.ts +1 -1
  42. package/dist/widgets/address-format.d.ts +68 -0
  43. package/dist/widgets/file-size-guard.d.ts +41 -0
  44. package/dist/widgets/nativeDateValue.d.ts +37 -0
  45. package/dist/widgets/toDomProps.d.ts +111 -0
  46. package/dist/widgets/toHostGroupProps.d.ts +91 -0
  47. package/dist/widgets/types.d.ts +128 -33
  48. package/dist/widgets/useRecordQuery.d.ts +7 -1
  49. package/dist/withFieldCarrier.d.ts +56 -0
  50. package/package.json +20 -16
  51. package/src/FieldEditWidget.tsx +17 -1
  52. package/src/__tests__/AddressCellRenderer.test.tsx +166 -0
  53. package/src/__tests__/CurrencyField.minorUnits.test.tsx +191 -0
  54. package/src/__tests__/CurrencyField.symbol.test.tsx +198 -0
  55. package/src/__tests__/DateCellRenderer.test.tsx +9 -0
  56. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +146 -0
  57. package/src/__tests__/PercentCellRenderer.locale.test.tsx +152 -0
  58. package/src/__tests__/PercentCellRenderer.test.tsx +115 -0
  59. package/src/__tests__/boolean-label-association-e2e.test.tsx +298 -0
  60. package/src/__tests__/capability-multiselect-retired.test.ts +109 -0
  61. package/src/__tests__/cell-truncation.test.tsx +96 -0
  62. package/src/__tests__/composite-group-label-e2e.test.tsx +436 -0
  63. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +403 -0
  64. package/src/__tests__/date-formatter-residue-4272.test.ts +120 -0
  65. package/src/__tests__/date-locale-channel.test.tsx +305 -0
  66. package/src/__tests__/field-carrier-sdui.test.tsx +169 -0
  67. package/src/__tests__/formatCurrency.centsDigit.test.tsx +158 -0
  68. package/src/__tests__/formatCurrency.minorUnits.test.tsx +221 -0
  69. package/src/__tests__/group-labelling-declaration.test.ts +148 -0
  70. package/src/__tests__/lookupCellRecordLink.test.tsx +171 -0
  71. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +115 -0
  72. package/src/__tests__/percent-formatter-locale-4553.test.ts +147 -0
  73. package/src/__tests__/percent-tie-halfup-4590.test.ts +224 -0
  74. package/src/__tests__/select-label-association-e2e.test.tsx +199 -0
  75. package/src/__tests__/spec-symbol-batch7.test.ts +29 -15
  76. package/src/__tests__/validation-feedback.test.tsx +22 -13
  77. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +265 -0
  78. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +338 -0
  79. package/src/__tests__/widget-dom-leak-e2e.test.tsx +357 -0
  80. package/src/__tests__/widget-props-contract.test.tsx +22 -9
  81. package/src/complex-widgets.test.tsx +22 -6
  82. package/src/currency.ts +130 -0
  83. package/src/datetime-widgets.test.tsx +94 -2
  84. package/src/field-type-alias.multiple.test.ts +81 -0
  85. package/src/field-type-alias.ts +58 -2
  86. package/src/index.css +44 -0
  87. package/src/index.tsx +558 -268
  88. package/src/standard-widgets.test.tsx +8 -1
  89. package/src/widgets/AddressField.postalCode.test.tsx +257 -0
  90. package/src/widgets/AddressField.tsx +107 -38
  91. package/src/widgets/AddressField.uniqueIds.test.tsx +93 -0
  92. package/src/widgets/AvatarField.tsx +9 -2
  93. package/src/widgets/BooleanField.tsx +44 -10
  94. package/src/widgets/CapabilityMultiSelectField.tsx +17 -5
  95. package/src/widgets/CharacterCount.tsx +263 -0
  96. package/src/widgets/CheckboxesField.tsx +66 -16
  97. package/src/widgets/CodeField.tsx +9 -2
  98. package/src/widgets/ColorField.tsx +11 -2
  99. package/src/widgets/CurrencyField.tsx +60 -19
  100. package/src/widgets/DateField.tsx +13 -4
  101. package/src/widgets/DateTimeField.tsx +14 -5
  102. package/src/widgets/EmailField.tsx +5 -5
  103. package/src/widgets/FileField.tsx +39 -11
  104. package/src/widgets/FilterConditionField.tsx +11 -0
  105. package/src/widgets/FormulaField.tsx +7 -2
  106. package/src/widgets/FullscreenFieldEditor.tsx +60 -0
  107. package/src/widgets/GeolocationField.tsx +49 -8
  108. package/src/widgets/GeolocationField.uniqueIds.test.tsx +88 -0
  109. package/src/widgets/GridField.test.tsx +226 -0
  110. package/src/widgets/GridField.tsx +115 -11
  111. package/src/widgets/ImageField.maxSize.test.tsx +154 -0
  112. package/src/widgets/ImageField.tsx +63 -6
  113. package/src/widgets/LocationField.tsx +9 -2
  114. package/src/widgets/LookupField.gateHintLabel.test.tsx +135 -0
  115. package/src/widgets/LookupField.hydration.test.tsx +1 -1
  116. package/src/widgets/LookupField.tsx +49 -10
  117. package/src/widgets/MasterDetailField.tsx +1 -1
  118. package/src/widgets/MultiSelectField.tsx +86 -16
  119. package/src/widgets/NumberField.tsx +3 -3
  120. package/src/widgets/ObjectField.tsx +17 -8
  121. package/src/widgets/ObjectRefField.tsx +10 -0
  122. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +88 -0
  123. package/src/widgets/OptionsEmptyState.test.tsx +169 -0
  124. package/src/widgets/OptionsEmptyState.tsx +99 -0
  125. package/src/widgets/PasswordField.tsx +3 -3
  126. package/src/widgets/PeoplePicker.tsx +7 -1
  127. package/src/widgets/PercentField.tsx +5 -4
  128. package/src/widgets/PhoneField.tsx +5 -5
  129. package/src/widgets/QRCodeField.tsx +9 -2
  130. package/src/widgets/RadioField.tsx +58 -16
  131. package/src/widgets/RatingField.tsx +32 -4
  132. package/src/widgets/RecipientPickerField.tsx +18 -0
  133. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +191 -0
  134. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +124 -0
  135. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +611 -0
  136. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +112 -0
  137. package/src/widgets/RecordPickerDialog.tsx +235 -21
  138. package/src/widgets/RichTextField.tsx +151 -24
  139. package/src/widgets/SelectField.tsx +84 -22
  140. package/src/widgets/SliderField.tsx +1 -1
  141. package/src/widgets/SummaryField.tsx +1 -1
  142. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +68 -0
  143. package/src/widgets/TagsField.placeholder.test.tsx +95 -0
  144. package/src/widgets/TagsField.tsx +23 -2
  145. package/src/widgets/TextAreaField.tsx +171 -93
  146. package/src/widgets/TextField.tsx +3 -3
  147. package/src/widgets/TimeField.tsx +2 -2
  148. package/src/widgets/UrlField.tsx +5 -5
  149. package/src/widgets/UserField.tsx +4 -3
  150. package/src/widgets/VectorField.tsx +1 -1
  151. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +25 -1
  152. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +248 -0
  153. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +116 -0
  154. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +244 -0
  155. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +195 -0
  156. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +417 -0
  157. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +287 -0
  158. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +160 -0
  159. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +161 -0
  160. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +346 -0
  161. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +246 -0
  162. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +175 -0
  163. package/src/widgets/address-format.ts +120 -0
  164. package/src/widgets/file-size-guard.ts +55 -0
  165. package/src/widgets/nativeDateValue.test.ts +115 -0
  166. package/src/widgets/nativeDateValue.ts +112 -0
  167. package/src/widgets/optionWidgets.gatedOptions.test.tsx +267 -0
  168. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +150 -0
  169. package/src/widgets/toDomProps.ts +204 -0
  170. package/src/widgets/toHostGroupProps.ts +102 -0
  171. package/src/widgets/types.ts +135 -35
  172. package/src/widgets/useFieldTranslation.ts +46 -4
  173. package/src/widgets/useRecordQuery.ts +30 -3
  174. package/src/withFieldCarrier.tsx +75 -0
  175. package/dist/FieldEditWidget.test.d.ts +0 -8
  176. package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
  177. package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
  178. package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
  179. package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
  180. package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
  181. package/dist/__tests__/registry-collision.test.d.ts +0 -0
  182. package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
  183. package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
  184. package/dist/__tests__/validation-feedback.test.d.ts +0 -0
  185. package/dist/__tests__/widget-props-contract.test.d.ts +0 -8
  186. package/dist/coerce-safe-value.test.d.ts +0 -1
  187. package/dist/complex-widgets.test.d.ts +0 -1
  188. package/dist/datetime-widgets.test.d.ts +0 -1
  189. package/dist/field-type-coverage.test.d.ts +0 -1
  190. package/dist/standard-widgets.test.d.ts +0 -0
  191. package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
  192. package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
  193. package/dist/widgets/GridField.test.d.ts +0 -1
  194. package/dist/widgets/ImageLightbox.test.d.ts +0 -1
  195. package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
  196. package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
  197. package/dist/widgets/LookupField.idField.test.d.ts +0 -1
  198. package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
  199. package/dist/widgets/PeoplePicker.test.d.ts +0 -8
  200. package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
  201. package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
  202. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  203. package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
  204. package/dist/widgets/UserFieldPicker.test.d.ts +0 -8
  205. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
  206. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
  207. package/dist/widgets/deriveLookupColumns.test.d.ts +0 -1
  208. package/dist/widgets/file-value.test.d.ts +0 -1
  209. package/dist/widgets/personDisplay.test.d.ts +0 -8
  210. package/dist/widgets/recentLookups.test.d.ts +0 -1
  211. package/dist/widgets/useRecordQuery.test.d.ts +0 -8
  212. package/dist/widgets/useUploadingSignal.test.d.ts +0 -1
@@ -3,6 +3,7 @@ import { Input } from '@object-ui/components';
3
3
  import { Button } from '@object-ui/components';
4
4
  import { Eye, EyeOff } from 'lucide-react';
5
5
  import { FieldWidgetComponentProps } from './types';
6
+ import { toDomProps } from './toDomProps';
6
7
 
7
8
  /**
8
9
  * PasswordField - Secure password input with visibility toggle
@@ -10,14 +11,13 @@ import { FieldWidgetComponentProps } from './types';
10
11
  */
11
12
  export function PasswordField({ value, onChange, field, readonly, className, ...props }: FieldWidgetComponentProps<string>) {
12
13
  const [showPassword, setShowPassword] = useState(false);
13
- const config = field || (props as any).schema;
14
+ const config = field;
14
15
 
15
16
  if (readonly) {
16
17
  return <span className="text-sm">••••••••</span>;
17
18
  }
18
19
 
19
- // Filter out non-DOM props
20
- const { inputType, ...domProps } = props as any;
20
+ const domProps = toDomProps(props);
21
21
 
22
22
  return (
23
23
  <div className="relative">
@@ -316,8 +316,14 @@ export function PeoplePicker({
316
316
  // returns the same records (StrictMode double-effect, refetch-on-focus)
317
317
  // re-emits a new array; resetting on identity alone yanked the cursor to -1
318
318
  // mid-navigation (flaky ArrowDown→Enter, and a real UX nit).
319
+ //
320
+ // The id separator is the escaped U+0000 spelling, not the byte itself
321
+ // (objectstack#5450): one raw NUL made this whole file binary to grep and
322
+ // ripgrep — a content search printed `binary file matches` and no line. The
323
+ // runtime value is unchanged, and it is the same separator the `cursorEpoch`
324
+ // key below already spelled this way.
319
325
  const recordsSignature = useMemo(
320
- () => query.records.map(r => String(getPersonId(r, idField))).join(''),
326
+ () => query.records.map(r => String(getPersonId(r, idField))).join('\u0000'),
321
327
  [query.records, idField],
322
328
  );
323
329
  // The reset runs in the RENDER PHASE (the "adjusting state during render"
@@ -1,14 +1,15 @@
1
1
  import React from 'react';
2
2
  import { Input, Slider, EmptyValue } from '@object-ui/components';
3
3
  import { FieldWidgetComponentProps } from './types';
4
+ import { toDomProps } from './toDomProps';
4
5
 
5
6
  /**
6
7
  * PercentField - Percentage input with configurable decimal precision
7
8
  * Stores values as decimals (0-1) and displays as percentages (0-100%)
8
9
  * Includes a slider for interactive control.
9
10
  */
10
- export function PercentField({ value, onChange, field, readonly, errorMessage, className, ...props }: FieldWidgetComponentProps<number>) {
11
- const percentField = (field || (props as any).schema) as any;
11
+ export function PercentField({ value, onChange, field, readonly, error, className, ...props }: FieldWidgetComponentProps<number>) {
12
+ const percentField = field as any;
12
13
  const precision = percentField?.precision ?? 2;
13
14
 
14
15
  // Convention detection. A field declaring `max > 1` (e.g. `max: 100`) stores
@@ -64,7 +65,7 @@ export function PercentField({ value, onChange, field, readonly, errorMessage, c
64
65
  <div className="space-y-2">
65
66
  <div className="relative">
66
67
  <Input
67
- {...props}
68
+ {...toDomProps(props)}
68
69
  type="number"
69
70
  value={displayValue}
70
71
  onChange={handleChange}
@@ -72,7 +73,7 @@ export function PercentField({ value, onChange, field, readonly, errorMessage, c
72
73
  disabled={readonly || props.disabled}
73
74
  className={`pr-8 ${className || ''}`}
74
75
  step={Math.pow(10, -precision).toFixed(precision)}
75
- aria-invalid={!!errorMessage}
76
+ aria-invalid={!!error}
76
77
  />
77
78
  <span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-gray-500">
78
79
  %
@@ -1,13 +1,14 @@
1
1
  import React from 'react';
2
2
  import { Input, EmptyValue } from '@object-ui/components';
3
3
  import { FieldWidgetComponentProps } from './types';
4
+ import { toDomProps } from './toDomProps';
4
5
 
5
6
  /**
6
7
  * PhoneField - Telephone number input with tel link in readonly mode
7
8
  * Renders as a clickable tel link when readonly, standard phone input otherwise
8
9
  */
9
- export function PhoneField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetComponentProps<string>) {
10
- const config = field || (props as any).schema;
10
+ export function PhoneField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
11
+ const config = field;
11
12
  if (readonly) {
12
13
  if (!value) return <EmptyValue />;
13
14
  return (
@@ -20,8 +21,7 @@ export function PhoneField({ value, onChange, field, readonly, errorMessage, ...
20
21
  );
21
22
  }
22
23
 
23
- // Filter out non-DOM props
24
- const { inputType, ...domProps } = props as any;
24
+ const domProps = toDomProps(props);
25
25
 
26
26
  return (
27
27
  <Input
@@ -31,7 +31,7 @@ export function PhoneField({ value, onChange, field, readonly, errorMessage, ...
31
31
  onChange={(e) => onChange(e.target.value)}
32
32
  placeholder={config?.placeholder || '(555) 123-4567'}
33
33
  disabled={readonly || domProps.disabled}
34
- aria-invalid={!!errorMessage}
34
+ aria-invalid={!!error}
35
35
  />
36
36
  );
37
37
  }
@@ -2,14 +2,15 @@ import React from 'react';
2
2
  import { Input, Button, EmptyValue } from '@object-ui/components';
3
3
  import { QrCode, Copy } from 'lucide-react';
4
4
  import { FieldWidgetComponentProps } from './types';
5
+ import { toDomProps } from './toDomProps';
5
6
 
6
7
  /**
7
8
  * QR Code field widget - generates QR codes from text
8
9
  * Uses a simple SVG-based QR code generator
9
10
  */
10
- export function QRCodeField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
11
+ export function QRCodeField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
11
12
  const [showQR, setShowQR] = React.useState(false);
12
- const config = field || (props as any).schema;
13
+ const config = field;
13
14
 
14
15
  // Simple QR code generation using an external library would be ideal
15
16
  // For now, we'll use a service API approach or placeholder
@@ -47,12 +48,18 @@ export function QRCodeField({ value, onChange, field, readonly, ...props }: Fiel
47
48
  <div className="space-y-3">
48
49
  <div className="flex items-center gap-2">
49
50
  <Input
51
+ // DOM pass-through onto the real focusable control (objectui#3318).
52
+ {...toDomProps(props)}
50
53
  type="text"
51
54
  value={value || ''}
52
55
  onChange={(e) => onChange(e.target.value)}
53
56
  placeholder={config?.placeholder || 'Enter text for QR code'}
54
57
  disabled={readonly || props.disabled}
55
58
  className={props.className}
59
+ // AFTER the spread so this widget's own computation wins: `error`
60
+ // is the published validation slot (#3222), `!!undefined` →
61
+ // explicit "false".
62
+ aria-invalid={!!error}
56
63
  />
57
64
  {value && (
58
65
  <>
@@ -2,6 +2,9 @@ import React, { useId, useEffect } from 'react';
2
2
  import { RadioGroup, RadioGroupItem, Label, EmptyValue } from '@object-ui/components';
3
3
  import { isValueStillOffered, type OptionLike } from '@object-ui/core';
4
4
  import { FieldWidgetComponentProps } from './types';
5
+ import { toDomProps } from './toDomProps';
6
+ import { toHostGroupProps } from './toHostGroupProps';
7
+ import { OptionsEmptyState } from './OptionsEmptyState';
5
8
  import { useCascadingOptions } from './useCascadingOptions';
6
9
 
7
10
  type Option = OptionLike;
@@ -24,14 +27,14 @@ export function RadioField({
24
27
  field,
25
28
  readonly,
26
29
  className,
27
- schema,
28
30
  dependentValues,
29
31
  dependsOn: dependsOnProp,
30
- emptyHint: _emptyHint,
32
+ emptyHint,
31
33
  dataSource: _dataSource,
34
+ error,
32
35
  ...props
33
36
  }: FieldWidgetComponentProps<string>) {
34
- const config = (field || schema) as any;
37
+ const config = field as any;
35
38
  const rawOptions: Option[] = config?.options || [];
36
39
  const groupId = useId();
37
40
  const fieldName = props.name || config?.name || props.id || '';
@@ -47,42 +50,81 @@ export function RadioField({
47
50
  // (parent changed / predicate flipped), drop it so no stale pair persists.
48
51
  useEffect(() => {
49
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;
50
61
  if (value === undefined || value === null || (value as unknown) === '') return;
51
62
  if (!isValueStillOffered(value, options)) onChange?.(undefined as unknown as string);
52
63
  // eslint-disable-next-line react-hooks/exhaustive-deps
53
64
  }, [options, gated]);
54
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
+
55
75
  if (readonly) {
56
- 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} />;
57
80
  // Label from the raw set so a stored value hidden by `visibleWhen` still
58
81
  // renders its label rather than a bare id.
59
82
  const opt = rawOptions.find((o) => o.value === value);
60
- return <span className="text-sm">{opt?.label || String(value)}</span>;
83
+ return <span {...hostGroupProps} className="text-sm">{opt?.label || String(value)}</span>;
61
84
  }
62
85
 
63
86
  // No offered options is unfillable — surface a legible state instead of an
64
- // empty radio group: a dependency-gated list prompts for its controlling
65
- // field; an unconfigured / fully-filtered list says so. Mirrors the select.
87
+ // empty radio group: the host's `emptyHint` when it computed one, else this
88
+ // widget's own translated copy. Shared with the select / multiselect /
89
+ // checkboxes so the four cannot drift again (objectui#3231).
66
90
  if (options.length === 0) {
67
- const hint = gated
68
- ? `Select ${dependsOnFields.join(' / ')} first`
69
- : 'No options available';
70
91
  return (
71
- <div
72
- data-testid={fieldName ? `radio-empty-${fieldName}` : undefined}
73
- 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"
74
- >
75
- {hint}
76
- </div>
92
+ <OptionsEmptyState
93
+ emptyHint={emptyHint}
94
+ gated={gated}
95
+ dependsOnFields={dependsOnFields}
96
+ testId={fieldName ? `radio-empty-${fieldName}` : undefined}
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}
101
+ />
77
102
  );
78
103
  }
79
104
 
80
105
  return (
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
115
+ // `Select.Root` (#3306) this Root IS a real DOM element — a
116
+ // `<div role="radiogroup">` — and `radiogroup` is exactly the role
117
+ // WAI-ARIA designates to carry `aria-invalid` for a set of radios
118
+ // (`radio` itself does not support it): the group's state is announced
119
+ // when focus lands on any radio inside it.
81
120
  <RadioGroup
121
+ {...toDomProps(props)}
82
122
  value={value ?? ''}
83
123
  onValueChange={onChange}
84
124
  disabled={props.disabled}
85
125
  className={className}
126
+ // AFTER the spread so this widget's own computation wins (#3222).
127
+ aria-invalid={!!error}
86
128
  >
87
129
  {options.map((opt) => {
88
130
  // Radix speaks strings — stringify the (possibly numeric,
@@ -2,14 +2,16 @@ import React from 'react';
2
2
  import { Star } from 'lucide-react';
3
3
  import { cn } from '@object-ui/components';
4
4
  import { FieldWidgetComponentProps } from './types';
5
+ import { toDomProps } from './toDomProps';
6
+ import { toHostGroupProps } from './toHostGroupProps';
5
7
 
6
8
  /**
7
9
  * Rating field widget - provides a star rating input
8
10
  * Supports numeric values from 0 to max (default 5)
9
11
  */
10
- export function RatingField({ value, onChange, field, readonly, className, ...props }: FieldWidgetComponentProps<number>) {
12
+ export function RatingField({ value, onChange, field, readonly, className, error, ...props }: FieldWidgetComponentProps<number>) {
11
13
  // Get rating-specific configuration from field metadata
12
- const ratingField = (field || (props as any).schema) as any;
14
+ const ratingField = field as any;
13
15
  const max = ratingField?.max ?? 5;
14
16
  const currentValue = value ?? 0;
15
17
 
@@ -18,8 +20,11 @@ export function RatingField({ value, onChange, field, readonly, className, ...pr
18
20
  const displayValue = hoverValue !== null ? hoverValue : currentValue;
19
21
 
20
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`.
21
26
  return (
22
- <div className={cn("flex items-center gap-1", className)}>
27
+ <div {...toHostGroupProps(props)} className={cn("flex items-center gap-1", className)}>
23
28
  {Array.from({ length: max }, (_, i) => (
24
29
  <Star
25
30
  key={i}
@@ -37,8 +42,30 @@ export function RatingField({ value, onChange, field, readonly, className, ...pr
37
42
  );
38
43
  }
39
44
 
45
+ // DOM pass-through (objectui#3318): the container carries the form
46
+ // renderer's id / aria-describedby, but NOT its `aria-invalid` — a plain
47
+ // wrapper div is not where assistive tech reads the invalid state. That
48
+ // state goes onto every focusable star button below, computed from the
49
+ // published `error` slot (#3222). `name` is withheld too: it is only
50
+ // DOM-legal on form controls, and on this div it is exactly the leak
51
+ // #3291 sweeps for.
52
+ const {
53
+ 'aria-invalid': _hostAriaInvalid,
54
+ name: _domName,
55
+ ...groupDomProps
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;
62
+
40
63
  return (
41
- <div 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
+ >
42
69
  {Array.from({ length: max }, (_, i) => (
43
70
  <button
44
71
  key={i}
@@ -48,6 +75,7 @@ export function RatingField({ value, onChange, field, readonly, className, ...pr
48
75
  onMouseLeave={() => setHoverValue(null)}
49
76
  className="focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 rounded"
50
77
  disabled={readonly || props.disabled}
78
+ aria-invalid={!!error}
51
79
  >
52
80
  <Star
53
81
  className={`w-5 h-5 transition-colors ${
@@ -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
  /**
@@ -53,6 +54,7 @@ export function RecipientPickerField({
53
54
  onChange,
54
55
  readonly,
55
56
  className,
57
+ error,
56
58
  ...props
57
59
  }: FieldWidgetComponentProps<string>) {
58
60
  const ctx = React.useContext(SchemaRendererContext);
@@ -133,6 +135,8 @@ export function RecipientPickerField({
133
135
  // field is never un-editable.
134
136
  return (
135
137
  <input
138
+ // DOM pass-through onto the real focusable control (objectui#3318).
139
+ {...toDomProps(props)}
136
140
  className={cn(
137
141
  'w-full rounded border bg-background px-2 py-1 text-sm',
138
142
  className,
@@ -140,6 +144,7 @@ export function RecipientPickerField({
140
144
  value={value ?? ''}
141
145
  disabled={disabled || readonly}
142
146
  onChange={(e) => onChange(e.target.value as any)}
147
+ aria-invalid={!!error}
143
148
  />
144
149
  );
145
150
  }
@@ -149,8 +154,19 @@ export function RecipientPickerField({
149
154
  return <span className={className}>{options.find((o) => o.value === value)?.label ?? value}</span>;
150
155
  }
151
156
 
157
+ // DOM pass-through onto the combobox trigger — the widget's real focusable
158
+ // control (objectui#3318). `name` is withheld: the trigger is a button, not
159
+ // a submission control (same reasoning as #3306's SelectTrigger).
160
+ //
161
+ // NOTE this widget stays on the #3318 ledger regardless: its dependency-
162
+ // gated state (no `recipient_type` chosen yet — the state a fresh form and
163
+ // the registry sweep render) is a plain hint paragraph with no focusable
164
+ // control, so there is nothing there to carry the attribute.
165
+ const { name: _domName, ...triggerDomProps } = toDomProps(props);
166
+
152
167
  return (
153
168
  <Combobox
169
+ {...triggerDomProps}
154
170
  options={options}
155
171
  value={value ?? ''}
156
172
  onValueChange={(v) => onChange(v as any)}
@@ -163,6 +179,8 @@ export function RecipientPickerField({
163
179
  emptyText={records === null ? t('fields.recipient.loading') : t('fields.recipient.empty')}
164
180
  disabled={disabled}
165
181
  className={cn('w-full', className)}
182
+ // AFTER the spread so this widget's own computation wins (#3222).
183
+ aria-invalid={!!error}
166
184
  />
167
185
  );
168
186
  }
@@ -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
+ });