@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,436 @@
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
+ * End-to-end: a composite / grouped field's VISIBLE label is the accessible name
11
+ * of the thing the widget renders (objectui#3961).
12
+ *
13
+ * The sibling case of #3952 (`BooleanField`, a single control that renamed the
14
+ * host's id). Here the widget has no single labelable control to hand the label
15
+ * to, and the failure came in two shapes — measured in a real form on
16
+ * `origin/main`, one field per row, reading each `<label for>` against the DOM:
17
+ *
18
+ * ```
19
+ * address for=_r_0_-form-item -> MISSING byLabelText=0 role+name=0
20
+ * geolocation for=_r_2_-form-item -> MISSING byLabelText=0 role+name=0
21
+ * checkboxes for=_r_4_-form-item -> div byLabelText=0 role+name=0
22
+ * radio for=_r_6_-form-item -> div[radiogroup] byLabelText=0 role+name=0
23
+ * rating for=_r_a_-form-item -> div byLabelText=0 role+name=0
24
+ * file for=_r_b_-form-item -> div[role=button] byLabelText=0 role+name=0
25
+ * ```
26
+ *
27
+ * - **id overwritten** (`address` / `geolocation`): the host id was spread onto
28
+ * the first sub-input and then replaced by that input's own `subId(...)`
29
+ * (objectui#3343 — correctly, sub-inputs need unique ids), so the `for` named
30
+ * an id NOTHING carried. Clicking "Shipping Address" did nothing and the group
31
+ * label was absent from the accessibility tree entirely.
32
+ * - **id kept, on a `div`** (`checkboxes` / `radio` / `rating` / `file`): the
33
+ * `for` "resolved", but `<label for>` on a non-labelable element is inert —
34
+ * `HTMLLabelElement.control` is `null`, so it activates nothing and
35
+ * contributes no name. Both columns on the right show it: zero elements named
36
+ * by that label, in either query.
37
+ *
38
+ * So all six ended in the same place — a visible label that names NOTHING — and
39
+ * the fix is the WAI-ARIA group pattern: the widget declares that it must be
40
+ * named by IDREF (`ComponentMeta.labelling: 'group'`), the form renderer
41
+ * publishes its label's `id` and drops the `for`, and the widget's group
42
+ * container answers with `role="group"` + the handed-down `aria-labelledby`.
43
+ *
44
+ * Two directions per widget, because a one-directional pin here is nearly free to
45
+ * pass by accident: the name must be present when hosted, and STANDALONE
46
+ * rendering (the inline grid editor, a bare SDUI node — nobody hands an id, and
47
+ * there is no host label to point at) must stay exactly as it was.
48
+ *
49
+ * The widgets are registered raw rather than through `registerAllFields()`, which
50
+ * wraps every loader in `React.lazy`: an unbounded module load inside a bounded
51
+ * `findBy`/`waitFor` window is the repo's known flake generator (AGENTS.md
52
+ * 测试纪律, objectui#3010). Same components, no Suspense boundary. The
53
+ * DECLARATION that ships with the real registration is pinned separately, in
54
+ * `group-labelling-declaration.test.ts`.
55
+ */
56
+
57
+ import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest';
58
+ import { render, screen, cleanup } from '@testing-library/react';
59
+ import '@testing-library/jest-dom';
60
+ import { ComponentRegistry } from '@object-ui/core';
61
+ // Module scope: pulls in the form renderer's registration side effect.
62
+ import '@object-ui/components';
63
+
64
+ import { AddressField } from '../widgets/AddressField';
65
+ import { GeolocationField } from '../widgets/GeolocationField';
66
+ import { CheckboxesField } from '../widgets/CheckboxesField';
67
+ import { RadioField } from '../widgets/RadioField';
68
+ import { RatingField } from '../widgets/RatingField';
69
+ import { FileField } from '../widgets/FileField';
70
+ import { MultiSelectField } from '../widgets/MultiSelectField';
71
+ import { TextField } from '../widgets/TextField';
72
+
73
+ /**
74
+ * The role each widget's named surface answers with. Not uniform, and
75
+ * deliberately so:
76
+ *
77
+ * - `radio` keeps `radiogroup` — already a member of the group role family, more
78
+ * specific than `group`, and the correct carrier of `aria-invalid` for a set
79
+ * of radios (#3318). Overriding it with `group` would be a regression.
80
+ * - `file` is `button`: it is NOT a composite — it has exactly one control, the
81
+ * dropzone, which is a `div[role="button"]` (the keyboard path to the hidden
82
+ * file input). It needs IDREF labelling because a `div` cannot be
83
+ * `for`-labelled, not because it is a group, so no `role="group"` wrapper is
84
+ * invented for it.
85
+ * - `multiselect` is `group`, and arrived with objectui#3975 rather than #3961:
86
+ * #3961's probe covered the six above, and re-running it over the whole widget
87
+ * map afterwards put `multiselect` on the byte-identical `checkboxes` shape —
88
+ * host id kept, on the chip row's wrapper `div`, `for` inert. Measured on the
89
+ * tree that already had #3961's fix, i.e. this is its residual, not a
90
+ * regression of it:
91
+ *
92
+ * ```
93
+ * checkboxes for=(none) ownId=…-group-label byLabelText=1(div[role=group]) ← #3961
94
+ * multiselect for=…-form-item ownId=(none) byLabelText=0 ← #3975
95
+ * ```
96
+ */
97
+ const HOSTED: Array<[type: string, role: string]> = [
98
+ ['address', 'group'],
99
+ ['geolocation', 'group'],
100
+ ['checkboxes', 'group'],
101
+ ['radio', 'radiogroup'],
102
+ ['rating', 'group'],
103
+ ['file', 'button'],
104
+ ['multiselect', 'group'],
105
+ ];
106
+
107
+ const OPTIONS = [
108
+ { label: 'Alpha', value: 'a' },
109
+ { label: 'Beta', value: 'b' },
110
+ ];
111
+
112
+ const WIDGETS: Record<string, any> = {
113
+ address: AddressField,
114
+ geolocation: GeolocationField,
115
+ checkboxes: CheckboxesField,
116
+ radio: RadioField,
117
+ rating: RatingField,
118
+ file: FileField,
119
+ multiselect: MultiSelectField,
120
+ };
121
+
122
+ beforeAll(() => {
123
+ for (const [type, Component] of Object.entries(WIDGETS)) {
124
+ ComponentRegistry.register(type, Component as any, {
125
+ namespace: 'field',
126
+ skipFallback: true,
127
+ // The declaration under test, mirroring `FIELD_TYPES_GROUP_LABELLED`.
128
+ labelling: 'group',
129
+ });
130
+ }
131
+ // Positive control: an ordinary single-control widget, undeclared.
132
+ ComponentRegistry.register('text', TextField as any, { namespace: 'field', skipFallback: true });
133
+ }, 30000);
134
+
135
+ beforeEach(() => {
136
+ if (!(Element.prototype as any).scrollIntoView) {
137
+ (Element.prototype as any).scrollIntoView = () => {};
138
+ }
139
+ });
140
+
141
+ afterEach(() => {
142
+ cleanup();
143
+ vi.restoreAllMocks();
144
+ });
145
+
146
+ /** HTML's labelable elements — the only ones a `<label for>` can address. */
147
+ const LABELABLE = new Set(['button', 'input', 'meter', 'output', 'progress', 'select', 'textarea']);
148
+
149
+ /**
150
+ * Every `<label for=…>` in the document, with what it resolves to and whether
151
+ * that target can legally be labelled. Both failure shapes of this issue appear
152
+ * here: `resolves: false` is the overwritten-id case, `labelable: false` the
153
+ * inert-`div` case.
154
+ */
155
+ function labelTargets(): Array<{ text: string; forId: string; resolves: boolean; labelable: boolean }> {
156
+ return Array.from(document.querySelectorAll('label'))
157
+ .map((el) => {
158
+ const forId = el.getAttribute('for') ?? '';
159
+ const target = forId ? document.getElementById(forId) : null;
160
+ return {
161
+ text: (el.textContent ?? '').trim(),
162
+ forId,
163
+ resolves: !!target,
164
+ labelable: !!target && LABELABLE.has(target.tagName.toLowerCase()),
165
+ };
166
+ })
167
+ .filter((l) => l.forId !== '');
168
+ }
169
+
170
+ function fieldConfig(type: string) {
171
+ const config: any = { name: `f_${type}`, label: `Group Label ${type}`, type };
172
+ if (type === 'checkboxes' || type === 'radio' || type === 'multiselect') config.options = OPTIONS;
173
+ return config;
174
+ }
175
+
176
+ /** The real form renderer hosting one field — the #3961 reproduction. */
177
+ function renderForm(fields: any[]) {
178
+ const Form = ComponentRegistry.get('form')!;
179
+ return render(
180
+ <Form
181
+ schema={{
182
+ type: 'form',
183
+ mode: 'create',
184
+ showSubmit: false,
185
+ showCancel: false,
186
+ defaultValues: {},
187
+ fields,
188
+ }}
189
+ />,
190
+ );
191
+ }
192
+
193
+ const hostLabelOf = (name: string): HTMLLabelElement => {
194
+ const el = document.querySelector(`[data-field="${name}"] > label`);
195
+ if (!el) throw new Error(`no host label rendered for field "${name}"`);
196
+ return el as HTMLLabelElement;
197
+ };
198
+
199
+ describe('a form-hosted composite field is NAMED by its host label (objectui#3961)', () => {
200
+ it.each(HOSTED)('%s: the host label is the accessible name of the %s', (type, role) => {
201
+ renderForm([fieldConfig(type)]);
202
+
203
+ // The assertion that was 0 before this fix and is 1 after, for all six.
204
+ const named = screen.getAllByRole(role, { name: `Group Label ${type}` });
205
+ expect(named).toHaveLength(1);
206
+ // `getByLabelText` resolves the same association from the other side.
207
+ expect(screen.getAllByLabelText(`Group Label ${type}`)).toEqual(named);
208
+ });
209
+
210
+ it.each(HOSTED)('%s: the named element is the one the host handed its id to', (type, role) => {
211
+ // The host id and the host name must land on the SAME element. They came
212
+ // apart in both directions before: `address` dropped the id (overwritten by a
213
+ // sub-input's), `checkboxes` kept it on a container the label could not name.
214
+ renderForm([fieldConfig(type)]);
215
+
216
+ const named = screen.getByRole(role, { name: `Group Label ${type}` });
217
+ expect(named.getAttribute('id')).toMatch(/-form-item$/);
218
+ });
219
+
220
+ it.each(HOSTED)('%s: the host label emits an id and no `for`', (type) => {
221
+ renderForm([fieldConfig(type)]);
222
+
223
+ const label = hostLabelOf(`f_${type}`);
224
+ expect(label).not.toHaveAttribute('for');
225
+ expect(label.id).not.toBe('');
226
+ // The IDREF is not dangling — `aria-labelledby` naming a missing id fails
227
+ // silently, which is indistinguishable from success in the markup.
228
+ expect(document.getElementById(label.id)).toBe(label);
229
+ });
230
+
231
+ it.each(HOSTED)('%s: no label in the field is left with an inert or dangling `for`', (type) => {
232
+ // The class-wide invariant this issue is an instance of, and the one that can
233
+ // only be stated once the group labels stop emitting `for` at all: EVERY
234
+ // remaining `for` (the widgets' own sub-labels) must resolve to a real
235
+ // labelable control.
236
+ renderForm([fieldConfig(type)]);
237
+
238
+ expect(labelTargets().filter((l) => !l.resolves)).toEqual([]);
239
+ expect(labelTargets().filter((l) => !l.labelable)).toEqual([]);
240
+ });
241
+
242
+ it('all of them in ONE form: every group is named, every `for` still resolves', () => {
243
+ renderForm(HOSTED.map(([type]) => fieldConfig(type)));
244
+
245
+ for (const [type, role] of HOSTED) {
246
+ expect(screen.getAllByRole(role, { name: `Group Label ${type}` })).toHaveLength(1);
247
+ }
248
+ expect(labelTargets().filter((l) => !l.resolves || !l.labelable)).toEqual([]);
249
+ });
250
+ });
251
+
252
+ describe('the group name does not swallow the sub-controls\' own names (objectui#3961)', () => {
253
+ it('address sub-inputs keep their own labels', () => {
254
+ // Why `aria-labelledby` is held back from the first sub-input rather than
255
+ // riding along with the rest of the DOM pass-through: `aria-labelledby`
256
+ // OVERRIDES a control's `<label for>`. Spread onto the street box, the group
257
+ // name would REPLACE "Street Address" — the "one input, two labels,
258
+ // concatenated name" outcome the issue rejected option 2 for.
259
+ renderForm([fieldConfig('address')]);
260
+
261
+ expect(screen.getByLabelText('Street Address')).toHaveAccessibleName('Street Address');
262
+ expect(screen.getByLabelText('City')).toHaveAccessibleName('City');
263
+ expect(screen.getByLabelText('Street Address')).not.toHaveAttribute('aria-labelledby');
264
+ });
265
+
266
+ it('the description stays on the first focusable sub-input, not on the container', () => {
267
+ // #3318's deliberate choice, preserved: only the `id` moved out to the
268
+ // container. A description or error must be announced when focus lands on
269
+ // something focusable, and a group container is not focusable — so
270
+ // `aria-describedby` stays where a screen reader will actually reach it.
271
+ renderForm([{ ...fieldConfig('address'), description: 'Where we ship it' }]);
272
+
273
+ const street = screen.getByLabelText('Street Address');
274
+ expect(street.getAttribute('aria-describedby')).toContain('-form-item-description');
275
+ const group = screen.getByRole('group', { name: 'Group Label address' });
276
+ expect(group).not.toHaveAttribute('aria-describedby');
277
+ });
278
+
279
+ it('checkboxes options keep their per-option labels', () => {
280
+ renderForm([fieldConfig('checkboxes')]);
281
+
282
+ expect(screen.getByLabelText('Alpha')).toHaveAttribute('role', 'checkbox');
283
+ expect(screen.getByLabelText('Beta')).toHaveAttribute('role', 'checkbox');
284
+ });
285
+
286
+ it('multiselect chips keep their own names (objectui#3975)', () => {
287
+ // The chips name themselves from their text CONTENT, not from a sub-label,
288
+ // so the group's `aria-labelledby` sits one level up and must not reach them.
289
+ // `aria-labelledby` on a chip would REPLACE "Alpha" with the group name —
290
+ // the same override this file refuses for `address`'s street box.
291
+ renderForm([fieldConfig('multiselect')]);
292
+
293
+ const alpha = screen.getByRole('button', { name: 'Alpha' });
294
+ expect(alpha).toHaveAccessibleName('Alpha');
295
+ expect(alpha).not.toHaveAttribute('aria-labelledby');
296
+ expect(screen.getByRole('button', { name: 'Beta' })).toBeTruthy();
297
+ // The group is the CONTAINER, not one of the chips.
298
+ expect(screen.getByRole('group', { name: 'Group Label multiselect' })).toBe(alpha.parentElement);
299
+ });
300
+ });
301
+
302
+ describe('an undeclared single-control field keeps the plain `for` association (objectui#3961)', () => {
303
+ it('text: the label still points at the input, with no second naming channel', () => {
304
+ // The positive control for the DECLARATION. `text` is not in
305
+ // `FIELD_TYPES_GROUP_LABELLED`, so it must travel the unchanged path: a
306
+ // working `for`, and NO `aria-labelledby` — two channels naming one control
307
+ // is what #3290 / #3222 / #3952 each refused.
308
+ renderForm([{ name: 'f_text', label: 'Plain Text', type: 'text' }]);
309
+
310
+ const input = screen.getByLabelText('Plain Text');
311
+ expect(input.tagName.toLowerCase()).toBe('input');
312
+ expect(input).not.toHaveAttribute('aria-labelledby');
313
+ const label = hostLabelOf('f_text');
314
+ expect(label.getAttribute('for')).toBe(input.getAttribute('id'));
315
+ expect(label.id).toBe('');
316
+ expect(screen.queryAllByRole('group')).toHaveLength(0);
317
+ });
318
+
319
+ it('a group field and a control field in one form each get their own treatment', () => {
320
+ renderForm([{ name: 'f_text', label: 'Plain Text', type: 'text' }, fieldConfig('rating')]);
321
+
322
+ expect(hostLabelOf('f_text')).toHaveAttribute('for');
323
+ expect(hostLabelOf('f_rating')).not.toHaveAttribute('for');
324
+ expect(screen.getByRole('group', { name: 'Group Label rating' })).toBeTruthy();
325
+ expect(screen.getByLabelText('Plain Text').tagName.toLowerCase()).toBe('input');
326
+ });
327
+ });
328
+
329
+ describe('STANDALONE composite widgets are unchanged (objectui#3961)', () => {
330
+ // `FieldEditWidget` (the grid's inline cell editor) and bare SDUI nodes render
331
+ // these widgets with no id and no host label. There is nothing to associate, so
332
+ // nothing may be emitted: an unnamed `role="group"` adds no information and
333
+ // announces a container that has no name to give.
334
+
335
+ it('address: no group role, and every sub-label still resolves to its input', () => {
336
+ render(
337
+ <AddressField
338
+ value={{ street: '1 Main St' }}
339
+ onChange={() => {}}
340
+ field={{ name: 'shipping', label: 'Shipping Address', type: 'address' } as any}
341
+ />,
342
+ );
343
+
344
+ expect(screen.queryAllByRole('group')).toHaveLength(0);
345
+ expect(document.querySelector('[aria-labelledby]')).toBeNull();
346
+ expect(labelTargets().filter((l) => !l.resolves || !l.labelable)).toEqual([]);
347
+ // The sub-input ids of objectui#3343 are untouched: only the (absent) HOST id
348
+ // changed place.
349
+ expect(screen.getByLabelText('Street Address')).toHaveValue('1 Main St');
350
+ });
351
+
352
+ it('geolocation: no group role, sub-labels intact', () => {
353
+ render(
354
+ <GeolocationField
355
+ value={{ latitude: 1, longitude: 2 }}
356
+ onChange={() => {}}
357
+ field={{ name: 'where', label: 'Where', type: 'geolocation' } as any}
358
+ />,
359
+ );
360
+
361
+ expect(screen.queryAllByRole('group')).toHaveLength(0);
362
+ expect(document.querySelector('[aria-labelledby]')).toBeNull();
363
+ expect(labelTargets().filter((l) => !l.resolves || !l.labelable)).toEqual([]);
364
+ expect(screen.getByLabelText('Latitude')).toHaveValue(1);
365
+ });
366
+
367
+ it('checkboxes: the container stays a plain div', () => {
368
+ render(
369
+ <CheckboxesField
370
+ value={[]}
371
+ onChange={() => {}}
372
+ field={{ name: 'tags', label: 'Tags', type: 'checkboxes', options: OPTIONS } as any}
373
+ />,
374
+ );
375
+
376
+ expect(screen.queryAllByRole('group')).toHaveLength(0);
377
+ expect(screen.getByTestId('checkboxes-tags')).not.toHaveAttribute('role');
378
+ });
379
+
380
+ it('multiselect: the chip row stays a plain div (objectui#3975)', () => {
381
+ render(
382
+ <MultiSelectField
383
+ value={[]}
384
+ onChange={() => {}}
385
+ field={{ name: 'tags', label: 'Tags', type: 'multiselect', options: OPTIONS } as any}
386
+ />,
387
+ );
388
+
389
+ expect(screen.queryAllByRole('group')).toHaveLength(0);
390
+ expect(screen.getByTestId('multiselect-tags')).not.toHaveAttribute('role');
391
+ // The chips are still reachable by their own names with no group around them.
392
+ expect(screen.getByRole('button', { name: 'Alpha' })).toBeTruthy();
393
+ });
394
+
395
+ it('rating: the container stays a plain div', () => {
396
+ const { container } = render(
397
+ <RatingField
398
+ value={3}
399
+ onChange={() => {}}
400
+ field={{ name: 'score', label: 'Score', type: 'rating' } as any}
401
+ />,
402
+ );
403
+
404
+ expect(screen.queryAllByRole('group')).toHaveLength(0);
405
+ expect(container.firstElementChild).not.toHaveAttribute('role');
406
+ });
407
+
408
+ it('radio: the radiogroup survives without a name', () => {
409
+ render(
410
+ <RadioField
411
+ value={undefined as any}
412
+ onChange={() => {}}
413
+ field={{ name: 'plan', label: 'Plan', type: 'radio', options: OPTIONS } as any}
414
+ />,
415
+ );
416
+
417
+ // Radix owns this role; nothing in this change may take it away.
418
+ const group = screen.getByRole('radiogroup');
419
+ expect(group).not.toHaveAttribute('aria-labelledby');
420
+ expect(labelTargets().filter((l) => !l.resolves || !l.labelable)).toEqual([]);
421
+ });
422
+
423
+ it('file: the dropzone keeps role=button and gains no name it was not given', () => {
424
+ render(
425
+ <FileField
426
+ value={null}
427
+ onChange={() => {}}
428
+ field={{ name: 'attachment', label: 'Attachment', type: 'file' } as any}
429
+ />,
430
+ );
431
+
432
+ const dropzone = screen.getByRole('button');
433
+ expect(dropzone).not.toHaveAttribute('aria-labelledby');
434
+ expect(screen.queryAllByRole('group')).toHaveLength(0);
435
+ });
436
+ });