@object-ui/fields 17.5.0 → 17.6.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 (229) hide show
  1. package/CHANGELOG.md +714 -0
  2. package/dist/FieldEditWidget.d.ts +16 -2
  3. package/dist/field-type-alias.d.ts +16 -0
  4. package/dist/index.cjs +4 -4
  5. package/dist/index.css +27 -9
  6. package/dist/index.d.ts +425 -72
  7. package/dist/index.js +2530 -2228
  8. package/dist/widgets/AddressField.d.ts +3 -3
  9. package/dist/widgets/AutoNumberField.d.ts +1 -1
  10. package/dist/widgets/AvatarField.d.ts +1 -1
  11. package/dist/widgets/BooleanField.d.ts +1 -1
  12. package/dist/widgets/CapabilityMultiSelectField.d.ts +1 -1
  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/DateField.d.ts +1 -1
  18. package/dist/widgets/DateTimeField.d.ts +1 -1
  19. package/dist/widgets/EmailField.d.ts +1 -1
  20. package/dist/widgets/FileField.d.ts +18 -2
  21. package/dist/widgets/FilterConditionField.d.ts +28 -1
  22. package/dist/widgets/FormulaField.d.ts +1 -1
  23. package/dist/widgets/FullscreenFieldEditor.d.ts +9 -2
  24. package/dist/widgets/GeolocationField.d.ts +1 -1
  25. package/dist/widgets/GridField.d.ts +56 -4
  26. package/dist/widgets/ImageField.d.ts +1 -1
  27. package/dist/widgets/LocationField.d.ts +1 -1
  28. package/dist/widgets/LookupField.d.ts +1 -1
  29. package/dist/widgets/MasterDetailField.d.ts +1 -1
  30. package/dist/widgets/MultiSelectField.d.ts +1 -1
  31. package/dist/widgets/NumberField.d.ts +1 -1
  32. package/dist/widgets/ObjectField.d.ts +1 -1
  33. package/dist/widgets/ObjectRefField.d.ts +1 -1
  34. package/dist/widgets/OptionsEmptyState.d.ts +13 -7
  35. package/dist/widgets/PasswordField.d.ts +1 -1
  36. package/dist/widgets/PercentField.d.ts +1 -1
  37. package/dist/widgets/PhoneField.d.ts +1 -1
  38. package/dist/widgets/QRCodeField.d.ts +1 -1
  39. package/dist/widgets/RadioField.d.ts +1 -1
  40. package/dist/widgets/RatingField.d.ts +1 -1
  41. package/dist/widgets/RecipientPickerField.d.ts +1 -1
  42. package/dist/widgets/RecordPickerDialog.d.ts +2 -4
  43. package/dist/widgets/RichTextField.d.ts +37 -1
  44. package/dist/widgets/SelectField.d.ts +1 -1
  45. package/dist/widgets/SignatureField.d.ts +32 -2
  46. package/dist/widgets/SliderField.d.ts +37 -2
  47. package/dist/widgets/SummaryField.d.ts +1 -1
  48. package/dist/widgets/TagsField.d.ts +1 -1
  49. package/dist/widgets/TextAreaField.d.ts +6 -2
  50. package/dist/widgets/TextField.d.ts +1 -1
  51. package/dist/widgets/TimeField.d.ts +1 -1
  52. package/dist/widgets/UrlField.d.ts +1 -1
  53. package/dist/widgets/UserField.d.ts +1 -1
  54. package/dist/widgets/VectorField.d.ts +1 -1
  55. package/dist/widgets/_cell-renderer-bridge.d.ts +1 -1
  56. package/dist/widgets/address-format.d.ts +27 -7
  57. package/dist/widgets/lookupColumnDisplay.d.ts +93 -0
  58. package/dist/widgets/richTextDisplay.d.ts +80 -0
  59. package/dist/widgets/toHostGroupProps.d.ts +119 -21
  60. package/dist/widgets/types.d.ts +8 -3
  61. package/package.json +9 -10
  62. package/dist/widgets/CharacterCount.d.ts +0 -56
  63. package/src/FieldEditWidget.test.ts +0 -133
  64. package/src/FieldEditWidget.tsx +0 -207
  65. package/src/__tests__/AddressCellRenderer.test.tsx +0 -166
  66. package/src/__tests__/CurrencyField.minorUnits.test.tsx +0 -191
  67. package/src/__tests__/CurrencyField.symbol.test.tsx +0 -198
  68. package/src/__tests__/DateCellRenderer.test.tsx +0 -82
  69. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +0 -146
  70. package/src/__tests__/NumberCellRenderer.test.tsx +0 -60
  71. package/src/__tests__/PercentCellRenderer.locale.test.tsx +0 -152
  72. package/src/__tests__/PercentCellRenderer.test.tsx +0 -115
  73. package/src/__tests__/boolean-label-association-e2e.test.tsx +0 -298
  74. package/src/__tests__/capability-multiselect-retired.test.ts +0 -109
  75. package/src/__tests__/cell-truncation.test.tsx +0 -96
  76. package/src/__tests__/composite-group-label-e2e.test.tsx +0 -436
  77. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +0 -403
  78. package/src/__tests__/date-formatter-residue-4272.test.ts +0 -120
  79. package/src/__tests__/date-locale-channel.test.tsx +0 -305
  80. package/src/__tests__/field-carrier-sdui.test.tsx +0 -169
  81. package/src/__tests__/formatCurrency.centsDigit.test.tsx +0 -158
  82. package/src/__tests__/formatCurrency.minorUnits.test.tsx +0 -221
  83. package/src/__tests__/group-labelling-declaration.test.ts +0 -148
  84. package/src/__tests__/lookupCellDisplayField.test.tsx +0 -107
  85. package/src/__tests__/lookupCellDisplayName.test.tsx +0 -119
  86. package/src/__tests__/lookupCellRecordLink.test.tsx +0 -171
  87. package/src/__tests__/lookupCellReferenceKey.test.tsx +0 -57
  88. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +0 -115
  89. package/src/__tests__/percent-formatter-locale-4553.test.ts +0 -147
  90. package/src/__tests__/percent-tie-halfup-4590.test.ts +0 -224
  91. package/src/__tests__/registry-collision.test.ts +0 -91
  92. package/src/__tests__/resolveFieldCurrency.test.ts +0 -20
  93. package/src/__tests__/select-label-association-e2e.test.tsx +0 -199
  94. package/src/__tests__/spec-symbol-batch7.test.ts +0 -126
  95. package/src/__tests__/validation-feedback.test.tsx +0 -361
  96. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +0 -265
  97. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +0 -338
  98. package/src/__tests__/widget-dom-leak-e2e.test.tsx +0 -357
  99. package/src/__tests__/widget-props-contract.test.tsx +0 -142
  100. package/src/coerce-safe-value.test.ts +0 -34
  101. package/src/complex-widgets.test.tsx +0 -924
  102. package/src/currency.ts +0 -144
  103. package/src/datetime-widgets.test.tsx +0 -185
  104. package/src/field-type-alias.multiple.test.ts +0 -81
  105. package/src/field-type-alias.ts +0 -156
  106. package/src/field-type-coverage.test.ts +0 -67
  107. package/src/index.css +0 -44
  108. package/src/index.tsx +0 -2745
  109. package/src/standard-widgets.test.tsx +0 -364
  110. package/src/widgets/AddressField.postalCode.test.tsx +0 -257
  111. package/src/widgets/AddressField.tsx +0 -182
  112. package/src/widgets/AddressField.uniqueIds.test.tsx +0 -93
  113. package/src/widgets/AutoNumberField.tsx +0 -18
  114. package/src/widgets/AvatarField.tsx +0 -117
  115. package/src/widgets/BooleanField.tsx +0 -86
  116. package/src/widgets/CapabilityMultiSelectField.test.tsx +0 -168
  117. package/src/widgets/CapabilityMultiSelectField.tsx +0 -241
  118. package/src/widgets/CharacterCount.tsx +0 -263
  119. package/src/widgets/CheckboxesField.cascade.test.tsx +0 -143
  120. package/src/widgets/CheckboxesField.tsx +0 -169
  121. package/src/widgets/CodeField.tsx +0 -41
  122. package/src/widgets/ColorField.tsx +0 -53
  123. package/src/widgets/CurrencyField.tsx +0 -135
  124. package/src/widgets/DateField.tsx +0 -40
  125. package/src/widgets/DateTimeField.tsx +0 -46
  126. package/src/widgets/EmailField.tsx +0 -37
  127. package/src/widgets/FileField.tsx +0 -512
  128. package/src/widgets/FilterConditionField.tsx +0 -454
  129. package/src/widgets/FormulaField.tsx +0 -38
  130. package/src/widgets/FullscreenFieldEditor.tsx +0 -60
  131. package/src/widgets/GeolocationField.tsx +0 -193
  132. package/src/widgets/GeolocationField.uniqueIds.test.tsx +0 -88
  133. package/src/widgets/GridField.test.tsx +0 -632
  134. package/src/widgets/GridField.tsx +0 -1070
  135. package/src/widgets/ImageCropperDialog.tsx +0 -245
  136. package/src/widgets/ImageField.maxSize.test.tsx +0 -154
  137. package/src/widgets/ImageField.tsx +0 -305
  138. package/src/widgets/ImageLightbox.test.tsx +0 -52
  139. package/src/widgets/ImageLightbox.tsx +0 -108
  140. package/src/widgets/LocationField.tsx +0 -57
  141. package/src/widgets/LookupField.dependsOn.test.tsx +0 -233
  142. package/src/widgets/LookupField.gateHintLabel.test.tsx +0 -135
  143. package/src/widgets/LookupField.hydration.test.tsx +0 -136
  144. package/src/widgets/LookupField.idField.test.tsx +0 -61
  145. package/src/widgets/LookupField.tsx +0 -1295
  146. package/src/widgets/MarkdownContent.tsx +0 -19
  147. package/src/widgets/MasterDetailField.tsx +0 -131
  148. package/src/widgets/MultiSelectField.cascade.test.tsx +0 -143
  149. package/src/widgets/MultiSelectField.tsx +0 -196
  150. package/src/widgets/NumberField.tsx +0 -49
  151. package/src/widgets/ObjectField.tsx +0 -94
  152. package/src/widgets/ObjectRefField.tsx +0 -121
  153. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +0 -88
  154. package/src/widgets/OptionsEmptyState.test.tsx +0 -169
  155. package/src/widgets/OptionsEmptyState.tsx +0 -99
  156. package/src/widgets/PasswordField.tsx +0 -49
  157. package/src/widgets/PeoplePicker.test.tsx +0 -360
  158. package/src/widgets/PeoplePicker.tsx +0 -579
  159. package/src/widgets/PeoplePickerComponents.test.tsx +0 -104
  160. package/src/widgets/PercentField.tsx +0 -95
  161. package/src/widgets/PersonRow.tsx +0 -117
  162. package/src/widgets/PhoneField.tsx +0 -37
  163. package/src/widgets/QRCodeField.tsx +0 -103
  164. package/src/widgets/RadioField.cascade.test.tsx +0 -141
  165. package/src/widgets/RadioField.tsx +0 -143
  166. package/src/widgets/RatingField.tsx +0 -94
  167. package/src/widgets/RecipientPickerField.test.tsx +0 -157
  168. package/src/widgets/RecipientPickerField.tsx +0 -188
  169. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +0 -191
  170. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +0 -124
  171. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +0 -611
  172. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +0 -112
  173. package/src/widgets/RecordPickerDialog.tsx +0 -1365
  174. package/src/widgets/RichTextField.tsx +0 -176
  175. package/src/widgets/SelectField.cascade.test.tsx +0 -138
  176. package/src/widgets/SelectField.tsx +0 -193
  177. package/src/widgets/SelectionTray.tsx +0 -110
  178. package/src/widgets/SignatureField.tsx +0 -140
  179. package/src/widgets/SliderField.tsx +0 -39
  180. package/src/widgets/SummaryField.tsx +0 -31
  181. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +0 -68
  182. package/src/widgets/TagsField.placeholder.test.tsx +0 -95
  183. package/src/widgets/TagsField.tsx +0 -88
  184. package/src/widgets/TextAreaField.tsx +0 -213
  185. package/src/widgets/TextField.tsx +0 -45
  186. package/src/widgets/TimeField.tsx +0 -31
  187. package/src/widgets/UrlField.tsx +0 -46
  188. package/src/widgets/UserField.tsx +0 -59
  189. package/src/widgets/UserFieldPicker.test.tsx +0 -78
  190. package/src/widgets/VectorField.tsx +0 -29
  191. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +0 -66
  192. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +0 -166
  193. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +0 -248
  194. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +0 -116
  195. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +0 -244
  196. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +0 -195
  197. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +0 -417
  198. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +0 -287
  199. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +0 -160
  200. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +0 -161
  201. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +0 -346
  202. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +0 -246
  203. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +0 -175
  204. package/src/widgets/_cell-renderer-bridge.ts +0 -19
  205. package/src/widgets/address-format.ts +0 -120
  206. package/src/widgets/deriveLookupColumns.test.ts +0 -112
  207. package/src/widgets/deriveLookupColumns.ts +0 -108
  208. package/src/widgets/file-size-guard.ts +0 -55
  209. package/src/widgets/file-value.test.ts +0 -230
  210. package/src/widgets/file-value.ts +0 -246
  211. package/src/widgets/nativeDateValue.test.ts +0 -115
  212. package/src/widgets/nativeDateValue.ts +0 -112
  213. package/src/widgets/openNativePicker.ts +0 -18
  214. package/src/widgets/optionWidgets.gatedOptions.test.tsx +0 -267
  215. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +0 -150
  216. package/src/widgets/personDisplay.test.ts +0 -113
  217. package/src/widgets/personDisplay.ts +0 -92
  218. package/src/widgets/recentLookups.test.ts +0 -45
  219. package/src/widgets/recentLookups.ts +0 -47
  220. package/src/widgets/toDomProps.ts +0 -204
  221. package/src/widgets/toHostGroupProps.ts +0 -102
  222. package/src/widgets/types.ts +0 -242
  223. package/src/widgets/useCascadingOptions.ts +0 -46
  224. package/src/widgets/useFieldTranslation.ts +0 -132
  225. package/src/widgets/useRecordQuery.test.ts +0 -174
  226. package/src/widgets/useRecordQuery.ts +0 -291
  227. package/src/widgets/useUploadingSignal.test.tsx +0 -62
  228. package/src/widgets/useUploadingSignal.ts +0 -20
  229. package/src/withFieldCarrier.tsx +0 -75
@@ -1,96 +0,0 @@
1
- /**
2
- * Regression for objectstack-ai/objectui#3466: single-line cell renderers
3
- * emitted a BARE INLINE `span.truncate`. An inline box has no width box, so
4
- * `overflow:hidden` / `text-overflow:ellipsis` never engage — the value
5
- * rendered at full content width and its tail was silently clipped by
6
- * whatever ancestor happened to clip (the record-detail card edge), with no
7
- * ellipsis and no way to read the full text (the detail row's `title` is the
8
- * inline-edit hint, not the value). Same mechanism as the JSON cell fix in
9
- * objectui#2578.
10
- *
11
- * Pin: every single-line value renderer emits a BLOCK-level, `max-w-full`
12
- * truncating span that carries the full text in `title` (so hovering the
13
- * value shows the full text, taking precedence over any ancestor `title`).
14
- */
15
- import { describe, it, expect, vi } from 'vitest';
16
- import { render, screen } from '@testing-library/react';
17
- import '@testing-library/jest-dom';
18
- import React from 'react';
19
-
20
- import {
21
- TextCellRenderer,
22
- LookupCellRenderer,
23
- UserCellRenderer,
24
- FileCellRenderer,
25
- SelectCellRenderer,
26
- } from '../index';
27
- import { SchemaRendererProvider } from '@object-ui/react';
28
-
29
- const LONG =
30
- 'A remarkably long referenced-record title that a detail card column can never fit on a single line without an ellipsis';
31
-
32
- /** The block-level truncation contract every single-line value span must meet. */
33
- function expectTruncating(el: HTMLElement, fullText: string) {
34
- expect(el).toHaveClass('block', 'max-w-full', 'truncate');
35
- expect(el).toHaveAttribute('title', fullText);
36
- }
37
-
38
- describe('cell renderers truncate for real and expose the full text (issue #3466)', () => {
39
- it('TextCellRenderer: block-level truncating span with title fallback', () => {
40
- render(<TextCellRenderer value={LONG} field={{ type: 'text' } as any} />);
41
- expectTruncating(screen.getByText(LONG), LONG);
42
- });
43
-
44
- it('LookupCellRenderer: expanded record object', () => {
45
- const ds = { find: vi.fn(), findOne: vi.fn() } as any;
46
- render(
47
- <SchemaRendererProvider dataSource={ds}>
48
- <LookupCellRenderer
49
- value={{ id: 'rec1', name: LONG }}
50
- field={{ type: 'lookup', reference_to: 'obj_a' } as any}
51
- />
52
- </SchemaRendererProvider>,
53
- );
54
- expectTruncating(screen.getByText(LONG), LONG);
55
- });
56
-
57
- it('LookupCellRenderer: primitive non-opaque value', () => {
58
- const ds = { find: vi.fn(), findOne: vi.fn() } as any;
59
- render(
60
- <SchemaRendererProvider dataSource={ds}>
61
- <LookupCellRenderer value={LONG} field={{ type: 'lookup' } as any} />
62
- </SchemaRendererProvider>,
63
- );
64
- expectTruncating(screen.getByText(LONG), LONG);
65
- });
66
-
67
- it('UserCellRenderer: display name beside the avatar', () => {
68
- render(<UserCellRenderer value={{ name: LONG }} field={{ type: 'user' } as any} />);
69
- expectTruncating(screen.getByText(LONG), LONG);
70
- });
71
-
72
- it('FileCellRenderer: single file name', () => {
73
- render(<FileCellRenderer value={{ name: LONG }} field={{ type: 'file' } as any} />);
74
- expectTruncating(screen.getByText(LONG), LONG);
75
- });
76
-
77
- it('SelectCellRenderer (dot): bounded container with title, shrinkable label', () => {
78
- render(
79
- <SelectCellRenderer
80
- value="opt1"
81
- field={{
82
- type: 'select',
83
- appearance: 'dot',
84
- options: [{ value: 'opt1', label: LONG }],
85
- } as any}
86
- />,
87
- );
88
- const label = screen.getByText(LONG);
89
- // The label shrinks inside the dot row; the row itself is width-bounded
90
- // and carries the full text on hover.
91
- expect(label).toHaveClass('min-w-0', 'truncate');
92
- const row = label.parentElement!;
93
- expect(row).toHaveClass('max-w-full');
94
- expect(row).toHaveAttribute('title', LONG);
95
- });
96
- });
@@ -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
- });