@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
@@ -0,0 +1,102 @@
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 { toDomProps } from './toDomProps';
10
+
11
+ /**
12
+ * The props that make a group-labelled widget's rendered surface the thing its
13
+ * host label actually NAMES, on the surfaces that are not controls
14
+ * (objectui#3990).
15
+ *
16
+ * ## What it is for
17
+ *
18
+ * A widget declared `labelling: 'group'` (objectui#3961) is told by the form
19
+ * renderer: your label publishes an `id` and drops its `for`, so name yourself
20
+ * by IDREF. The renderer hands down exactly two facts that address the field as
21
+ * a WHOLE:
22
+ *
23
+ * - `id` — the host's control id (`…-form-item`), the id the label's `for`
24
+ * used to point at;
25
+ * - `aria-labelledby` — the IDREF of that visible label.
26
+ *
27
+ * Every editable branch already consumes them inside its wider `toDomProps`
28
+ * spread. The branches that returned EARLY did not — a field-level
29
+ * `readonly: true`, or an option list with zero offered options — so the label
30
+ * published an id that no element in the document referenced. Measured on
31
+ * `origin/main`, one field per row, reading the host label against the DOM:
32
+ *
33
+ * ```
34
+ * multiselect readonly+value consumers=0 byLabelText=0 namedByRole=[]
35
+ * multiselect readonly+empty consumers=0 byLabelText=0 namedByRole=[]
36
+ * multiselect zeroOptions consumers=0 byLabelText=0 namedByRole=[]
37
+ * multiselect editable consumers=1 byLabelText=1 namedByRole=[group:1]
38
+ * ```
39
+ *
40
+ * All seven group-labelled types measured the same, in every readonly state.
41
+ * This is quieter than the dangling `for` #3961 replaced: a `for` that resolves
42
+ * to nothing can at least be swept for, while "a label published an id and
43
+ * nobody consumed it" looks like healthy markup.
44
+ *
45
+ * ## Why this narrow pair, and not the whole `toDomProps` result
46
+ *
47
+ * Everything else in the DOM pass-through addresses a CONTROL, and a readonly
48
+ * display has none: `aria-describedby` / `aria-required` are announced when
49
+ * focus lands on something focusable, `disabled` / `tabIndex` / the focus
50
+ * handlers only mean something on an interactive element, and `name` is
51
+ * DOM-legal on form controls only — on a `div` it is exactly the leak
52
+ * objectui#3291 sweeps for. Spreading the full result would also put the host's
53
+ * `className` on top of the widget's own, because the widgets whose readonly
54
+ * surface is a placeholder or a plain container (`EmptyValue`, `FileField`,
55
+ * `AddressField`) keep `className` inside `props`.
56
+ *
57
+ * ## Why `role` travels with the two keys
58
+ *
59
+ * `aria-labelledby` on a role-less `div` / `span` names NOTHING — `generic`
60
+ * prohibits an author name — so the pair is inert without a role that supports
61
+ * naming. `group` is the one that fits a readonly surface: it names a set of
62
+ * values (chips, checked labels, stars, file names, a formatted address)
63
+ * without promising the interactivity `textbox` or `radiogroup` would. It is
64
+ * deliberately NOT the role the same widget answers with while editable:
65
+ * `RadioField`'s readonly branch renders the chosen label as text with no radios
66
+ * left in it, and `FileField`'s renders file names with no dropzone button.
67
+ *
68
+ * The role is emitted ONLY when a host actually named the field, mirroring
69
+ * every editable branch's `isLabelledGroup` test: standalone rendering (the
70
+ * grid's inline cell editor, a bare SDUI node) hands down neither key, so every
71
+ * value here is `undefined`, React emits no attribute, and that markup stays
72
+ * byte-identical to what it was.
73
+ */
74
+ export interface HostGroupProps {
75
+ /** The host's control id — the id its label would have pointed `for` at. */
76
+ id?: string;
77
+ /** IDREF of the host's visible label. */
78
+ 'aria-labelledby'?: string;
79
+ /** `'group'` when — and only when — a host named this surface. */
80
+ role?: 'group';
81
+ }
82
+
83
+ /**
84
+ * Read the two whole-field keys out of a widget's leftover props, plus the role
85
+ * that makes them mean something. See {@link HostGroupProps}.
86
+ *
87
+ * Routed through {@link toDomProps} rather than reading `props` directly so the
88
+ * pair keeps ONE gate: those keys reach an element only if the DOM pass-through
89
+ * whitelist still forwards them, and its two compile-time assertions bind that
90
+ * whitelist to the props contract in both directions.
91
+ */
92
+ export function toHostGroupProps(props: {
93
+ id?: string;
94
+ 'aria-labelledby'?: string;
95
+ }): HostGroupProps {
96
+ const { id, 'aria-labelledby': labelledBy } = toDomProps(props);
97
+ return {
98
+ id,
99
+ 'aria-labelledby': labelledBy,
100
+ ...(labelledBy != null ? { role: 'group' as const } : null),
101
+ };
102
+ }
@@ -1,6 +1,49 @@
1
1
  import type { AriaAttributes, FocusEventHandler, MouseEventHandler } from 'react';
2
2
  import type { DependsOnInput, FieldMetadata } from '@object-ui/types';
3
3
 
4
+ /**
5
+ * DOM pass-through: what a widget's `...props` spread may legitimately put on
6
+ * the element it renders.
7
+ *
8
+ * A NAMED type rather than an inline block of {@link FieldWidgetComponentProps}
9
+ * so the compiler can bind it to its runtime executor in BOTH directions
10
+ * (objectui#3291). `toDomProps` asserts:
11
+ *
12
+ * - every key it forwards is declared on the widget contract — deleting one
13
+ * here without deleting it there is a compile error;
14
+ * - every key of THIS type is one it forwards — adding one here without
15
+ * adding it to `DOM_PASS_THROUGH_KEYS` is a compile error too.
16
+ *
17
+ * The second direction is the one that matters for the failure mode this repo
18
+ * treats as first-class: DECLARED BUT NOT DELIVERED (objectui#3290's
19
+ * `aria-required` that never reached a control, objectui#3222's validation slot
20
+ * nobody produced). Without it, a key added here would type-check, read as
21
+ * supported, and silently never reach the DOM — and the leak test cannot see
22
+ * that class of bug, because it looks for attributes that ARRIVE, not for ones
23
+ * that go missing.
24
+ *
25
+ * `className` and `disabled` are deliberately NOT here. They are DOM-legal and
26
+ * `toDomProps` does forward them, but they are declared on the controlled-input
27
+ * block of {@link FieldWidgetComponentProps} because widgets also INTERPRET
28
+ * them (className is composed with the widget's own classes; disabled is OR-ed
29
+ * with readonly). They are therefore bound in the forward direction only.
30
+ *
31
+ * `AriaAttributes` and the `data-${string}` family are matched by prefix at
32
+ * runtime rather than key-by-key, so they are intersected in separately.
33
+ *
34
+ * Adding a key here is a contract change: say who produces it and who reads it.
35
+ */
36
+ export type FieldWidgetDomProps = {
37
+ id?: string;
38
+ /** react-hook-form's field name, spread in by the form renderer. */
39
+ name?: string;
40
+ autoFocus?: boolean;
41
+ tabIndex?: number;
42
+ onBlur?: FocusEventHandler<HTMLElement>;
43
+ onFocus?: FocusEventHandler<HTMLElement>;
44
+ onClick?: MouseEventHandler<HTMLElement>;
45
+ };
46
+
4
47
  /**
5
48
  * Props every field widget in this package receives at RUNTIME.
6
49
  *
@@ -14,13 +57,27 @@ import type { DependsOnInput, FieldMetadata } from '@object-ui/types';
14
57
  * the widgets in this directory actually implement, against
15
58
  * `@object-ui/types`'s much richer `FieldMetadata`.
16
59
  *
17
- * The two are NOT interchangeable, and the divergence is not cosmetic: the
18
- * spec's contract names the error slot `error`, this one names it
19
- * `errorMessage`, so a widget written to the spec's declared props renders no
20
- * validation message here. That divergence is tracked in objectui#3222 and is
21
- * deliberately NOT resolved here — but it is now *visible*: reading
22
- * `props.error` or `props.required` off this type is a compile error rather
23
- * than a silent `any`.
60
+ * The two are NOT interchangeable, but they no longer disagree on the
61
+ * validation slot: this type used to call it `errorMessage` while the spec
62
+ * calls it `error`, so a widget written to the published contract read
63
+ * `undefined` forever. objectui#3222 resolved that in the direction the
64
+ * contract points — the slot below IS the spec's `error`, and the form
65
+ * renderer now produces it (see `error`'s own doc comment). Reading
66
+ * `props.required` off this type is still a compile error rather than a
67
+ * silent `any`; that key is deliberately NOT lowered here (see below).
68
+ *
69
+ * ## Why `required` is still absent, though the spec declares it
70
+ *
71
+ * The required MARKER is drawn exactly once, by the form renderer's
72
+ * `<FormLabel>` (the `*` with `aria-label="required"`, which the label
73
+ * association folds into the control's accessible name). Handing `required`
74
+ * to widgets would give that marker a second possible author, and the very
75
+ * next AI-written widget draws its own asterisk — the same double-display
76
+ * failure that keeps the validation TEXT out of the widget below. The a11y
77
+ * state a widget genuinely could carry is `aria-required` on the input, and
78
+ * that needs no new key at all: `AriaAttributes` is already part of this type
79
+ * and every widget already forwards it to its control. Tracked separately;
80
+ * do not add `required` here to "align" without that decision.
24
81
  *
25
82
  * ## Why there is no `[key: string]: any` (objectui#3221)
26
83
  *
@@ -40,6 +97,22 @@ import type { DependsOnInput, FieldMetadata } from '@object-ui/types';
40
97
  * is expressed as a template-literal index signature, which — unlike
41
98
  * `[key: string]` — leaves `keyof` finite, so an undeclared prop still fails.
42
99
  *
100
+ * ## Why there is no `schema` (objectui#3233)
101
+ *
102
+ * `schema` used to be declared here as a SECOND carrier for what `field`
103
+ * already means — `SchemaRenderer` handed the SDUI node down as `schema`, the
104
+ * form renderer forwarded `schema={props.field || props.schema || props}`
105
+ * *alongside* `field`, and ~30 widgets resolved their config as
106
+ * `field || schema`. One concept, two keys, two producers: exactly the
107
+ * consumer-side tolerance AGENTS.md #0.1 forbids, and exactly the shape that
108
+ * lets an AI-written widget pick the wrong spelling and still "work" under one
109
+ * host while reading `undefined` under another.
110
+ *
111
+ * v17 converged it at the PRODUCERS. `field` is the only carrier a widget ever
112
+ * sees; the SDUI node → `field` translation happens once, in this package's
113
+ * registry adapter (`withFieldCarrier`), not ~30 times in the widgets. Reading
114
+ * `props.schema` is now a compile error rather than a silent second contract.
115
+ *
43
116
  * Adding a key here is a contract change: say who produces it and who reads it.
44
117
  */
45
118
  export type FieldWidgetComponentProps<T = any> = {
@@ -48,14 +121,38 @@ export type FieldWidgetComponentProps<T = any> = {
48
121
  value: T;
49
122
  onChange: (val: T) => void;
50
123
  /**
51
- * The field's metadata. Deliberately the looser `@object-ui/types` shape
52
- * rather than the spec's, to avoid a circular dependency for now.
124
+ * The field's metadata, and the SINGLE carrier for it (objectui#3233).
125
+ *
126
+ * Deliberately the looser `@object-ui/types` shape rather than the spec's,
127
+ * to avoid a circular dependency for now.
128
+ *
129
+ * Every host produces it: the form renderer's `renderFieldComponent`, the
130
+ * inline-edit hosts (`FieldEditWidget`), `ActionParamDialog` /
131
+ * `BulkActionDialog`, and — since #3233 — the registry adapter that adapts
132
+ * `SchemaRenderer`'s SDUI node onto this contract (`withFieldCarrier` in
133
+ * `packages/fields/src/withFieldCarrier.tsx`). A widget reads `props.field`
134
+ * and nothing else; there is no second key to check.
53
135
  */
54
136
  field: FieldMetadata;
55
137
  readonly?: boolean;
56
138
  disabled?: boolean;
57
139
  className?: string;
58
- errorMessage?: string;
140
+ /**
141
+ * The active validation message for this field, named as
142
+ * `@objectstack/spec/ui`'s `FieldWidgetPropsSchema` names it (objectui#3222).
143
+ *
144
+ * **Producer**: the form renderer, from react-hook-form's
145
+ * `fieldState.error?.message` (`packages/components/src/renderers/form/
146
+ * form.tsx`). Before #3222 nothing in the repo produced it under EITHER
147
+ * spelling, so the seven widgets computing `aria-invalid={!!errorMessage}`
148
+ * were computing it from a permanent `undefined` — `aria-invalid` was never
149
+ * once set and a screen reader was never told the field had failed.
150
+ *
151
+ * **Consumer**: a widget reads this ONLY to drive `aria-invalid` on the
152
+ * control it renders. The message TEXT stays with the form renderer's
153
+ * `<FormMessage/>`; a widget that also renders it double-displays it.
154
+ */
155
+ error?: string;
59
156
  /**
60
157
  * Upload widgets (`file`/`image`) fire this when their in-progress state
61
158
  * flips, so a host can block submit until a presigned upload settles. Other
@@ -65,17 +162,6 @@ export type FieldWidgetComponentProps<T = any> = {
65
162
 
66
163
  /* ── Host plumbing: what a rendering host actually forwards ─────────────── */
67
164
 
68
- /**
69
- * The raw authored node the widget was rendered from. Two hosts supply it:
70
- * `SchemaRenderer` (`<Component schema={schema} {...schema} />`) and the
71
- * form renderer's `renderFieldComponent` (`schema={props.field || ...}`),
72
- * which is why ~25 widgets read `field || schema` for their config.
73
- *
74
- * This is a second carrier for what `field` already means, i.e. a de-facto
75
- * second contract (AGENTS.md #0.1) — declared here so it is at least
76
- * visible; converging the two is tracked separately.
77
- */
78
- schema?: FieldMetadata | Record<string, unknown>;
79
165
  /**
80
166
  * DataSource for widgets that query records (lookup / user / object-ref /
81
167
  * recipient-picker / grid). Injected by the form renderer for the field
@@ -99,9 +185,27 @@ export type FieldWidgetComponentProps<T = any> = {
99
185
  */
100
186
  dependsOn?: DependsOnInput;
101
187
  /**
102
- * Hint shown when a dependency-gated option list is still waiting on its
103
- * controlling field. Forwarded by the form renderer; the option widgets in
104
- * this package currently discard it and render their own message instead.
188
+ * Controlling-field name → its human LABEL, for widgets that have to NAME a
189
+ * sibling field in user-visible copy (the dependent-lookup gate hint).
190
+ *
191
+ * Only the form renderer knows a field's label — the widget sees its own
192
+ * metadata and a `depends_on` list of API names. Without this map the gate
193
+ * sentence interpolated the raw API name into every locale, `en` included
194
+ * ("Select crm_account first"), which is an internal identifier leaking into
195
+ * the UI, not merely an untranslated word (objectstack#5407). This is the
196
+ * lookup-side counterpart of `emptyHint`, which the form already resolves to
197
+ * labels for the fixed-option widgets. A name with no entry falls back to
198
+ * itself, so a host that passes nothing renders exactly what it did before.
199
+ */
200
+ dependsOnLabels?: Record<string, string>;
201
+ /**
202
+ * Hint shown when an option list cannot be filled — typically a
203
+ * dependency-gated list still waiting on its controlling field (#2284).
204
+ * Forwarded by the form renderer, which resolves the controlling fields to
205
+ * their human LABELS. **When supplied it wins**; the option widgets fall back
206
+ * to their own translated copy only when a host computed none (objectui#3231
207
+ * — they used to discard this prop and always render their own hardcoded
208
+ * English). See `OptionsEmptyState`, the single consumer.
105
209
  */
106
210
  emptyHint?: string;
107
211
  /**
@@ -121,17 +225,13 @@ export type FieldWidgetComponentProps<T = any> = {
121
225
  */
122
226
  onCreateNew?: (searchQuery: string) => void;
123
227
 
124
- /* ── DOM pass-through: what `...props` may legitimately reach an input ──── */
125
-
126
- id?: string;
127
- /** react-hook-form's field name, spread in by the form renderer. */
128
- name?: string;
129
- autoFocus?: boolean;
130
- tabIndex?: number;
131
- onBlur?: FocusEventHandler<HTMLElement>;
132
- onFocus?: FocusEventHandler<HTMLElement>;
133
- onClick?: MouseEventHandler<HTMLElement>;
134
- } & AriaAttributes & {
228
+ /* ── DOM pass-through ───────────────────────────────────────────────────── */
229
+ //
230
+ // Lives in the named {@link FieldWidgetDomProps} above, because that is what
231
+ // lets the compiler bind the declaration to `toDomProps` — its runtime
232
+ // executor — in BOTH directions (objectui#3291). Add a DOM key THERE, not
233
+ // here, and the compiler will make you add it to the whitelist too.
234
+ } & FieldWidgetDomProps & AriaAttributes & {
135
235
  /**
136
236
  * Arbitrary `data-*` attributes (test ids, analytics hooks). Open by design,
137
237
  * but a template-literal key — `keyof` stays finite, so this does NOT
@@ -6,14 +6,13 @@ import { createSafeTranslation } from '@object-ui/i18n';
6
6
 
7
7
  const FIELD_DEFAULTS: Record<string, string> = {
8
8
  'common.selectOption': 'Select an option',
9
- 'common.select': 'Select...',
10
- 'common.search': 'Search',
11
- 'common.loading': 'Loading...',
9
+ 'common.select': 'Select…',
10
+ 'common.loading': 'Loading…',
12
11
  'common.noResults': 'No results found',
13
12
  'common.cancel': 'Cancel',
14
13
  'common.confirm': 'Confirm',
15
14
  'table.selected': '{{count}} selected',
16
- 'table.search': 'Search...',
15
+ 'table.search': 'Search…',
17
16
  'lookup.loading': 'Loading…',
18
17
  'lookup.noOptions': 'No options found',
19
18
  'lookup.noRecords': 'No records found',
@@ -39,6 +38,12 @@ const FIELD_DEFAULTS: Record<string, string> = {
39
38
  'lookup.nextPage': 'Next page',
40
39
  'lookup.jumpToPage': 'Jump to page',
41
40
  'lookup.retry': 'Retry',
41
+ // objectui#3231 — the empty / dependency-gated state of the fixed-option
42
+ // widgets (select, multiselect, radio, checkboxes). Only used when the host
43
+ // supplies no `emptyHint`; the gate sentence shares its key with the form
44
+ // renderer so both cannot drift apart in a locale.
45
+ 'fields.options.empty': 'No options available',
46
+ 'fields.options.selectFirst': 'Select {{fields}} first',
42
47
  // objectstack#3821 — sharing-rule authoring widgets (object-ref /
43
48
  // recipient-picker / filter-condition). The recipient placeholder is keyed
44
49
  // PER TYPE rather than interpolating the enum value into an English
@@ -69,6 +74,43 @@ const FIELD_DEFAULTS: Record<string, string> = {
69
74
  'fields.filterCondition.jsonOnly': 'This criteria can only be edited as JSON',
70
75
  'fields.filterCondition.editAsJson': 'Edit as JSON',
71
76
  'fields.filterCondition.useVisualBuilder': 'Use visual builder',
77
+ // objectui#3342 — the tags widget's input hint. Used only when the field
78
+ // author declared no `placeholder` of their own (author declaration wins).
79
+ 'fields.tags.placeholder': 'Type and press Enter to add…',
80
+ // objectui#3406 — the accessible name of `TextAreaField`'s character
81
+ // counter, rendered only when the field declares `maxLength`. The visible
82
+ // `{n}/{max}` is digits; this sentence is what a screen reader speaks, and
83
+ // the element is `aria-live`, so before the key existed a non-English
84
+ // session heard English on every keystroke. Byte-identical to the literal
85
+ // it replaces, so a no-provider render is unchanged.
86
+ 'fields.textarea.characterCount': 'Character count: {{count}} of {{max}}',
87
+ // objectui#3408 — the NEAR-LIMIT warning, spoken by the counter's status
88
+ // region after the typist pauses and only once they are inside the last 10%
89
+ // (or last 20 characters) of the cap. The sentence above became the field's
90
+ // `aria-describedby` description in the same change; this one is the only
91
+ // thing that is ever announced mid-typing, which is why it says what is
92
+ // LEFT rather than repeating the whole count.
93
+ //
94
+ // Colon form, not "You have {{count}} characters remaining": `count` makes
95
+ // i18next attempt plural lookup (`_one` / `_few` / …) before the base key,
96
+ // and these packs declare the base key only — the same base-key fallback
97
+ // `characterCount` already relies on. A sentence whose grammar does not
98
+ // depend on the number stays correct at 1 without ten plural entries per
99
+ // pack, and matches the sibling key's shape.
100
+ 'fields.textarea.charactersRemaining': 'Characters remaining: {{count}}',
101
+ // The fullscreen long-text dialog's copy (objectui#3404) is NOT declared
102
+ // here any more. It moved with the implementation: `FullscreenFieldEditor` is
103
+ // a thin wrapper over `FullscreenEditor` in `@object-ui/components`
104
+ // (objectui#3398), and that primitive carries its own `createSafeTranslation`
105
+ // defaults for the same `form.fullscreen.*` / `common.cancel` keys — still
106
+ // the built-in branch's keys, still byte-identical English, so provider-less
107
+ // rendering is unchanged and the ten locale packs need no edit. Leaving the
108
+ // entries here would have re-created in the defaults map exactly the
109
+ // duplication #3398 removed from the components, and this map's own
110
+ // discipline is that a default is declared where it is read.
111
+ //
112
+ // `common.cancel` stays above: `RecordPickerDialog` and `PeoplePicker` read
113
+ // it independently of anything fullscreen.
72
114
  // objectui#2600 B5 — capability picker scope group headers.
73
115
  'capability.group.platform': 'Platform',
74
116
  'capability.group.org': 'Organization',
@@ -56,8 +56,14 @@ export interface UseRecordQueryOptions {
56
56
  * `$filter` — already merged by the caller (base `lookup_filters`, dependent
57
57
  * lookup chain, candidate hygiene like `banned != true`, …). Compared by
58
58
  * value, so a referentially-new-but-equal object each render will not loop.
59
+ *
60
+ * Either the `QueryParams.$filter` record form or a lowered ObjectQL AST node
61
+ * (an array), since the picker's merge now produces both (#3831). Typed
62
+ * `unknown` rather than `Record< string, any >` so an array cannot slip
63
+ * through a type that silently accepts it; emptiness is decided by
64
+ * {@link hasFilter}, not by `Object.keys`.
59
65
  */
60
- filter?: Record<string, any>;
66
+ filter?: unknown;
61
67
  /** `$expand` — related entities to include (e.g. `['primary_business_unit_id']`). */
62
68
  expand?: string[];
63
69
  /** `$searchFields` — narrow the server searchable set (ADR-0061). */
@@ -97,6 +103,22 @@ export interface UseRecordQueryResult {
97
103
  refetch: () => void;
98
104
  }
99
105
 
106
+ /**
107
+ * Is this filter source worth sending as `$filter` at all?
108
+ *
109
+ * Array-aware on purpose. The picker's merge yields either the
110
+ * `QueryParams.$filter` record form or a lowered ObjectQL AST node (#3831), and
111
+ * `Object.keys` on an array returns its INDICES — so the record-only test read
112
+ * `['0','1','2']` for an AST node and was right only by accident. An empty array
113
+ * is "no filter" for the same reason an empty object is.
114
+ */
115
+ function hasFilter(filter: unknown): boolean {
116
+ if (filter === null || filter === undefined) return false;
117
+ if (Array.isArray(filter)) return filter.length > 0;
118
+ if (typeof filter !== 'object') return false;
119
+ return Object.keys(filter as object).length > 0;
120
+ }
121
+
100
122
  export function useRecordQuery(options: UseRecordQueryOptions): UseRecordQueryResult {
101
123
  const {
102
124
  dataSource,
@@ -123,7 +145,7 @@ export function useRecordQuery(options: UseRecordQueryOptions): UseRecordQueryRe
123
145
  // Stable signatures for object/array inputs so the fetch effect keys on their
124
146
  // *value*, not a fresh reference every render.
125
147
  const filterSignature = useMemo(
126
- () => (filter && Object.keys(filter).length ? JSON.stringify(filter) : ''),
148
+ () => (hasFilter(filter) ? JSON.stringify(filter) : ''),
127
149
  [filter],
128
150
  );
129
151
  const expandSignature = useMemo(() => (expand?.length ? expand.join(',') : ''), [expand]);
@@ -150,7 +172,12 @@ export function useRecordQuery(options: UseRecordQueryOptions): UseRecordQueryRe
150
172
  if (searchTerm && searchTerm.trim()) params.$search = searchTerm.trim();
151
173
  if (searchFields && searchFields.length > 0) params.$searchFields = searchFields;
152
174
  if (sortArg) params.$orderby = { [sortArg.field]: sortArg.direction };
153
- if (filter && Object.keys(filter).length > 0) params.$filter = filter;
175
+ // `QueryParams.$filter` is declared `Record< string, any >`, which the
176
+ // AST-array form does not describe — the cast is at this ONE assignment
177
+ // rather than widening a shared type that several other producers
178
+ // (plugin-list's `buildEffectiveFilter`, plugin-view's ObjectView)
179
+ // already feed arrays through.
180
+ if (hasFilter(filter)) params.$filter = filter as Record<string, any>;
154
181
  if (expand && expand.length > 0) params.$expand = expand;
155
182
 
156
183
  const result = await dataSource.find(objectName, params);
@@ -0,0 +1,75 @@
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
+
11
+ /**
12
+ * Adapt `SchemaRenderer`'s SDUI carrier onto the field-widget contract —
13
+ * objectui#3233, the producer-side half of retiring `schema` from
14
+ * {@link import('./widgets/types').FieldWidgetComponentProps}.
15
+ *
16
+ * ## Why this exists
17
+ *
18
+ * Two kinds of host render a registered field widget, and they name the
19
+ * metadata differently *for good reason*:
20
+ *
21
+ * - the **form renderer** (`renderFieldComponent` in
22
+ * `@object-ui/components`), the inline-edit hosts (`FieldEditWidget`) and
23
+ * the action dialogs pass **`field`** — the field metadata they already
24
+ * hold;
25
+ * - **`SchemaRenderer`** (`@object-ui/react`) passes **`schema`** — the
26
+ * authored node. That key is the UNIVERSAL SDUI contract *every* registered
27
+ * component receives (`element:*`, `page:*`, `object-grid`,
28
+ * `ReportRenderer` …). It is not being retired and must not be: it is what
29
+ * the renderer means by "the node you were rendered from".
30
+ *
31
+ * What WAS wrong is that both keys reached the widget, so ~30 widgets resolved
32
+ * their config as `field || schema` — one concept under two spellings, a second
33
+ * de-facto contract on the widget side (AGENTS.md #0.1) and precisely the shape
34
+ * that lets an AI-written widget pick one spelling and appear to work under one
35
+ * host while reading `undefined` under the other.
36
+ *
37
+ * The translation belongs at the seam, exactly once: here. Downstream `field`
38
+ * is the only carrier, and a widget reading `props.schema` no longer compiles.
39
+ *
40
+ * ## Guarantee
41
+ *
42
+ * Payload-preserving by construction: the node is forwarded **by reference**,
43
+ * so a widget's `field` is the very object it used to read through `schema`.
44
+ * `field` wins when a host supplies both (the form path always does, and it
45
+ * holds the resolved metadata rather than the raw node).
46
+ *
47
+ * `schema` is *consumed* here rather than forwarded — with the key gone from
48
+ * the contract, passing it on would only reach the DOM as a stray attribute
49
+ * through a widget's `...props` spread.
50
+ *
51
+ * ## For widget authors outside this repo
52
+ *
53
+ * Registering a widget raw means `SchemaRenderer` hands it `schema` and no
54
+ * `field`. Wrap it once at registration and the widget only ever implements
55
+ * `FieldWidgetComponentProps`:
56
+ *
57
+ * ```tsx
58
+ * import { withFieldCarrier } from '@object-ui/fields';
59
+ *
60
+ * ComponentRegistry.register('color', withFieldCarrier(ColorPickerField), {
61
+ * namespace: 'field',
62
+ * });
63
+ * ```
64
+ */
65
+ export function withFieldCarrier<P extends object>(
66
+ FieldWidget: React.ComponentType<P>,
67
+ ): React.FC<Record<string, any>> {
68
+ const FieldCarrierAdapter: React.FC<Record<string, any>> = ({ schema, field, ...props }) => (
69
+ <FieldWidget {...(props as unknown as P)} field={field ?? schema} />
70
+ );
71
+ FieldCarrierAdapter.displayName = `FieldCarrier(${
72
+ (FieldWidget as any).displayName || (FieldWidget as any).name || 'Component'
73
+ })`;
74
+ return FieldCarrierAdapter;
75
+ }
@@ -1,8 +0,0 @@
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
- export {};
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -1 +0,0 @@
1
- export {};
@@ -1,8 +0,0 @@
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
- export {};
File without changes
@@ -1,8 +0,0 @@
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
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
File without changes
File without changes
File without changes
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1,8 +0,0 @@
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
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
File without changes
@@ -1,8 +0,0 @@
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
- export {};