@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,403 +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 group-labelled field's visible label names the widget's surface
11
- * in EVERY state it renders — not only the editable one (objectui#3990).
12
- *
13
- * The residual of objectui#3961 / #3975. Those two moved the association from an
14
- * inert `<label for>` to the WAI-ARIA group pattern (the label publishes its own
15
- * `id`, the widget answers with `aria-labelledby` + a role that can carry a
16
- * name), and pinned it in `composite-group-label-e2e.test.tsx` — entirely on
17
- * EDITABLE samples. Every one of these widgets also returns EARLY, before the
18
- * container that consumes that IDREF, for a field-level `readonly: true` and for
19
- * an option list that resolved to zero offered options. Measured on
20
- * `origin/main` with a real form + the same bare registration this file uses,
21
- * counting the elements that reference the host label's published id:
22
- *
23
- * ```
24
- * consumers byLabelText namedByRole
25
- * multiselect readonly+value 0 0 []
26
- * multiselect readonly+empty 0 0 []
27
- * multiselect zeroOptions 0 0 []
28
- * multiselect editable 1 1 [group:1] ← #3975
29
- * ```
30
- *
31
- * All seven types measured identically, in every readonly state. Both `for`-less
32
- * halves of the association were dropped by those returns, so the visible label
33
- * was the accessible name of NOTHING — the same user-visible outcome #3961
34
- * fixed, in the states its probe never sampled. It is not a regression of either
35
- * PR: before #3961 the same states rendered a `for` pointing at an id no element
36
- * carried, which named nothing either. The shape changed; the defect did not.
37
- *
38
- * ## Why the role is `group` here even where the editable branch says otherwise
39
- *
40
- * `aria-labelledby` on a role-less `div` / `span` names nothing (`generic`
41
- * prohibits an author name), so the readonly surface needs a role that supports
42
- * naming, and `group` is the only one that does not lie about interactivity:
43
- *
44
- * - `radio` answers `radiogroup` while editable, but its readonly branch renders
45
- * the CHOSEN option's label as text — there is not one radio left in it;
46
- * - `file` answers `button` while editable (the dropzone is its single control),
47
- * but readonly there is no dropzone, only the file names.
48
- *
49
- * So the roles this file expects deliberately differ from `HOSTED` in the
50
- * editable e2e for exactly those two, and the last describe block pins that the
51
- * editable answers are untouched.
52
- *
53
- * The widgets are registered raw rather than through `registerAllFields()`, whose
54
- * `React.lazy` loaders put an unbounded module load inside a bounded `findBy`
55
- * window — this repo's known flake generator (AGENTS.md 测试纪律, objectui#3010).
56
- */
57
-
58
- import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest';
59
- import { render, screen, cleanup } from '@testing-library/react';
60
- import '@testing-library/jest-dom';
61
- import { ComponentRegistry } from '@object-ui/core';
62
- // Module scope: pulls in the form renderer's registration side effect.
63
- import '@object-ui/components';
64
-
65
- import { AddressField } from '../widgets/AddressField';
66
- import { GeolocationField } from '../widgets/GeolocationField';
67
- import { CheckboxesField } from '../widgets/CheckboxesField';
68
- import { RadioField } from '../widgets/RadioField';
69
- import { RatingField } from '../widgets/RatingField';
70
- import { FileField } from '../widgets/FileField';
71
- import { MultiSelectField } from '../widgets/MultiSelectField';
72
- import { SelectField } from '../widgets/SelectField';
73
-
74
- const WIDGETS: Record<string, any> = {
75
- address: AddressField,
76
- geolocation: GeolocationField,
77
- checkboxes: CheckboxesField,
78
- radio: RadioField,
79
- rating: RatingField,
80
- file: FileField,
81
- multiselect: MultiSelectField,
82
- };
83
-
84
- /** The option widgets — the only ones with a zero-option state to measure. */
85
- const OPTION_TYPES = ['checkboxes', 'radio', 'multiselect'] as const;
86
-
87
- /**
88
- * Where the placeholder sits when a readonly field has no value. Three real
89
- * shapes, so the assertion is per-widget rather than one presumed form:
90
- *
91
- * - `'is-placeholder'`: the whole surface IS `EmptyValue`, which therefore
92
- * carries the group props itself;
93
- * - `'contains-placeholder'`: `geolocation` keeps its icon row and renders the
94
- * placeholder inside it, so the row is the named group;
95
- * - `'no-placeholder'`: `rating` has no empty state at all — an unset rating
96
- * renders the same "0 / max" star row.
97
- */
98
- const EMPTY_SHAPE: Record<string, 'is-placeholder' | 'contains-placeholder' | 'no-placeholder'> = {
99
- address: 'is-placeholder',
100
- checkboxes: 'is-placeholder',
101
- radio: 'is-placeholder',
102
- file: 'is-placeholder',
103
- multiselect: 'is-placeholder',
104
- geolocation: 'contains-placeholder',
105
- rating: 'no-placeholder',
106
- };
107
-
108
- const OPTIONS = [
109
- { label: 'Alpha', value: 'a' },
110
- { label: 'Beta', value: 'b' },
111
- ];
112
-
113
- /** A stored value per type, so the readonly branch renders its filled shape. */
114
- const VALUES: Record<string, unknown> = {
115
- address: { street: '1 Main St', city: 'Springfield' },
116
- geolocation: { latitude: 1.5, longitude: 2.5 },
117
- checkboxes: ['a'],
118
- radio: 'a',
119
- rating: 3,
120
- file: { id: 'f1', name: 'report.pdf' },
121
- multiselect: ['a'],
122
- };
123
-
124
- /** The role the EDITABLE branch answers with — unchanged by this issue. */
125
- const EDITABLE_ROLE: Record<string, string> = {
126
- address: 'group',
127
- geolocation: 'group',
128
- checkboxes: 'group',
129
- radio: 'radiogroup',
130
- rating: 'group',
131
- file: 'button',
132
- multiselect: 'group',
133
- };
134
-
135
- const TYPES = Object.keys(WIDGETS);
136
-
137
- beforeAll(() => {
138
- for (const [type, Component] of Object.entries(WIDGETS)) {
139
- ComponentRegistry.register(type, Component as any, {
140
- namespace: 'field',
141
- skipFallback: true,
142
- // The declaration under test, mirroring `FIELD_TYPES_GROUP_LABELLED`.
143
- labelling: 'group',
144
- });
145
- }
146
- }, 30000);
147
-
148
- beforeEach(() => {
149
- if (!(Element.prototype as any).scrollIntoView) {
150
- (Element.prototype as any).scrollIntoView = () => {};
151
- }
152
- });
153
-
154
- afterEach(() => {
155
- cleanup();
156
- vi.restoreAllMocks();
157
- });
158
-
159
- /** HTML's labelable elements — the only ones a `<label for>` can address. */
160
- const LABELABLE = new Set(['button', 'input', 'meter', 'output', 'progress', 'select', 'textarea']);
161
-
162
- function labelTargets(): Array<{ forId: string; resolves: boolean; labelable: boolean }> {
163
- return Array.from(document.querySelectorAll('label'))
164
- .map((el) => {
165
- const forId = el.getAttribute('for') ?? '';
166
- const target = forId ? document.getElementById(forId) : null;
167
- return {
168
- forId,
169
- resolves: !!target,
170
- labelable: !!target && LABELABLE.has(target.tagName.toLowerCase()),
171
- };
172
- })
173
- .filter((l) => l.forId !== '');
174
- }
175
-
176
- function fieldConfig(
177
- type: string,
178
- opts: { readonly?: boolean; zeroOptions?: boolean } = {},
179
- ): Record<string, unknown> {
180
- const config: any = { name: `f_${type}`, label: `Group Label ${type}`, type };
181
- if ((OPTION_TYPES as readonly string[]).includes(type)) {
182
- config.options = opts.zeroOptions ? [] : OPTIONS;
183
- }
184
- // Field-level readonly — the state this issue is about. NOT the form-level
185
- // `mode: 'view'`, which renders a different (detail) path and was already
186
- // naming its group correctly.
187
- if (opts.readonly) config.readonly = true;
188
- return config;
189
- }
190
-
191
- /** The real form renderer hosting one field — the #3990 reproduction. */
192
- function renderForm(fields: any[], defaultValues: Record<string, unknown> = {}) {
193
- const Form = ComponentRegistry.get('form')!;
194
- return render(
195
- <Form
196
- schema={{
197
- type: 'form',
198
- mode: 'create',
199
- showSubmit: false,
200
- showCancel: false,
201
- defaultValues,
202
- fields,
203
- }}
204
- />,
205
- );
206
- }
207
-
208
- const hostLabelOf = (name: string): HTMLLabelElement => {
209
- const el = document.querySelector(`[data-field="${name}"] > label`);
210
- if (!el) throw new Error(`no host label rendered for field "${name}"`);
211
- return el as HTMLLabelElement;
212
- };
213
-
214
- describe('a READONLY group-labelled field is named by its host label (objectui#3990)', () => {
215
- it.each(TYPES)('%s: the host label names the readonly surface', (type) => {
216
- renderForm([fieldConfig(type, { readonly: true })], { [`f_${type}`]: VALUES[type] });
217
-
218
- // 0 before this fix, 1 after — for all seven, whatever their readonly shape.
219
- const named = screen.getAllByRole('group', { name: `Group Label ${type}` });
220
- expect(named).toHaveLength(1);
221
- // The same association read from the other side.
222
- expect(screen.getAllByLabelText(`Group Label ${type}`)).toEqual(named);
223
- });
224
-
225
- it.each(TYPES)('%s: the readonly surface is the element the host handed its id to', (type) => {
226
- // The name and the host id must land on the SAME element, as in the editable
227
- // path: the readonly branches dropped BOTH, so the `…-form-item` id existed
228
- // on no element in the document.
229
- renderForm([fieldConfig(type, { readonly: true })], { [`f_${type}`]: VALUES[type] });
230
-
231
- const named = screen.getByRole('group', { name: `Group Label ${type}` });
232
- expect(named.getAttribute('id')).toMatch(/-form-item$/);
233
- });
234
-
235
- it.each(TYPES)('%s: readonly with NO value names the placeholder surface', (type) => {
236
- renderForm([fieldConfig(type, { readonly: true })]);
237
-
238
- const named = screen.getByRole('group', { name: `Group Label ${type}` });
239
- const shape = EMPTY_SHAPE[type];
240
- if (shape === 'is-placeholder') {
241
- // `EmptyValue` itself is the field's whole surface here. Its own
242
- // `aria-label` ("No value") stays in the markup and is outranked by
243
- // `aria-labelledby` per accname — on the `generic` role it carried before
244
- // this change, that author name was not exposed at all.
245
- expect(named).toHaveAttribute('data-slot', 'empty-value');
246
- expect(named).toHaveAccessibleName(`Group Label ${type}`);
247
- } else if (shape === 'contains-placeholder') {
248
- expect(named.querySelector('[data-slot="empty-value"]')).not.toBeNull();
249
- } else {
250
- // `rating` has no empty state: an unset rating is the same star row.
251
- expect(named.querySelector('[data-slot="empty-value"]')).toBeNull();
252
- expect(named.textContent).toContain('0 / 5');
253
- }
254
- });
255
-
256
- it.each(TYPES)('%s: the host label still emits an id and no `for` when readonly', (type) => {
257
- renderForm([fieldConfig(type, { readonly: true })], { [`f_${type}`]: VALUES[type] });
258
-
259
- const label = hostLabelOf(`f_${type}`);
260
- expect(label).not.toHaveAttribute('for');
261
- expect(label.id).not.toBe('');
262
- // A dangling IDREF fails silently, which reads exactly like success.
263
- expect(document.getElementById(label.id)).toBe(label);
264
- // And no OTHER label in the field is left with an inert/dangling `for`.
265
- expect(labelTargets().filter((l) => !l.resolves || !l.labelable)).toEqual([]);
266
- });
267
-
268
- it.each(TYPES)('%s: the readonly surface takes the two whole-field keys ONLY', (type) => {
269
- // The narrow pair is deliberate (`toHostGroupProps`): a readonly display has
270
- // no focusable control, so control-only plumbing must not ride along. `name`
271
- // is the one that would be a DOM leak of the objectui#3291 class — it is
272
- // legal on form controls only, and these surfaces are `div` / `span`.
273
- renderForm([{ ...fieldConfig(type, { readonly: true }), description: 'Some help' }], {
274
- [`f_${type}`]: VALUES[type],
275
- });
276
-
277
- const named = screen.getByRole('group', { name: `Group Label ${type}` });
278
- expect(named).not.toHaveAttribute('name');
279
- expect(named).not.toHaveAttribute('aria-invalid');
280
- });
281
-
282
- it('all seven readonly in ONE form: each is named exactly once', () => {
283
- const fields = TYPES.map((type) => fieldConfig(type, { readonly: true }));
284
- const values = Object.fromEntries(TYPES.map((type) => [`f_${type}`, VALUES[type]]));
285
- renderForm(fields, values);
286
-
287
- for (const type of TYPES) {
288
- expect(screen.getAllByRole('group', { name: `Group Label ${type}` })).toHaveLength(1);
289
- }
290
- expect(labelTargets().filter((l) => !l.resolves || !l.labelable)).toEqual([]);
291
- });
292
- });
293
-
294
- describe('a ZERO-OPTION group-labelled field is named by its host label (objectui#3990)', () => {
295
- it.each(OPTION_TYPES)('%s: the unfillable-options box is the named group', (type) => {
296
- renderForm([fieldConfig(type, { zeroOptions: true })]);
297
-
298
- const named = screen.getAllByRole('group', { name: `Group Label ${type}` });
299
- expect(named).toHaveLength(1);
300
- // The named element is the shared `OptionsEmptyState` box, not some ancestor:
301
- // in this state it is the only thing the widget renders.
302
- expect(named[0]).toBe(screen.getByTestId(`${type}-empty-f_${type}`));
303
- expect(named[0].getAttribute('id')).toMatch(/-form-item$/);
304
- expect(screen.getAllByLabelText(`Group Label ${type}`)).toEqual(named);
305
- });
306
-
307
- it.each(OPTION_TYPES)('%s: readonly wins over zero options, and is still named', (type) => {
308
- // Both early returns at once. The readonly branch comes first, so the
309
- // measured surface is the readonly one — and it must still be named.
310
- renderForm([fieldConfig(type, { readonly: true, zeroOptions: true })], {
311
- [`f_${type}`]: VALUES[type],
312
- });
313
-
314
- expect(screen.getAllByRole('group', { name: `Group Label ${type}` })).toHaveLength(1);
315
- expect(screen.queryByTestId(`${type}-empty-f_${type}`)).toBeNull();
316
- });
317
- });
318
-
319
- describe('the editable answers are untouched (objectui#3961 / #3975 regression)', () => {
320
- it.each(TYPES)('%s: editable keeps its own role, readonly answers `group`', (type) => {
321
- // The drift pin: one widget, two surfaces, both named. `radio` and `file`
322
- // deliberately answer with DIFFERENT roles in the two states, so a future
323
- // "unify the roles" edit has to come here and say which it broke.
324
- renderForm([fieldConfig(type)], { [`f_${type}`]: VALUES[type] });
325
- expect(
326
- screen.getAllByRole(EDITABLE_ROLE[type], { name: `Group Label ${type}` }),
327
- ).toHaveLength(1);
328
- cleanup();
329
-
330
- renderForm([fieldConfig(type, { readonly: true })], { [`f_${type}`]: VALUES[type] });
331
- expect(screen.getAllByRole('group', { name: `Group Label ${type}` })).toHaveLength(1);
332
- });
333
- });
334
-
335
- describe('STANDALONE readonly widgets are unchanged (objectui#3990)', () => {
336
- // `FieldEditWidget` (the grid's inline cell editor) and bare SDUI nodes hand
337
- // down no id and no host label, so there is nothing to associate and nothing
338
- // may be emitted: an unnamed `role="group"` announces a container with no name
339
- // to give, and an id nobody asked for can collide with the page's own.
340
-
341
- it.each(TYPES)('%s: no role, no IDREF, no id', (type) => {
342
- const Widget = WIDGETS[type];
343
- render(
344
- <Widget
345
- value={VALUES[type]}
346
- onChange={() => {}}
347
- readonly
348
- field={{
349
- name: type,
350
- label: `Group Label ${type}`,
351
- type,
352
- ...((OPTION_TYPES as readonly string[]).includes(type) ? { options: OPTIONS } : null),
353
- }}
354
- />,
355
- );
356
-
357
- expect(screen.queryAllByRole('group')).toHaveLength(0);
358
- expect(document.querySelector('[aria-labelledby]')).toBeNull();
359
- expect(document.querySelector('[id]')).toBeNull();
360
- });
361
-
362
- it.each(TYPES)('%s: no value, still nothing emitted', (type) => {
363
- const Widget = WIDGETS[type];
364
- render(
365
- <Widget
366
- value={undefined}
367
- onChange={() => {}}
368
- readonly
369
- field={{
370
- name: type,
371
- label: `Group Label ${type}`,
372
- type,
373
- ...((OPTION_TYPES as readonly string[]).includes(type) ? { options: OPTIONS } : null),
374
- }}
375
- />,
376
- );
377
-
378
- expect(screen.queryAllByRole('group')).toHaveLength(0);
379
- expect(document.querySelector('[aria-labelledby]')).toBeNull();
380
- // The placeholder keeps its own name and gains no role it was not given.
381
- const placeholder = document.querySelector('[data-slot="empty-value"]');
382
- if (placeholder) expect(placeholder).not.toHaveAttribute('role');
383
- });
384
-
385
- it('the shared options-empty box emits nothing for a widget that is not group-labelled', () => {
386
- // The positive control for `OptionsEmptyState`'s new prop. The single
387
- // `SelectField` is NOT in `FIELD_TYPES_GROUP_LABELLED` — its label keeps a
388
- // plain, working `for` — so the shared box must stay attribute-for-attribute
389
- // what it was: no `role`, no IDREF, no host id.
390
- render(
391
- <SelectField
392
- value={undefined}
393
- onChange={() => {}}
394
- field={{ name: 'stage', label: 'Stage', type: 'select', options: [] } as any}
395
- />,
396
- );
397
-
398
- const box = screen.getByTestId('select-empty-stage');
399
- expect(box).not.toHaveAttribute('role');
400
- expect(box).not.toHaveAttribute('aria-labelledby');
401
- expect(box).not.toHaveAttribute('id');
402
- });
403
- });
@@ -1,120 +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
- * objectui#4272 — the two FORMATTER-level en-US channels the #4468 fix
11
- * (PR #4512) deliberately left behind.
12
- *
13
- * PR #4512 pointed every date *renderer* at `useDisplayLocale()`. It could not
14
- * reach these two, because both are properties of the formatter's signature
15
- * rather than of any renderer:
16
- *
17
- * 1. `formatDate`'s `'short'` branch hardcoded the tag —
18
- * `date.toLocaleDateString('en-US', { month: 'short' })` — so it rendered
19
- * an English month name even when a caller DID thread `options.locale`
20
- * into the very same call. The sibling default branch two lines below
21
- * already honored `options?.locale`; `'short'` was the outlier.
22
- * 2. `formatDateTime` took no options parameter at all, so no caller could
23
- * localize it however hard it tried; it passed `undefined` to `Intl`,
24
- * which means "the MACHINE's locale" — neither of the repo's two locale
25
- * channels.
26
- *
27
- * ── Why these cases live in a file with NO provider ──────────────────────
28
- * objectui#4514: `useObjectTranslation()` outside a provider reports
29
- * react-i18next's GLOBAL language, which any `I18nProvider` mounted earlier in
30
- * the same file leaves behind. These are pure-function cases that touch no
31
- * hook at all, so they are immune — but they are kept apart from the
32
- * provider-mounting cases regardless, so the split stays structural rather
33
- * than something a later edit can quietly erode. The provider-mounted halves
34
- * of this card live in `RecordPickerDialog.dateLocale.test.tsx`,
35
- * `plugin-grid/.../mobileCardDateLocale.test.tsx` and
36
- * `plugin-gantt/.../ObjectGantt.dateLocale.test.tsx`.
37
- *
38
- * ── Directions (measured, not presumed) ──────────────────────────────────
39
- * Runner: node v22.22.2 / ICU 78.2 / TZ=UTC / machine locale en-US.
40
- * `en` and `en-US` produce an IDENTICAL `{month:'short'}` for all 12 months,
41
- * so every `en` case below is GREEN ON BOTH SIDES — it is the byte-identical
42
- * must-not-change pin, NOT red evidence. The `zh` and `de` cases are the ones
43
- * that actually go red against unfixed code.
44
- */
45
-
46
- import { describe, it, expect } from 'vitest';
47
- import { formatDate, formatDateTime } from '../index';
48
-
49
- /** Local-parts dates: the rendered day is then the same in every timezone. */
50
- const AUG = new Date(2026, 7, 15, 12, 0, 0);
51
- const OCT = new Date(2026, 9, 15, 12, 0, 0);
52
- /** A non-current year, so the year-dropping default branch is not in play. */
53
- const INSTANT = new Date(2024, 0, 5, 8, 30, 0);
54
-
55
- describe("formatDate 'short' honors the threaded locale (objectui#4272)", () => {
56
- it('zh renders the Chinese month, not the hardcoded English one', () => {
57
- expect(formatDate(AUG, 'short', { locale: 'zh' })).toBe("8月 15, '26");
58
- });
59
-
60
- /**
61
- * A non-CJK second locale, on a month whose English and German short forms
62
- * differ (`Oct` / `Okt`). August would NOT discriminate — German also
63
- * abbreviates it `Aug` — so this case is deliberately October.
64
- */
65
- it('de renders the German month abbreviation', () => {
66
- expect(formatDate(OCT, 'short', { locale: 'de' })).toBe("Okt 15, '26");
67
- });
68
-
69
- /**
70
- * PIN, green on both sides: `en` and the runner's `en-US` agree on every
71
- * month name, so this asserts the English output is byte-identical after
72
- * the change. It is not evidence that the fix works.
73
- */
74
- it('en output is byte-identical (must-not-change)', () => {
75
- expect(formatDate(AUG, 'short', { locale: 'en' })).toBe("Aug 15, '26");
76
- });
77
-
78
- it('the composite shape around the month is unchanged', () => {
79
- // Day and 2-digit year, apostrophe and comma placement: the `'short'`
80
- // contract is "Jan 15, '24" and only the month token was localized.
81
- expect(formatDate(AUG, 'short', { locale: 'en' })).toMatch(/^Aug 15, '26$/);
82
- });
83
- });
84
-
85
- describe('formatDateTime accepts a locale at all (objectui#4272)', () => {
86
- it('zh renders the Chinese datetime form', () => {
87
- expect(formatDateTime(INSTANT, { locale: 'zh' })).toBe('2024年1月5日 08:30');
88
- });
89
-
90
- /** PIN, green on both sides — see the `en` note above. */
91
- it('en output is byte-identical (must-not-change)', () => {
92
- expect(formatDateTime(INSTANT, { locale: 'en' })).toBe('Jan 5, 2024, 08:30 AM');
93
- });
94
-
95
- /**
96
- * Backward compatibility: the parameter is optional and an existing caller
97
- * that passes nothing keeps the exact behavior it had.
98
- *
99
- * This is the ONE case where building the expectation from the runner is
100
- * the correct move rather than the objectui#4513 trap: the contract under
101
- * test IS "still the runtime default", so both sides of the comparison are
102
- * meant to be the machine's locale. Every other case in this file spells
103
- * its tag explicitly.
104
- */
105
- it('no options — still the runtime default, unchanged', () => {
106
- const runtimeDefault = INSTANT.toLocaleDateString(undefined, {
107
- year: 'numeric',
108
- month: 'short',
109
- day: 'numeric',
110
- hour: '2-digit',
111
- minute: '2-digit',
112
- });
113
- expect(formatDateTime(INSTANT)).toBe(runtimeDefault);
114
- });
115
-
116
- it('the empty / invalid guards are untouched', () => {
117
- expect(formatDateTime('', { locale: 'zh' })).toBe('—');
118
- expect(formatDateTime('not-a-date', { locale: 'zh' })).toBe('—');
119
- });
120
- });