@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
@@ -0,0 +1,112 @@
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
+ * Value adapters for the native date/time controls (objectui#3127).
11
+ *
12
+ * ## The defect these close
13
+ *
14
+ * `<input type="date">` accepts exactly `YYYY-MM-DD` and
15
+ * `<input type="datetime-local">` exactly `YYYY-MM-DDTHH:mm[:ss]` — a **local
16
+ * wall clock with no zone suffix**. Anything else is not "coerced" or
17
+ * "warned about": the browser SILENTLY rejects it. The attribute still lands
18
+ * in the DOM, so the value looks present to anyone inspecting markup, while
19
+ * `input.value` reads back `""` and the control paints its empty placeholder:
20
+ *
21
+ * ```js
22
+ * el.getAttribute('value') // "2026-08-16T00:33:00.000Z" ← the record's value
23
+ * el.value // "" ← what the user sees
24
+ * ```
25
+ *
26
+ * Records come back from the API as full ISO-8601 (`…T00:33:00.000Z`) — which
27
+ * is precisely one of those rejected shapes. So EVERY datetime field in an
28
+ * edit dialog rendered blank while holding a real value, and users read that
29
+ * as "the date was lost" and retyped it (which is the only way the display bug
30
+ * could turn into a data change).
31
+ *
32
+ * ## Why a round trip, and not just a formatter
33
+ *
34
+ * Formatting on the way IN without converting on the way OUT would be the
35
+ * worse bug: the control would then be read as UTC but written back as a naive
36
+ * local string, so a user in UTC+8 who picked "08:33" would store 08:33Z and
37
+ * see 16:33 on the detail page. Read basis and write basis must be the same
38
+ * one, so `toDateTimeInputValue` / `fromDateTimeInputValue` are a pair and are
39
+ * used as a pair.
40
+ *
41
+ * ## Shape kept in form state
42
+ *
43
+ * `datetime` round-trips through ISO — the shape the API hands us and the one
44
+ * display/format code already assumes. `date` stays a plain `YYYY-MM-DD`
45
+ * string, matching what the inline cell editor in `@object-ui/components`
46
+ * (`data-table.tsx`) and `@object-ui/plugin-detail`'s `InlineFieldInput`
47
+ * already do. Untouched fields are never re-emitted, so a form that opens and
48
+ * closes without an edit still submits nothing for them.
49
+ */
50
+
51
+ function pad2(n: number): string {
52
+ return String(n).padStart(2, '0');
53
+ }
54
+
55
+ /**
56
+ * Stored value → `<input type="date">` value (`YYYY-MM-DD`).
57
+ *
58
+ * A leading `YYYY-MM-DD` is passed through VERBATIM rather than re-parsed: a
59
+ * date-only string is parsed as UTC midnight by `new Date()`, so reading local
60
+ * calendar components back out of it shifts the day by one everywhere west of
61
+ * Greenwich. `2026-06-17` must stay June 17th in every zone.
62
+ */
63
+ export function toDateInputValue(value: unknown): string {
64
+ if (value == null || value === '') return '';
65
+ if (typeof value === 'string') {
66
+ const m = value.match(/^(\d{4}-\d{2}-\d{2})/);
67
+ if (m) return m[1];
68
+ }
69
+ const d = value instanceof Date ? value : new Date(String(value));
70
+ if (Number.isNaN(d.getTime())) return '';
71
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
72
+ }
73
+
74
+ /**
75
+ * Stored value → `<input type="datetime-local">` value (`YYYY-MM-DDTHH:mm`),
76
+ * expressed in the viewer's local wall clock — the same instant the detail
77
+ * page shows, so opening the editor never appears to move the time.
78
+ *
79
+ * A value that is ALREADY a zone-less local datetime is truncated to minutes
80
+ * in place instead of being round-tripped through `Date`, which would
81
+ * reinterpret it and then re-render it, twice risking an off-by-a-zone shift.
82
+ */
83
+ export function toDateTimeInputValue(value: unknown): string {
84
+ if (value == null || value === '') return '';
85
+ if (typeof value === 'string') {
86
+ const local = value.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})(?::\d{2}(?:\.\d+)?)?$/);
87
+ if (local) return `${local[1]}T${local[2]}`;
88
+ }
89
+ const d = value instanceof Date ? value : new Date(String(value));
90
+ if (Number.isNaN(d.getTime())) return '';
91
+ return (
92
+ `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}` +
93
+ `T${pad2(d.getHours())}:${pad2(d.getMinutes())}`
94
+ );
95
+ }
96
+
97
+ /**
98
+ * `<input type="datetime-local">` value → stored value (ISO-8601, UTC).
99
+ *
100
+ * The control hands us a zone-less local wall clock; `new Date()` reads that
101
+ * form as local time, which is the inverse of what `toDateTimeInputValue`
102
+ * wrote — the pair is what keeps read and write on one basis.
103
+ *
104
+ * An unparseable string is returned untouched rather than blanked: dropping a
105
+ * value the user can see in the box would be a data loss dressed up as a fix.
106
+ */
107
+ export function fromDateTimeInputValue(value: string): string {
108
+ if (!value) return '';
109
+ const d = new Date(value);
110
+ if (Number.isNaN(d.getTime())) return value;
111
+ return d.toISOString();
112
+ }
@@ -0,0 +1,267 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ /**
10
+ * A GATED option list never deletes the stored value (objectui#4247).
11
+ *
12
+ * The four fixed-option widgets end their cascade resolution with a "drop what
13
+ * is no longer offered" effect (ADR-0058 / #2715). `resolveCascadingOptions`
14
+ * returns an EMPTY offered set whenever the list is gated — a declared
15
+ * `dependsOn` parent is still empty — so on mount the effect found nothing
16
+ * "still offered" and wrote the field empty, while the widget was rendering
17
+ * `OptionsEmptyState` ("select Country first") right next to it: the control
18
+ * tells the user it cannot offer anything, and deletes what they had.
19
+ *
20
+ * Ruling (#4247): **gated means UNKNOWN, not invalid.** The cascade clear exists
21
+ * so a USER-DRIVEN parent change prunes a now-invalid child; a gated set on
22
+ * MOUNT is missing information — the record simply arrived with the parent
23
+ * empty (a later-cleared parent, an import, a partially-migrated row) — and
24
+ * destroying stored data on missing information is silent data loss. Convergence
25
+ * stays where it belongs: once the parent IS chosen and the resolved set
26
+ * genuinely excludes the stored value, the existing prune applies.
27
+ *
28
+ * Three states, kept apart here so a future edit cannot collapse them:
29
+ *
30
+ * 1. never-configured — no authored `options` at all. Guarded since #4220;
31
+ * control only, pinned in `optionWidgets.unconfiguredOptions.test.tsx`.
32
+ * 2. gated — an authored list withheld behind an unmet `dependsOn`. NEW: the
33
+ * value survives.
34
+ * 3. resolved-and-excludes — the parent IS chosen and the resolved set does not
35
+ * offer the stored value. Clears; that is the ADR-0058 contract.
36
+ *
37
+ * The gate is read from the resolver's own `gated` flag, never re-derived from
38
+ * `options.length === 0` — that would collide with state 1, which is a
39
+ * different case with a different guard.
40
+ */
41
+
42
+ import { describe, it, expect, vi } from 'vitest';
43
+ import React from 'react';
44
+ import { render, screen } from '@testing-library/react';
45
+ import '@testing-library/jest-dom';
46
+ import { SelectField } from './SelectField';
47
+ import { MultiSelectField } from './MultiSelectField';
48
+ import { CheckboxesField } from './CheckboxesField';
49
+ import { RadioField } from './RadioField';
50
+
51
+ /** Options that exist for exactly one country — the cascade the gate withholds. */
52
+ const PROVINCE_OPTIONS = [
53
+ { label: 'Zhejiang', value: 'zj', visibleWhen: "record.country == 'cn'" },
54
+ { label: 'California', value: 'ca', visibleWhen: "record.country == 'us'" },
55
+ ];
56
+
57
+ const provinceField = (type: string) =>
58
+ ({ name: 'province', type, dependsOn: 'country', options: PROVINCE_OPTIONS }) as any;
59
+
60
+ describe('option widgets — a GATED list never deletes the stored value (#4247)', () => {
61
+ it('MultiSelectField keeps its array and shows the parent-first hint', () => {
62
+ const onChange = vi.fn();
63
+ render(
64
+ <MultiSelectField
65
+ value={['zj']}
66
+ onChange={onChange}
67
+ field={provinceField('multiselect')}
68
+ {...({ name: 'province', dependentValues: {} } as any)}
69
+ />,
70
+ );
71
+ expect(screen.getByTestId('multiselect-empty-province')).toHaveTextContent(
72
+ /select country first/i,
73
+ );
74
+ expect(onChange).not.toHaveBeenCalled();
75
+ });
76
+
77
+ it('CheckboxesField keeps its array and shows the parent-first hint', () => {
78
+ const onChange = vi.fn();
79
+ render(
80
+ <CheckboxesField
81
+ value={['zj']}
82
+ onChange={onChange}
83
+ field={provinceField('checkboxes')}
84
+ {...({ name: 'province', dependentValues: {} } as any)}
85
+ />,
86
+ );
87
+ expect(screen.getByTestId('checkboxes-empty-province')).toHaveTextContent(
88
+ /select country first/i,
89
+ );
90
+ expect(onChange).not.toHaveBeenCalled();
91
+ });
92
+
93
+ it('RadioField keeps its value and shows the parent-first hint', () => {
94
+ const onChange = vi.fn();
95
+ render(
96
+ <RadioField
97
+ value="zj"
98
+ onChange={onChange}
99
+ field={provinceField('radio')}
100
+ {...({ name: 'province', dependentValues: {} } as any)}
101
+ />,
102
+ );
103
+ expect(screen.getByTestId('radio-empty-province')).toHaveTextContent(/select country first/i);
104
+ expect(onChange).not.toHaveBeenCalled();
105
+ });
106
+
107
+ it('SelectField keeps its value and shows the parent-first hint', () => {
108
+ const onChange = vi.fn();
109
+ render(
110
+ <SelectField
111
+ value="zj"
112
+ onChange={onChange}
113
+ field={provinceField('select')}
114
+ {...({ name: 'province', dependentValues: {} } as any)}
115
+ />,
116
+ );
117
+ expect(screen.getByTestId('select-empty-province')).toHaveTextContent(/select country first/i);
118
+ expect(onChange).not.toHaveBeenCalled();
119
+ });
120
+ });
121
+
122
+ describe('controls — a RESOLVED list still prunes what it does not offer (ADR-0058)', () => {
123
+ it('MultiSelectField drops the element the chosen parent excludes', () => {
124
+ const onChange = vi.fn();
125
+ render(
126
+ <MultiSelectField
127
+ value={['zj', 'ca']}
128
+ onChange={onChange}
129
+ field={provinceField('multiselect')}
130
+ {...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
131
+ />,
132
+ );
133
+ expect(onChange).toHaveBeenCalledWith(['zj']);
134
+ });
135
+
136
+ it('CheckboxesField drops the element the chosen parent excludes', () => {
137
+ const onChange = vi.fn();
138
+ render(
139
+ <CheckboxesField
140
+ value={['zj', 'ca']}
141
+ onChange={onChange}
142
+ field={provinceField('checkboxes')}
143
+ {...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
144
+ />,
145
+ );
146
+ expect(onChange).toHaveBeenCalledWith(['zj']);
147
+ });
148
+
149
+ it('RadioField clears a value the chosen parent excludes', () => {
150
+ const onChange = vi.fn();
151
+ render(
152
+ <RadioField
153
+ value="ca"
154
+ onChange={onChange}
155
+ field={provinceField('radio')}
156
+ {...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
157
+ />,
158
+ );
159
+ expect(onChange).toHaveBeenCalledWith(undefined);
160
+ });
161
+
162
+ it('SelectField clears a value the chosen parent excludes', () => {
163
+ const onChange = vi.fn();
164
+ render(
165
+ <SelectField
166
+ value="ca"
167
+ onChange={onChange}
168
+ field={provinceField('select')}
169
+ {...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
170
+ />,
171
+ );
172
+ expect(onChange).toHaveBeenCalledWith(undefined);
173
+ });
174
+ });
175
+
176
+ describe('transition — a gated field converges the moment its parent is chosen (#4247)', () => {
177
+ it('MultiSelectField: gated keeps, then prunes once the resolved set excludes', () => {
178
+ const onChange = vi.fn();
179
+ const { rerender } = render(
180
+ <MultiSelectField
181
+ value={['zj']}
182
+ onChange={onChange}
183
+ field={provinceField('multiselect')}
184
+ {...({ name: 'province', dependentValues: {} } as any)}
185
+ />,
186
+ );
187
+ // Gated: the value survives, nothing is staged.
188
+ expect(onChange).not.toHaveBeenCalled();
189
+
190
+ // The user picks the parent — the set resolves and genuinely excludes 'zj'.
191
+ rerender(
192
+ <MultiSelectField
193
+ value={['zj']}
194
+ onChange={onChange}
195
+ field={provinceField('multiselect')}
196
+ {...({ name: 'province', dependentValues: { country: 'us' } } as any)}
197
+ />,
198
+ );
199
+ expect(onChange).toHaveBeenCalledWith([]);
200
+ });
201
+
202
+ it('MultiSelectField: a resolved set that INCLUDES the stored value keeps it', () => {
203
+ const onChange = vi.fn();
204
+ const { rerender } = render(
205
+ <MultiSelectField
206
+ value={['zj']}
207
+ onChange={onChange}
208
+ field={provinceField('multiselect')}
209
+ {...({ name: 'province', dependentValues: {} } as any)}
210
+ />,
211
+ );
212
+ rerender(
213
+ <MultiSelectField
214
+ value={['zj']}
215
+ onChange={onChange}
216
+ field={provinceField('multiselect')}
217
+ {...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
218
+ />,
219
+ );
220
+ expect(onChange).not.toHaveBeenCalled();
221
+ expect(screen.getByTestId('multiselect-option-zj')).toBeInTheDocument();
222
+ });
223
+
224
+ it('SelectField: gated keeps, then clears once the resolved set excludes', () => {
225
+ const onChange = vi.fn();
226
+ const { rerender } = render(
227
+ <SelectField
228
+ value="zj"
229
+ onChange={onChange}
230
+ field={provinceField('select')}
231
+ {...({ name: 'province', dependentValues: {} } as any)}
232
+ />,
233
+ );
234
+ expect(onChange).not.toHaveBeenCalled();
235
+
236
+ rerender(
237
+ <SelectField
238
+ value="zj"
239
+ onChange={onChange}
240
+ field={provinceField('select')}
241
+ {...({ name: 'province', dependentValues: { country: 'us' } } as any)}
242
+ />,
243
+ );
244
+ expect(onChange).toHaveBeenCalledWith(undefined);
245
+ });
246
+
247
+ it('SelectField: a resolved set that INCLUDES the stored value keeps it', () => {
248
+ const onChange = vi.fn();
249
+ const { rerender } = render(
250
+ <SelectField
251
+ value="zj"
252
+ onChange={onChange}
253
+ field={provinceField('select')}
254
+ {...({ name: 'province', dependentValues: {} } as any)}
255
+ />,
256
+ );
257
+ rerender(
258
+ <SelectField
259
+ value="zj"
260
+ onChange={onChange}
261
+ field={provinceField('select')}
262
+ {...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
263
+ />,
264
+ );
265
+ expect(onChange).not.toHaveBeenCalled();
266
+ });
267
+ });
@@ -0,0 +1,150 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ /**
10
+ * An option widget with NO authored `options` keeps the value it was given
11
+ * (objectui#4220).
12
+ *
13
+ * The four fixed-option widgets each end their cascade resolution with a
14
+ * "clear what is no longer offered" effect (ADR-0058 / #2715). It ran against
15
+ * the RESOLVED set without asking why that set was empty, and the two causes
16
+ * are opposites:
17
+ *
18
+ * - the list cascaded down to zero — a real decision, and clearing is the
19
+ * documented behaviour;
20
+ * - the field declares no `options` at all — no decision was made anywhere,
21
+ * and the widget's own next branch renders `OptionsEmptyState`, i.e. it
22
+ * tells the user there is nothing to pick. Clearing here deletes the stored
23
+ * value of a field the user was shown nothing but a hint for, on MOUNT,
24
+ * without a single interaction.
25
+ *
26
+ * Measured on two live hosts, both of which mount these widgets outside a form:
27
+ * the grid's inline cell editor (`FieldEditWidget`, since #2942) and — since
28
+ * #4220 — the detail page's inline editor, which stages every `onChange` into
29
+ * the record draft the moment a section enters edit mode. An options-less
30
+ * `checkboxes` there wiped its array as soon as the user started editing any
31
+ * OTHER field in the same section, and the save bar wrote the empty array back.
32
+ *
33
+ * The prune now requires an authored list to prune against. The cascade itself
34
+ * is untouched — the controls below still clear a value the configured list no
35
+ * longer offers.
36
+ */
37
+
38
+ import { describe, it, expect, vi } from 'vitest';
39
+ import React from 'react';
40
+ import { render, screen } from '@testing-library/react';
41
+ import { SelectField } from './SelectField';
42
+ import { MultiSelectField } from './MultiSelectField';
43
+ import { CheckboxesField } from './CheckboxesField';
44
+ import { RadioField } from './RadioField';
45
+
46
+ const OPTIONS = [{ label: 'Alpha', value: 'alpha' }];
47
+
48
+ describe('option widgets — an unconfigured list never deletes the stored value (#4220)', () => {
49
+ it('MultiSelectField keeps its array and shows the unfillable state', () => {
50
+ const onChange = vi.fn();
51
+ render(
52
+ <MultiSelectField
53
+ value={['alpha', 'beta']}
54
+ onChange={onChange}
55
+ field={{ name: 'topics', type: 'multiselect' } as any}
56
+ />,
57
+ );
58
+ expect(screen.getByTestId('multiselect-empty-topics')).toBeInTheDocument();
59
+ expect(onChange).not.toHaveBeenCalled();
60
+ });
61
+
62
+ it('CheckboxesField keeps its array and shows the unfillable state', () => {
63
+ const onChange = vi.fn();
64
+ render(
65
+ <CheckboxesField
66
+ value={['alpha', 'beta']}
67
+ onChange={onChange}
68
+ field={{ name: 'perks', type: 'checkboxes' } as any}
69
+ />,
70
+ );
71
+ expect(screen.getByTestId('checkboxes-empty-perks')).toBeInTheDocument();
72
+ expect(onChange).not.toHaveBeenCalled();
73
+ });
74
+
75
+ it('RadioField keeps its value and shows the unfillable state', () => {
76
+ const onChange = vi.fn();
77
+ render(
78
+ <RadioField
79
+ value="alpha"
80
+ onChange={onChange}
81
+ field={{ name: 'tier', type: 'radio' } as any}
82
+ />,
83
+ );
84
+ expect(screen.getByTestId('radio-empty-tier')).toBeInTheDocument();
85
+ expect(onChange).not.toHaveBeenCalled();
86
+ });
87
+
88
+ it('SelectField keeps its value and shows the unfillable state', () => {
89
+ const onChange = vi.fn();
90
+ render(
91
+ <SelectField
92
+ value="alpha"
93
+ onChange={onChange}
94
+ field={{ name: 'stage', type: 'select' } as any}
95
+ />,
96
+ );
97
+ expect(screen.getByTestId('select-empty-stage')).toBeInTheDocument();
98
+ expect(onChange).not.toHaveBeenCalled();
99
+ });
100
+ });
101
+
102
+ describe('controls — a CONFIGURED list still cascade-clears what it no longer offers', () => {
103
+ it('MultiSelectField drops the unoffered element, keeps the offered one', () => {
104
+ const onChange = vi.fn();
105
+ render(
106
+ <MultiSelectField
107
+ value={['alpha', 'zeta']}
108
+ onChange={onChange}
109
+ field={{ name: 'topics', type: 'multiselect', options: OPTIONS } as any}
110
+ />,
111
+ );
112
+ expect(onChange).toHaveBeenCalledWith(['alpha']);
113
+ });
114
+
115
+ it('CheckboxesField drops the unoffered element, keeps the offered one', () => {
116
+ const onChange = vi.fn();
117
+ render(
118
+ <CheckboxesField
119
+ value={['alpha', 'zeta']}
120
+ onChange={onChange}
121
+ field={{ name: 'perks', type: 'checkboxes', options: OPTIONS } as any}
122
+ />,
123
+ );
124
+ expect(onChange).toHaveBeenCalledWith(['alpha']);
125
+ });
126
+
127
+ it('RadioField clears a value the configured list does not offer', () => {
128
+ const onChange = vi.fn();
129
+ render(
130
+ <RadioField
131
+ value="zeta"
132
+ onChange={onChange}
133
+ field={{ name: 'tier', type: 'radio', options: OPTIONS } as any}
134
+ />,
135
+ );
136
+ expect(onChange).toHaveBeenCalledWith(undefined);
137
+ });
138
+
139
+ it('SelectField clears a value the configured list does not offer', () => {
140
+ const onChange = vi.fn();
141
+ render(
142
+ <SelectField
143
+ value="zeta"
144
+ onChange={onChange}
145
+ field={{ name: 'stage', type: 'select', options: OPTIONS } as any}
146
+ />,
147
+ );
148
+ expect(onChange).toHaveBeenCalledWith(undefined);
149
+ });
150
+ });
@@ -6,6 +6,11 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
 
9
+ import {
10
+ pickDomProps,
11
+ type SduiDomPassThroughKey,
12
+ type DomProps as CoreDomProps,
13
+ } from '@object-ui/core';
9
14
  import type { FieldWidgetComponentProps, FieldWidgetDomProps } from './types';
10
15
 
11
16
  /**
@@ -62,6 +67,34 @@ import type { FieldWidgetComponentProps, FieldWidgetDomProps } from './types';
62
67
  * open spread. If a field node should be able to author one, DECLARE it on
63
68
  * `FieldWidgetComponentProps` first and add it here — do not reopen the spread
64
69
  * (AGENTS.md #0.1: fix the contract, never widen the consumer).
70
+ *
71
+ * ## Where the mechanism lives now, and why the key list stayed here
72
+ *
73
+ * objectui#4425 phase 2 promoted this whitelist to the SDUI widget contract
74
+ * generally, so the MECHANISM — filter by a declared key list plus the `aria-*`
75
+ * / `data-*` open families — moved to `@object-ui/core`
76
+ * (`utils/dom-props.ts`, `pickDomProps`), where every plugin package can reach
77
+ * it without depending on `@object-ui/fields` (the objectui#4409
78
+ * dependency-direction method: `plugin-chatbot` declares `@object-ui/core` and
79
+ * does not declare this package).
80
+ *
81
+ * The KEY LIST did not move, because it is not the same list. Measured on
82
+ * objectui#4431:
83
+ *
84
+ * - `name` and `disabled` are here and NOT in the SDUI set: both are legal
85
+ * only on form controls, which is what every widget in this package renders
86
+ * and what `FieldWidgetComponentProps` declares. `name` on the `<div>` an
87
+ * SDUI container widget renders is a leak — one of the 14 attributes #4431
88
+ * closed — and two widgets here already hand-strip it when they spread onto
89
+ * something that is not a control (`ObjectRefField`'s trigger,
90
+ * `FileField`'s dropzone).
91
+ * - `role` is in the SDUI set and NOT here: `SchemaRenderer` resolves it for
92
+ * every node from `@objectstack/spec`'s `AriaPropsSchema`, while this
93
+ * contract deliberately does not declare it (see "Deliberately NOT
94
+ * forwarded" above).
95
+ *
96
+ * So: one mechanism, two declarations, each bound by the assertions below to
97
+ * the contract it executes. Not two judges.
65
98
  */
66
99
  const DOM_PASS_THROUGH_KEYS = [
67
100
  /* ── The contract's own "DOM pass-through" block, verbatim ─────────────── */
@@ -126,22 +159,24 @@ type EveryDeclaredDomKeyIsForwarded =
126
159
  const _everyDeclaredDomKeyIsForwarded: EveryDeclaredDomKeyIsForwarded = true;
127
160
  void _everyDeclaredDomKeyIsForwarded;
128
161
 
129
- const DOM_PASS_THROUGH: ReadonlySet<string> = new Set<string>(DOM_PASS_THROUGH_KEYS);
130
-
131
162
  /**
132
- * `aria-*` is declared on the contract as React's `AriaAttributes`; `data-*`
133
- * is declared as an open template-literal family (open in HTML too, and the
134
- * only open family the contract has). Both are matched by prefix so the helper
135
- * needs no per-attribute list.
163
+ * Direction 3, added with the core lift (objectui#4431): every key of the SDUI
164
+ * contract's own whitelist that this contract also declares must be forwarded
165
+ * here too, so the two lists cannot silently drift apart. `role` is the ONE
166
+ * measured exception — the SDUI contract declares it, this one does not — and
167
+ * naming it in the `Exclude` is what makes the divergence a deliberate,
168
+ * reviewable act rather than an omission.
169
+ *
170
+ * Catches: a key added to the shared SDUI set that field widgets quietly stop
171
+ * delivering.
136
172
  */
137
- function isOpenDomFamily(key: string): boolean {
138
- return key.startsWith('data-') || key.startsWith('aria-');
139
- }
173
+ type SharedSduiKey = Exclude<SduiDomPassThroughKey, 'role'>;
174
+ type EverySharedSduiKeyIsForwarded = SharedSduiKey extends DomPassThroughKey ? true : never;
175
+ const _everySharedSduiKeyIsForwarded: EverySharedSduiKeyIsForwarded = true;
176
+ void _everySharedSduiKeyIsForwarded;
140
177
 
141
178
  /** The subset of `P` this helper forwards, with each key's declared type. */
142
- export type DomProps<P> = Pick<P, Extract<keyof P, DomPassThroughKey>> & {
143
- [K in Extract<keyof P, `data-${string}` | `aria-${string}`>]: P[K];
144
- };
179
+ export type DomProps<P> = CoreDomProps<P, DomPassThroughKey>;
145
180
 
146
181
  /**
147
182
  * Keep only what may legitimately become a DOM attribute, and drop everything
@@ -165,11 +200,5 @@ export type DomProps<P> = Pick<P, Extract<keyof P, DomPassThroughKey>> & {
165
200
  * governs what gets spread.
166
201
  */
167
202
  export function toDomProps<P extends object>(props: P): DomProps<P> {
168
- const domProps: Record<string, unknown> = {};
169
- for (const key of Object.keys(props)) {
170
- if (DOM_PASS_THROUGH.has(key) || isOpenDomFamily(key)) {
171
- domProps[key] = (props as Record<string, unknown>)[key];
172
- }
173
- }
174
- return domProps as DomProps<P>;
203
+ return pickDomProps(props, DOM_PASS_THROUGH_KEYS);
175
204
  }