@object-ui/fields 17.2.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 +1533 -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 +5 -5
- package/dist/index.css +633 -0
- package/dist/index.d.ts +52 -12
- package/dist/index.js +5167 -4759
- package/dist/widgets/AddressField.d.ts +4 -12
- package/dist/widgets/AvatarField.d.ts +1 -1
- package/dist/widgets/CharacterCount.d.ts +56 -0
- package/dist/widgets/CheckboxesField.d.ts +1 -1
- package/dist/widgets/CodeField.d.ts +1 -1
- package/dist/widgets/ColorField.d.ts +1 -1
- package/dist/widgets/CurrencyField.d.ts +1 -1
- package/dist/widgets/EmailField.d.ts +1 -1
- package/dist/widgets/FileField.d.ts +1 -1
- package/dist/widgets/FilterConditionField.d.ts +1 -1
- package/dist/widgets/FullscreenFieldEditor.d.ts +45 -0
- package/dist/widgets/GeolocationField.d.ts +1 -1
- package/dist/widgets/GridField.d.ts +12 -2
- package/dist/widgets/ImageField.d.ts +1 -1
- package/dist/widgets/LocationField.d.ts +1 -1
- package/dist/widgets/LookupField.d.ts +1 -1
- package/dist/widgets/MultiSelectField.d.ts +1 -1
- package/dist/widgets/ObjectField.d.ts +1 -1
- package/dist/widgets/ObjectRefField.d.ts +1 -1
- package/dist/widgets/OptionsEmptyState.d.ts +59 -0
- package/dist/widgets/PercentField.d.ts +1 -1
- package/dist/widgets/PhoneField.d.ts +1 -1
- package/dist/widgets/QRCodeField.d.ts +1 -1
- package/dist/widgets/RadioField.d.ts +1 -1
- package/dist/widgets/RatingField.d.ts +1 -1
- package/dist/widgets/RecipientPickerField.d.ts +1 -1
- package/dist/widgets/RecordPickerDialog.d.ts +49 -7
- package/dist/widgets/RichTextField.d.ts +19 -1
- package/dist/widgets/SelectField.d.ts +25 -5
- package/dist/widgets/TagsField.d.ts +1 -1
- package/dist/widgets/TextAreaField.d.ts +44 -5
- package/dist/widgets/UrlField.d.ts +1 -1
- 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 +111 -0
- package/dist/widgets/toHostGroupProps.d.ts +91 -0
- package/dist/widgets/types.d.ts +128 -33
- package/dist/widgets/useRecordQuery.d.ts +7 -1
- package/dist/withFieldCarrier.d.ts +56 -0
- package/package.json +20 -16
- 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__/cell-truncation.test.tsx +96 -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__/field-carrier-sdui.test.tsx +169 -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__/spec-symbol-batch7.test.ts +29 -15
- package/src/__tests__/validation-feedback.test.tsx +22 -13
- package/src/__tests__/widget-aria-invalid-e2e.test.tsx +265 -0
- package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +338 -0
- package/src/__tests__/widget-dom-leak-e2e.test.tsx +357 -0
- package/src/__tests__/widget-props-contract.test.tsx +22 -9
- 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 +558 -268
- package/src/standard-widgets.test.tsx +8 -1
- package/src/widgets/AddressField.postalCode.test.tsx +257 -0
- package/src/widgets/AddressField.tsx +107 -38
- package/src/widgets/AddressField.uniqueIds.test.tsx +93 -0
- package/src/widgets/AvatarField.tsx +9 -2
- package/src/widgets/BooleanField.tsx +44 -10
- package/src/widgets/CapabilityMultiSelectField.tsx +17 -5
- package/src/widgets/CharacterCount.tsx +263 -0
- package/src/widgets/CheckboxesField.tsx +66 -16
- package/src/widgets/CodeField.tsx +9 -2
- package/src/widgets/ColorField.tsx +11 -2
- package/src/widgets/CurrencyField.tsx +60 -19
- package/src/widgets/DateField.tsx +13 -4
- package/src/widgets/DateTimeField.tsx +14 -5
- package/src/widgets/EmailField.tsx +5 -5
- package/src/widgets/FileField.tsx +39 -11
- package/src/widgets/FilterConditionField.tsx +11 -0
- package/src/widgets/FormulaField.tsx +7 -2
- package/src/widgets/FullscreenFieldEditor.tsx +60 -0
- package/src/widgets/GeolocationField.tsx +49 -8
- package/src/widgets/GeolocationField.uniqueIds.test.tsx +88 -0
- package/src/widgets/GridField.test.tsx +226 -0
- package/src/widgets/GridField.tsx +115 -11
- package/src/widgets/ImageField.maxSize.test.tsx +154 -0
- package/src/widgets/ImageField.tsx +63 -6
- package/src/widgets/LocationField.tsx +9 -2
- package/src/widgets/LookupField.gateHintLabel.test.tsx +135 -0
- package/src/widgets/LookupField.hydration.test.tsx +1 -1
- package/src/widgets/LookupField.tsx +49 -10
- package/src/widgets/MasterDetailField.tsx +1 -1
- package/src/widgets/MultiSelectField.tsx +86 -16
- package/src/widgets/NumberField.tsx +3 -3
- package/src/widgets/ObjectField.tsx +17 -8
- package/src/widgets/ObjectRefField.tsx +10 -0
- package/src/widgets/OptionsEmptyState.no-provider.test.tsx +88 -0
- package/src/widgets/OptionsEmptyState.test.tsx +169 -0
- package/src/widgets/OptionsEmptyState.tsx +99 -0
- package/src/widgets/PasswordField.tsx +3 -3
- package/src/widgets/PeoplePicker.tsx +7 -1
- package/src/widgets/PercentField.tsx +5 -4
- package/src/widgets/PhoneField.tsx +5 -5
- package/src/widgets/QRCodeField.tsx +9 -2
- package/src/widgets/RadioField.tsx +58 -16
- package/src/widgets/RatingField.tsx +32 -4
- package/src/widgets/RecipientPickerField.tsx +18 -0
- package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +191 -0
- package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +124 -0
- package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +611 -0
- package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +112 -0
- package/src/widgets/RecordPickerDialog.tsx +235 -21
- package/src/widgets/RichTextField.tsx +151 -24
- package/src/widgets/SelectField.tsx +84 -22
- package/src/widgets/SliderField.tsx +1 -1
- package/src/widgets/SummaryField.tsx +1 -1
- package/src/widgets/TagsField.placeholder.no-provider.test.tsx +68 -0
- package/src/widgets/TagsField.placeholder.test.tsx +95 -0
- package/src/widgets/TagsField.tsx +23 -2
- package/src/widgets/TextAreaField.tsx +171 -93
- package/src/widgets/TextField.tsx +3 -3
- package/src/widgets/TimeField.tsx +2 -2
- package/src/widgets/UrlField.tsx +5 -5
- package/src/widgets/UserField.tsx +4 -3
- package/src/widgets/VectorField.tsx +1 -1
- package/src/widgets/__tests__/FilterConditionField.operators.test.ts +25 -1
- package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +248 -0
- package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +116 -0
- package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +244 -0
- package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +195 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +417 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +287 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +160 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +161 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +346 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +246 -0
- package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +175 -0
- 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 +204 -0
- package/src/widgets/toHostGroupProps.ts +102 -0
- package/src/widgets/types.ts +135 -35
- package/src/widgets/useFieldTranslation.ts +46 -4
- package/src/widgets/useRecordQuery.ts +30 -3
- package/src/withFieldCarrier.tsx +75 -0
- 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__/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-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/CapabilityMultiSelectField.test.d.ts +0 -0
- package/dist/widgets/CheckboxesField.cascade.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.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/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/SelectField.cascade.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/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
|
@@ -0,0 +1,267 @@
|
|
|
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
|
+
* A GATED option list never deletes the stored value (objectui#4247).
|
|
11
|
+
*
|
|
12
|
+
* The four fixed-option widgets end their cascade resolution with a "drop what
|
|
13
|
+
* is no longer offered" effect (ADR-0058 / #2715). `resolveCascadingOptions`
|
|
14
|
+
* returns an EMPTY offered set whenever the list is gated — a declared
|
|
15
|
+
* `dependsOn` parent is still empty — so on mount the effect found nothing
|
|
16
|
+
* "still offered" and wrote the field empty, while the widget was rendering
|
|
17
|
+
* `OptionsEmptyState` ("select Country first") right next to it: the control
|
|
18
|
+
* tells the user it cannot offer anything, and deletes what they had.
|
|
19
|
+
*
|
|
20
|
+
* Ruling (#4247): **gated means UNKNOWN, not invalid.** The cascade clear exists
|
|
21
|
+
* so a USER-DRIVEN parent change prunes a now-invalid child; a gated set on
|
|
22
|
+
* MOUNT is missing information — the record simply arrived with the parent
|
|
23
|
+
* empty (a later-cleared parent, an import, a partially-migrated row) — and
|
|
24
|
+
* destroying stored data on missing information is silent data loss. Convergence
|
|
25
|
+
* stays where it belongs: once the parent IS chosen and the resolved set
|
|
26
|
+
* genuinely excludes the stored value, the existing prune applies.
|
|
27
|
+
*
|
|
28
|
+
* Three states, kept apart here so a future edit cannot collapse them:
|
|
29
|
+
*
|
|
30
|
+
* 1. never-configured — no authored `options` at all. Guarded since #4220;
|
|
31
|
+
* control only, pinned in `optionWidgets.unconfiguredOptions.test.tsx`.
|
|
32
|
+
* 2. gated — an authored list withheld behind an unmet `dependsOn`. NEW: the
|
|
33
|
+
* value survives.
|
|
34
|
+
* 3. resolved-and-excludes — the parent IS chosen and the resolved set does not
|
|
35
|
+
* offer the stored value. Clears; that is the ADR-0058 contract.
|
|
36
|
+
*
|
|
37
|
+
* The gate is read from the resolver's own `gated` flag, never re-derived from
|
|
38
|
+
* `options.length === 0` — that would collide with state 1, which is a
|
|
39
|
+
* different case with a different guard.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
43
|
+
import React from 'react';
|
|
44
|
+
import { render, screen } from '@testing-library/react';
|
|
45
|
+
import '@testing-library/jest-dom';
|
|
46
|
+
import { SelectField } from './SelectField';
|
|
47
|
+
import { MultiSelectField } from './MultiSelectField';
|
|
48
|
+
import { CheckboxesField } from './CheckboxesField';
|
|
49
|
+
import { RadioField } from './RadioField';
|
|
50
|
+
|
|
51
|
+
/** Options that exist for exactly one country — the cascade the gate withholds. */
|
|
52
|
+
const PROVINCE_OPTIONS = [
|
|
53
|
+
{ label: 'Zhejiang', value: 'zj', visibleWhen: "record.country == 'cn'" },
|
|
54
|
+
{ label: 'California', value: 'ca', visibleWhen: "record.country == 'us'" },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
const provinceField = (type: string) =>
|
|
58
|
+
({ name: 'province', type, dependsOn: 'country', options: PROVINCE_OPTIONS }) as any;
|
|
59
|
+
|
|
60
|
+
describe('option widgets — a GATED list never deletes the stored value (#4247)', () => {
|
|
61
|
+
it('MultiSelectField keeps its array and shows the parent-first hint', () => {
|
|
62
|
+
const onChange = vi.fn();
|
|
63
|
+
render(
|
|
64
|
+
<MultiSelectField
|
|
65
|
+
value={['zj']}
|
|
66
|
+
onChange={onChange}
|
|
67
|
+
field={provinceField('multiselect')}
|
|
68
|
+
{...({ name: 'province', dependentValues: {} } as any)}
|
|
69
|
+
/>,
|
|
70
|
+
);
|
|
71
|
+
expect(screen.getByTestId('multiselect-empty-province')).toHaveTextContent(
|
|
72
|
+
/select country first/i,
|
|
73
|
+
);
|
|
74
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('CheckboxesField keeps its array and shows the parent-first hint', () => {
|
|
78
|
+
const onChange = vi.fn();
|
|
79
|
+
render(
|
|
80
|
+
<CheckboxesField
|
|
81
|
+
value={['zj']}
|
|
82
|
+
onChange={onChange}
|
|
83
|
+
field={provinceField('checkboxes')}
|
|
84
|
+
{...({ name: 'province', dependentValues: {} } as any)}
|
|
85
|
+
/>,
|
|
86
|
+
);
|
|
87
|
+
expect(screen.getByTestId('checkboxes-empty-province')).toHaveTextContent(
|
|
88
|
+
/select country first/i,
|
|
89
|
+
);
|
|
90
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('RadioField keeps its value and shows the parent-first hint', () => {
|
|
94
|
+
const onChange = vi.fn();
|
|
95
|
+
render(
|
|
96
|
+
<RadioField
|
|
97
|
+
value="zj"
|
|
98
|
+
onChange={onChange}
|
|
99
|
+
field={provinceField('radio')}
|
|
100
|
+
{...({ name: 'province', dependentValues: {} } as any)}
|
|
101
|
+
/>,
|
|
102
|
+
);
|
|
103
|
+
expect(screen.getByTestId('radio-empty-province')).toHaveTextContent(/select country first/i);
|
|
104
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('SelectField keeps its value and shows the parent-first hint', () => {
|
|
108
|
+
const onChange = vi.fn();
|
|
109
|
+
render(
|
|
110
|
+
<SelectField
|
|
111
|
+
value="zj"
|
|
112
|
+
onChange={onChange}
|
|
113
|
+
field={provinceField('select')}
|
|
114
|
+
{...({ name: 'province', dependentValues: {} } as any)}
|
|
115
|
+
/>,
|
|
116
|
+
);
|
|
117
|
+
expect(screen.getByTestId('select-empty-province')).toHaveTextContent(/select country first/i);
|
|
118
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('controls — a RESOLVED list still prunes what it does not offer (ADR-0058)', () => {
|
|
123
|
+
it('MultiSelectField drops the element the chosen parent excludes', () => {
|
|
124
|
+
const onChange = vi.fn();
|
|
125
|
+
render(
|
|
126
|
+
<MultiSelectField
|
|
127
|
+
value={['zj', 'ca']}
|
|
128
|
+
onChange={onChange}
|
|
129
|
+
field={provinceField('multiselect')}
|
|
130
|
+
{...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
|
|
131
|
+
/>,
|
|
132
|
+
);
|
|
133
|
+
expect(onChange).toHaveBeenCalledWith(['zj']);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('CheckboxesField drops the element the chosen parent excludes', () => {
|
|
137
|
+
const onChange = vi.fn();
|
|
138
|
+
render(
|
|
139
|
+
<CheckboxesField
|
|
140
|
+
value={['zj', 'ca']}
|
|
141
|
+
onChange={onChange}
|
|
142
|
+
field={provinceField('checkboxes')}
|
|
143
|
+
{...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
|
|
144
|
+
/>,
|
|
145
|
+
);
|
|
146
|
+
expect(onChange).toHaveBeenCalledWith(['zj']);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('RadioField clears a value the chosen parent excludes', () => {
|
|
150
|
+
const onChange = vi.fn();
|
|
151
|
+
render(
|
|
152
|
+
<RadioField
|
|
153
|
+
value="ca"
|
|
154
|
+
onChange={onChange}
|
|
155
|
+
field={provinceField('radio')}
|
|
156
|
+
{...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
|
|
157
|
+
/>,
|
|
158
|
+
);
|
|
159
|
+
expect(onChange).toHaveBeenCalledWith(undefined);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('SelectField clears a value the chosen parent excludes', () => {
|
|
163
|
+
const onChange = vi.fn();
|
|
164
|
+
render(
|
|
165
|
+
<SelectField
|
|
166
|
+
value="ca"
|
|
167
|
+
onChange={onChange}
|
|
168
|
+
field={provinceField('select')}
|
|
169
|
+
{...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
|
|
170
|
+
/>,
|
|
171
|
+
);
|
|
172
|
+
expect(onChange).toHaveBeenCalledWith(undefined);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('transition — a gated field converges the moment its parent is chosen (#4247)', () => {
|
|
177
|
+
it('MultiSelectField: gated keeps, then prunes once the resolved set excludes', () => {
|
|
178
|
+
const onChange = vi.fn();
|
|
179
|
+
const { rerender } = render(
|
|
180
|
+
<MultiSelectField
|
|
181
|
+
value={['zj']}
|
|
182
|
+
onChange={onChange}
|
|
183
|
+
field={provinceField('multiselect')}
|
|
184
|
+
{...({ name: 'province', dependentValues: {} } as any)}
|
|
185
|
+
/>,
|
|
186
|
+
);
|
|
187
|
+
// Gated: the value survives, nothing is staged.
|
|
188
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
189
|
+
|
|
190
|
+
// The user picks the parent — the set resolves and genuinely excludes 'zj'.
|
|
191
|
+
rerender(
|
|
192
|
+
<MultiSelectField
|
|
193
|
+
value={['zj']}
|
|
194
|
+
onChange={onChange}
|
|
195
|
+
field={provinceField('multiselect')}
|
|
196
|
+
{...({ name: 'province', dependentValues: { country: 'us' } } as any)}
|
|
197
|
+
/>,
|
|
198
|
+
);
|
|
199
|
+
expect(onChange).toHaveBeenCalledWith([]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('MultiSelectField: a resolved set that INCLUDES the stored value keeps it', () => {
|
|
203
|
+
const onChange = vi.fn();
|
|
204
|
+
const { rerender } = render(
|
|
205
|
+
<MultiSelectField
|
|
206
|
+
value={['zj']}
|
|
207
|
+
onChange={onChange}
|
|
208
|
+
field={provinceField('multiselect')}
|
|
209
|
+
{...({ name: 'province', dependentValues: {} } as any)}
|
|
210
|
+
/>,
|
|
211
|
+
);
|
|
212
|
+
rerender(
|
|
213
|
+
<MultiSelectField
|
|
214
|
+
value={['zj']}
|
|
215
|
+
onChange={onChange}
|
|
216
|
+
field={provinceField('multiselect')}
|
|
217
|
+
{...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
|
|
218
|
+
/>,
|
|
219
|
+
);
|
|
220
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
221
|
+
expect(screen.getByTestId('multiselect-option-zj')).toBeInTheDocument();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('SelectField: gated keeps, then clears once the resolved set excludes', () => {
|
|
225
|
+
const onChange = vi.fn();
|
|
226
|
+
const { rerender } = render(
|
|
227
|
+
<SelectField
|
|
228
|
+
value="zj"
|
|
229
|
+
onChange={onChange}
|
|
230
|
+
field={provinceField('select')}
|
|
231
|
+
{...({ name: 'province', dependentValues: {} } as any)}
|
|
232
|
+
/>,
|
|
233
|
+
);
|
|
234
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
235
|
+
|
|
236
|
+
rerender(
|
|
237
|
+
<SelectField
|
|
238
|
+
value="zj"
|
|
239
|
+
onChange={onChange}
|
|
240
|
+
field={provinceField('select')}
|
|
241
|
+
{...({ name: 'province', dependentValues: { country: 'us' } } as any)}
|
|
242
|
+
/>,
|
|
243
|
+
);
|
|
244
|
+
expect(onChange).toHaveBeenCalledWith(undefined);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('SelectField: a resolved set that INCLUDES the stored value keeps it', () => {
|
|
248
|
+
const onChange = vi.fn();
|
|
249
|
+
const { rerender } = render(
|
|
250
|
+
<SelectField
|
|
251
|
+
value="zj"
|
|
252
|
+
onChange={onChange}
|
|
253
|
+
field={provinceField('select')}
|
|
254
|
+
{...({ name: 'province', dependentValues: {} } as any)}
|
|
255
|
+
/>,
|
|
256
|
+
);
|
|
257
|
+
rerender(
|
|
258
|
+
<SelectField
|
|
259
|
+
value="zj"
|
|
260
|
+
onChange={onChange}
|
|
261
|
+
field={provinceField('select')}
|
|
262
|
+
{...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
|
|
263
|
+
/>,
|
|
264
|
+
);
|
|
265
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
266
|
+
});
|
|
267
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
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
|
+
* An option widget with NO authored `options` keeps the value it was given
|
|
11
|
+
* (objectui#4220).
|
|
12
|
+
*
|
|
13
|
+
* The four fixed-option widgets each end their cascade resolution with a
|
|
14
|
+
* "clear what is no longer offered" effect (ADR-0058 / #2715). It ran against
|
|
15
|
+
* the RESOLVED set without asking why that set was empty, and the two causes
|
|
16
|
+
* are opposites:
|
|
17
|
+
*
|
|
18
|
+
* - the list cascaded down to zero — a real decision, and clearing is the
|
|
19
|
+
* documented behaviour;
|
|
20
|
+
* - the field declares no `options` at all — no decision was made anywhere,
|
|
21
|
+
* and the widget's own next branch renders `OptionsEmptyState`, i.e. it
|
|
22
|
+
* tells the user there is nothing to pick. Clearing here deletes the stored
|
|
23
|
+
* value of a field the user was shown nothing but a hint for, on MOUNT,
|
|
24
|
+
* without a single interaction.
|
|
25
|
+
*
|
|
26
|
+
* Measured on two live hosts, both of which mount these widgets outside a form:
|
|
27
|
+
* the grid's inline cell editor (`FieldEditWidget`, since #2942) and — since
|
|
28
|
+
* #4220 — the detail page's inline editor, which stages every `onChange` into
|
|
29
|
+
* the record draft the moment a section enters edit mode. An options-less
|
|
30
|
+
* `checkboxes` there wiped its array as soon as the user started editing any
|
|
31
|
+
* OTHER field in the same section, and the save bar wrote the empty array back.
|
|
32
|
+
*
|
|
33
|
+
* The prune now requires an authored list to prune against. The cascade itself
|
|
34
|
+
* is untouched — the controls below still clear a value the configured list no
|
|
35
|
+
* longer offers.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
39
|
+
import React from 'react';
|
|
40
|
+
import { render, screen } from '@testing-library/react';
|
|
41
|
+
import { SelectField } from './SelectField';
|
|
42
|
+
import { MultiSelectField } from './MultiSelectField';
|
|
43
|
+
import { CheckboxesField } from './CheckboxesField';
|
|
44
|
+
import { RadioField } from './RadioField';
|
|
45
|
+
|
|
46
|
+
const OPTIONS = [{ label: 'Alpha', value: 'alpha' }];
|
|
47
|
+
|
|
48
|
+
describe('option widgets — an unconfigured list never deletes the stored value (#4220)', () => {
|
|
49
|
+
it('MultiSelectField keeps its array and shows the unfillable state', () => {
|
|
50
|
+
const onChange = vi.fn();
|
|
51
|
+
render(
|
|
52
|
+
<MultiSelectField
|
|
53
|
+
value={['alpha', 'beta']}
|
|
54
|
+
onChange={onChange}
|
|
55
|
+
field={{ name: 'topics', type: 'multiselect' } as any}
|
|
56
|
+
/>,
|
|
57
|
+
);
|
|
58
|
+
expect(screen.getByTestId('multiselect-empty-topics')).toBeInTheDocument();
|
|
59
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('CheckboxesField keeps its array and shows the unfillable state', () => {
|
|
63
|
+
const onChange = vi.fn();
|
|
64
|
+
render(
|
|
65
|
+
<CheckboxesField
|
|
66
|
+
value={['alpha', 'beta']}
|
|
67
|
+
onChange={onChange}
|
|
68
|
+
field={{ name: 'perks', type: 'checkboxes' } as any}
|
|
69
|
+
/>,
|
|
70
|
+
);
|
|
71
|
+
expect(screen.getByTestId('checkboxes-empty-perks')).toBeInTheDocument();
|
|
72
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('RadioField keeps its value and shows the unfillable state', () => {
|
|
76
|
+
const onChange = vi.fn();
|
|
77
|
+
render(
|
|
78
|
+
<RadioField
|
|
79
|
+
value="alpha"
|
|
80
|
+
onChange={onChange}
|
|
81
|
+
field={{ name: 'tier', type: 'radio' } as any}
|
|
82
|
+
/>,
|
|
83
|
+
);
|
|
84
|
+
expect(screen.getByTestId('radio-empty-tier')).toBeInTheDocument();
|
|
85
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('SelectField keeps its value and shows the unfillable state', () => {
|
|
89
|
+
const onChange = vi.fn();
|
|
90
|
+
render(
|
|
91
|
+
<SelectField
|
|
92
|
+
value="alpha"
|
|
93
|
+
onChange={onChange}
|
|
94
|
+
field={{ name: 'stage', type: 'select' } as any}
|
|
95
|
+
/>,
|
|
96
|
+
);
|
|
97
|
+
expect(screen.getByTestId('select-empty-stage')).toBeInTheDocument();
|
|
98
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('controls — a CONFIGURED list still cascade-clears what it no longer offers', () => {
|
|
103
|
+
it('MultiSelectField drops the unoffered element, keeps the offered one', () => {
|
|
104
|
+
const onChange = vi.fn();
|
|
105
|
+
render(
|
|
106
|
+
<MultiSelectField
|
|
107
|
+
value={['alpha', 'zeta']}
|
|
108
|
+
onChange={onChange}
|
|
109
|
+
field={{ name: 'topics', type: 'multiselect', options: OPTIONS } as any}
|
|
110
|
+
/>,
|
|
111
|
+
);
|
|
112
|
+
expect(onChange).toHaveBeenCalledWith(['alpha']);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('CheckboxesField drops the unoffered element, keeps the offered one', () => {
|
|
116
|
+
const onChange = vi.fn();
|
|
117
|
+
render(
|
|
118
|
+
<CheckboxesField
|
|
119
|
+
value={['alpha', 'zeta']}
|
|
120
|
+
onChange={onChange}
|
|
121
|
+
field={{ name: 'perks', type: 'checkboxes', options: OPTIONS } as any}
|
|
122
|
+
/>,
|
|
123
|
+
);
|
|
124
|
+
expect(onChange).toHaveBeenCalledWith(['alpha']);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('RadioField clears a value the configured list does not offer', () => {
|
|
128
|
+
const onChange = vi.fn();
|
|
129
|
+
render(
|
|
130
|
+
<RadioField
|
|
131
|
+
value="zeta"
|
|
132
|
+
onChange={onChange}
|
|
133
|
+
field={{ name: 'tier', type: 'radio', options: OPTIONS } as any}
|
|
134
|
+
/>,
|
|
135
|
+
);
|
|
136
|
+
expect(onChange).toHaveBeenCalledWith(undefined);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('SelectField clears a value the configured list does not offer', () => {
|
|
140
|
+
const onChange = vi.fn();
|
|
141
|
+
render(
|
|
142
|
+
<SelectField
|
|
143
|
+
value="zeta"
|
|
144
|
+
onChange={onChange}
|
|
145
|
+
field={{ name: 'stage', type: 'select', options: OPTIONS } as any}
|
|
146
|
+
/>,
|
|
147
|
+
);
|
|
148
|
+
expect(onChange).toHaveBeenCalledWith(undefined);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,204 @@
|
|
|
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
|
+
import {
|
|
10
|
+
pickDomProps,
|
|
11
|
+
type SduiDomPassThroughKey,
|
|
12
|
+
type DomProps as CoreDomProps,
|
|
13
|
+
} from '@object-ui/core';
|
|
14
|
+
import type { FieldWidgetComponentProps, FieldWidgetDomProps } from './types';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The RUNTIME EXECUTOR of the "DOM pass-through" section of
|
|
18
|
+
* {@link FieldWidgetComponentProps} (objectui#3291).
|
|
19
|
+
*
|
|
20
|
+
* ## The defect
|
|
21
|
+
*
|
|
22
|
+
* Field widgets forwarded their leftover props to a control with a bare
|
|
23
|
+
* spread — `const { inputType, ...domProps } = props as any; <Input
|
|
24
|
+
* {...domProps} />`. Whatever a host handed the widget therefore became a DOM
|
|
25
|
+
* attribute. Measured on `origin/main`, a real form + a real widget:
|
|
26
|
+
*
|
|
27
|
+
* ```
|
|
28
|
+
* <input placeholder="PH-f" zzcanary="CANARY-STR" zzcanaryobj="[object Object]"
|
|
29
|
+
* zzcanarynum="42" id="…" type="text" value="" name="f">
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* The `[object Object]` this issue is named for is an authored field-config
|
|
33
|
+
* key being `String()`-ed onto an attribute. React 19 does not warn: an
|
|
34
|
+
* all-lowercase unknown attribute is passed through in complete silence, which
|
|
35
|
+
* is why this survived so long.
|
|
36
|
+
*
|
|
37
|
+
* ## Why a whitelist, and not a list of keys to drop
|
|
38
|
+
*
|
|
39
|
+
* The biggest leak source is not any NAMED renderer prop — it is the open tail
|
|
40
|
+
* of author-supplied keys. The form renderer destructures a fixed set of known
|
|
41
|
+
* keys and forwards `...fieldProps` verbatim
|
|
42
|
+
* (`components/src/renderers/form/form.tsx`), and `SchemaRenderer` is wider
|
|
43
|
+
* still: it spreads the whole authored node as props and has no strip layer at
|
|
44
|
+
* all, so on the SDUI path a widget's own spread is the ONLY line of defence.
|
|
45
|
+
*
|
|
46
|
+
* A blacklist enumerating today's renderer-only keys (`error`, `emptyHint`,
|
|
47
|
+
* `dataSource`, `dependentValues`, `dependsOn`, `options`, `inputType`, …)
|
|
48
|
+
* would pass every one of the canaries above and would not stop the next
|
|
49
|
+
* authored key either. Only "keep the known-safe set, drop the rest" closes
|
|
50
|
+
* it, which is what this function does.
|
|
51
|
+
*
|
|
52
|
+
* ## Declared = enforced
|
|
53
|
+
*
|
|
54
|
+
* {@link FieldWidgetComponentProps} already DECLARES the closed set of keys a
|
|
55
|
+
* widget may receive, and {@link FieldWidgetDomProps} names the subset that may
|
|
56
|
+
* legitimately reach a DOM element (objectui#3221). Until now that was a
|
|
57
|
+
* type-level claim a widget could violate at runtime simply by spreading. This
|
|
58
|
+
* function is that declaration's executable form, and TWO compile-time
|
|
59
|
+
* assertions below bind them in both directions — forwarding an undeclared key
|
|
60
|
+
* and declaring an unforwarded DOM key are each a compile error. Neither
|
|
61
|
+
* direction can drift silently.
|
|
62
|
+
*
|
|
63
|
+
* ## Deliberately NOT forwarded
|
|
64
|
+
*
|
|
65
|
+
* `role` and other HTML global attributes are absent because the contract does
|
|
66
|
+
* not declare them. They reached the DOM before this change only through the
|
|
67
|
+
* open spread. If a field node should be able to author one, DECLARE it on
|
|
68
|
+
* `FieldWidgetComponentProps` first and add it here — do not reopen the spread
|
|
69
|
+
* (AGENTS.md #0.1: fix the contract, never widen the consumer).
|
|
70
|
+
*
|
|
71
|
+
* ## Where the mechanism lives now, and why the key list stayed here
|
|
72
|
+
*
|
|
73
|
+
* objectui#4425 phase 2 promoted this whitelist to the SDUI widget contract
|
|
74
|
+
* generally, so the MECHANISM — filter by a declared key list plus the `aria-*`
|
|
75
|
+
* / `data-*` open families — moved to `@object-ui/core`
|
|
76
|
+
* (`utils/dom-props.ts`, `pickDomProps`), where every plugin package can reach
|
|
77
|
+
* it without depending on `@object-ui/fields` (the objectui#4409
|
|
78
|
+
* dependency-direction method: `plugin-chatbot` declares `@object-ui/core` and
|
|
79
|
+
* does not declare this package).
|
|
80
|
+
*
|
|
81
|
+
* The KEY LIST did not move, because it is not the same list. Measured on
|
|
82
|
+
* objectui#4431:
|
|
83
|
+
*
|
|
84
|
+
* - `name` and `disabled` are here and NOT in the SDUI set: both are legal
|
|
85
|
+
* only on form controls, which is what every widget in this package renders
|
|
86
|
+
* and what `FieldWidgetComponentProps` declares. `name` on the `<div>` an
|
|
87
|
+
* SDUI container widget renders is a leak — one of the 14 attributes #4431
|
|
88
|
+
* closed — and two widgets here already hand-strip it when they spread onto
|
|
89
|
+
* something that is not a control (`ObjectRefField`'s trigger,
|
|
90
|
+
* `FileField`'s dropzone).
|
|
91
|
+
* - `role` is in the SDUI set and NOT here: `SchemaRenderer` resolves it for
|
|
92
|
+
* every node from `@objectstack/spec`'s `AriaPropsSchema`, while this
|
|
93
|
+
* contract deliberately does not declare it (see "Deliberately NOT
|
|
94
|
+
* forwarded" above).
|
|
95
|
+
*
|
|
96
|
+
* So: one mechanism, two declarations, each bound by the assertions below to
|
|
97
|
+
* the contract it executes. Not two judges.
|
|
98
|
+
*/
|
|
99
|
+
const DOM_PASS_THROUGH_KEYS = [
|
|
100
|
+
/* ── The contract's own "DOM pass-through" block, verbatim ─────────────── */
|
|
101
|
+
'id',
|
|
102
|
+
'name',
|
|
103
|
+
'autoFocus',
|
|
104
|
+
'tabIndex',
|
|
105
|
+
'onBlur',
|
|
106
|
+
'onFocus',
|
|
107
|
+
'onClick',
|
|
108
|
+
|
|
109
|
+
/* ── Two more declared keys that ARE valid DOM attributes ──────────────── */
|
|
110
|
+
//
|
|
111
|
+
// Both are declared on `FieldWidgetComponentProps` under the
|
|
112
|
+
// controlled-input contract rather than the DOM block, because widgets also
|
|
113
|
+
// INTERPRET them (`className` gets composed with the widget's own classes;
|
|
114
|
+
// `disabled` is OR-ed with `readonly`). They are still `class` and
|
|
115
|
+
// `disabled` on the element, and every widget here already forwards them.
|
|
116
|
+
//
|
|
117
|
+
// Withholding them would make this helper a silent styling- and
|
|
118
|
+
// interactivity-dropper: `<Input {...toDomProps(props)} />` — the shape this
|
|
119
|
+
// change teaches every future widget author to write — would quietly lose
|
|
120
|
+
// the host's className and disabled state. That is a NEW silent failure of
|
|
121
|
+
// exactly the kind the whitelist exists to prevent, so they are forwarded.
|
|
122
|
+
'className',
|
|
123
|
+
'disabled',
|
|
124
|
+
] as const;
|
|
125
|
+
|
|
126
|
+
type DomPassThroughKey = (typeof DOM_PASS_THROUGH_KEYS)[number];
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Compile-time link to the declaration, direction 1 of 2: every key forwarded
|
|
130
|
+
* at runtime must exist on {@link FieldWidgetComponentProps}. Deleting a key
|
|
131
|
+
* from the contract without deleting it here is a compile error.
|
|
132
|
+
*
|
|
133
|
+
* Catches: helper forwards something the contract no longer declares.
|
|
134
|
+
*/
|
|
135
|
+
type EveryForwardedKeyIsDeclared =
|
|
136
|
+
DomPassThroughKey extends keyof FieldWidgetComponentProps ? true : never;
|
|
137
|
+
const _everyForwardedKeyIsDeclared: EveryForwardedKeyIsDeclared = true;
|
|
138
|
+
void _everyForwardedKeyIsDeclared;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Direction 2 of 2: every key of {@link FieldWidgetDomProps} — the contract's
|
|
142
|
+
* DOM pass-through block — must be one this helper actually forwards. Adding a
|
|
143
|
+
* DOM key to the contract without adding it to `DOM_PASS_THROUGH_KEYS` is a
|
|
144
|
+
* compile error.
|
|
145
|
+
*
|
|
146
|
+
* Catches: DECLARED BUT NOT DELIVERED — a key that type-checks, reads as
|
|
147
|
+
* supported, and silently never reaches the element. That is the failure this
|
|
148
|
+
* repo treats as first-class (objectui#3290's `aria-required` that never
|
|
149
|
+
* reached a control; objectui#3222's validation slot nobody produced), and it
|
|
150
|
+
* is the one direction the leak test structurally CANNOT see: that test looks
|
|
151
|
+
* for attributes that arrive, not for ones that go missing.
|
|
152
|
+
*
|
|
153
|
+
* `className` / `disabled` are not part of `FieldWidgetDomProps` (they are
|
|
154
|
+
* controlled-input keys this helper also forwards — see above), so they are
|
|
155
|
+
* bound by direction 1 only. That is deliberate, not an oversight.
|
|
156
|
+
*/
|
|
157
|
+
type EveryDeclaredDomKeyIsForwarded =
|
|
158
|
+
keyof FieldWidgetDomProps extends DomPassThroughKey ? true : never;
|
|
159
|
+
const _everyDeclaredDomKeyIsForwarded: EveryDeclaredDomKeyIsForwarded = true;
|
|
160
|
+
void _everyDeclaredDomKeyIsForwarded;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Direction 3, added with the core lift (objectui#4431): every key of the SDUI
|
|
164
|
+
* contract's own whitelist that this contract also declares must be forwarded
|
|
165
|
+
* here too, so the two lists cannot silently drift apart. `role` is the ONE
|
|
166
|
+
* measured exception — the SDUI contract declares it, this one does not — and
|
|
167
|
+
* naming it in the `Exclude` is what makes the divergence a deliberate,
|
|
168
|
+
* reviewable act rather than an omission.
|
|
169
|
+
*
|
|
170
|
+
* Catches: a key added to the shared SDUI set that field widgets quietly stop
|
|
171
|
+
* delivering.
|
|
172
|
+
*/
|
|
173
|
+
type SharedSduiKey = Exclude<SduiDomPassThroughKey, 'role'>;
|
|
174
|
+
type EverySharedSduiKeyIsForwarded = SharedSduiKey extends DomPassThroughKey ? true : never;
|
|
175
|
+
const _everySharedSduiKeyIsForwarded: EverySharedSduiKeyIsForwarded = true;
|
|
176
|
+
void _everySharedSduiKeyIsForwarded;
|
|
177
|
+
|
|
178
|
+
/** The subset of `P` this helper forwards, with each key's declared type. */
|
|
179
|
+
export type DomProps<P> = CoreDomProps<P, DomPassThroughKey>;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Keep only what may legitimately become a DOM attribute, and drop everything
|
|
183
|
+
* else — renderer plumbing, and any extra key an author put on the field
|
|
184
|
+
* config or SDUI node.
|
|
185
|
+
*
|
|
186
|
+
* Replaces the bare `{...props}` spread in every field widget that renders a
|
|
187
|
+
* host element:
|
|
188
|
+
*
|
|
189
|
+
* ```tsx
|
|
190
|
+
* // before — forwards whatever it was handed
|
|
191
|
+
* const { inputType, ...domProps } = props as any;
|
|
192
|
+
* return <Input {...domProps} … />;
|
|
193
|
+
*
|
|
194
|
+
* // after
|
|
195
|
+
* return <Input {...toDomProps(props)} … />;
|
|
196
|
+
* ```
|
|
197
|
+
*
|
|
198
|
+
* Semantic props a widget INTERPRETS (`value`, `onChange`, `field`,
|
|
199
|
+
* `readonly`, `error`, …) are read from `props` as before; this function only
|
|
200
|
+
* governs what gets spread.
|
|
201
|
+
*/
|
|
202
|
+
export function toDomProps<P extends object>(props: P): DomProps<P> {
|
|
203
|
+
return pickDomProps(props, DOM_PASS_THROUGH_KEYS);
|
|
204
|
+
}
|