@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
@@ -0,0 +1,83 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ import React from 'react';
10
+ import { cn } from '@object-ui/components';
11
+ import { useFieldTranslation } from './useFieldTranslation';
12
+
13
+ /**
14
+ * The "this option list cannot be filled" state shared by every fixed-option
15
+ * widget (`SelectField` single, `MultiSelectField`, `RadioField`,
16
+ * `CheckboxesField`) — objectui#3231.
17
+ *
18
+ * ## One producer, one consumer
19
+ *
20
+ * `emptyHint` is a declared prop on `FieldWidgetComponentProps`, computed by
21
+ * the form renderer (`form.tsx`, the `#2284` dependency gate) and forwarded to
22
+ * these widgets. **When the host supplies it, it wins**: the host resolves the
23
+ * controlling fields to their human LABELS ("Select Country first"), where a
24
+ * widget on its own only knows the raw metadata names ("Select country first").
25
+ * Absent a host hint — a standalone widget, the inline grid editor, an action
26
+ * param dialog — the widget falls back to its own copy.
27
+ *
28
+ * Each of the four widgets used to inline this box, destructure `emptyHint`
29
+ * into `_emptyHint` and throw it away, then render a hardcoded English literal.
30
+ * Four independent copies is precisely why they drifted together, so the box
31
+ * AND the copy live here only: adding a fifth option widget cannot re-introduce
32
+ * the gap without deliberately re-implementing it.
33
+ *
34
+ * ## Fallback copy is translated
35
+ *
36
+ * The fallback goes through `useFieldTranslation()` (`fields.options.*`,
37
+ * present in all ten locale packs) rather than an English string literal — the
38
+ * gate sentence is the SAME i18n key the form renderer uses, so the two can
39
+ * never say different things in the same locale.
40
+ */
41
+ export interface OptionsEmptyStateProps {
42
+ /**
43
+ * Host-computed hint. Wins over the widget's own copy whenever it is a
44
+ * non-empty string — this is the whole point of the prop.
45
+ */
46
+ emptyHint?: string;
47
+ /** The list is waiting on a `dependsOn` controlling field (vs. unconfigured). */
48
+ gated: boolean;
49
+ /** Controlling field names, used by the fallback gate sentence. */
50
+ dependsOnFields: readonly string[];
51
+ /** Widget-specific stable locator, e.g. `select-empty-${fieldName}`. */
52
+ testId?: string;
53
+ /** Widget-specific sizing (the dropdown is a fixed `h-9`, lists grow). */
54
+ className?: string;
55
+ }
56
+
57
+ export function OptionsEmptyState({
58
+ emptyHint,
59
+ gated,
60
+ dependsOnFields,
61
+ testId,
62
+ className,
63
+ }: OptionsEmptyStateProps) {
64
+ const { t } = useFieldTranslation();
65
+ // The host's hint when it computed one; otherwise this widget's own copy,
66
+ // translated. Never an English literal — that was the reported defect.
67
+ const hint =
68
+ emptyHint ||
69
+ (gated
70
+ ? t('fields.options.selectFirst', { fields: dependsOnFields.join(' / ') })
71
+ : t('fields.options.empty'));
72
+ return (
73
+ <div
74
+ data-testid={testId}
75
+ className={cn(
76
+ 'flex w-full items-center rounded-md border border-input bg-muted/30 px-3 py-2 text-sm text-muted-foreground',
77
+ className,
78
+ )}
79
+ >
80
+ {hint}
81
+ </div>
82
+ );
83
+ }
@@ -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,8 @@ 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 { OptionsEmptyState } from './OptionsEmptyState';
5
7
  import { useCascadingOptions } from './useCascadingOptions';
6
8
 
7
9
  type Option = OptionLike;
@@ -24,14 +26,14 @@ export function RadioField({
24
26
  field,
25
27
  readonly,
26
28
  className,
27
- schema,
28
29
  dependentValues,
29
30
  dependsOn: dependsOnProp,
30
- emptyHint: _emptyHint,
31
+ emptyHint,
31
32
  dataSource: _dataSource,
33
+ error,
32
34
  ...props
33
35
  }: FieldWidgetComponentProps<string>) {
34
- const config = (field || schema) as any;
36
+ const config = field as any;
35
37
  const rawOptions: Option[] = config?.options || [];
36
38
  const groupId = useId();
37
39
  const fieldName = props.name || config?.name || props.id || '';
@@ -61,28 +63,36 @@ export function RadioField({
61
63
  }
62
64
 
63
65
  // 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.
66
+ // empty radio group: the host's `emptyHint` when it computed one, else this
67
+ // widget's own translated copy. Shared with the select / multiselect /
68
+ // checkboxes so the four cannot drift again (objectui#3231).
66
69
  if (options.length === 0) {
67
- const hint = gated
68
- ? `Select ${dependsOnFields.join(' / ')} first`
69
- : 'No options available';
70
70
  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>
71
+ <OptionsEmptyState
72
+ emptyHint={emptyHint}
73
+ gated={gated}
74
+ dependsOnFields={dependsOnFields}
75
+ testId={fieldName ? `radio-empty-${fieldName}` : undefined}
76
+ className="min-h-9"
77
+ />
77
78
  );
78
79
  }
79
80
 
80
81
  return (
82
+ // DOM pass-through onto the radiogroup (objectui#3318). Unlike Radix
83
+ // `Select.Root` (#3306) this Root IS a real DOM element — a
84
+ // `<div role="radiogroup">` — and `radiogroup` is exactly the role
85
+ // WAI-ARIA designates to carry `aria-invalid` for a set of radios
86
+ // (`radio` itself does not support it): the group's state is announced
87
+ // when focus lands on any radio inside it.
81
88
  <RadioGroup
89
+ {...toDomProps(props)}
82
90
  value={value ?? ''}
83
91
  onValueChange={onChange}
84
92
  disabled={props.disabled}
85
93
  className={className}
94
+ // AFTER the spread so this widget's own computation wins (#3222).
95
+ aria-invalid={!!error}
86
96
  >
87
97
  {options.map((opt) => {
88
98
  // Radix speaks strings — stringify the (possibly numeric,
@@ -2,14 +2,15 @@ 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';
5
6
 
6
7
  /**
7
8
  * Rating field widget - provides a star rating input
8
9
  * Supports numeric values from 0 to max (default 5)
9
10
  */
10
- export function RatingField({ value, onChange, field, readonly, className, ...props }: FieldWidgetComponentProps<number>) {
11
+ export function RatingField({ value, onChange, field, readonly, className, error, ...props }: FieldWidgetComponentProps<number>) {
11
12
  // Get rating-specific configuration from field metadata
12
- const ratingField = (field || (props as any).schema) as any;
13
+ const ratingField = field as any;
13
14
  const max = ratingField?.max ?? 5;
14
15
  const currentValue = value ?? 0;
15
16
 
@@ -37,8 +38,21 @@ export function RatingField({ value, onChange, field, readonly, className, ...pr
37
38
  );
38
39
  }
39
40
 
41
+ // DOM pass-through (objectui#3318): the container carries the form
42
+ // renderer's id / aria-describedby, but NOT its `aria-invalid` — a plain
43
+ // wrapper div is not where assistive tech reads the invalid state. That
44
+ // state goes onto every focusable star button below, computed from the
45
+ // published `error` slot (#3222). `name` is withheld too: it is only
46
+ // DOM-legal on form controls, and on this div it is exactly the leak
47
+ // #3291 sweeps for.
48
+ const {
49
+ 'aria-invalid': _hostAriaInvalid,
50
+ name: _domName,
51
+ ...groupDomProps
52
+ } = toDomProps(props);
53
+
40
54
  return (
41
- <div className={cn("flex items-center gap-1", className)}>
55
+ <div {...groupDomProps} className={cn("flex items-center gap-1", className)}>
42
56
  {Array.from({ length: max }, (_, i) => (
43
57
  <button
44
58
  key={i}
@@ -48,6 +62,7 @@ export function RatingField({ value, onChange, field, readonly, className, ...pr
48
62
  onMouseLeave={() => setHoverValue(null)}
49
63
  className="focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 rounded"
50
64
  disabled={readonly || props.disabled}
65
+ aria-invalid={!!error}
51
66
  >
52
67
  <Star
53
68
  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
  }