@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.
- package/CHANGELOG.md +941 -0
- package/README.md +1 -12
- package/dist/FieldEditWidget.d.ts +7 -1
- package/dist/currency.d.ts +46 -0
- package/dist/field-type-alias.d.ts +19 -1
- package/dist/index.cjs +4 -4
- package/dist/index.css +633 -0
- package/dist/index.d.ts +48 -12
- package/dist/index.js +5026 -4933
- package/dist/widgets/AddressField.d.ts +3 -11
- package/dist/widgets/FullscreenFieldEditor.d.ts +42 -152
- package/dist/widgets/GridField.d.ts +12 -2
- package/dist/widgets/OptionsEmptyState.d.ts +15 -1
- package/dist/widgets/RecordPickerDialog.d.ts +32 -6
- package/dist/widgets/SelectField.d.ts +25 -5
- package/dist/widgets/address-format.d.ts +68 -0
- package/dist/widgets/file-size-guard.d.ts +41 -0
- package/dist/widgets/nativeDateValue.d.ts +37 -0
- package/dist/widgets/toDomProps.d.ts +30 -10
- package/dist/widgets/toHostGroupProps.d.ts +91 -0
- package/dist/widgets/useRecordQuery.d.ts +7 -1
- package/package.json +17 -13
- package/src/FieldEditWidget.tsx +17 -1
- package/src/__tests__/AddressCellRenderer.test.tsx +166 -0
- package/src/__tests__/CurrencyField.minorUnits.test.tsx +191 -0
- package/src/__tests__/CurrencyField.symbol.test.tsx +198 -0
- package/src/__tests__/DateCellRenderer.test.tsx +9 -0
- package/src/__tests__/NumberCellRenderer.grouping.test.tsx +146 -0
- package/src/__tests__/PercentCellRenderer.locale.test.tsx +152 -0
- package/src/__tests__/PercentCellRenderer.test.tsx +115 -0
- package/src/__tests__/boolean-label-association-e2e.test.tsx +298 -0
- package/src/__tests__/capability-multiselect-retired.test.ts +109 -0
- package/src/__tests__/composite-group-label-e2e.test.tsx +436 -0
- package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +403 -0
- package/src/__tests__/date-formatter-residue-4272.test.ts +120 -0
- package/src/__tests__/date-locale-channel.test.tsx +305 -0
- package/src/__tests__/formatCurrency.centsDigit.test.tsx +158 -0
- package/src/__tests__/formatCurrency.minorUnits.test.tsx +221 -0
- package/src/__tests__/group-labelling-declaration.test.ts +148 -0
- package/src/__tests__/lookupCellRecordLink.test.tsx +171 -0
- package/src/__tests__/percent-cell-vs-measure-4576.test.ts +115 -0
- package/src/__tests__/percent-formatter-locale-4553.test.ts +147 -0
- package/src/__tests__/percent-tie-halfup-4590.test.ts +224 -0
- package/src/__tests__/select-label-association-e2e.test.tsx +199 -0
- package/src/__tests__/widget-dom-leak-e2e.test.tsx +29 -270
- package/src/complex-widgets.test.tsx +22 -6
- package/src/currency.ts +130 -0
- package/src/datetime-widgets.test.tsx +94 -2
- package/src/field-type-alias.multiple.test.ts +81 -0
- package/src/field-type-alias.ts +58 -2
- package/src/index.css +44 -0
- package/src/index.tsx +489 -247
- package/src/standard-widgets.test.tsx +8 -1
- package/src/widgets/AddressField.postalCode.test.tsx +257 -0
- package/src/widgets/AddressField.tsx +79 -29
- package/src/widgets/AddressField.uniqueIds.test.tsx +2 -1
- package/src/widgets/BooleanField.tsx +41 -7
- package/src/widgets/CapabilityMultiSelectField.tsx +17 -5
- package/src/widgets/CheckboxesField.tsx +35 -2
- package/src/widgets/CurrencyField.tsx +55 -15
- package/src/widgets/DateField.tsx +11 -2
- package/src/widgets/DateTimeField.tsx +12 -3
- package/src/widgets/FileField.tsx +27 -9
- package/src/widgets/FormulaField.tsx +6 -1
- package/src/widgets/FullscreenFieldEditor.tsx +47 -258
- package/src/widgets/GeolocationField.tsx +29 -3
- package/src/widgets/GridField.test.tsx +226 -0
- package/src/widgets/GridField.tsx +114 -10
- package/src/widgets/ImageField.maxSize.test.tsx +154 -0
- package/src/widgets/ImageField.tsx +54 -4
- package/src/widgets/LookupField.hydration.test.tsx +1 -1
- package/src/widgets/LookupField.tsx +8 -4
- package/src/widgets/MultiSelectField.tsx +56 -2
- package/src/widgets/OptionsEmptyState.tsx +16 -0
- package/src/widgets/RadioField.tsx +35 -3
- package/src/widgets/RatingField.tsx +15 -2
- package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +191 -0
- package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +124 -0
- package/src/widgets/RecordPickerDialog.tsx +64 -15
- package/src/widgets/RichTextField.tsx +1 -1
- package/src/widgets/SelectField.tsx +36 -5
- package/src/widgets/UserField.tsx +3 -2
- package/src/widgets/__tests__/FilterConditionField.operators.test.ts +25 -1
- package/src/widgets/address-format.ts +120 -0
- package/src/widgets/file-size-guard.ts +55 -0
- package/src/widgets/nativeDateValue.test.ts +115 -0
- package/src/widgets/nativeDateValue.ts +112 -0
- package/src/widgets/optionWidgets.gatedOptions.test.tsx +267 -0
- package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +150 -0
- package/src/widgets/toDomProps.ts +48 -19
- package/src/widgets/toHostGroupProps.ts +102 -0
- package/src/widgets/useFieldTranslation.ts +15 -33
- package/src/widgets/useRecordQuery.ts +30 -3
- package/dist/FieldEditWidget.test.d.ts +0 -8
- package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
- package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
- package/dist/__tests__/cell-truncation.test.d.ts +0 -0
- package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
- package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
- package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
- package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
- package/dist/__tests__/registry-collision.test.d.ts +0 -0
- package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
- package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
- package/dist/__tests__/validation-feedback.test.d.ts +0 -0
- package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-props-contract.test.d.ts +0 -8
- package/dist/coerce-safe-value.test.d.ts +0 -1
- package/dist/complex-widgets.test.d.ts +0 -1
- package/dist/datetime-widgets.test.d.ts +0 -1
- package/dist/field-type-coverage.test.d.ts +0 -1
- package/dist/standard-widgets.test.d.ts +0 -0
- package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
- package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
- package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
- package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
- package/dist/widgets/GridField.test.d.ts +0 -1
- package/dist/widgets/ImageLightbox.test.d.ts +0 -1
- package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
- package/dist/widgets/LookupField.gateHintLabel.test.d.ts +0 -8
- package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
- package/dist/widgets/LookupField.idField.test.d.ts +0 -1
- package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
- package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
- package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
- package/dist/widgets/PeoplePicker.test.d.ts +0 -8
- package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
- package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
- package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
- package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +0 -8
- package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +0 -8
- package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
- package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
- package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
- package/dist/widgets/UserFieldPicker.test.d.ts +0 -8
- package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
- package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
- package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
- package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
- package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
- package/dist/widgets/deriveLookupColumns.test.d.ts +0 -1
- package/dist/widgets/file-value.test.d.ts +0 -1
- package/dist/widgets/personDisplay.test.d.ts +0 -8
- package/dist/widgets/recentLookups.test.d.ts +0 -1
- package/dist/widgets/useRecordQuery.test.d.ts +0 -8
- package/dist/widgets/useUploadingSignal.test.d.ts +0 -1
|
@@ -61,7 +61,14 @@ describe('Standard Field Widgets', () => {
|
|
|
61
61
|
field={{ ...fieldMock, currency: 'EUR' } as any}
|
|
62
62
|
/>
|
|
63
63
|
);
|
|
64
|
-
|
|
64
|
+
// Was `EUR`, the bare code: the adornment came from a hand-written
|
|
65
|
+
// `currency === 'USD' ? '$' : currency` ternary, so every currency but one
|
|
66
|
+
// fell through to its code. objectui#4414 converged the symbol on the
|
|
67
|
+
// single `Intl` channel the readonly branch of this same widget already
|
|
68
|
+
// formatted through, which answers `€` here — a DOCUMENTED change, pinned
|
|
69
|
+
// in full (with the locale-dependence it brings) in
|
|
70
|
+
// `__tests__/CurrencyField.symbol.test.tsx`.
|
|
71
|
+
expect(screen.getByText('€')).toBeInTheDocument();
|
|
65
72
|
});
|
|
66
73
|
});
|
|
67
74
|
|
|
@@ -0,0 +1,257 @@
|
|
|
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
|
+
* Postal-code round trip of the composite `address` widget (objectstack#5143).
|
|
11
|
+
*
|
|
12
|
+
* The widget used to read and write the part name `zipCode`. The platform
|
|
13
|
+
* stores — and `/api/v1/data/**` serves back — `postalCode`, the name
|
|
14
|
+
* `@objectstack/spec`'s `AddressValueSchema` declares. The two never met:
|
|
15
|
+
*
|
|
16
|
+
* - opening a stored address showed an EMPTY ZIP box (the stored
|
|
17
|
+
* `postalCode` had no input reading it), and
|
|
18
|
+
* - anything typed into that box was written back as `zipCode`, which the
|
|
19
|
+
* enforced value schema strips (`$strip`) — destroying it at the contract
|
|
20
|
+
* boundary: lost outright on a new record, and on an existing one silently
|
|
21
|
+
* discarded while the stale stored code survived.
|
|
22
|
+
*
|
|
23
|
+
* Four of five parts round-tripped, which is what made it survive review.
|
|
24
|
+
*
|
|
25
|
+
* These tests pin BOTH directions, plus the read-time compatibility limb for
|
|
26
|
+
* data the broken build already wrote:
|
|
27
|
+
*
|
|
28
|
+
* 1. stored `postalCode` reaches the ZIP input;
|
|
29
|
+
* 2. every edit writes the postal code back under `postalCode` and never
|
|
30
|
+
* under `zipCode` — verified against `AddressValueSchema.parse`, i.e. the
|
|
31
|
+
* platform's own contract, not a local restatement of it;
|
|
32
|
+
* 3. a legacy `zipCode`-shaped value still DISPLAYS, and the first edit of
|
|
33
|
+
* any part migrates it onto `postalCode` instead of writing the split
|
|
34
|
+
* shape back out.
|
|
35
|
+
*
|
|
36
|
+
* Reverse verification (measured, not presumed — `git show origin/main` of the
|
|
37
|
+
* widget, this file unchanged): 9 of 12 go red — 1, 2, 5, 6, 7, 8, 9, 10, 12.
|
|
38
|
+
* The three that stay GREEN are worth naming, because one of them refines the
|
|
39
|
+
* issue's stated mechanism:
|
|
40
|
+
*
|
|
41
|
+
* - 3 stays green. The old widget spread the WHOLE stored object through on
|
|
42
|
+
* every write (`{...address, [part]: v}`), so an untouched `postalCode`
|
|
43
|
+
* survived an edit of an unrelated part. The issue's "saving drops the
|
|
44
|
+
* postal code" therefore does NOT reproduce for a value nobody touched;
|
|
45
|
+
* the loss the old code really caused is test 6's: a postal code the user
|
|
46
|
+
* TYPES is written under a key `AddressValueSchema` strips, so the typed
|
|
47
|
+
* correction is silently discarded and the stale stored code stays — and on
|
|
48
|
+
* a new record (test 12) it is lost outright. 3 is kept as a contract pin
|
|
49
|
+
* for the pass-through, not as evidence of the fix.
|
|
50
|
+
* - 4 stays green: it pins the legacy READ, and the old code read that key
|
|
51
|
+
* natively. It is meaningful as a pair with 5, which no revert satisfies.
|
|
52
|
+
* - 11 stays green: it reads the spec, not the widget. That is the point —
|
|
53
|
+
* it fails only if the CONTRACT moves under us.
|
|
54
|
+
*/
|
|
55
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
56
|
+
import React from 'react';
|
|
57
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
58
|
+
import '@testing-library/jest-dom';
|
|
59
|
+
import { AddressValueSchema } from '@objectstack/spec/data';
|
|
60
|
+
import { AddressField, type AddressValue } from './AddressField';
|
|
61
|
+
|
|
62
|
+
const field = { name: 'billing_address', type: 'address' } as any;
|
|
63
|
+
|
|
64
|
+
const ZIP_LABEL = 'ZIP / Postal Code';
|
|
65
|
+
|
|
66
|
+
/** The document the REST API served back in the issue's evidence. */
|
|
67
|
+
const STORED: AddressValue = {
|
|
68
|
+
street: '中策路 1 号',
|
|
69
|
+
city: '杭州',
|
|
70
|
+
state: '浙江',
|
|
71
|
+
postalCode: '310018',
|
|
72
|
+
country: 'CN',
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** The same address as written by builds up to 17.0.0-rc.1 (the broken key). */
|
|
76
|
+
const LEGACY = {
|
|
77
|
+
street: '中策路 1 号',
|
|
78
|
+
city: '杭州',
|
|
79
|
+
state: '浙江',
|
|
80
|
+
zipCode: '310018',
|
|
81
|
+
country: 'CN',
|
|
82
|
+
} as AddressValue;
|
|
83
|
+
|
|
84
|
+
/** Resolve a sub-input exactly as a browser does on label activation. */
|
|
85
|
+
function subInput(labelText: string): HTMLInputElement {
|
|
86
|
+
const label = screen.getByText(labelText);
|
|
87
|
+
const forId = label.getAttribute('for');
|
|
88
|
+
expect(forId, `label "${labelText}" must carry htmlFor`).toBeTruthy();
|
|
89
|
+
const control = document.getElementById(forId!);
|
|
90
|
+
expect(control, `htmlFor of "${labelText}" must resolve`).not.toBeNull();
|
|
91
|
+
return control as HTMLInputElement;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The most recent object the widget handed to `onChange`. */
|
|
95
|
+
function lastWrite(written: AddressValue[]): AddressValue {
|
|
96
|
+
expect(written.length, 'the widget must have written at least once').toBeGreaterThan(0);
|
|
97
|
+
return written[written.length - 1];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Controlled harness — the widget is controlled, so a round trip needs a
|
|
102
|
+
* parent that stores what the widget writes and feeds it straight back.
|
|
103
|
+
*/
|
|
104
|
+
function Harness({ initial, onWrite }: { initial: AddressValue; onWrite: (v: AddressValue) => void }) {
|
|
105
|
+
const [value, setValue] = React.useState<AddressValue>(initial);
|
|
106
|
+
return (
|
|
107
|
+
<AddressField
|
|
108
|
+
value={value}
|
|
109
|
+
onChange={(next) => {
|
|
110
|
+
onWrite(next);
|
|
111
|
+
setValue(next);
|
|
112
|
+
}}
|
|
113
|
+
field={field}
|
|
114
|
+
/>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
describe('AddressField — postal code round trip (objectstack#5143)', () => {
|
|
119
|
+
it('1. loads the stored `postalCode` into the ZIP input', () => {
|
|
120
|
+
render(<AddressField value={STORED} onChange={vi.fn()} field={field} />);
|
|
121
|
+
|
|
122
|
+
expect(subInput(ZIP_LABEL).value).toBe('310018');
|
|
123
|
+
// The four parts that always worked must keep working.
|
|
124
|
+
expect(subInput('Street Address').value).toBe('中策路 1 号');
|
|
125
|
+
expect(subInput('City').value).toBe('杭州');
|
|
126
|
+
expect(subInput('State / Province').value).toBe('浙江');
|
|
127
|
+
expect(subInput('Country').value).toBe('CN');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('2. keeps the postal code when an UNRELATED part is edited, under `postalCode`', () => {
|
|
131
|
+
const written: AddressValue[] = [];
|
|
132
|
+
render(<Harness initial={STORED} onWrite={(v) => written.push(v)} />);
|
|
133
|
+
|
|
134
|
+
fireEvent.change(subInput('City'), { target: { value: '宁波' } });
|
|
135
|
+
|
|
136
|
+
const last = lastWrite(written);
|
|
137
|
+
expect(last.city).toBe('宁波');
|
|
138
|
+
expect(last.postalCode).toBe('310018');
|
|
139
|
+
expect(Object.keys(last)).not.toContain('zipCode');
|
|
140
|
+
// …and it is still on screen after the write is fed back in.
|
|
141
|
+
expect(subInput(ZIP_LABEL).value).toBe('310018');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('3. the written address survives the enforced platform value contract', () => {
|
|
145
|
+
const written: AddressValue[] = [];
|
|
146
|
+
render(<Harness initial={STORED} onWrite={(v) => written.push(v)} />);
|
|
147
|
+
|
|
148
|
+
fireEvent.change(subInput('City'), { target: { value: '宁波' } });
|
|
149
|
+
|
|
150
|
+
// `AddressValueSchema` is `$strip`: an unknown part name is DROPPED, which
|
|
151
|
+
// is how a `zipCode`-shaped write lost the postal code on the way to
|
|
152
|
+
// storage. Parsing the widget's own output is the end-to-end pin.
|
|
153
|
+
const parsed = AddressValueSchema.parse(lastWrite(written));
|
|
154
|
+
expect(parsed.postalCode).toBe('310018');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('4. still DISPLAYS a legacy `zipCode`-shaped value written by earlier builds', () => {
|
|
158
|
+
render(<AddressField value={LEGACY} onChange={vi.fn()} field={field} />);
|
|
159
|
+
|
|
160
|
+
expect(subInput(ZIP_LABEL).value).toBe('310018');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('5. migrates a legacy value onto `postalCode` on the first edit of any part', () => {
|
|
164
|
+
const written: AddressValue[] = [];
|
|
165
|
+
render(<Harness initial={LEGACY} onWrite={(v) => written.push(v)} />);
|
|
166
|
+
|
|
167
|
+
fireEvent.change(subInput('Street Address'), { target: { value: '中策路 2 号' } });
|
|
168
|
+
|
|
169
|
+
const last = lastWrite(written);
|
|
170
|
+
expect(last.street).toBe('中策路 2 号');
|
|
171
|
+
expect(last.postalCode).toBe('310018');
|
|
172
|
+
expect(Object.keys(last)).not.toContain('zipCode');
|
|
173
|
+
expect(AddressValueSchema.parse(last).postalCode).toBe('310018');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('6. writes an edit of the ZIP box itself under `postalCode`', () => {
|
|
177
|
+
const written: AddressValue[] = [];
|
|
178
|
+
render(<Harness initial={STORED} onWrite={(v) => written.push(v)} />);
|
|
179
|
+
|
|
180
|
+
fireEvent.change(subInput(ZIP_LABEL), { target: { value: '310019' } });
|
|
181
|
+
|
|
182
|
+
const last = lastWrite(written);
|
|
183
|
+
expect(last.postalCode).toBe('310019');
|
|
184
|
+
expect(Object.keys(last)).not.toContain('zipCode');
|
|
185
|
+
expect(subInput(ZIP_LABEL).value).toBe('310019');
|
|
186
|
+
// The sharpest statement of the bug: under the old key the CORRECTION was
|
|
187
|
+
// stripped by the contract and the stale stored code survived, with no
|
|
188
|
+
// error anywhere — the user saw 310019 in the box and 310018 in the record.
|
|
189
|
+
expect(AddressValueSchema.parse(last).postalCode).toBe('310019');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('7. prefers the canonical key when a record carries both', () => {
|
|
193
|
+
const both = { ...STORED, zipCode: '999999' } as AddressValue;
|
|
194
|
+
const written: AddressValue[] = [];
|
|
195
|
+
render(<Harness initial={both} onWrite={(v) => written.push(v)} />);
|
|
196
|
+
|
|
197
|
+
expect(subInput(ZIP_LABEL).value).toBe('310018');
|
|
198
|
+
|
|
199
|
+
fireEvent.change(subInput('City'), { target: { value: '宁波' } });
|
|
200
|
+
|
|
201
|
+
const last = lastWrite(written);
|
|
202
|
+
expect(last.postalCode).toBe('310018');
|
|
203
|
+
expect(Object.keys(last)).not.toContain('zipCode');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('8. clearing the ZIP box clears the postal code instead of resurrecting the legacy value', () => {
|
|
207
|
+
const written: AddressValue[] = [];
|
|
208
|
+
render(<Harness initial={LEGACY} onWrite={(v) => written.push(v)} />);
|
|
209
|
+
|
|
210
|
+
fireEvent.change(subInput(ZIP_LABEL), { target: { value: '' } });
|
|
211
|
+
|
|
212
|
+
const last = lastWrite(written);
|
|
213
|
+
expect(last.postalCode).toBe('');
|
|
214
|
+
expect(Object.keys(last)).not.toContain('zipCode');
|
|
215
|
+
expect(subInput(ZIP_LABEL).value).toBe('');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('9. formats the postal code in read-only mode, canonical and legacy alike', () => {
|
|
219
|
+
const canonical = render(
|
|
220
|
+
<AddressField value={STORED} onChange={vi.fn()} field={field} readonly />,
|
|
221
|
+
);
|
|
222
|
+
expect(canonical.container.textContent).toContain('浙江 310018');
|
|
223
|
+
canonical.unmount();
|
|
224
|
+
|
|
225
|
+
const legacy = render(
|
|
226
|
+
<AddressField value={LEGACY} onChange={vi.fn()} field={field} readonly />,
|
|
227
|
+
);
|
|
228
|
+
expect(legacy.container.textContent).toContain('浙江 310018');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('10. binds the ZIP sub-input to the canonical part name, and nothing to the legacy one', () => {
|
|
232
|
+
const { baseElement } = render(
|
|
233
|
+
<AddressField value={STORED} onChange={vi.fn()} field={field} />,
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
expect(subInput(ZIP_LABEL).id).toMatch(/-postalCode$/);
|
|
237
|
+
const ids = Array.from(baseElement.querySelectorAll('[id]')).map((el) => el.id);
|
|
238
|
+
expect(ids.filter((id) => id.endsWith('-zipCode'))).toEqual([]);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('11. spells the part the way the spec spells it (direction guard)', () => {
|
|
242
|
+
const specParts = Object.keys(AddressValueSchema.shape);
|
|
243
|
+
expect(specParts).toContain('postalCode');
|
|
244
|
+
expect(specParts).not.toContain('zipCode');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('12. a postal code entered on a NEW record reaches storage instead of being stripped', () => {
|
|
248
|
+
const written: AddressValue[] = [];
|
|
249
|
+
render(<Harness initial={{}} onWrite={(v) => written.push(v)} />);
|
|
250
|
+
|
|
251
|
+
fireEvent.change(subInput(ZIP_LABEL), { target: { value: '310018' } });
|
|
252
|
+
|
|
253
|
+
// Nothing stored to mask the loss: under the old key the parse produced an
|
|
254
|
+
// address with no postal code at all.
|
|
255
|
+
expect(AddressValueSchema.parse(lastWrite(written)).postalCode).toBe('310018');
|
|
256
|
+
});
|
|
257
|
+
});
|
|
@@ -2,21 +2,28 @@ import React, { useId } from 'react';
|
|
|
2
2
|
import { Input, Label, EmptyValue } from '@object-ui/components';
|
|
3
3
|
import { FieldWidgetComponentProps } from './types';
|
|
4
4
|
import { toDomProps } from './toDomProps';
|
|
5
|
+
import { toHostGroupProps } from './toHostGroupProps';
|
|
6
|
+
// The value shape and the single-line formatting rule now live in a pure module
|
|
7
|
+
// so the display (read) cell renderer formats a stored address exactly the way
|
|
8
|
+
// this widget's readonly branch does — one rule, not two copies that drift
|
|
9
|
+
// (objectui#4037). Behaviour here is unchanged; only the definition moved.
|
|
10
|
+
import {
|
|
11
|
+
formatAddress,
|
|
12
|
+
readPostalCode,
|
|
13
|
+
type AddressValue,
|
|
14
|
+
type LegacyAddressValue,
|
|
15
|
+
} from './address-format';
|
|
5
16
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
export
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
state?: string;
|
|
13
|
-
zipCode?: string;
|
|
14
|
-
country?: string;
|
|
15
|
-
}
|
|
17
|
+
// Re-exported through its declaring module rather than bare, so the spec-symbol
|
|
18
|
+
// guard resolves the name to where it is actually defined: `address-format`
|
|
19
|
+
// imports `AddressValue` from `@objectstack/spec/data` (objectui#4167), and a
|
|
20
|
+
// bare `export type { AddressValue }` here would read to that guard as a second,
|
|
21
|
+
// local declaration of a name the spec owns.
|
|
22
|
+
export type { AddressValue } from './address-format';
|
|
16
23
|
|
|
17
24
|
/**
|
|
18
25
|
* Address field widget - provides a structured address input
|
|
19
|
-
* Supports street, city, state,
|
|
26
|
+
* Supports street, city, state, postal code, and country
|
|
20
27
|
*/
|
|
21
28
|
export function AddressField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<AddressValue>) {
|
|
22
29
|
const address = value || {};
|
|
@@ -25,7 +32,33 @@ export function AddressField({ value, onChange, field, readonly, error, ...props
|
|
|
25
32
|
// the composite's validation state goes onto EVERY focusable sub-input via
|
|
26
33
|
// `aria-invalid={!!error}` (a form-level failure means the whole address is
|
|
27
34
|
// missing/invalid, and whichever sub-input the user reaches must announce it).
|
|
28
|
-
|
|
35
|
+
//
|
|
36
|
+
// Two keys are held back from that spread and land on the group CONTAINER
|
|
37
|
+
// instead (objectui#3961), because they address the field as a WHOLE:
|
|
38
|
+
//
|
|
39
|
+
// - `id` — the host's control id. It never reached the DOM at all: it was
|
|
40
|
+
// spread here and then overwritten one line later by `id={subId('street')}`
|
|
41
|
+
// (objectui#3343), which is why the form's "Shipping Address" label pointed
|
|
42
|
+
// at an id no element carried. The sub-input ids stay exactly as they are;
|
|
43
|
+
// only the host id moves out to the element it was always meant to name.
|
|
44
|
+
// - `aria-labelledby` — the host's label, associated by IDREF. On the street
|
|
45
|
+
// input it would be actively wrong: `aria-labelledby` OVERRIDES a control's
|
|
46
|
+
// `<label for>`, so the first sub-input would be announced as "Shipping
|
|
47
|
+
// Address" and its own "Street Address" label would vanish.
|
|
48
|
+
//
|
|
49
|
+
// Everything else (`aria-describedby`, `name`, focus handlers …) deliberately
|
|
50
|
+
// stays on the first sub-input: a description or error must be announced when
|
|
51
|
+
// focus lands on something focusable, and a container is not.
|
|
52
|
+
const {
|
|
53
|
+
id: _hostId,
|
|
54
|
+
'aria-labelledby': _hostLabelledBy,
|
|
55
|
+
...domProps
|
|
56
|
+
} = toDomProps(props);
|
|
57
|
+
// The pair held back above, in the one spelling every group-labelled widget
|
|
58
|
+
// uses for it — and computed HERE, before the readonly early return below,
|
|
59
|
+
// because that return used to drop both keys on the floor: a published label
|
|
60
|
+
// id with no consumer in the document (objectui#3990). See `toHostGroupProps`.
|
|
61
|
+
const hostGroupProps = toHostGroupProps(props);
|
|
29
62
|
// Sub-input ids (objectui#3343): `useId()` prefix + sub-field name — the
|
|
30
63
|
// `groupId` paradigm of RadioField / CheckboxesField. Hardcoded literals
|
|
31
64
|
// ("street", "city", …) collide as soon as a form renders two address
|
|
@@ -33,30 +66,47 @@ export function AddressField({ value, onChange, field, readonly, error, ...props
|
|
|
33
66
|
const groupId = useId();
|
|
34
67
|
const subId = (name: keyof AddressValue) => `${groupId}-${name}`;
|
|
35
68
|
|
|
69
|
+
// Read through the legacy key, write only the canonical one (objectstack#5143).
|
|
70
|
+
const postalCode = readPostalCode(address);
|
|
71
|
+
|
|
36
72
|
const handleFieldChange = (fieldName: keyof AddressValue, fieldValue: string) => {
|
|
73
|
+
// Normalize while we are here: the object we write back never carries
|
|
74
|
+
// `zipCode`, and a legacy postal code is carried forward under
|
|
75
|
+
// `postalCode`. Without the carry, editing an unrelated part of a
|
|
76
|
+
// legacy-shaped address would write the postal code straight back out of
|
|
77
|
+
// the record — the very data loss this fixes, one build later.
|
|
78
|
+
const canonical: LegacyAddressValue = { ...address };
|
|
79
|
+
delete canonical.zipCode;
|
|
37
80
|
onChange({
|
|
38
|
-
...
|
|
81
|
+
...canonical,
|
|
82
|
+
...(postalCode ? { postalCode } : null),
|
|
39
83
|
[fieldName]: fieldValue,
|
|
40
84
|
});
|
|
41
85
|
};
|
|
42
86
|
|
|
43
|
-
const formatAddress = (addr: AddressValue): string => {
|
|
44
|
-
const parts = [
|
|
45
|
-
addr.street,
|
|
46
|
-
addr.city,
|
|
47
|
-
[addr.state, addr.zipCode].filter(Boolean).join(' '),
|
|
48
|
-
addr.country,
|
|
49
|
-
].filter(Boolean);
|
|
50
|
-
return parts.join(', ');
|
|
51
|
-
};
|
|
52
|
-
|
|
53
87
|
if (readonly) {
|
|
88
|
+
// Readonly the composite collapses to ONE formatted line — no sub-inputs and
|
|
89
|
+
// no sub-labels — so that line is the surface the host label names, and
|
|
90
|
+
// `EmptyValue` is the same surface with nothing in it (objectui#3990). The
|
|
91
|
+
// placeholder's own `aria-label` ("No value") is outranked by
|
|
92
|
+
// `aria-labelledby` per accname, and on its `generic` role an author name was
|
|
93
|
+
// never exposed anyway.
|
|
54
94
|
const formatted = formatAddress(address);
|
|
55
|
-
return formatted ?
|
|
95
|
+
return formatted ? (
|
|
96
|
+
<span {...hostGroupProps} className="text-sm">{formatted}</span>
|
|
97
|
+
) : (
|
|
98
|
+
<EmptyValue {...hostGroupProps} />
|
|
99
|
+
);
|
|
56
100
|
}
|
|
57
101
|
|
|
58
102
|
return (
|
|
59
|
-
|
|
103
|
+
// `role="group"` only when a host actually named this container
|
|
104
|
+
// (objectui#3961): an unnamed group adds nothing for assistive tech, and
|
|
105
|
+
// standalone rendering — the inline grid editor, a bare SDUI node — must stay
|
|
106
|
+
// byte-identical to what it was before. That condition now lives in
|
|
107
|
+
// `toHostGroupProps` so this container and the readonly line above cannot
|
|
108
|
+
// answer differently (objectui#3990).
|
|
109
|
+
<div className="space-y-3" {...hostGroupProps}>
|
|
60
110
|
<div>
|
|
61
111
|
<Label htmlFor={subId('street')} className="text-xs">Street Address</Label>
|
|
62
112
|
<Input
|
|
@@ -102,12 +152,12 @@ export function AddressField({ value, onChange, field, readonly, error, ...props
|
|
|
102
152
|
|
|
103
153
|
<div className="grid grid-cols-2 gap-3">
|
|
104
154
|
<div>
|
|
105
|
-
<Label htmlFor={subId('
|
|
155
|
+
<Label htmlFor={subId('postalCode')} className="text-xs">ZIP / Postal Code</Label>
|
|
106
156
|
<Input
|
|
107
|
-
id={subId('
|
|
157
|
+
id={subId('postalCode')}
|
|
108
158
|
type="text"
|
|
109
|
-
value={
|
|
110
|
-
onChange={(e) => handleFieldChange('
|
|
159
|
+
value={postalCode || ''}
|
|
160
|
+
onChange={(e) => handleFieldChange('postalCode', e.target.value)}
|
|
111
161
|
placeholder="94102"
|
|
112
162
|
disabled={readonly || props.disabled}
|
|
113
163
|
aria-invalid={!!error}
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* Unique sub-input ids for the composite `address` widget (objectui#3343).
|
|
11
11
|
*
|
|
12
12
|
* The widget used to hardcode literal ids ("street", "city", "state",
|
|
13
|
-
* "zipCode"
|
|
13
|
+
* "postalCode" — spelled "zipCode" until objectstack#5143 —, "country") on its
|
|
14
|
+
* sub-inputs. Two address fields in one form —
|
|
14
15
|
* e.g. billing + shipping — then produced duplicate DOM ids, and every
|
|
15
16
|
* `Label htmlFor` resolved to the FIRST match in the document: each sub-label
|
|
16
17
|
* of the second field clicked/announced the first field's input.
|
|
@@ -13,8 +13,42 @@ export function BooleanField({ value, onChange, field, readonly, ...props }: Fie
|
|
|
13
13
|
const widget = config?.widget;
|
|
14
14
|
// Generate unique ID using React's useId hook - must be before early returns (rules of hooks)
|
|
15
15
|
const generatedId = useId();
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* The HOST owns the control's id (objectui#3952).
|
|
18
|
+
*
|
|
19
|
+
* `<FormControl>` is a Radix `Slot` that hands its child the id its
|
|
20
|
+
* `<FormLabel htmlFor>` already points at (`_r_N_-form-item`). This widget
|
|
21
|
+
* used to REPLACE it with the field name, so the form's visible label
|
|
22
|
+
* referenced an id no element carried: `label for="_r_3_-form-item"` against
|
|
23
|
+
* `all ids in form = ["notifications"]`. Clicking the label — the normal
|
|
24
|
+
* affordance for a switch/checkbox row — toggled nothing, on every generated
|
|
25
|
+
* form in every app.
|
|
26
|
+
*
|
|
27
|
+
* `id` is a DECLARED key of the widget contract (`FieldWidgetDomProps`) and
|
|
28
|
+
* `toDomProps` forwards it, so honouring it here is the contract's own
|
|
29
|
+
* direction: the fix belongs at the consumer that discarded a delivered
|
|
30
|
+
* value, not in the form renderer (AGENTS.md #0.1).
|
|
31
|
+
*
|
|
32
|
+
* The fallback chain stays for the STANDALONE hosts that hand down no id —
|
|
33
|
+
* `FieldEditWidget` (the grid's inline cell editor) and a bare SDUI node.
|
|
34
|
+
*/
|
|
35
|
+
const hostId = props.id;
|
|
36
|
+
const id = hostId || config?.name || generatedId;
|
|
17
37
|
const label = config?.label || 'Checkbox';
|
|
38
|
+
/**
|
|
39
|
+
* A host that supplies the id is a host that renders its own label for it
|
|
40
|
+
* (that is what the id is FOR), so the widget's `sr-only` label would be the
|
|
41
|
+
* second copy of one name — the other half of #3952, and why a naive
|
|
42
|
+
* `getByText(label)` on a boolean field returned two nodes.
|
|
43
|
+
*
|
|
44
|
+
* Standalone it is the control's ONLY accessible name (`FieldEditWidget`
|
|
45
|
+
* renders no label of its own), so there it stays. Deliberately keyed on the
|
|
46
|
+
* host id rather than on `config.label`: inside a form the naming is the
|
|
47
|
+
* host's job for EVERY widget — a text field in a label-less form row is
|
|
48
|
+
* equally unnamed — and the generic `'Checkbox'` fallback emitted there was
|
|
49
|
+
* an actively WRONG accessible name, not a safety net.
|
|
50
|
+
*/
|
|
51
|
+
const emitOwnLabel = !hostId;
|
|
18
52
|
|
|
19
53
|
if (readonly) {
|
|
20
54
|
return <span className="text-sm">{value ? 'Yes' : 'No'}</span>;
|
|
@@ -25,28 +59,28 @@ export function BooleanField({ value, onChange, field, readonly, ...props }: Fie
|
|
|
25
59
|
if (widget === 'checkbox') {
|
|
26
60
|
return (
|
|
27
61
|
<div className="flex items-center space-x-2">
|
|
28
|
-
<Checkbox
|
|
62
|
+
<Checkbox
|
|
29
63
|
{...domProps}
|
|
30
64
|
id={id}
|
|
31
65
|
checked={!!value}
|
|
32
66
|
onCheckedChange={(checked) => onChange(!!checked)}
|
|
33
67
|
disabled={readonly || domProps.disabled}
|
|
34
68
|
/>
|
|
35
|
-
<Label htmlFor={id} className="sr-only">{label}</Label>
|
|
69
|
+
{emitOwnLabel && <Label htmlFor={id} className="sr-only">{label}</Label>}
|
|
36
70
|
</div>
|
|
37
71
|
)
|
|
38
72
|
}
|
|
39
73
|
|
|
40
74
|
return (
|
|
41
75
|
<div className="flex items-center space-x-2">
|
|
42
|
-
<Switch
|
|
76
|
+
<Switch
|
|
43
77
|
{...domProps}
|
|
44
|
-
id={id}
|
|
45
|
-
checked={!!value}
|
|
78
|
+
id={id}
|
|
79
|
+
checked={!!value}
|
|
46
80
|
onCheckedChange={onChange}
|
|
47
81
|
disabled={readonly || domProps.disabled}
|
|
48
82
|
/>
|
|
49
|
-
<Label htmlFor={id} className="sr-only">{label}</Label>
|
|
83
|
+
{emitOwnLabel && <Label htmlFor={id} className="sr-only">{label}</Label>}
|
|
50
84
|
</div>
|
|
51
85
|
);
|
|
52
86
|
}
|
|
@@ -18,9 +18,18 @@ import { useFieldTranslation } from './useFieldTranslation';
|
|
|
18
18
|
* labelled, with the capability description on hover — while round-tripping the
|
|
19
19
|
* stored value **byte-for-byte** as a JSON string of names.
|
|
20
20
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* ## Not a registry field widget (objectui#3308)
|
|
22
|
+
*
|
|
23
|
+
* This is a plain component, imported directly by its host — Studio's
|
|
24
|
+
* `PermissionMatrixEditor` (`packages/app-shell`), which is where ADR-0056 P2
|
|
25
|
+
* put the capability editor. It is deliberately NOT reachable through the field
|
|
26
|
+
* registry, and `widget: 'capability-multiselect'` is a **retired** hint: the
|
|
27
|
+
* key only ever existed on the docs-site-only `registerFields()` path, nothing
|
|
28
|
+
* ever stamped the hint (ADR-0056 P1 stamps `permission-facet-link` on all six
|
|
29
|
+
* `sys_permission_set` facets), and it was removed under ADR-0049
|
|
30
|
+
* enforce-or-remove. Do not re-register it without re-deciding ADR-0056 P2 —
|
|
31
|
+
* a field carrying that hint now degrades to its declared `type` renderer,
|
|
32
|
+
* which is the defined behavior for an unregistered widget.
|
|
24
33
|
*/
|
|
25
34
|
|
|
26
35
|
interface Capability {
|
|
@@ -98,8 +107,11 @@ export function CapabilityMultiSelectField({
|
|
|
98
107
|
const selected = React.useMemo(() => parseCapabilityNames(value), [value]);
|
|
99
108
|
|
|
100
109
|
// Load the live capability registry (active only). Mirrors LookupField's
|
|
101
|
-
// context-dataSource access
|
|
102
|
-
//
|
|
110
|
+
// context-dataSource access: no registration path forwards a `dataSource`
|
|
111
|
+
// prop to a field widget, so the SchemaRenderer context is the reliable
|
|
112
|
+
// source. (The absent forwarder named here used to be the docs-demo
|
|
113
|
+
// `createFieldRenderer`, removed in objectui#3910 — the conclusion is
|
|
114
|
+
// unchanged and now holds for the only remaining path.)
|
|
103
115
|
React.useEffect(() => {
|
|
104
116
|
if (!dataSource || typeof (dataSource as any).find !== 'function') return;
|
|
105
117
|
let cancelled = false;
|
|
@@ -3,6 +3,7 @@ import { Checkbox, Label, EmptyValue, Badge } from '@object-ui/components';
|
|
|
3
3
|
import type { OptionLike } from '@object-ui/core';
|
|
4
4
|
import { FieldWidgetComponentProps } from './types';
|
|
5
5
|
import { toDomProps } from './toDomProps';
|
|
6
|
+
import { toHostGroupProps } from './toHostGroupProps';
|
|
6
7
|
import { OptionsEmptyState } from './OptionsEmptyState';
|
|
7
8
|
import { useCascadingOptions } from './useCascadingOptions';
|
|
8
9
|
|
|
@@ -51,16 +52,36 @@ export function CheckboxesField({
|
|
|
51
52
|
// per-element clear the multi-value case needs (cf. the scalar select/radio).
|
|
52
53
|
useEffect(() => {
|
|
53
54
|
if (readonly) return;
|
|
55
|
+
// Never configured → nothing to prune against; see `MultiSelectField`'s
|
|
56
|
+
// copy of this guard for the measured failure (objectui#4220).
|
|
57
|
+
if (rawOptions.length === 0) return;
|
|
58
|
+
// Gated → the authored list is withheld until the `dependsOn` parent is
|
|
59
|
+
// chosen, so the empty offered set is missing information, not a verdict on
|
|
60
|
+
// the stored value; clearing here fired on mount (objectui#4247). Same
|
|
61
|
+
// reasoning, at length, in `MultiSelectField`.
|
|
62
|
+
if (gated) return;
|
|
54
63
|
if (selected.length === 0) return;
|
|
55
64
|
const stillOffered = selected.filter((v) => options.some((o) => o.value === v));
|
|
56
65
|
if (stillOffered.length !== selected.length) onChange(stillOffered);
|
|
57
66
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
58
67
|
}, [options, gated]);
|
|
59
68
|
|
|
69
|
+
// The host's group label has to be consumed on EVERY surface this widget can
|
|
70
|
+
// render, not only the editable one (objectui#3990): both branches below
|
|
71
|
+
// return before the editable container's `groupDomProps` spread, which is how
|
|
72
|
+
// a field-level `readonly: true` and a zero-option list ended up with a
|
|
73
|
+
// published label id and no consumer at all. See `toHostGroupProps`.
|
|
74
|
+
const hostGroupProps = toHostGroupProps(props);
|
|
75
|
+
|
|
60
76
|
if (readonly) {
|
|
61
|
-
|
|
77
|
+
// The readonly display of a checkbox set is the set of CHECKED labels — a
|
|
78
|
+
// set of values, so the same `group` answer as the editable list.
|
|
79
|
+
// `EmptyValue` is that surface with nothing in it; its own `aria-label`
|
|
80
|
+
// ("No value") is outranked by `aria-labelledby` per accname, and on the
|
|
81
|
+
// `generic` role that span carries it was never exposed anyway.
|
|
82
|
+
if (selected.length === 0) return <EmptyValue {...hostGroupProps} />;
|
|
62
83
|
return (
|
|
63
|
-
<div className="flex flex-wrap gap-1">
|
|
84
|
+
<div {...hostGroupProps} className="flex flex-wrap gap-1">
|
|
64
85
|
{selected.map((v) => {
|
|
65
86
|
// Label from the raw set so a stored value hidden by `visibleWhen`
|
|
66
87
|
// still renders its label rather than a bare id.
|
|
@@ -83,6 +104,9 @@ export function CheckboxesField({
|
|
|
83
104
|
dependsOnFields={dependsOnFields}
|
|
84
105
|
testId={fieldName ? `checkboxes-empty-${fieldName}` : undefined}
|
|
85
106
|
className="min-h-9"
|
|
107
|
+
// This box IS the field in this state, so it is what the host label
|
|
108
|
+
// names (objectui#3990).
|
|
109
|
+
hostGroupProps={hostGroupProps}
|
|
86
110
|
/>
|
|
87
111
|
);
|
|
88
112
|
}
|
|
@@ -105,10 +129,19 @@ export function CheckboxesField({
|
|
|
105
129
|
name: _domName,
|
|
106
130
|
...groupDomProps
|
|
107
131
|
} = toDomProps(props);
|
|
132
|
+
// When the host associated its visible label with this container by IDREF
|
|
133
|
+
// (`aria-labelledby`, objectui#3961) the container IS the labelled group, so it
|
|
134
|
+
// answers with the matching role — without one, the name sits on a generic
|
|
135
|
+
// `div` and contributes nothing: `label for` pointing here was already inert
|
|
136
|
+
// (`HTMLLabelElement.control` is null for a div), which is the whole defect.
|
|
137
|
+
// Absent (standalone: the inline grid editor, a bare SDUI node) nothing is
|
|
138
|
+
// emitted and the markup is unchanged.
|
|
139
|
+
const isLabelledGroup = groupDomProps['aria-labelledby'] != null;
|
|
108
140
|
|
|
109
141
|
return (
|
|
110
142
|
<div
|
|
111
143
|
{...groupDomProps}
|
|
144
|
+
role={isLabelledGroup ? 'group' : undefined}
|
|
112
145
|
className={className}
|
|
113
146
|
data-testid={fieldName ? `checkboxes-${fieldName}` : undefined}
|
|
114
147
|
>
|