@object-ui/fields 17.5.0 → 17.7.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 (249) hide show
  1. package/CHANGELOG.md +6150 -0
  2. package/README.md +108 -8
  3. package/dist/FieldEditWidget.d.ts +70 -8
  4. package/dist/coerceToSafeValue.d.ts +54 -0
  5. package/dist/currency.d.ts +1 -1
  6. package/dist/field-type-alias.d.ts +16 -0
  7. package/dist/index.cjs +5 -5
  8. package/dist/index.css +53 -11
  9. package/dist/index.d.ts +653 -128
  10. package/dist/index.js +4275 -3171
  11. package/dist/widgets/AddressField.d.ts +3 -3
  12. package/dist/widgets/AutoNumberField.d.ts +1 -1
  13. package/dist/widgets/AvatarField.d.ts +20 -4
  14. package/dist/widgets/BooleanField.d.ts +2 -2
  15. package/dist/widgets/CapabilityMultiSelectField.d.ts +1 -1
  16. package/dist/widgets/CheckboxesField.d.ts +1 -1
  17. package/dist/widgets/CodeField.d.ts +1 -1
  18. package/dist/widgets/ColorField.d.ts +1 -1
  19. package/dist/widgets/CurrencyField.d.ts +1 -1
  20. package/dist/widgets/DateField.d.ts +2 -2
  21. package/dist/widgets/DateTimeField.d.ts +2 -2
  22. package/dist/widgets/EmailField.d.ts +1 -1
  23. package/dist/widgets/FileField.d.ts +18 -2
  24. package/dist/widgets/FilterConditionField.d.ts +43 -2
  25. package/dist/widgets/FormulaField.d.ts +1 -1
  26. package/dist/widgets/FullscreenFieldEditor.d.ts +9 -2
  27. package/dist/widgets/GeolocationField.d.ts +10 -4
  28. package/dist/widgets/GridField.d.ts +102 -86
  29. package/dist/widgets/ImageField.d.ts +1 -1
  30. package/dist/widgets/LocationField.d.ts +31 -4
  31. package/dist/widgets/LookupField.d.ts +9 -1
  32. package/dist/widgets/MasterDetailField.d.ts +1 -1
  33. package/dist/widgets/MultiSelectField.d.ts +1 -1
  34. package/dist/widgets/NumberField.d.ts +2 -2
  35. package/dist/widgets/ObjectField.d.ts +10 -3
  36. package/dist/widgets/ObjectRefField.d.ts +1 -1
  37. package/dist/widgets/OptionsEmptyState.d.ts +38 -8
  38. package/dist/widgets/PasswordField.d.ts +15 -1
  39. package/dist/widgets/PeoplePicker.d.ts +17 -2
  40. package/dist/widgets/PercentField.d.ts +3 -2
  41. package/dist/widgets/PersonRow.d.ts +13 -2
  42. package/dist/widgets/PhoneField.d.ts +1 -1
  43. package/dist/widgets/QRCodeField.d.ts +1 -1
  44. package/dist/widgets/RadioField.d.ts +1 -1
  45. package/dist/widgets/RatingField.d.ts +1 -1
  46. package/dist/widgets/RecipientPickerField.d.ts +44 -1
  47. package/dist/widgets/RecordPickerDialog.d.ts +33 -16
  48. package/dist/widgets/RichTextField.d.ts +37 -1
  49. package/dist/widgets/SelectField.d.ts +1 -1
  50. package/dist/widgets/SelectionTray.d.ts +13 -2
  51. package/dist/widgets/SignatureField.d.ts +32 -2
  52. package/dist/widgets/SliderField.d.ts +37 -2
  53. package/dist/widgets/SummaryField.d.ts +1 -1
  54. package/dist/widgets/TagsField.d.ts +1 -1
  55. package/dist/widgets/TextAreaField.d.ts +6 -2
  56. package/dist/widgets/TextField.d.ts +2 -2
  57. package/dist/widgets/TimeField.d.ts +2 -2
  58. package/dist/widgets/UrlField.d.ts +1 -1
  59. package/dist/widgets/UserField.d.ts +1 -1
  60. package/dist/widgets/VectorField.d.ts +1 -1
  61. package/dist/widgets/_cell-renderer-bridge.d.ts +1 -1
  62. package/dist/widgets/address-format.d.ts +27 -7
  63. package/dist/widgets/file-affordance.d.ts +33 -0
  64. package/dist/widgets/file-size-guard.d.ts +5 -23
  65. package/dist/widgets/file-value.d.ts +38 -9
  66. package/dist/widgets/lookupColumnDisplay.d.ts +110 -0
  67. package/dist/widgets/nativeDateValue.d.ts +9 -27
  68. package/dist/widgets/number-format.d.ts +137 -0
  69. package/dist/widgets/numberBadInput.d.ts +135 -0
  70. package/dist/widgets/personDisplay.d.ts +22 -4
  71. package/dist/widgets/richTextDisplay.d.ts +116 -0
  72. package/dist/widgets/toDomProps.d.ts +10 -1
  73. package/dist/widgets/toHostGroupProps.d.ts +172 -21
  74. package/dist/widgets/toHostProps.d.ts +105 -0
  75. package/dist/widgets/types.d.ts +19 -6
  76. package/dist/widgets/upload-error-message.d.ts +6 -0
  77. package/dist/widgets/uploadingScope.d.ts +142 -0
  78. package/dist/widgets/useCascadingOptions.d.ts +3 -2
  79. package/dist/widgets/useRecordQuery.d.ts +4 -2
  80. package/dist/widgets/useUploadingSignal.d.ts +8 -0
  81. package/package.json +11 -13
  82. package/dist/widgets/CharacterCount.d.ts +0 -56
  83. package/src/FieldEditWidget.test.ts +0 -133
  84. package/src/FieldEditWidget.tsx +0 -207
  85. package/src/__tests__/AddressCellRenderer.test.tsx +0 -166
  86. package/src/__tests__/CurrencyField.minorUnits.test.tsx +0 -191
  87. package/src/__tests__/CurrencyField.symbol.test.tsx +0 -198
  88. package/src/__tests__/DateCellRenderer.test.tsx +0 -82
  89. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +0 -146
  90. package/src/__tests__/NumberCellRenderer.test.tsx +0 -60
  91. package/src/__tests__/PercentCellRenderer.locale.test.tsx +0 -152
  92. package/src/__tests__/PercentCellRenderer.test.tsx +0 -115
  93. package/src/__tests__/boolean-label-association-e2e.test.tsx +0 -298
  94. package/src/__tests__/capability-multiselect-retired.test.ts +0 -109
  95. package/src/__tests__/cell-truncation.test.tsx +0 -96
  96. package/src/__tests__/composite-group-label-e2e.test.tsx +0 -436
  97. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +0 -403
  98. package/src/__tests__/date-formatter-residue-4272.test.ts +0 -120
  99. package/src/__tests__/date-locale-channel.test.tsx +0 -305
  100. package/src/__tests__/field-carrier-sdui.test.tsx +0 -169
  101. package/src/__tests__/formatCurrency.centsDigit.test.tsx +0 -158
  102. package/src/__tests__/formatCurrency.minorUnits.test.tsx +0 -221
  103. package/src/__tests__/group-labelling-declaration.test.ts +0 -148
  104. package/src/__tests__/lookupCellDisplayField.test.tsx +0 -107
  105. package/src/__tests__/lookupCellDisplayName.test.tsx +0 -119
  106. package/src/__tests__/lookupCellRecordLink.test.tsx +0 -171
  107. package/src/__tests__/lookupCellReferenceKey.test.tsx +0 -57
  108. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +0 -115
  109. package/src/__tests__/percent-formatter-locale-4553.test.ts +0 -147
  110. package/src/__tests__/percent-tie-halfup-4590.test.ts +0 -224
  111. package/src/__tests__/registry-collision.test.ts +0 -91
  112. package/src/__tests__/resolveFieldCurrency.test.ts +0 -20
  113. package/src/__tests__/select-label-association-e2e.test.tsx +0 -199
  114. package/src/__tests__/spec-symbol-batch7.test.ts +0 -126
  115. package/src/__tests__/validation-feedback.test.tsx +0 -361
  116. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +0 -265
  117. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +0 -338
  118. package/src/__tests__/widget-dom-leak-e2e.test.tsx +0 -357
  119. package/src/__tests__/widget-props-contract.test.tsx +0 -142
  120. package/src/coerce-safe-value.test.ts +0 -34
  121. package/src/complex-widgets.test.tsx +0 -924
  122. package/src/currency.ts +0 -144
  123. package/src/datetime-widgets.test.tsx +0 -185
  124. package/src/field-type-alias.multiple.test.ts +0 -81
  125. package/src/field-type-alias.ts +0 -156
  126. package/src/field-type-coverage.test.ts +0 -67
  127. package/src/index.css +0 -44
  128. package/src/index.tsx +0 -2745
  129. package/src/standard-widgets.test.tsx +0 -364
  130. package/src/widgets/AddressField.postalCode.test.tsx +0 -257
  131. package/src/widgets/AddressField.tsx +0 -182
  132. package/src/widgets/AddressField.uniqueIds.test.tsx +0 -93
  133. package/src/widgets/AutoNumberField.tsx +0 -18
  134. package/src/widgets/AvatarField.tsx +0 -117
  135. package/src/widgets/BooleanField.tsx +0 -86
  136. package/src/widgets/CapabilityMultiSelectField.test.tsx +0 -168
  137. package/src/widgets/CapabilityMultiSelectField.tsx +0 -241
  138. package/src/widgets/CharacterCount.tsx +0 -263
  139. package/src/widgets/CheckboxesField.cascade.test.tsx +0 -143
  140. package/src/widgets/CheckboxesField.tsx +0 -169
  141. package/src/widgets/CodeField.tsx +0 -41
  142. package/src/widgets/ColorField.tsx +0 -53
  143. package/src/widgets/CurrencyField.tsx +0 -135
  144. package/src/widgets/DateField.tsx +0 -40
  145. package/src/widgets/DateTimeField.tsx +0 -46
  146. package/src/widgets/EmailField.tsx +0 -37
  147. package/src/widgets/FileField.tsx +0 -512
  148. package/src/widgets/FilterConditionField.tsx +0 -454
  149. package/src/widgets/FormulaField.tsx +0 -38
  150. package/src/widgets/FullscreenFieldEditor.tsx +0 -60
  151. package/src/widgets/GeolocationField.tsx +0 -193
  152. package/src/widgets/GeolocationField.uniqueIds.test.tsx +0 -88
  153. package/src/widgets/GridField.test.tsx +0 -632
  154. package/src/widgets/GridField.tsx +0 -1070
  155. package/src/widgets/ImageCropperDialog.tsx +0 -245
  156. package/src/widgets/ImageField.maxSize.test.tsx +0 -154
  157. package/src/widgets/ImageField.tsx +0 -305
  158. package/src/widgets/ImageLightbox.test.tsx +0 -52
  159. package/src/widgets/ImageLightbox.tsx +0 -108
  160. package/src/widgets/LocationField.tsx +0 -57
  161. package/src/widgets/LookupField.dependsOn.test.tsx +0 -233
  162. package/src/widgets/LookupField.gateHintLabel.test.tsx +0 -135
  163. package/src/widgets/LookupField.hydration.test.tsx +0 -136
  164. package/src/widgets/LookupField.idField.test.tsx +0 -61
  165. package/src/widgets/LookupField.tsx +0 -1295
  166. package/src/widgets/MarkdownContent.tsx +0 -19
  167. package/src/widgets/MasterDetailField.tsx +0 -131
  168. package/src/widgets/MultiSelectField.cascade.test.tsx +0 -143
  169. package/src/widgets/MultiSelectField.tsx +0 -196
  170. package/src/widgets/NumberField.tsx +0 -49
  171. package/src/widgets/ObjectField.tsx +0 -94
  172. package/src/widgets/ObjectRefField.tsx +0 -121
  173. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +0 -88
  174. package/src/widgets/OptionsEmptyState.test.tsx +0 -169
  175. package/src/widgets/OptionsEmptyState.tsx +0 -99
  176. package/src/widgets/PasswordField.tsx +0 -49
  177. package/src/widgets/PeoplePicker.test.tsx +0 -360
  178. package/src/widgets/PeoplePicker.tsx +0 -579
  179. package/src/widgets/PeoplePickerComponents.test.tsx +0 -104
  180. package/src/widgets/PercentField.tsx +0 -95
  181. package/src/widgets/PersonRow.tsx +0 -117
  182. package/src/widgets/PhoneField.tsx +0 -37
  183. package/src/widgets/QRCodeField.tsx +0 -103
  184. package/src/widgets/RadioField.cascade.test.tsx +0 -141
  185. package/src/widgets/RadioField.tsx +0 -143
  186. package/src/widgets/RatingField.tsx +0 -94
  187. package/src/widgets/RecipientPickerField.test.tsx +0 -157
  188. package/src/widgets/RecipientPickerField.tsx +0 -188
  189. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +0 -191
  190. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +0 -124
  191. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +0 -611
  192. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +0 -112
  193. package/src/widgets/RecordPickerDialog.tsx +0 -1365
  194. package/src/widgets/RichTextField.tsx +0 -176
  195. package/src/widgets/SelectField.cascade.test.tsx +0 -138
  196. package/src/widgets/SelectField.tsx +0 -193
  197. package/src/widgets/SelectionTray.tsx +0 -110
  198. package/src/widgets/SignatureField.tsx +0 -140
  199. package/src/widgets/SliderField.tsx +0 -39
  200. package/src/widgets/SummaryField.tsx +0 -31
  201. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +0 -68
  202. package/src/widgets/TagsField.placeholder.test.tsx +0 -95
  203. package/src/widgets/TagsField.tsx +0 -88
  204. package/src/widgets/TextAreaField.tsx +0 -213
  205. package/src/widgets/TextField.tsx +0 -45
  206. package/src/widgets/TimeField.tsx +0 -31
  207. package/src/widgets/UrlField.tsx +0 -46
  208. package/src/widgets/UserField.tsx +0 -59
  209. package/src/widgets/UserFieldPicker.test.tsx +0 -78
  210. package/src/widgets/VectorField.tsx +0 -29
  211. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +0 -66
  212. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +0 -166
  213. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +0 -248
  214. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +0 -116
  215. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +0 -244
  216. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +0 -195
  217. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +0 -417
  218. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +0 -287
  219. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +0 -160
  220. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +0 -161
  221. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +0 -346
  222. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +0 -246
  223. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +0 -175
  224. package/src/widgets/_cell-renderer-bridge.ts +0 -19
  225. package/src/widgets/address-format.ts +0 -120
  226. package/src/widgets/deriveLookupColumns.test.ts +0 -112
  227. package/src/widgets/deriveLookupColumns.ts +0 -108
  228. package/src/widgets/file-size-guard.ts +0 -55
  229. package/src/widgets/file-value.test.ts +0 -230
  230. package/src/widgets/file-value.ts +0 -246
  231. package/src/widgets/nativeDateValue.test.ts +0 -115
  232. package/src/widgets/nativeDateValue.ts +0 -112
  233. package/src/widgets/openNativePicker.ts +0 -18
  234. package/src/widgets/optionWidgets.gatedOptions.test.tsx +0 -267
  235. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +0 -150
  236. package/src/widgets/personDisplay.test.ts +0 -113
  237. package/src/widgets/personDisplay.ts +0 -92
  238. package/src/widgets/recentLookups.test.ts +0 -45
  239. package/src/widgets/recentLookups.ts +0 -47
  240. package/src/widgets/toDomProps.ts +0 -204
  241. package/src/widgets/toHostGroupProps.ts +0 -102
  242. package/src/widgets/types.ts +0 -242
  243. package/src/widgets/useCascadingOptions.ts +0 -46
  244. package/src/widgets/useFieldTranslation.ts +0 -132
  245. package/src/widgets/useRecordQuery.test.ts +0 -174
  246. package/src/widgets/useRecordQuery.ts +0 -291
  247. package/src/widgets/useUploadingSignal.test.tsx +0 -62
  248. package/src/widgets/useUploadingSignal.ts +0 -20
  249. package/src/withFieldCarrier.tsx +0 -75
@@ -1,436 +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
-
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
- });