@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
|
@@ -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
|
+
});
|
|
@@ -1,110 +1,179 @@
|
|
|
1
|
-
import React from 'react';
|
|
1
|
+
import React, { useId } from 'react';
|
|
2
2
|
import { Input, Label, EmptyValue } from '@object-ui/components';
|
|
3
3
|
import { FieldWidgetComponentProps } from './types';
|
|
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';
|
|
4
16
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
export
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
state?: string;
|
|
12
|
-
zipCode?: string;
|
|
13
|
-
country?: string;
|
|
14
|
-
}
|
|
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';
|
|
15
23
|
|
|
16
24
|
/**
|
|
17
25
|
* Address field widget - provides a structured address input
|
|
18
|
-
* Supports street, city, state,
|
|
26
|
+
* Supports street, city, state, postal code, and country
|
|
19
27
|
*/
|
|
20
|
-
export function AddressField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<AddressValue>) {
|
|
28
|
+
export function AddressField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<AddressValue>) {
|
|
21
29
|
const address = value || {};
|
|
30
|
+
// DOM pass-through (objectui#3318): the whitelist spread goes onto the FIRST
|
|
31
|
+
// sub-input (street) — one carrier for the form renderer's aria-describedby;
|
|
32
|
+
// the composite's validation state goes onto EVERY focusable sub-input via
|
|
33
|
+
// `aria-invalid={!!error}` (a form-level failure means the whole address is
|
|
34
|
+
// missing/invalid, and whichever sub-input the user reaches must announce it).
|
|
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);
|
|
62
|
+
// Sub-input ids (objectui#3343): `useId()` prefix + sub-field name — the
|
|
63
|
+
// `groupId` paradigm of RadioField / CheckboxesField. Hardcoded literals
|
|
64
|
+
// ("street", "city", …) collide as soon as a form renders two address
|
|
65
|
+
// fields, and every label's htmlFor then resolves to the FIRST match.
|
|
66
|
+
const groupId = useId();
|
|
67
|
+
const subId = (name: keyof AddressValue) => `${groupId}-${name}`;
|
|
68
|
+
|
|
69
|
+
// Read through the legacy key, write only the canonical one (objectstack#5143).
|
|
70
|
+
const postalCode = readPostalCode(address);
|
|
22
71
|
|
|
23
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;
|
|
24
80
|
onChange({
|
|
25
|
-
...
|
|
81
|
+
...canonical,
|
|
82
|
+
...(postalCode ? { postalCode } : null),
|
|
26
83
|
[fieldName]: fieldValue,
|
|
27
84
|
});
|
|
28
85
|
};
|
|
29
86
|
|
|
30
|
-
const formatAddress = (addr: AddressValue): string => {
|
|
31
|
-
const parts = [
|
|
32
|
-
addr.street,
|
|
33
|
-
addr.city,
|
|
34
|
-
[addr.state, addr.zipCode].filter(Boolean).join(' '),
|
|
35
|
-
addr.country,
|
|
36
|
-
].filter(Boolean);
|
|
37
|
-
return parts.join(', ');
|
|
38
|
-
};
|
|
39
|
-
|
|
40
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.
|
|
41
94
|
const formatted = formatAddress(address);
|
|
42
|
-
return formatted ?
|
|
95
|
+
return formatted ? (
|
|
96
|
+
<span {...hostGroupProps} className="text-sm">{formatted}</span>
|
|
97
|
+
) : (
|
|
98
|
+
<EmptyValue {...hostGroupProps} />
|
|
99
|
+
);
|
|
43
100
|
}
|
|
44
101
|
|
|
45
102
|
return (
|
|
46
|
-
|
|
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}>
|
|
47
110
|
<div>
|
|
48
|
-
<Label htmlFor=
|
|
111
|
+
<Label htmlFor={subId('street')} className="text-xs">Street Address</Label>
|
|
49
112
|
<Input
|
|
50
|
-
|
|
113
|
+
{...domProps}
|
|
114
|
+
id={subId('street')}
|
|
51
115
|
type="text"
|
|
52
116
|
value={address.street || ''}
|
|
53
117
|
onChange={(e) => handleFieldChange('street', e.target.value)}
|
|
54
118
|
placeholder="123 Main St"
|
|
55
119
|
disabled={readonly || props.disabled}
|
|
56
120
|
className={props.className}
|
|
121
|
+
aria-invalid={!!error}
|
|
57
122
|
/>
|
|
58
123
|
</div>
|
|
59
124
|
|
|
60
125
|
<div className="grid grid-cols-2 gap-3">
|
|
61
126
|
<div>
|
|
62
|
-
<Label htmlFor=
|
|
127
|
+
<Label htmlFor={subId('city')} className="text-xs">City</Label>
|
|
63
128
|
<Input
|
|
64
|
-
id=
|
|
129
|
+
id={subId('city')}
|
|
65
130
|
type="text"
|
|
66
131
|
value={address.city || ''}
|
|
67
132
|
onChange={(e) => handleFieldChange('city', e.target.value)}
|
|
68
133
|
placeholder="San Francisco"
|
|
69
134
|
disabled={readonly || props.disabled}
|
|
135
|
+
aria-invalid={!!error}
|
|
70
136
|
/>
|
|
71
137
|
</div>
|
|
72
138
|
|
|
73
139
|
<div>
|
|
74
|
-
<Label htmlFor=
|
|
140
|
+
<Label htmlFor={subId('state')} className="text-xs">State / Province</Label>
|
|
75
141
|
<Input
|
|
76
|
-
id=
|
|
142
|
+
id={subId('state')}
|
|
77
143
|
type="text"
|
|
78
144
|
value={address.state || ''}
|
|
79
145
|
onChange={(e) => handleFieldChange('state', e.target.value)}
|
|
80
146
|
placeholder="CA"
|
|
81
147
|
disabled={readonly || props.disabled}
|
|
148
|
+
aria-invalid={!!error}
|
|
82
149
|
/>
|
|
83
150
|
</div>
|
|
84
151
|
</div>
|
|
85
152
|
|
|
86
153
|
<div className="grid grid-cols-2 gap-3">
|
|
87
154
|
<div>
|
|
88
|
-
<Label htmlFor=
|
|
155
|
+
<Label htmlFor={subId('postalCode')} className="text-xs">ZIP / Postal Code</Label>
|
|
89
156
|
<Input
|
|
90
|
-
id=
|
|
157
|
+
id={subId('postalCode')}
|
|
91
158
|
type="text"
|
|
92
|
-
value={
|
|
93
|
-
onChange={(e) => handleFieldChange('
|
|
159
|
+
value={postalCode || ''}
|
|
160
|
+
onChange={(e) => handleFieldChange('postalCode', e.target.value)}
|
|
94
161
|
placeholder="94102"
|
|
95
162
|
disabled={readonly || props.disabled}
|
|
163
|
+
aria-invalid={!!error}
|
|
96
164
|
/>
|
|
97
165
|
</div>
|
|
98
166
|
|
|
99
167
|
<div>
|
|
100
|
-
<Label htmlFor=
|
|
168
|
+
<Label htmlFor={subId('country')} className="text-xs">Country</Label>
|
|
101
169
|
<Input
|
|
102
|
-
id=
|
|
170
|
+
id={subId('country')}
|
|
103
171
|
type="text"
|
|
104
172
|
value={address.country || ''}
|
|
105
173
|
onChange={(e) => handleFieldChange('country', e.target.value)}
|
|
106
174
|
placeholder="United States"
|
|
107
175
|
disabled={readonly || props.disabled}
|
|
176
|
+
aria-invalid={!!error}
|
|
108
177
|
/>
|
|
109
178
|
</div>
|
|
110
179
|
</div>
|
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
* Unique sub-input ids for the composite `address` widget (objectui#3343).
|
|
11
|
+
*
|
|
12
|
+
* The widget used to hardcode literal ids ("street", "city", "state",
|
|
13
|
+
* "postalCode" — spelled "zipCode" until objectstack#5143 —, "country") on its
|
|
14
|
+
* sub-inputs. Two address fields in one form —
|
|
15
|
+
* e.g. billing + shipping — then produced duplicate DOM ids, and every
|
|
16
|
+
* `Label htmlFor` resolved to the FIRST match in the document: each sub-label
|
|
17
|
+
* of the second field clicked/announced the first field's input.
|
|
18
|
+
*
|
|
19
|
+
* The fix follows the `groupId` paradigm of RadioField / CheckboxesField:
|
|
20
|
+
* a `useId()` prefix + the sub-field name. These tests pin it by rendering
|
|
21
|
+
* TWO instances in one form:
|
|
22
|
+
*
|
|
23
|
+
* 1. every `[id]` in the document is globally unique (red again if anyone
|
|
24
|
+
* reverts to hardcoded literals — both instances would emit id="street");
|
|
25
|
+
* 2. each sub-label of the SECOND instance resolves — via the exact
|
|
26
|
+
* mechanism browsers use for label activation, `for` → getElementById —
|
|
27
|
+
* to an input inside its OWN instance, and focusing it lands there.
|
|
28
|
+
*/
|
|
29
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
30
|
+
import React from 'react';
|
|
31
|
+
import { render, within } from '@testing-library/react';
|
|
32
|
+
import '@testing-library/jest-dom';
|
|
33
|
+
import { AddressField } from './AddressField';
|
|
34
|
+
|
|
35
|
+
const field = { name: 'address', type: 'address' } as any;
|
|
36
|
+
|
|
37
|
+
const SUB_LABELS = [
|
|
38
|
+
'Street Address',
|
|
39
|
+
'City',
|
|
40
|
+
'State / Province',
|
|
41
|
+
'ZIP / Postal Code',
|
|
42
|
+
'Country',
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
function renderTwoInOneForm() {
|
|
46
|
+
return render(
|
|
47
|
+
<form>
|
|
48
|
+
<div data-testid="address-a">
|
|
49
|
+
<AddressField value={{}} onChange={vi.fn()} field={field} />
|
|
50
|
+
</div>
|
|
51
|
+
<div data-testid="address-b">
|
|
52
|
+
<AddressField value={{}} onChange={vi.fn()} field={field} />
|
|
53
|
+
</div>
|
|
54
|
+
</form>,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('AddressField — unique sub-input ids (objectui#3343)', () => {
|
|
59
|
+
it('two address fields in one form produce globally unique DOM ids', () => {
|
|
60
|
+
const { baseElement } = renderTwoInOneForm();
|
|
61
|
+
const ids = Array.from(baseElement.querySelectorAll('[id]')).map((el) => el.id);
|
|
62
|
+
// 5 sub-inputs per instance — both instances must actually render ids.
|
|
63
|
+
expect(ids.length).toBeGreaterThanOrEqual(10);
|
|
64
|
+
const duplicates = ids.filter((id, i) => ids.indexOf(id) !== i);
|
|
65
|
+
expect(duplicates).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("each sub-label of the SECOND field focuses that field's own input", () => {
|
|
69
|
+
const { getByTestId } = renderTwoInOneForm();
|
|
70
|
+
const first = getByTestId('address-a');
|
|
71
|
+
const second = getByTestId('address-b');
|
|
72
|
+
|
|
73
|
+
for (const text of SUB_LABELS) {
|
|
74
|
+
const label = within(second).getByText(text);
|
|
75
|
+
const forId = label.getAttribute('for');
|
|
76
|
+
expect(forId, `label "${text}" must carry htmlFor`).toBeTruthy();
|
|
77
|
+
|
|
78
|
+
// Exactly what the browser does on label click / SR announcement:
|
|
79
|
+
// resolve `for` against the document.
|
|
80
|
+
const control = document.getElementById(forId!);
|
|
81
|
+
expect(control, `htmlFor of "${text}" must resolve`).not.toBeNull();
|
|
82
|
+
expect(control!.tagName).toBe('INPUT');
|
|
83
|
+
expect(
|
|
84
|
+
second.contains(control),
|
|
85
|
+
`"${text}" must resolve into its OWN field, not the first one`,
|
|
86
|
+
).toBe(true);
|
|
87
|
+
expect(first.contains(control)).toBe(false);
|
|
88
|
+
|
|
89
|
+
(control as HTMLInputElement).focus();
|
|
90
|
+
expect(control).toHaveFocus();
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -2,16 +2,17 @@ import React from 'react';
|
|
|
2
2
|
import { Avatar, AvatarFallback, AvatarImage, Button } from '@object-ui/components';
|
|
3
3
|
import { Upload, X } from 'lucide-react';
|
|
4
4
|
import { FieldWidgetComponentProps } from './types';
|
|
5
|
+
import { toDomProps } from './toDomProps';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Avatar field widget - provides an avatar/profile picture uploader
|
|
8
9
|
* Supports image URLs or file uploads
|
|
9
10
|
*/
|
|
10
|
-
export function AvatarField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
|
|
11
|
+
export function AvatarField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
|
|
11
12
|
const [isHovered, setIsHovered] = React.useState(false);
|
|
12
13
|
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
|
13
14
|
|
|
14
|
-
const avatarField =
|
|
15
|
+
const avatarField = field as any;
|
|
15
16
|
|
|
16
17
|
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
17
18
|
const file = event.target.files?.[0];
|
|
@@ -92,11 +93,17 @@ export function AvatarField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
92
93
|
className="hidden"
|
|
93
94
|
/>
|
|
94
95
|
<Button
|
|
96
|
+
// DOM pass-through onto the widget's real focusable control — the
|
|
97
|
+
// upload button is the keyboard path to the hidden file input
|
|
98
|
+
// (objectui#3318).
|
|
99
|
+
{...toDomProps(props)}
|
|
95
100
|
type="button"
|
|
96
101
|
variant="outline"
|
|
97
102
|
size="sm"
|
|
98
103
|
onClick={() => fileInputRef.current?.click()}
|
|
99
104
|
disabled={readonly || props.disabled}
|
|
105
|
+
// AFTER the spread so this widget's own computation wins (#3222).
|
|
106
|
+
aria-invalid={!!error}
|
|
100
107
|
>
|
|
101
108
|
<Upload className="w-4 h-4 mr-2" />
|
|
102
109
|
{value ? 'Change' : 'Upload'} Avatar
|