@object-ui/fields 17.3.0 → 17.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/CHANGELOG.md +941 -0
  2. package/README.md +1 -12
  3. package/dist/FieldEditWidget.d.ts +7 -1
  4. package/dist/currency.d.ts +46 -0
  5. package/dist/field-type-alias.d.ts +19 -1
  6. package/dist/index.cjs +4 -4
  7. package/dist/index.css +633 -0
  8. package/dist/index.d.ts +48 -12
  9. package/dist/index.js +5026 -4933
  10. package/dist/widgets/AddressField.d.ts +3 -11
  11. package/dist/widgets/FullscreenFieldEditor.d.ts +42 -152
  12. package/dist/widgets/GridField.d.ts +12 -2
  13. package/dist/widgets/OptionsEmptyState.d.ts +15 -1
  14. package/dist/widgets/RecordPickerDialog.d.ts +32 -6
  15. package/dist/widgets/SelectField.d.ts +25 -5
  16. package/dist/widgets/address-format.d.ts +68 -0
  17. package/dist/widgets/file-size-guard.d.ts +41 -0
  18. package/dist/widgets/nativeDateValue.d.ts +37 -0
  19. package/dist/widgets/toDomProps.d.ts +30 -10
  20. package/dist/widgets/toHostGroupProps.d.ts +91 -0
  21. package/dist/widgets/useRecordQuery.d.ts +7 -1
  22. package/package.json +17 -13
  23. package/src/FieldEditWidget.tsx +17 -1
  24. package/src/__tests__/AddressCellRenderer.test.tsx +166 -0
  25. package/src/__tests__/CurrencyField.minorUnits.test.tsx +191 -0
  26. package/src/__tests__/CurrencyField.symbol.test.tsx +198 -0
  27. package/src/__tests__/DateCellRenderer.test.tsx +9 -0
  28. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +146 -0
  29. package/src/__tests__/PercentCellRenderer.locale.test.tsx +152 -0
  30. package/src/__tests__/PercentCellRenderer.test.tsx +115 -0
  31. package/src/__tests__/boolean-label-association-e2e.test.tsx +298 -0
  32. package/src/__tests__/capability-multiselect-retired.test.ts +109 -0
  33. package/src/__tests__/composite-group-label-e2e.test.tsx +436 -0
  34. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +403 -0
  35. package/src/__tests__/date-formatter-residue-4272.test.ts +120 -0
  36. package/src/__tests__/date-locale-channel.test.tsx +305 -0
  37. package/src/__tests__/formatCurrency.centsDigit.test.tsx +158 -0
  38. package/src/__tests__/formatCurrency.minorUnits.test.tsx +221 -0
  39. package/src/__tests__/group-labelling-declaration.test.ts +148 -0
  40. package/src/__tests__/lookupCellRecordLink.test.tsx +171 -0
  41. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +115 -0
  42. package/src/__tests__/percent-formatter-locale-4553.test.ts +147 -0
  43. package/src/__tests__/percent-tie-halfup-4590.test.ts +224 -0
  44. package/src/__tests__/select-label-association-e2e.test.tsx +199 -0
  45. package/src/__tests__/widget-dom-leak-e2e.test.tsx +29 -270
  46. package/src/complex-widgets.test.tsx +22 -6
  47. package/src/currency.ts +130 -0
  48. package/src/datetime-widgets.test.tsx +94 -2
  49. package/src/field-type-alias.multiple.test.ts +81 -0
  50. package/src/field-type-alias.ts +58 -2
  51. package/src/index.css +44 -0
  52. package/src/index.tsx +489 -247
  53. package/src/standard-widgets.test.tsx +8 -1
  54. package/src/widgets/AddressField.postalCode.test.tsx +257 -0
  55. package/src/widgets/AddressField.tsx +79 -29
  56. package/src/widgets/AddressField.uniqueIds.test.tsx +2 -1
  57. package/src/widgets/BooleanField.tsx +41 -7
  58. package/src/widgets/CapabilityMultiSelectField.tsx +17 -5
  59. package/src/widgets/CheckboxesField.tsx +35 -2
  60. package/src/widgets/CurrencyField.tsx +55 -15
  61. package/src/widgets/DateField.tsx +11 -2
  62. package/src/widgets/DateTimeField.tsx +12 -3
  63. package/src/widgets/FileField.tsx +27 -9
  64. package/src/widgets/FormulaField.tsx +6 -1
  65. package/src/widgets/FullscreenFieldEditor.tsx +47 -258
  66. package/src/widgets/GeolocationField.tsx +29 -3
  67. package/src/widgets/GridField.test.tsx +226 -0
  68. package/src/widgets/GridField.tsx +114 -10
  69. package/src/widgets/ImageField.maxSize.test.tsx +154 -0
  70. package/src/widgets/ImageField.tsx +54 -4
  71. package/src/widgets/LookupField.hydration.test.tsx +1 -1
  72. package/src/widgets/LookupField.tsx +8 -4
  73. package/src/widgets/MultiSelectField.tsx +56 -2
  74. package/src/widgets/OptionsEmptyState.tsx +16 -0
  75. package/src/widgets/RadioField.tsx +35 -3
  76. package/src/widgets/RatingField.tsx +15 -2
  77. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +191 -0
  78. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +124 -0
  79. package/src/widgets/RecordPickerDialog.tsx +64 -15
  80. package/src/widgets/RichTextField.tsx +1 -1
  81. package/src/widgets/SelectField.tsx +36 -5
  82. package/src/widgets/UserField.tsx +3 -2
  83. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +25 -1
  84. package/src/widgets/address-format.ts +120 -0
  85. package/src/widgets/file-size-guard.ts +55 -0
  86. package/src/widgets/nativeDateValue.test.ts +115 -0
  87. package/src/widgets/nativeDateValue.ts +112 -0
  88. package/src/widgets/optionWidgets.gatedOptions.test.tsx +267 -0
  89. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +150 -0
  90. package/src/widgets/toDomProps.ts +48 -19
  91. package/src/widgets/toHostGroupProps.ts +102 -0
  92. package/src/widgets/useFieldTranslation.ts +15 -33
  93. package/src/widgets/useRecordQuery.ts +30 -3
  94. package/dist/FieldEditWidget.test.d.ts +0 -8
  95. package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
  96. package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
  97. package/dist/__tests__/cell-truncation.test.d.ts +0 -0
  98. package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
  99. package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
  100. package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
  101. package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
  102. package/dist/__tests__/registry-collision.test.d.ts +0 -0
  103. package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
  104. package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
  105. package/dist/__tests__/validation-feedback.test.d.ts +0 -0
  106. package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
  107. package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
  108. package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
  109. package/dist/__tests__/widget-props-contract.test.d.ts +0 -8
  110. package/dist/coerce-safe-value.test.d.ts +0 -1
  111. package/dist/complex-widgets.test.d.ts +0 -1
  112. package/dist/datetime-widgets.test.d.ts +0 -1
  113. package/dist/field-type-coverage.test.d.ts +0 -1
  114. package/dist/standard-widgets.test.d.ts +0 -0
  115. package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
  116. package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
  117. package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
  118. package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
  119. package/dist/widgets/GridField.test.d.ts +0 -1
  120. package/dist/widgets/ImageLightbox.test.d.ts +0 -1
  121. package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
  122. package/dist/widgets/LookupField.gateHintLabel.test.d.ts +0 -8
  123. package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
  124. package/dist/widgets/LookupField.idField.test.d.ts +0 -1
  125. package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
  126. package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
  127. package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
  128. package/dist/widgets/PeoplePicker.test.d.ts +0 -8
  129. package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
  130. package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
  131. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  132. package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +0 -8
  133. package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +0 -8
  134. package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
  135. package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
  136. package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
  137. package/dist/widgets/UserFieldPicker.test.d.ts +0 -8
  138. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
  139. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
  140. package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
  141. package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
  142. package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
  143. package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
  144. package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
  145. package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
  146. package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
  147. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
  148. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
  149. package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
  150. package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
  151. package/dist/widgets/deriveLookupColumns.test.d.ts +0 -1
  152. package/dist/widgets/file-value.test.d.ts +0 -1
  153. package/dist/widgets/personDisplay.test.d.ts +0 -8
  154. package/dist/widgets/recentLookups.test.d.ts +0 -1
  155. package/dist/widgets/useRecordQuery.test.d.ts +0 -8
  156. package/dist/widgets/useUploadingSignal.test.d.ts +0 -1
@@ -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
+ });
@@ -2,21 +2,28 @@ import React, { useId } from 'react';
2
2
  import { Input, Label, EmptyValue } from '@object-ui/components';
3
3
  import { FieldWidgetComponentProps } from './types';
4
4
  import { toDomProps } from './toDomProps';
5
+ import { toHostGroupProps } from './toHostGroupProps';
6
+ // The value shape and the single-line formatting rule now live in a pure module
7
+ // so the display (read) cell renderer formats a stored address exactly the way
8
+ // this widget's readonly branch does — one rule, not two copies that drift
9
+ // (objectui#4037). Behaviour here is unchanged; only the definition moved.
10
+ import {
11
+ formatAddress,
12
+ readPostalCode,
13
+ type AddressValue,
14
+ type LegacyAddressValue,
15
+ } from './address-format';
5
16
 
6
- /**
7
- * Address data structure
8
- */
9
- export interface AddressValue {
10
- street?: string;
11
- city?: string;
12
- state?: string;
13
- zipCode?: string;
14
- country?: string;
15
- }
17
+ // Re-exported through its declaring module rather than bare, so the spec-symbol
18
+ // guard resolves the name to where it is actually defined: `address-format`
19
+ // imports `AddressValue` from `@objectstack/spec/data` (objectui#4167), and a
20
+ // bare `export type { AddressValue }` here would read to that guard as a second,
21
+ // local declaration of a name the spec owns.
22
+ export type { AddressValue } from './address-format';
16
23
 
17
24
  /**
18
25
  * Address field widget - provides a structured address input
19
- * Supports street, city, state, zip code, and country
26
+ * Supports street, city, state, postal code, and country
20
27
  */
21
28
  export function AddressField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<AddressValue>) {
22
29
  const address = value || {};
@@ -25,7 +32,33 @@ export function AddressField({ value, onChange, field, readonly, error, ...props
25
32
  // the composite's validation state goes onto EVERY focusable sub-input via
26
33
  // `aria-invalid={!!error}` (a form-level failure means the whole address is
27
34
  // missing/invalid, and whichever sub-input the user reaches must announce it).
28
- const domProps = toDomProps(props);
35
+ //
36
+ // Two keys are held back from that spread and land on the group CONTAINER
37
+ // instead (objectui#3961), because they address the field as a WHOLE:
38
+ //
39
+ // - `id` — the host's control id. It never reached the DOM at all: it was
40
+ // spread here and then overwritten one line later by `id={subId('street')}`
41
+ // (objectui#3343), which is why the form's "Shipping Address" label pointed
42
+ // at an id no element carried. The sub-input ids stay exactly as they are;
43
+ // only the host id moves out to the element it was always meant to name.
44
+ // - `aria-labelledby` — the host's label, associated by IDREF. On the street
45
+ // input it would be actively wrong: `aria-labelledby` OVERRIDES a control's
46
+ // `<label for>`, so the first sub-input would be announced as "Shipping
47
+ // Address" and its own "Street Address" label would vanish.
48
+ //
49
+ // Everything else (`aria-describedby`, `name`, focus handlers …) deliberately
50
+ // stays on the first sub-input: a description or error must be announced when
51
+ // focus lands on something focusable, and a container is not.
52
+ const {
53
+ id: _hostId,
54
+ 'aria-labelledby': _hostLabelledBy,
55
+ ...domProps
56
+ } = toDomProps(props);
57
+ // The pair held back above, in the one spelling every group-labelled widget
58
+ // uses for it — and computed HERE, before the readonly early return below,
59
+ // because that return used to drop both keys on the floor: a published label
60
+ // id with no consumer in the document (objectui#3990). See `toHostGroupProps`.
61
+ const hostGroupProps = toHostGroupProps(props);
29
62
  // Sub-input ids (objectui#3343): `useId()` prefix + sub-field name — the
30
63
  // `groupId` paradigm of RadioField / CheckboxesField. Hardcoded literals
31
64
  // ("street", "city", …) collide as soon as a form renders two address
@@ -33,30 +66,47 @@ export function AddressField({ value, onChange, field, readonly, error, ...props
33
66
  const groupId = useId();
34
67
  const subId = (name: keyof AddressValue) => `${groupId}-${name}`;
35
68
 
69
+ // Read through the legacy key, write only the canonical one (objectstack#5143).
70
+ const postalCode = readPostalCode(address);
71
+
36
72
  const handleFieldChange = (fieldName: keyof AddressValue, fieldValue: string) => {
73
+ // Normalize while we are here: the object we write back never carries
74
+ // `zipCode`, and a legacy postal code is carried forward under
75
+ // `postalCode`. Without the carry, editing an unrelated part of a
76
+ // legacy-shaped address would write the postal code straight back out of
77
+ // the record — the very data loss this fixes, one build later.
78
+ const canonical: LegacyAddressValue = { ...address };
79
+ delete canonical.zipCode;
37
80
  onChange({
38
- ...address,
81
+ ...canonical,
82
+ ...(postalCode ? { postalCode } : null),
39
83
  [fieldName]: fieldValue,
40
84
  });
41
85
  };
42
86
 
43
- const formatAddress = (addr: AddressValue): string => {
44
- const parts = [
45
- addr.street,
46
- addr.city,
47
- [addr.state, addr.zipCode].filter(Boolean).join(' '),
48
- addr.country,
49
- ].filter(Boolean);
50
- return parts.join(', ');
51
- };
52
-
53
87
  if (readonly) {
88
+ // Readonly the composite collapses to ONE formatted line — no sub-inputs and
89
+ // no sub-labels — so that line is the surface the host label names, and
90
+ // `EmptyValue` is the same surface with nothing in it (objectui#3990). The
91
+ // placeholder's own `aria-label` ("No value") is outranked by
92
+ // `aria-labelledby` per accname, and on its `generic` role an author name was
93
+ // never exposed anyway.
54
94
  const formatted = formatAddress(address);
55
- return formatted ? <span className="text-sm">{formatted}</span> : <EmptyValue />;
95
+ return formatted ? (
96
+ <span {...hostGroupProps} className="text-sm">{formatted}</span>
97
+ ) : (
98
+ <EmptyValue {...hostGroupProps} />
99
+ );
56
100
  }
57
101
 
58
102
  return (
59
- <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}>
60
110
  <div>
61
111
  <Label htmlFor={subId('street')} className="text-xs">Street Address</Label>
62
112
  <Input
@@ -102,12 +152,12 @@ export function AddressField({ value, onChange, field, readonly, error, ...props
102
152
 
103
153
  <div className="grid grid-cols-2 gap-3">
104
154
  <div>
105
- <Label htmlFor={subId('zipCode')} className="text-xs">ZIP / Postal Code</Label>
155
+ <Label htmlFor={subId('postalCode')} className="text-xs">ZIP / Postal Code</Label>
106
156
  <Input
107
- id={subId('zipCode')}
157
+ id={subId('postalCode')}
108
158
  type="text"
109
- value={address.zipCode || ''}
110
- onChange={(e) => handleFieldChange('zipCode', e.target.value)}
159
+ value={postalCode || ''}
160
+ onChange={(e) => handleFieldChange('postalCode', e.target.value)}
111
161
  placeholder="94102"
112
162
  disabled={readonly || props.disabled}
113
163
  aria-invalid={!!error}
@@ -10,7 +10,8 @@
10
10
  * Unique sub-input ids for the composite `address` widget (objectui#3343).
11
11
  *
12
12
  * The widget used to hardcode literal ids ("street", "city", "state",
13
- * "zipCode", "country") on its sub-inputs. Two address fields in one form —
13
+ * "postalCode" — spelled "zipCode" until objectstack#5143 —, "country") on its
14
+ * sub-inputs. Two address fields in one form —
14
15
  * e.g. billing + shipping — then produced duplicate DOM ids, and every
15
16
  * `Label htmlFor` resolved to the FIRST match in the document: each sub-label
16
17
  * of the second field clicked/announced the first field's input.
@@ -13,8 +13,42 @@ export function BooleanField({ value, onChange, field, readonly, ...props }: Fie
13
13
  const widget = config?.widget;
14
14
  // Generate unique ID using React's useId hook - must be before early returns (rules of hooks)
15
15
  const generatedId = useId();
16
- const id = config?.name || generatedId;
16
+ /**
17
+ * The HOST owns the control's id (objectui#3952).
18
+ *
19
+ * `<FormControl>` is a Radix `Slot` that hands its child the id its
20
+ * `<FormLabel htmlFor>` already points at (`_r_N_-form-item`). This widget
21
+ * used to REPLACE it with the field name, so the form's visible label
22
+ * referenced an id no element carried: `label for="_r_3_-form-item"` against
23
+ * `all ids in form = ["notifications"]`. Clicking the label — the normal
24
+ * affordance for a switch/checkbox row — toggled nothing, on every generated
25
+ * form in every app.
26
+ *
27
+ * `id` is a DECLARED key of the widget contract (`FieldWidgetDomProps`) and
28
+ * `toDomProps` forwards it, so honouring it here is the contract's own
29
+ * direction: the fix belongs at the consumer that discarded a delivered
30
+ * value, not in the form renderer (AGENTS.md #0.1).
31
+ *
32
+ * The fallback chain stays for the STANDALONE hosts that hand down no id —
33
+ * `FieldEditWidget` (the grid's inline cell editor) and a bare SDUI node.
34
+ */
35
+ const hostId = props.id;
36
+ const id = hostId || config?.name || generatedId;
17
37
  const label = config?.label || 'Checkbox';
38
+ /**
39
+ * A host that supplies the id is a host that renders its own label for it
40
+ * (that is what the id is FOR), so the widget's `sr-only` label would be the
41
+ * second copy of one name — the other half of #3952, and why a naive
42
+ * `getByText(label)` on a boolean field returned two nodes.
43
+ *
44
+ * Standalone it is the control's ONLY accessible name (`FieldEditWidget`
45
+ * renders no label of its own), so there it stays. Deliberately keyed on the
46
+ * host id rather than on `config.label`: inside a form the naming is the
47
+ * host's job for EVERY widget — a text field in a label-less form row is
48
+ * equally unnamed — and the generic `'Checkbox'` fallback emitted there was
49
+ * an actively WRONG accessible name, not a safety net.
50
+ */
51
+ const emitOwnLabel = !hostId;
18
52
 
19
53
  if (readonly) {
20
54
  return <span className="text-sm">{value ? 'Yes' : 'No'}</span>;
@@ -25,28 +59,28 @@ export function BooleanField({ value, onChange, field, readonly, ...props }: Fie
25
59
  if (widget === 'checkbox') {
26
60
  return (
27
61
  <div className="flex items-center space-x-2">
28
- <Checkbox
62
+ <Checkbox
29
63
  {...domProps}
30
64
  id={id}
31
65
  checked={!!value}
32
66
  onCheckedChange={(checked) => onChange(!!checked)}
33
67
  disabled={readonly || domProps.disabled}
34
68
  />
35
- <Label htmlFor={id} className="sr-only">{label}</Label>
69
+ {emitOwnLabel && <Label htmlFor={id} className="sr-only">{label}</Label>}
36
70
  </div>
37
71
  )
38
72
  }
39
73
 
40
74
  return (
41
75
  <div className="flex items-center space-x-2">
42
- <Switch
76
+ <Switch
43
77
  {...domProps}
44
- id={id}
45
- checked={!!value}
78
+ id={id}
79
+ checked={!!value}
46
80
  onCheckedChange={onChange}
47
81
  disabled={readonly || domProps.disabled}
48
82
  />
49
- <Label htmlFor={id} className="sr-only">{label}</Label>
83
+ {emitOwnLabel && <Label htmlFor={id} className="sr-only">{label}</Label>}
50
84
  </div>
51
85
  );
52
86
  }
@@ -18,9 +18,18 @@ import { useFieldTranslation } from './useFieldTranslation';
18
18
  * labelled, with the capability description on hover — while round-tripping the
19
19
  * stored value **byte-for-byte** as a JSON string of names.
20
20
  *
21
- * Reached via the field `widget: 'capability-multiselect'` hint (stamped onto
22
- * the object metadata in MetadataProvider), registered in the field registry as
23
- * `field:capability-multiselect`.
21
+ * ## Not a registry field widget (objectui#3308)
22
+ *
23
+ * This is a plain component, imported directly by its host — Studio's
24
+ * `PermissionMatrixEditor` (`packages/app-shell`), which is where ADR-0056 P2
25
+ * put the capability editor. It is deliberately NOT reachable through the field
26
+ * registry, and `widget: 'capability-multiselect'` is a **retired** hint: the
27
+ * key only ever existed on the docs-site-only `registerFields()` path, nothing
28
+ * ever stamped the hint (ADR-0056 P1 stamps `permission-facet-link` on all six
29
+ * `sys_permission_set` facets), and it was removed under ADR-0049
30
+ * enforce-or-remove. Do not re-register it without re-deciding ADR-0056 P2 —
31
+ * a field carrying that hint now degrades to its declared `type` renderer,
32
+ * which is the defined behavior for an unregistered widget.
24
33
  */
25
34
 
26
35
  interface Capability {
@@ -98,8 +107,11 @@ export function CapabilityMultiSelectField({
98
107
  const selected = React.useMemo(() => parseCapabilityNames(value), [value]);
99
108
 
100
109
  // Load the live capability registry (active only). Mirrors LookupField's
101
- // context-dataSource access — createFieldRenderer does not forward the
102
- // dataSource prop, so the SchemaRenderer context is the reliable source.
110
+ // context-dataSource access: no registration path forwards a `dataSource`
111
+ // prop to a field widget, so the SchemaRenderer context is the reliable
112
+ // source. (The absent forwarder named here used to be the docs-demo
113
+ // `createFieldRenderer`, removed in objectui#3910 — the conclusion is
114
+ // unchanged and now holds for the only remaining path.)
103
115
  React.useEffect(() => {
104
116
  if (!dataSource || typeof (dataSource as any).find !== 'function') return;
105
117
  let cancelled = false;
@@ -3,6 +3,7 @@ import { Checkbox, Label, EmptyValue, Badge } from '@object-ui/components';
3
3
  import type { OptionLike } from '@object-ui/core';
4
4
  import { FieldWidgetComponentProps } from './types';
5
5
  import { toDomProps } from './toDomProps';
6
+ import { toHostGroupProps } from './toHostGroupProps';
6
7
  import { OptionsEmptyState } from './OptionsEmptyState';
7
8
  import { useCascadingOptions } from './useCascadingOptions';
8
9
 
@@ -51,16 +52,36 @@ export function CheckboxesField({
51
52
  // per-element clear the multi-value case needs (cf. the scalar select/radio).
52
53
  useEffect(() => {
53
54
  if (readonly) return;
55
+ // Never configured → nothing to prune against; see `MultiSelectField`'s
56
+ // copy of this guard for the measured failure (objectui#4220).
57
+ if (rawOptions.length === 0) return;
58
+ // Gated → the authored list is withheld until the `dependsOn` parent is
59
+ // chosen, so the empty offered set is missing information, not a verdict on
60
+ // the stored value; clearing here fired on mount (objectui#4247). Same
61
+ // reasoning, at length, in `MultiSelectField`.
62
+ if (gated) return;
54
63
  if (selected.length === 0) return;
55
64
  const stillOffered = selected.filter((v) => options.some((o) => o.value === v));
56
65
  if (stillOffered.length !== selected.length) onChange(stillOffered);
57
66
  // eslint-disable-next-line react-hooks/exhaustive-deps
58
67
  }, [options, gated]);
59
68
 
69
+ // The host's group label has to be consumed on EVERY surface this widget can
70
+ // render, not only the editable one (objectui#3990): both branches below
71
+ // return before the editable container's `groupDomProps` spread, which is how
72
+ // a field-level `readonly: true` and a zero-option list ended up with a
73
+ // published label id and no consumer at all. See `toHostGroupProps`.
74
+ const hostGroupProps = toHostGroupProps(props);
75
+
60
76
  if (readonly) {
61
- if (selected.length === 0) return <EmptyValue />;
77
+ // The readonly display of a checkbox set is the set of CHECKED labels — a
78
+ // set of values, so the same `group` answer as the editable list.
79
+ // `EmptyValue` is that surface with nothing in it; its own `aria-label`
80
+ // ("No value") is outranked by `aria-labelledby` per accname, and on the
81
+ // `generic` role that span carries it was never exposed anyway.
82
+ if (selected.length === 0) return <EmptyValue {...hostGroupProps} />;
62
83
  return (
63
- <div className="flex flex-wrap gap-1">
84
+ <div {...hostGroupProps} className="flex flex-wrap gap-1">
64
85
  {selected.map((v) => {
65
86
  // Label from the raw set so a stored value hidden by `visibleWhen`
66
87
  // still renders its label rather than a bare id.
@@ -83,6 +104,9 @@ export function CheckboxesField({
83
104
  dependsOnFields={dependsOnFields}
84
105
  testId={fieldName ? `checkboxes-empty-${fieldName}` : undefined}
85
106
  className="min-h-9"
107
+ // This box IS the field in this state, so it is what the host label
108
+ // names (objectui#3990).
109
+ hostGroupProps={hostGroupProps}
86
110
  />
87
111
  );
88
112
  }
@@ -105,10 +129,19 @@ export function CheckboxesField({
105
129
  name: _domName,
106
130
  ...groupDomProps
107
131
  } = toDomProps(props);
132
+ // When the host associated its visible label with this container by IDREF
133
+ // (`aria-labelledby`, objectui#3961) the container IS the labelled group, so it
134
+ // answers with the matching role — without one, the name sits on a generic
135
+ // `div` and contributes nothing: `label for` pointing here was already inert
136
+ // (`HTMLLabelElement.control` is null for a div), which is the whole defect.
137
+ // Absent (standalone: the inline grid editor, a bare SDUI node) nothing is
138
+ // emitted and the markup is unchanged.
139
+ const isLabelledGroup = groupDomProps['aria-labelledby'] != null;
108
140
 
109
141
  return (
110
142
  <div
111
143
  {...groupDomProps}
144
+ role={isLabelledGroup ? 'group' : undefined}
112
145
  className={className}
113
146
  data-testid={fieldName ? `checkboxes-${fieldName}` : undefined}
114
147
  >