@object-ui/fields 17.3.0 → 17.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/CHANGELOG.md +941 -0
  2. package/README.md +1 -12
  3. package/dist/FieldEditWidget.d.ts +7 -1
  4. package/dist/currency.d.ts +46 -0
  5. package/dist/field-type-alias.d.ts +19 -1
  6. package/dist/index.cjs +4 -4
  7. package/dist/index.css +633 -0
  8. package/dist/index.d.ts +48 -12
  9. package/dist/index.js +5026 -4933
  10. package/dist/widgets/AddressField.d.ts +3 -11
  11. package/dist/widgets/FullscreenFieldEditor.d.ts +42 -152
  12. package/dist/widgets/GridField.d.ts +12 -2
  13. package/dist/widgets/OptionsEmptyState.d.ts +15 -1
  14. package/dist/widgets/RecordPickerDialog.d.ts +32 -6
  15. package/dist/widgets/SelectField.d.ts +25 -5
  16. package/dist/widgets/address-format.d.ts +68 -0
  17. package/dist/widgets/file-size-guard.d.ts +41 -0
  18. package/dist/widgets/nativeDateValue.d.ts +37 -0
  19. package/dist/widgets/toDomProps.d.ts +30 -10
  20. package/dist/widgets/toHostGroupProps.d.ts +91 -0
  21. package/dist/widgets/useRecordQuery.d.ts +7 -1
  22. package/package.json +17 -13
  23. package/src/FieldEditWidget.tsx +17 -1
  24. package/src/__tests__/AddressCellRenderer.test.tsx +166 -0
  25. package/src/__tests__/CurrencyField.minorUnits.test.tsx +191 -0
  26. package/src/__tests__/CurrencyField.symbol.test.tsx +198 -0
  27. package/src/__tests__/DateCellRenderer.test.tsx +9 -0
  28. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +146 -0
  29. package/src/__tests__/PercentCellRenderer.locale.test.tsx +152 -0
  30. package/src/__tests__/PercentCellRenderer.test.tsx +115 -0
  31. package/src/__tests__/boolean-label-association-e2e.test.tsx +298 -0
  32. package/src/__tests__/capability-multiselect-retired.test.ts +109 -0
  33. package/src/__tests__/composite-group-label-e2e.test.tsx +436 -0
  34. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +403 -0
  35. package/src/__tests__/date-formatter-residue-4272.test.ts +120 -0
  36. package/src/__tests__/date-locale-channel.test.tsx +305 -0
  37. package/src/__tests__/formatCurrency.centsDigit.test.tsx +158 -0
  38. package/src/__tests__/formatCurrency.minorUnits.test.tsx +221 -0
  39. package/src/__tests__/group-labelling-declaration.test.ts +148 -0
  40. package/src/__tests__/lookupCellRecordLink.test.tsx +171 -0
  41. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +115 -0
  42. package/src/__tests__/percent-formatter-locale-4553.test.ts +147 -0
  43. package/src/__tests__/percent-tie-halfup-4590.test.ts +224 -0
  44. package/src/__tests__/select-label-association-e2e.test.tsx +199 -0
  45. package/src/__tests__/widget-dom-leak-e2e.test.tsx +29 -270
  46. package/src/complex-widgets.test.tsx +22 -6
  47. package/src/currency.ts +130 -0
  48. package/src/datetime-widgets.test.tsx +94 -2
  49. package/src/field-type-alias.multiple.test.ts +81 -0
  50. package/src/field-type-alias.ts +58 -2
  51. package/src/index.css +44 -0
  52. package/src/index.tsx +489 -247
  53. package/src/standard-widgets.test.tsx +8 -1
  54. package/src/widgets/AddressField.postalCode.test.tsx +257 -0
  55. package/src/widgets/AddressField.tsx +79 -29
  56. package/src/widgets/AddressField.uniqueIds.test.tsx +2 -1
  57. package/src/widgets/BooleanField.tsx +41 -7
  58. package/src/widgets/CapabilityMultiSelectField.tsx +17 -5
  59. package/src/widgets/CheckboxesField.tsx +35 -2
  60. package/src/widgets/CurrencyField.tsx +55 -15
  61. package/src/widgets/DateField.tsx +11 -2
  62. package/src/widgets/DateTimeField.tsx +12 -3
  63. package/src/widgets/FileField.tsx +27 -9
  64. package/src/widgets/FormulaField.tsx +6 -1
  65. package/src/widgets/FullscreenFieldEditor.tsx +47 -258
  66. package/src/widgets/GeolocationField.tsx +29 -3
  67. package/src/widgets/GridField.test.tsx +226 -0
  68. package/src/widgets/GridField.tsx +114 -10
  69. package/src/widgets/ImageField.maxSize.test.tsx +154 -0
  70. package/src/widgets/ImageField.tsx +54 -4
  71. package/src/widgets/LookupField.hydration.test.tsx +1 -1
  72. package/src/widgets/LookupField.tsx +8 -4
  73. package/src/widgets/MultiSelectField.tsx +56 -2
  74. package/src/widgets/OptionsEmptyState.tsx +16 -0
  75. package/src/widgets/RadioField.tsx +35 -3
  76. package/src/widgets/RatingField.tsx +15 -2
  77. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +191 -0
  78. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +124 -0
  79. package/src/widgets/RecordPickerDialog.tsx +64 -15
  80. package/src/widgets/RichTextField.tsx +1 -1
  81. package/src/widgets/SelectField.tsx +36 -5
  82. package/src/widgets/UserField.tsx +3 -2
  83. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +25 -1
  84. package/src/widgets/address-format.ts +120 -0
  85. package/src/widgets/file-size-guard.ts +55 -0
  86. package/src/widgets/nativeDateValue.test.ts +115 -0
  87. package/src/widgets/nativeDateValue.ts +112 -0
  88. package/src/widgets/optionWidgets.gatedOptions.test.tsx +267 -0
  89. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +150 -0
  90. package/src/widgets/toDomProps.ts +48 -19
  91. package/src/widgets/toHostGroupProps.ts +102 -0
  92. package/src/widgets/useFieldTranslation.ts +15 -33
  93. package/src/widgets/useRecordQuery.ts +30 -3
  94. package/dist/FieldEditWidget.test.d.ts +0 -8
  95. package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
  96. package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
  97. package/dist/__tests__/cell-truncation.test.d.ts +0 -0
  98. package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
  99. package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
  100. package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
  101. package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
  102. package/dist/__tests__/registry-collision.test.d.ts +0 -0
  103. package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
  104. package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
  105. package/dist/__tests__/validation-feedback.test.d.ts +0 -0
  106. package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
  107. package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
  108. package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
  109. package/dist/__tests__/widget-props-contract.test.d.ts +0 -8
  110. package/dist/coerce-safe-value.test.d.ts +0 -1
  111. package/dist/complex-widgets.test.d.ts +0 -1
  112. package/dist/datetime-widgets.test.d.ts +0 -1
  113. package/dist/field-type-coverage.test.d.ts +0 -1
  114. package/dist/standard-widgets.test.d.ts +0 -0
  115. package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
  116. package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
  117. package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
  118. package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
  119. package/dist/widgets/GridField.test.d.ts +0 -1
  120. package/dist/widgets/ImageLightbox.test.d.ts +0 -1
  121. package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
  122. package/dist/widgets/LookupField.gateHintLabel.test.d.ts +0 -8
  123. package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
  124. package/dist/widgets/LookupField.idField.test.d.ts +0 -1
  125. package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
  126. package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
  127. package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
  128. package/dist/widgets/PeoplePicker.test.d.ts +0 -8
  129. package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
  130. package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
  131. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  132. package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +0 -8
  133. package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +0 -8
  134. package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
  135. package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
  136. package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
  137. package/dist/widgets/UserFieldPicker.test.d.ts +0 -8
  138. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
  139. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
  140. package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
  141. package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
  142. package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
  143. package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
  144. package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
  145. package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
  146. package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
  147. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
  148. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
  149. package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
  150. package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
  151. package/dist/widgets/deriveLookupColumns.test.d.ts +0 -1
  152. package/dist/widgets/file-value.test.d.ts +0 -1
  153. package/dist/widgets/personDisplay.test.d.ts +0 -8
  154. package/dist/widgets/recentLookups.test.d.ts +0 -1
  155. package/dist/widgets/useRecordQuery.test.d.ts +0 -8
  156. package/dist/widgets/useUploadingSignal.test.d.ts +0 -1
@@ -0,0 +1,403 @@
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
+ });
@@ -0,0 +1,120 @@
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
+ });