@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.
Files changed (212) hide show
  1. package/CHANGELOG.md +1533 -0
  2. package/README.md +1 -12
  3. package/dist/FieldEditWidget.d.ts +7 -1
  4. package/dist/currency.d.ts +46 -0
  5. package/dist/field-type-alias.d.ts +19 -1
  6. package/dist/index.cjs +5 -5
  7. package/dist/index.css +633 -0
  8. package/dist/index.d.ts +52 -12
  9. package/dist/index.js +5167 -4759
  10. package/dist/widgets/AddressField.d.ts +4 -12
  11. package/dist/widgets/AvatarField.d.ts +1 -1
  12. package/dist/widgets/CharacterCount.d.ts +56 -0
  13. package/dist/widgets/CheckboxesField.d.ts +1 -1
  14. package/dist/widgets/CodeField.d.ts +1 -1
  15. package/dist/widgets/ColorField.d.ts +1 -1
  16. package/dist/widgets/CurrencyField.d.ts +1 -1
  17. package/dist/widgets/EmailField.d.ts +1 -1
  18. package/dist/widgets/FileField.d.ts +1 -1
  19. package/dist/widgets/FilterConditionField.d.ts +1 -1
  20. package/dist/widgets/FullscreenFieldEditor.d.ts +45 -0
  21. package/dist/widgets/GeolocationField.d.ts +1 -1
  22. package/dist/widgets/GridField.d.ts +12 -2
  23. package/dist/widgets/ImageField.d.ts +1 -1
  24. package/dist/widgets/LocationField.d.ts +1 -1
  25. package/dist/widgets/LookupField.d.ts +1 -1
  26. package/dist/widgets/MultiSelectField.d.ts +1 -1
  27. package/dist/widgets/ObjectField.d.ts +1 -1
  28. package/dist/widgets/ObjectRefField.d.ts +1 -1
  29. package/dist/widgets/OptionsEmptyState.d.ts +59 -0
  30. package/dist/widgets/PercentField.d.ts +1 -1
  31. package/dist/widgets/PhoneField.d.ts +1 -1
  32. package/dist/widgets/QRCodeField.d.ts +1 -1
  33. package/dist/widgets/RadioField.d.ts +1 -1
  34. package/dist/widgets/RatingField.d.ts +1 -1
  35. package/dist/widgets/RecipientPickerField.d.ts +1 -1
  36. package/dist/widgets/RecordPickerDialog.d.ts +49 -7
  37. package/dist/widgets/RichTextField.d.ts +19 -1
  38. package/dist/widgets/SelectField.d.ts +25 -5
  39. package/dist/widgets/TagsField.d.ts +1 -1
  40. package/dist/widgets/TextAreaField.d.ts +44 -5
  41. package/dist/widgets/UrlField.d.ts +1 -1
  42. package/dist/widgets/address-format.d.ts +68 -0
  43. package/dist/widgets/file-size-guard.d.ts +41 -0
  44. package/dist/widgets/nativeDateValue.d.ts +37 -0
  45. package/dist/widgets/toDomProps.d.ts +111 -0
  46. package/dist/widgets/toHostGroupProps.d.ts +91 -0
  47. package/dist/widgets/types.d.ts +128 -33
  48. package/dist/widgets/useRecordQuery.d.ts +7 -1
  49. package/dist/withFieldCarrier.d.ts +56 -0
  50. package/package.json +20 -16
  51. package/src/FieldEditWidget.tsx +17 -1
  52. package/src/__tests__/AddressCellRenderer.test.tsx +166 -0
  53. package/src/__tests__/CurrencyField.minorUnits.test.tsx +191 -0
  54. package/src/__tests__/CurrencyField.symbol.test.tsx +198 -0
  55. package/src/__tests__/DateCellRenderer.test.tsx +9 -0
  56. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +146 -0
  57. package/src/__tests__/PercentCellRenderer.locale.test.tsx +152 -0
  58. package/src/__tests__/PercentCellRenderer.test.tsx +115 -0
  59. package/src/__tests__/boolean-label-association-e2e.test.tsx +298 -0
  60. package/src/__tests__/capability-multiselect-retired.test.ts +109 -0
  61. package/src/__tests__/cell-truncation.test.tsx +96 -0
  62. package/src/__tests__/composite-group-label-e2e.test.tsx +436 -0
  63. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +403 -0
  64. package/src/__tests__/date-formatter-residue-4272.test.ts +120 -0
  65. package/src/__tests__/date-locale-channel.test.tsx +305 -0
  66. package/src/__tests__/field-carrier-sdui.test.tsx +169 -0
  67. package/src/__tests__/formatCurrency.centsDigit.test.tsx +158 -0
  68. package/src/__tests__/formatCurrency.minorUnits.test.tsx +221 -0
  69. package/src/__tests__/group-labelling-declaration.test.ts +148 -0
  70. package/src/__tests__/lookupCellRecordLink.test.tsx +171 -0
  71. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +115 -0
  72. package/src/__tests__/percent-formatter-locale-4553.test.ts +147 -0
  73. package/src/__tests__/percent-tie-halfup-4590.test.ts +224 -0
  74. package/src/__tests__/select-label-association-e2e.test.tsx +199 -0
  75. package/src/__tests__/spec-symbol-batch7.test.ts +29 -15
  76. package/src/__tests__/validation-feedback.test.tsx +22 -13
  77. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +265 -0
  78. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +338 -0
  79. package/src/__tests__/widget-dom-leak-e2e.test.tsx +357 -0
  80. package/src/__tests__/widget-props-contract.test.tsx +22 -9
  81. package/src/complex-widgets.test.tsx +22 -6
  82. package/src/currency.ts +130 -0
  83. package/src/datetime-widgets.test.tsx +94 -2
  84. package/src/field-type-alias.multiple.test.ts +81 -0
  85. package/src/field-type-alias.ts +58 -2
  86. package/src/index.css +44 -0
  87. package/src/index.tsx +558 -268
  88. package/src/standard-widgets.test.tsx +8 -1
  89. package/src/widgets/AddressField.postalCode.test.tsx +257 -0
  90. package/src/widgets/AddressField.tsx +107 -38
  91. package/src/widgets/AddressField.uniqueIds.test.tsx +93 -0
  92. package/src/widgets/AvatarField.tsx +9 -2
  93. package/src/widgets/BooleanField.tsx +44 -10
  94. package/src/widgets/CapabilityMultiSelectField.tsx +17 -5
  95. package/src/widgets/CharacterCount.tsx +263 -0
  96. package/src/widgets/CheckboxesField.tsx +66 -16
  97. package/src/widgets/CodeField.tsx +9 -2
  98. package/src/widgets/ColorField.tsx +11 -2
  99. package/src/widgets/CurrencyField.tsx +60 -19
  100. package/src/widgets/DateField.tsx +13 -4
  101. package/src/widgets/DateTimeField.tsx +14 -5
  102. package/src/widgets/EmailField.tsx +5 -5
  103. package/src/widgets/FileField.tsx +39 -11
  104. package/src/widgets/FilterConditionField.tsx +11 -0
  105. package/src/widgets/FormulaField.tsx +7 -2
  106. package/src/widgets/FullscreenFieldEditor.tsx +60 -0
  107. package/src/widgets/GeolocationField.tsx +49 -8
  108. package/src/widgets/GeolocationField.uniqueIds.test.tsx +88 -0
  109. package/src/widgets/GridField.test.tsx +226 -0
  110. package/src/widgets/GridField.tsx +115 -11
  111. package/src/widgets/ImageField.maxSize.test.tsx +154 -0
  112. package/src/widgets/ImageField.tsx +63 -6
  113. package/src/widgets/LocationField.tsx +9 -2
  114. package/src/widgets/LookupField.gateHintLabel.test.tsx +135 -0
  115. package/src/widgets/LookupField.hydration.test.tsx +1 -1
  116. package/src/widgets/LookupField.tsx +49 -10
  117. package/src/widgets/MasterDetailField.tsx +1 -1
  118. package/src/widgets/MultiSelectField.tsx +86 -16
  119. package/src/widgets/NumberField.tsx +3 -3
  120. package/src/widgets/ObjectField.tsx +17 -8
  121. package/src/widgets/ObjectRefField.tsx +10 -0
  122. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +88 -0
  123. package/src/widgets/OptionsEmptyState.test.tsx +169 -0
  124. package/src/widgets/OptionsEmptyState.tsx +99 -0
  125. package/src/widgets/PasswordField.tsx +3 -3
  126. package/src/widgets/PeoplePicker.tsx +7 -1
  127. package/src/widgets/PercentField.tsx +5 -4
  128. package/src/widgets/PhoneField.tsx +5 -5
  129. package/src/widgets/QRCodeField.tsx +9 -2
  130. package/src/widgets/RadioField.tsx +58 -16
  131. package/src/widgets/RatingField.tsx +32 -4
  132. package/src/widgets/RecipientPickerField.tsx +18 -0
  133. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +191 -0
  134. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +124 -0
  135. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +611 -0
  136. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +112 -0
  137. package/src/widgets/RecordPickerDialog.tsx +235 -21
  138. package/src/widgets/RichTextField.tsx +151 -24
  139. package/src/widgets/SelectField.tsx +84 -22
  140. package/src/widgets/SliderField.tsx +1 -1
  141. package/src/widgets/SummaryField.tsx +1 -1
  142. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +68 -0
  143. package/src/widgets/TagsField.placeholder.test.tsx +95 -0
  144. package/src/widgets/TagsField.tsx +23 -2
  145. package/src/widgets/TextAreaField.tsx +171 -93
  146. package/src/widgets/TextField.tsx +3 -3
  147. package/src/widgets/TimeField.tsx +2 -2
  148. package/src/widgets/UrlField.tsx +5 -5
  149. package/src/widgets/UserField.tsx +4 -3
  150. package/src/widgets/VectorField.tsx +1 -1
  151. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +25 -1
  152. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +248 -0
  153. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +116 -0
  154. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +244 -0
  155. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +195 -0
  156. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +417 -0
  157. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +287 -0
  158. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +160 -0
  159. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +161 -0
  160. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +346 -0
  161. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +246 -0
  162. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +175 -0
  163. package/src/widgets/address-format.ts +120 -0
  164. package/src/widgets/file-size-guard.ts +55 -0
  165. package/src/widgets/nativeDateValue.test.ts +115 -0
  166. package/src/widgets/nativeDateValue.ts +112 -0
  167. package/src/widgets/optionWidgets.gatedOptions.test.tsx +267 -0
  168. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +150 -0
  169. package/src/widgets/toDomProps.ts +204 -0
  170. package/src/widgets/toHostGroupProps.ts +102 -0
  171. package/src/widgets/types.ts +135 -35
  172. package/src/widgets/useFieldTranslation.ts +46 -4
  173. package/src/widgets/useRecordQuery.ts +30 -3
  174. package/src/withFieldCarrier.tsx +75 -0
  175. package/dist/FieldEditWidget.test.d.ts +0 -8
  176. package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
  177. package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
  178. package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
  179. package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
  180. package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
  181. package/dist/__tests__/registry-collision.test.d.ts +0 -0
  182. package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
  183. package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
  184. package/dist/__tests__/validation-feedback.test.d.ts +0 -0
  185. package/dist/__tests__/widget-props-contract.test.d.ts +0 -8
  186. package/dist/coerce-safe-value.test.d.ts +0 -1
  187. package/dist/complex-widgets.test.d.ts +0 -1
  188. package/dist/datetime-widgets.test.d.ts +0 -1
  189. package/dist/field-type-coverage.test.d.ts +0 -1
  190. package/dist/standard-widgets.test.d.ts +0 -0
  191. package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
  192. package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
  193. package/dist/widgets/GridField.test.d.ts +0 -1
  194. package/dist/widgets/ImageLightbox.test.d.ts +0 -1
  195. package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
  196. package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
  197. package/dist/widgets/LookupField.idField.test.d.ts +0 -1
  198. package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
  199. package/dist/widgets/PeoplePicker.test.d.ts +0 -8
  200. package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
  201. package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
  202. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  203. package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
  204. package/dist/widgets/UserFieldPicker.test.d.ts +0 -8
  205. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
  206. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
  207. package/dist/widgets/deriveLookupColumns.test.d.ts +0 -1
  208. package/dist/widgets/file-value.test.d.ts +0 -1
  209. package/dist/widgets/personDisplay.test.d.ts +0 -8
  210. package/dist/widgets/recentLookups.test.d.ts +0 -1
  211. package/dist/widgets/useRecordQuery.test.d.ts +0 -8
  212. 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
- expect(screen.getByText('EUR')).toBeInTheDocument();
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
- * Address data structure
7
- */
8
- export interface AddressValue {
9
- street?: string;
10
- city?: string;
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, zip code, and country
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
- ...address,
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 ? <span className="text-sm">{formatted}</span> : <EmptyValue />;
95
+ return formatted ? (
96
+ <span {...hostGroupProps} className="text-sm">{formatted}</span>
97
+ ) : (
98
+ <EmptyValue {...hostGroupProps} />
99
+ );
43
100
  }
44
101
 
45
102
  return (
46
- <div className="space-y-3">
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="street" className="text-xs">Street Address</Label>
111
+ <Label htmlFor={subId('street')} className="text-xs">Street Address</Label>
49
112
  <Input
50
- id="street"
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="city" className="text-xs">City</Label>
127
+ <Label htmlFor={subId('city')} className="text-xs">City</Label>
63
128
  <Input
64
- id="city"
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="state" className="text-xs">State / Province</Label>
140
+ <Label htmlFor={subId('state')} className="text-xs">State / Province</Label>
75
141
  <Input
76
- id="state"
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="zipCode" className="text-xs">ZIP / Postal Code</Label>
155
+ <Label htmlFor={subId('postalCode')} className="text-xs">ZIP / Postal Code</Label>
89
156
  <Input
90
- id="zipCode"
157
+ id={subId('postalCode')}
91
158
  type="text"
92
- value={address.zipCode || ''}
93
- onChange={(e) => handleFieldChange('zipCode', e.target.value)}
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="country" className="text-xs">Country</Label>
168
+ <Label htmlFor={subId('country')} className="text-xs">Country</Label>
101
169
  <Input
102
- id="country"
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 = (field || (props as any).schema) as any;
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