@object-ui/fields 17.5.0 → 17.7.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 (249) hide show
  1. package/CHANGELOG.md +6150 -0
  2. package/README.md +108 -8
  3. package/dist/FieldEditWidget.d.ts +70 -8
  4. package/dist/coerceToSafeValue.d.ts +54 -0
  5. package/dist/currency.d.ts +1 -1
  6. package/dist/field-type-alias.d.ts +16 -0
  7. package/dist/index.cjs +5 -5
  8. package/dist/index.css +53 -11
  9. package/dist/index.d.ts +653 -128
  10. package/dist/index.js +4275 -3171
  11. package/dist/widgets/AddressField.d.ts +3 -3
  12. package/dist/widgets/AutoNumberField.d.ts +1 -1
  13. package/dist/widgets/AvatarField.d.ts +20 -4
  14. package/dist/widgets/BooleanField.d.ts +2 -2
  15. package/dist/widgets/CapabilityMultiSelectField.d.ts +1 -1
  16. package/dist/widgets/CheckboxesField.d.ts +1 -1
  17. package/dist/widgets/CodeField.d.ts +1 -1
  18. package/dist/widgets/ColorField.d.ts +1 -1
  19. package/dist/widgets/CurrencyField.d.ts +1 -1
  20. package/dist/widgets/DateField.d.ts +2 -2
  21. package/dist/widgets/DateTimeField.d.ts +2 -2
  22. package/dist/widgets/EmailField.d.ts +1 -1
  23. package/dist/widgets/FileField.d.ts +18 -2
  24. package/dist/widgets/FilterConditionField.d.ts +43 -2
  25. package/dist/widgets/FormulaField.d.ts +1 -1
  26. package/dist/widgets/FullscreenFieldEditor.d.ts +9 -2
  27. package/dist/widgets/GeolocationField.d.ts +10 -4
  28. package/dist/widgets/GridField.d.ts +102 -86
  29. package/dist/widgets/ImageField.d.ts +1 -1
  30. package/dist/widgets/LocationField.d.ts +31 -4
  31. package/dist/widgets/LookupField.d.ts +9 -1
  32. package/dist/widgets/MasterDetailField.d.ts +1 -1
  33. package/dist/widgets/MultiSelectField.d.ts +1 -1
  34. package/dist/widgets/NumberField.d.ts +2 -2
  35. package/dist/widgets/ObjectField.d.ts +10 -3
  36. package/dist/widgets/ObjectRefField.d.ts +1 -1
  37. package/dist/widgets/OptionsEmptyState.d.ts +38 -8
  38. package/dist/widgets/PasswordField.d.ts +15 -1
  39. package/dist/widgets/PeoplePicker.d.ts +17 -2
  40. package/dist/widgets/PercentField.d.ts +3 -2
  41. package/dist/widgets/PersonRow.d.ts +13 -2
  42. package/dist/widgets/PhoneField.d.ts +1 -1
  43. package/dist/widgets/QRCodeField.d.ts +1 -1
  44. package/dist/widgets/RadioField.d.ts +1 -1
  45. package/dist/widgets/RatingField.d.ts +1 -1
  46. package/dist/widgets/RecipientPickerField.d.ts +44 -1
  47. package/dist/widgets/RecordPickerDialog.d.ts +33 -16
  48. package/dist/widgets/RichTextField.d.ts +37 -1
  49. package/dist/widgets/SelectField.d.ts +1 -1
  50. package/dist/widgets/SelectionTray.d.ts +13 -2
  51. package/dist/widgets/SignatureField.d.ts +32 -2
  52. package/dist/widgets/SliderField.d.ts +37 -2
  53. package/dist/widgets/SummaryField.d.ts +1 -1
  54. package/dist/widgets/TagsField.d.ts +1 -1
  55. package/dist/widgets/TextAreaField.d.ts +6 -2
  56. package/dist/widgets/TextField.d.ts +2 -2
  57. package/dist/widgets/TimeField.d.ts +2 -2
  58. package/dist/widgets/UrlField.d.ts +1 -1
  59. package/dist/widgets/UserField.d.ts +1 -1
  60. package/dist/widgets/VectorField.d.ts +1 -1
  61. package/dist/widgets/_cell-renderer-bridge.d.ts +1 -1
  62. package/dist/widgets/address-format.d.ts +27 -7
  63. package/dist/widgets/file-affordance.d.ts +33 -0
  64. package/dist/widgets/file-size-guard.d.ts +5 -23
  65. package/dist/widgets/file-value.d.ts +38 -9
  66. package/dist/widgets/lookupColumnDisplay.d.ts +110 -0
  67. package/dist/widgets/nativeDateValue.d.ts +9 -27
  68. package/dist/widgets/number-format.d.ts +137 -0
  69. package/dist/widgets/numberBadInput.d.ts +135 -0
  70. package/dist/widgets/personDisplay.d.ts +22 -4
  71. package/dist/widgets/richTextDisplay.d.ts +116 -0
  72. package/dist/widgets/toDomProps.d.ts +10 -1
  73. package/dist/widgets/toHostGroupProps.d.ts +172 -21
  74. package/dist/widgets/toHostProps.d.ts +105 -0
  75. package/dist/widgets/types.d.ts +19 -6
  76. package/dist/widgets/upload-error-message.d.ts +6 -0
  77. package/dist/widgets/uploadingScope.d.ts +142 -0
  78. package/dist/widgets/useCascadingOptions.d.ts +3 -2
  79. package/dist/widgets/useRecordQuery.d.ts +4 -2
  80. package/dist/widgets/useUploadingSignal.d.ts +8 -0
  81. package/package.json +11 -13
  82. package/dist/widgets/CharacterCount.d.ts +0 -56
  83. package/src/FieldEditWidget.test.ts +0 -133
  84. package/src/FieldEditWidget.tsx +0 -207
  85. package/src/__tests__/AddressCellRenderer.test.tsx +0 -166
  86. package/src/__tests__/CurrencyField.minorUnits.test.tsx +0 -191
  87. package/src/__tests__/CurrencyField.symbol.test.tsx +0 -198
  88. package/src/__tests__/DateCellRenderer.test.tsx +0 -82
  89. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +0 -146
  90. package/src/__tests__/NumberCellRenderer.test.tsx +0 -60
  91. package/src/__tests__/PercentCellRenderer.locale.test.tsx +0 -152
  92. package/src/__tests__/PercentCellRenderer.test.tsx +0 -115
  93. package/src/__tests__/boolean-label-association-e2e.test.tsx +0 -298
  94. package/src/__tests__/capability-multiselect-retired.test.ts +0 -109
  95. package/src/__tests__/cell-truncation.test.tsx +0 -96
  96. package/src/__tests__/composite-group-label-e2e.test.tsx +0 -436
  97. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +0 -403
  98. package/src/__tests__/date-formatter-residue-4272.test.ts +0 -120
  99. package/src/__tests__/date-locale-channel.test.tsx +0 -305
  100. package/src/__tests__/field-carrier-sdui.test.tsx +0 -169
  101. package/src/__tests__/formatCurrency.centsDigit.test.tsx +0 -158
  102. package/src/__tests__/formatCurrency.minorUnits.test.tsx +0 -221
  103. package/src/__tests__/group-labelling-declaration.test.ts +0 -148
  104. package/src/__tests__/lookupCellDisplayField.test.tsx +0 -107
  105. package/src/__tests__/lookupCellDisplayName.test.tsx +0 -119
  106. package/src/__tests__/lookupCellRecordLink.test.tsx +0 -171
  107. package/src/__tests__/lookupCellReferenceKey.test.tsx +0 -57
  108. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +0 -115
  109. package/src/__tests__/percent-formatter-locale-4553.test.ts +0 -147
  110. package/src/__tests__/percent-tie-halfup-4590.test.ts +0 -224
  111. package/src/__tests__/registry-collision.test.ts +0 -91
  112. package/src/__tests__/resolveFieldCurrency.test.ts +0 -20
  113. package/src/__tests__/select-label-association-e2e.test.tsx +0 -199
  114. package/src/__tests__/spec-symbol-batch7.test.ts +0 -126
  115. package/src/__tests__/validation-feedback.test.tsx +0 -361
  116. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +0 -265
  117. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +0 -338
  118. package/src/__tests__/widget-dom-leak-e2e.test.tsx +0 -357
  119. package/src/__tests__/widget-props-contract.test.tsx +0 -142
  120. package/src/coerce-safe-value.test.ts +0 -34
  121. package/src/complex-widgets.test.tsx +0 -924
  122. package/src/currency.ts +0 -144
  123. package/src/datetime-widgets.test.tsx +0 -185
  124. package/src/field-type-alias.multiple.test.ts +0 -81
  125. package/src/field-type-alias.ts +0 -156
  126. package/src/field-type-coverage.test.ts +0 -67
  127. package/src/index.css +0 -44
  128. package/src/index.tsx +0 -2745
  129. package/src/standard-widgets.test.tsx +0 -364
  130. package/src/widgets/AddressField.postalCode.test.tsx +0 -257
  131. package/src/widgets/AddressField.tsx +0 -182
  132. package/src/widgets/AddressField.uniqueIds.test.tsx +0 -93
  133. package/src/widgets/AutoNumberField.tsx +0 -18
  134. package/src/widgets/AvatarField.tsx +0 -117
  135. package/src/widgets/BooleanField.tsx +0 -86
  136. package/src/widgets/CapabilityMultiSelectField.test.tsx +0 -168
  137. package/src/widgets/CapabilityMultiSelectField.tsx +0 -241
  138. package/src/widgets/CharacterCount.tsx +0 -263
  139. package/src/widgets/CheckboxesField.cascade.test.tsx +0 -143
  140. package/src/widgets/CheckboxesField.tsx +0 -169
  141. package/src/widgets/CodeField.tsx +0 -41
  142. package/src/widgets/ColorField.tsx +0 -53
  143. package/src/widgets/CurrencyField.tsx +0 -135
  144. package/src/widgets/DateField.tsx +0 -40
  145. package/src/widgets/DateTimeField.tsx +0 -46
  146. package/src/widgets/EmailField.tsx +0 -37
  147. package/src/widgets/FileField.tsx +0 -512
  148. package/src/widgets/FilterConditionField.tsx +0 -454
  149. package/src/widgets/FormulaField.tsx +0 -38
  150. package/src/widgets/FullscreenFieldEditor.tsx +0 -60
  151. package/src/widgets/GeolocationField.tsx +0 -193
  152. package/src/widgets/GeolocationField.uniqueIds.test.tsx +0 -88
  153. package/src/widgets/GridField.test.tsx +0 -632
  154. package/src/widgets/GridField.tsx +0 -1070
  155. package/src/widgets/ImageCropperDialog.tsx +0 -245
  156. package/src/widgets/ImageField.maxSize.test.tsx +0 -154
  157. package/src/widgets/ImageField.tsx +0 -305
  158. package/src/widgets/ImageLightbox.test.tsx +0 -52
  159. package/src/widgets/ImageLightbox.tsx +0 -108
  160. package/src/widgets/LocationField.tsx +0 -57
  161. package/src/widgets/LookupField.dependsOn.test.tsx +0 -233
  162. package/src/widgets/LookupField.gateHintLabel.test.tsx +0 -135
  163. package/src/widgets/LookupField.hydration.test.tsx +0 -136
  164. package/src/widgets/LookupField.idField.test.tsx +0 -61
  165. package/src/widgets/LookupField.tsx +0 -1295
  166. package/src/widgets/MarkdownContent.tsx +0 -19
  167. package/src/widgets/MasterDetailField.tsx +0 -131
  168. package/src/widgets/MultiSelectField.cascade.test.tsx +0 -143
  169. package/src/widgets/MultiSelectField.tsx +0 -196
  170. package/src/widgets/NumberField.tsx +0 -49
  171. package/src/widgets/ObjectField.tsx +0 -94
  172. package/src/widgets/ObjectRefField.tsx +0 -121
  173. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +0 -88
  174. package/src/widgets/OptionsEmptyState.test.tsx +0 -169
  175. package/src/widgets/OptionsEmptyState.tsx +0 -99
  176. package/src/widgets/PasswordField.tsx +0 -49
  177. package/src/widgets/PeoplePicker.test.tsx +0 -360
  178. package/src/widgets/PeoplePicker.tsx +0 -579
  179. package/src/widgets/PeoplePickerComponents.test.tsx +0 -104
  180. package/src/widgets/PercentField.tsx +0 -95
  181. package/src/widgets/PersonRow.tsx +0 -117
  182. package/src/widgets/PhoneField.tsx +0 -37
  183. package/src/widgets/QRCodeField.tsx +0 -103
  184. package/src/widgets/RadioField.cascade.test.tsx +0 -141
  185. package/src/widgets/RadioField.tsx +0 -143
  186. package/src/widgets/RatingField.tsx +0 -94
  187. package/src/widgets/RecipientPickerField.test.tsx +0 -157
  188. package/src/widgets/RecipientPickerField.tsx +0 -188
  189. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +0 -191
  190. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +0 -124
  191. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +0 -611
  192. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +0 -112
  193. package/src/widgets/RecordPickerDialog.tsx +0 -1365
  194. package/src/widgets/RichTextField.tsx +0 -176
  195. package/src/widgets/SelectField.cascade.test.tsx +0 -138
  196. package/src/widgets/SelectField.tsx +0 -193
  197. package/src/widgets/SelectionTray.tsx +0 -110
  198. package/src/widgets/SignatureField.tsx +0 -140
  199. package/src/widgets/SliderField.tsx +0 -39
  200. package/src/widgets/SummaryField.tsx +0 -31
  201. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +0 -68
  202. package/src/widgets/TagsField.placeholder.test.tsx +0 -95
  203. package/src/widgets/TagsField.tsx +0 -88
  204. package/src/widgets/TextAreaField.tsx +0 -213
  205. package/src/widgets/TextField.tsx +0 -45
  206. package/src/widgets/TimeField.tsx +0 -31
  207. package/src/widgets/UrlField.tsx +0 -46
  208. package/src/widgets/UserField.tsx +0 -59
  209. package/src/widgets/UserFieldPicker.test.tsx +0 -78
  210. package/src/widgets/VectorField.tsx +0 -29
  211. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +0 -66
  212. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +0 -166
  213. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +0 -248
  214. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +0 -116
  215. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +0 -244
  216. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +0 -195
  217. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +0 -417
  218. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +0 -287
  219. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +0 -160
  220. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +0 -161
  221. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +0 -346
  222. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +0 -246
  223. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +0 -175
  224. package/src/widgets/_cell-renderer-bridge.ts +0 -19
  225. package/src/widgets/address-format.ts +0 -120
  226. package/src/widgets/deriveLookupColumns.test.ts +0 -112
  227. package/src/widgets/deriveLookupColumns.ts +0 -108
  228. package/src/widgets/file-size-guard.ts +0 -55
  229. package/src/widgets/file-value.test.ts +0 -230
  230. package/src/widgets/file-value.ts +0 -246
  231. package/src/widgets/nativeDateValue.test.ts +0 -115
  232. package/src/widgets/nativeDateValue.ts +0 -112
  233. package/src/widgets/openNativePicker.ts +0 -18
  234. package/src/widgets/optionWidgets.gatedOptions.test.tsx +0 -267
  235. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +0 -150
  236. package/src/widgets/personDisplay.test.ts +0 -113
  237. package/src/widgets/personDisplay.ts +0 -92
  238. package/src/widgets/recentLookups.test.ts +0 -45
  239. package/src/widgets/recentLookups.ts +0 -47
  240. package/src/widgets/toDomProps.ts +0 -204
  241. package/src/widgets/toHostGroupProps.ts +0 -102
  242. package/src/widgets/types.ts +0 -242
  243. package/src/widgets/useCascadingOptions.ts +0 -46
  244. package/src/widgets/useFieldTranslation.ts +0 -132
  245. package/src/widgets/useRecordQuery.test.ts +0 -174
  246. package/src/widgets/useRecordQuery.ts +0 -291
  247. package/src/widgets/useUploadingSignal.test.tsx +0 -62
  248. package/src/widgets/useUploadingSignal.ts +0 -20
  249. package/src/withFieldCarrier.tsx +0 -75
@@ -1,361 +0,0 @@
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
- * P3.2 Field Widget Polish - Validation Feedback
11
- *
12
- * Tests that field widgets consistently handle validation feedback:
13
- * `error` prop, aria-invalid attribute, and disabled states.
14
- *
15
- * The slot is `error` — the name `@objectstack/spec/ui`'s
16
- * `FieldWidgetPropsSchema` gives it — since objectui#3222. These are the
17
- * CONSUMER half only: they prove a widget handed a message marks its control
18
- * invalid. That was true before #3222 too, under the name `errorMessage`, and
19
- * it did not help anyone, because no host ever passed the prop. The PRODUCER
20
- * half is `form-error-delivery.test.tsx` in `@object-ui/components` plus
21
- * `widget-aria-invalid-e2e.test.tsx` next to this file; a rename alone would
22
- * have swapped one dead key for another.
23
- */
24
-
25
- import { describe, it, expect, vi } from 'vitest';
26
- import React from 'react';
27
- import { render, screen } from '@testing-library/react';
28
- import '@testing-library/jest-dom';
29
-
30
- import { TextField } from '../widgets/TextField';
31
- import { NumberField } from '../widgets/NumberField';
32
- import { EmailField } from '../widgets/EmailField';
33
- import { UrlField } from '../widgets/UrlField';
34
- import { PhoneField } from '../widgets/PhoneField';
35
- import { TextAreaField } from '../widgets/TextAreaField';
36
- import { CurrencyField } from '../widgets/CurrencyField';
37
- import { BooleanField } from '../widgets/BooleanField';
38
- import { SelectField } from '../widgets/SelectField';
39
-
40
- const noop = vi.fn();
41
-
42
- describe('P3.2 Validation Feedback', () => {
43
- // ---------------------------------------------------------------
44
- // aria-invalid on `error`
45
- // ---------------------------------------------------------------
46
- describe('aria-invalid attribute', () => {
47
- it('EmailField sets aria-invalid when `error` provided', () => {
48
- render(
49
- <EmailField
50
- value=""
51
- onChange={noop}
52
- field={{ type: 'email' } as any}
53
- error="Invalid email"
54
- />
55
- );
56
- const input = screen.getByRole('textbox');
57
- expect(input).toHaveAttribute('aria-invalid', 'true');
58
- });
59
-
60
- it('EmailField does not set aria-invalid without `error`', () => {
61
- render(
62
- <EmailField
63
- value=""
64
- onChange={noop}
65
- field={{ type: 'email' } as any}
66
- />
67
- );
68
- const input = screen.getByRole('textbox');
69
- expect(input).not.toHaveAttribute('aria-invalid', 'true');
70
- });
71
-
72
- it('UrlField sets aria-invalid when `error` provided', () => {
73
- render(
74
- <UrlField
75
- value=""
76
- onChange={noop}
77
- field={{ type: 'url' } as any}
78
- error="Invalid URL"
79
- />
80
- );
81
- const input = screen.getByDisplayValue('');
82
- expect(input).toHaveAttribute('aria-invalid', 'true');
83
- });
84
-
85
- it('PhoneField sets aria-invalid when `error` provided', () => {
86
- render(
87
- <PhoneField
88
- value=""
89
- onChange={noop}
90
- field={{ type: 'phone' } as any}
91
- error="Invalid phone"
92
- />
93
- );
94
- const input = screen.getByRole('textbox');
95
- expect(input).toHaveAttribute('aria-invalid', 'true');
96
- });
97
-
98
- it('TextAreaField sets aria-invalid when `error` provided', () => {
99
- render(
100
- <TextAreaField
101
- value=""
102
- onChange={noop}
103
- field={{ type: 'textarea' } as any}
104
- error="Required"
105
- />
106
- );
107
- const textarea = screen.getByRole('textbox');
108
- expect(textarea).toHaveAttribute('aria-invalid', 'true');
109
- });
110
-
111
- it('CurrencyField sets aria-invalid when `error` provided', () => {
112
- render(
113
- <CurrencyField
114
- value={0}
115
- onChange={noop}
116
- field={{ type: 'currency' } as any}
117
- error="Must be positive"
118
- />
119
- );
120
- const input = screen.getByRole('spinbutton');
121
- expect(input).toHaveAttribute('aria-invalid', 'true');
122
- });
123
- });
124
-
125
- // ---------------------------------------------------------------
126
- // Disabled state consistency
127
- // ---------------------------------------------------------------
128
- describe('disabled state', () => {
129
- it('TextField supports disabled prop', () => {
130
- render(
131
- <TextField
132
- value="test"
133
- onChange={noop}
134
- field={{ type: 'text' } as any}
135
- disabled={true}
136
- />
137
- );
138
- expect(screen.getByRole('textbox')).toBeDisabled();
139
- });
140
-
141
- it('NumberField supports disabled prop', () => {
142
- render(
143
- <NumberField
144
- value={5}
145
- onChange={noop}
146
- field={{ type: 'number' } as any}
147
- disabled={true}
148
- />
149
- );
150
- expect(screen.getByRole('spinbutton')).toBeDisabled();
151
- });
152
-
153
- it('EmailField supports disabled prop', () => {
154
- render(
155
- <EmailField
156
- value=""
157
- onChange={noop}
158
- field={{ type: 'email' } as any}
159
- disabled={true}
160
- />
161
- );
162
- expect(screen.getByRole('textbox')).toBeDisabled();
163
- });
164
-
165
- it('PhoneField supports disabled prop', () => {
166
- render(
167
- <PhoneField
168
- value=""
169
- onChange={noop}
170
- field={{ type: 'phone' } as any}
171
- disabled={true}
172
- />
173
- );
174
- expect(screen.getByRole('textbox')).toBeDisabled();
175
- });
176
-
177
- it('TextAreaField supports disabled prop', () => {
178
- render(
179
- <TextAreaField
180
- value=""
181
- onChange={noop}
182
- field={{ type: 'textarea' } as any}
183
- disabled={true}
184
- />
185
- );
186
- expect(screen.getByRole('textbox')).toBeDisabled();
187
- });
188
- });
189
-
190
- // ---------------------------------------------------------------
191
- // Readonly mode consistency
192
- // ---------------------------------------------------------------
193
- describe('readonly mode output', () => {
194
- it('TextField shows dash for empty value in readonly', () => {
195
- const { container } = render(
196
- <TextField
197
- value=""
198
- onChange={noop}
199
- field={{ type: 'text' } as any}
200
- readonly={true}
201
- />
202
- );
203
- expect(container.textContent).toBe('—');
204
- });
205
-
206
- it('NumberField shows dash for null value in readonly', () => {
207
- const { container } = render(
208
- <NumberField
209
- value={null as any}
210
- onChange={noop}
211
- field={{ type: 'number' } as any}
212
- readonly={true}
213
- />
214
- );
215
- expect(container.textContent).toBe('—');
216
- });
217
-
218
- it('EmailField shows dash for empty value in readonly', () => {
219
- const { container } = render(
220
- <EmailField
221
- value=""
222
- onChange={noop}
223
- field={{ type: 'email' } as any}
224
- readonly={true}
225
- />
226
- );
227
- expect(container.textContent).toBe('—');
228
- });
229
-
230
- it('UrlField shows dash for empty value in readonly', () => {
231
- const { container } = render(
232
- <UrlField
233
- value=""
234
- onChange={noop}
235
- field={{ type: 'url' } as any}
236
- readonly={true}
237
- />
238
- );
239
- expect(container.textContent).toBe('—');
240
- });
241
-
242
- it('PhoneField shows dash for empty value in readonly', () => {
243
- const { container } = render(
244
- <PhoneField
245
- value=""
246
- onChange={noop}
247
- field={{ type: 'phone' } as any}
248
- readonly={true}
249
- />
250
- );
251
- expect(container.textContent).toBe('—');
252
- });
253
-
254
- it('TextAreaField shows dash for empty value in readonly', () => {
255
- const { container } = render(
256
- <TextAreaField
257
- value=""
258
- onChange={noop}
259
- field={{ type: 'textarea' } as any}
260
- readonly={true}
261
- />
262
- );
263
- expect(container.textContent).toBe('—');
264
- });
265
-
266
- it('BooleanField shows Yes/No in readonly', () => {
267
- const { container: yesC } = render(
268
- <BooleanField
269
- value={true}
270
- onChange={noop}
271
- field={{ type: 'boolean' } as any}
272
- readonly={true}
273
- />
274
- );
275
- expect(yesC.textContent).toBe('Yes');
276
-
277
- const { container: noC } = render(
278
- <BooleanField
279
- value={false}
280
- onChange={noop}
281
- field={{ type: 'boolean' } as any}
282
- readonly={true}
283
- />
284
- );
285
- expect(noC.textContent).toBe('No');
286
- });
287
-
288
- it('SelectField shows dash for empty value in readonly', () => {
289
- const { container } = render(
290
- <SelectField
291
- value=""
292
- onChange={noop}
293
- field={{ type: 'select', options: [] } as any}
294
- readonly={true}
295
- />
296
- );
297
- expect(container.textContent).toBe('—');
298
- });
299
-
300
- it('CurrencyField shows dash for null value in readonly', () => {
301
- const { container } = render(
302
- <CurrencyField
303
- value={null as any}
304
- onChange={noop}
305
- field={{ type: 'currency' } as any}
306
- readonly={true}
307
- />
308
- );
309
- expect(container.textContent).toBe('—');
310
- });
311
- });
312
-
313
- // ---------------------------------------------------------------
314
- // Placeholder consistency
315
- // ---------------------------------------------------------------
316
- describe('placeholder support', () => {
317
- it('TextField uses field placeholder', () => {
318
- render(
319
- <TextField
320
- value=""
321
- onChange={noop}
322
- field={{ type: 'text', placeholder: 'Enter name...' } as any}
323
- />
324
- );
325
- expect(screen.getByPlaceholderText('Enter name...')).toBeInTheDocument();
326
- });
327
-
328
- it('EmailField uses default placeholder', () => {
329
- render(
330
- <EmailField
331
- value=""
332
- onChange={noop}
333
- field={{ type: 'email' } as any}
334
- />
335
- );
336
- expect(screen.getByPlaceholderText('email@example.com')).toBeInTheDocument();
337
- });
338
-
339
- it('UrlField uses default placeholder', () => {
340
- render(
341
- <UrlField
342
- value=""
343
- onChange={noop}
344
- field={{ type: 'url' } as any}
345
- />
346
- );
347
- expect(screen.getByPlaceholderText('https://example.com')).toBeInTheDocument();
348
- });
349
-
350
- it('PhoneField uses default placeholder', () => {
351
- render(
352
- <PhoneField
353
- value=""
354
- onChange={noop}
355
- field={{ type: 'phone' } as any}
356
- />
357
- );
358
- expect(screen.getByPlaceholderText('(555) 123-4567')).toBeInTheDocument();
359
- });
360
- });
361
- });
@@ -1,265 +0,0 @@
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
- * End-to-end: a REAL widget, inside the REAL form renderer, announces an
11
- * invalid field to assistive tech (objectui#3222).
12
- *
13
- * The two halves of this contract have unit tests of their own — the widgets
14
- * mark themselves invalid when handed an `error`
15
- * (`validation-feedback.test.tsx`), and the form renderer produces one
16
- * (`form-error-delivery.test.tsx` in `@object-ui/components`). Neither alone
17
- * would have caught the actual defect, because the defect was precisely that
18
- * the two halves never met: the widgets read `errorMessage`, the spec's
19
- * published contract said `error`, and NOTHING in `packages/` or `apps/`
20
- * produced either. Seven widgets computed `aria-invalid={!!errorMessage}` from
21
- * a value that was `undefined` forever, so `aria-invalid` had never once been
22
- * set on a failing field.
23
- *
24
- * So this file joins them: real react-hook-form validation, real widget, one
25
- * assertion per widget that the control is `aria-invalid="true"` after the
26
- * failure and `"false"` before it. `aria-invalid="false"` up front is the load
27
- * bearing half of each pair — `<FormControl>` is a Radix `Slot` that hands its
28
- * child a correct `aria-invalid`, and the widget's own attribute is written
29
- * AFTER the props spread, so it wins. A widget computing it from a prop nobody
30
- * passes does not merely miss the signal, it OVERWRITES the correct one with
31
- * `false`.
32
- *
33
- * The widgets are registered raw rather than through `registerAllFields()`,
34
- * which wraps every loader in `React.lazy`: an unbounded module load inside a
35
- * bounded `findBy`/`waitFor` window is the repo's known flake generator
36
- * (AGENTS.md 测试纪律, objectui#3010). Same component, no Suspense boundary.
37
- */
38
-
39
- import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest';
40
- import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
41
- import '@testing-library/jest-dom';
42
- import { ComponentRegistry } from '@object-ui/core';
43
- // Module scope: pulls in the form renderer's registration side effect.
44
- import '@object-ui/components';
45
-
46
- import { EmailField } from '../widgets/EmailField';
47
- import { PhoneField } from '../widgets/PhoneField';
48
- import { UrlField } from '../widgets/UrlField';
49
- import { TextAreaField } from '../widgets/TextAreaField';
50
- import { CurrencyField } from '../widgets/CurrencyField';
51
- import { PercentField } from '../widgets/PercentField';
52
- import { RichTextField } from '../widgets/RichTextField';
53
- import { TextField } from '../widgets/TextField';
54
- import { SelectField } from '../widgets/SelectField';
55
-
56
- /**
57
- * The widgets that read the validation slot, by their form-path key: the seven
58
- * from objectui#3222, plus `select` (objectui#3306) — the widget whose control
59
- * is a Radix trigger rather than a native input, where every aria-* used to be
60
- * swallowed by the non-DOM `Select.Root`.
61
- */
62
- const WIDGETS = [
63
- ['email', EmailField],
64
- ['phone', PhoneField],
65
- ['url', UrlField],
66
- ['textarea', TextAreaField],
67
- ['currency', CurrencyField],
68
- ['percent', PercentField],
69
- ['markdown', RichTextField],
70
- ['select', SelectField],
71
- ] as const;
72
-
73
- beforeAll(() => {
74
- for (const [type, Widget] of WIDGETS) {
75
- ComponentRegistry.register(type, Widget as any, { namespace: 'field', skipFallback: true });
76
- }
77
- }, 30000);
78
-
79
- beforeEach(() => {
80
- if (!(Element.prototype as any).scrollIntoView) {
81
- (Element.prototype as any).scrollIntoView = () => {};
82
- }
83
- });
84
-
85
- afterEach(() => {
86
- cleanup();
87
- vi.restoreAllMocks();
88
- });
89
-
90
- function renderForm(field: any, defaultValue: unknown) {
91
- const Form = ComponentRegistry.get('form')!;
92
- return render(
93
- <Form
94
- schema={{
95
- type: 'form',
96
- mode: 'create',
97
- showSubmit: true,
98
- showCancel: false,
99
- submitLabel: 'Create',
100
- defaultValues: { [field.name]: defaultValue },
101
- fields: [field],
102
- onSubmit: () => {},
103
- }}
104
- />,
105
- );
106
- }
107
-
108
- /**
109
- * The control the widget rendered, located through the form row rather than by
110
- * role — the seven widgets render `input[type=email|tel|url|number]` and
111
- * `textarea` between them, and the point of the assertion is the ATTRIBUTE,
112
- * not which element type carries it.
113
- */
114
- function controlOf(name: string): Element {
115
- const control = document.querySelector(
116
- `[data-field="${name}"] input, [data-field="${name}"] textarea`,
117
- );
118
- if (!control) throw new Error(`no control rendered for field "${name}"`);
119
- return control;
120
- }
121
-
122
- const submit = () => fireEvent.click(screen.getByRole('button', { name: /create/i }));
123
-
124
- describe('field widgets announce an invalid field to AT (objectui#3222)', () => {
125
- // `field:textarea` because the bare `textarea` key is a BUILTIN_FIELD_TYPE in
126
- // the form renderer and never reaches the registry — it is the prefixed id
127
- // (`mapFieldTypeToFormType`) that resolves to this package's widget.
128
- it.each([
129
- ['email', 'email', ''],
130
- ['phone', 'phone', ''],
131
- ['url', 'url', ''],
132
- ['field:textarea', 'notes', ''],
133
- ['currency', 'amount', null],
134
- ['percent', 'ratio', null],
135
- ['markdown', 'body', ''],
136
- ] as const)(
137
- 'a required %s field is aria-invalid only AFTER validation fails',
138
- async (type, name, emptyValue) => {
139
- renderForm({ name, label: name, type, required: true }, emptyValue);
140
-
141
- // Before the submit there is no error, and the widget must say so —
142
- // this is the half that a Slot-provided `aria-invalid` cannot fake.
143
- expect(controlOf(name)).toHaveAttribute('aria-invalid', 'false');
144
-
145
- submit();
146
-
147
- await waitFor(() => {
148
- expect(controlOf(name)).toHaveAttribute('aria-invalid', 'true');
149
- });
150
- },
151
- );
152
-
153
- it('the message itself is rendered once, by the form — not by the widget', async () => {
154
- // Responsibility split (the ruling's scope item 3): the widget consumes
155
- // `error` for `aria-invalid` ONLY. `<FormMessage/>` owns the text; a widget
156
- // that printed it too would show the user the same sentence twice.
157
- renderForm({ name: 'email', label: 'Email', type: 'email', required: true }, '');
158
-
159
- submit();
160
-
161
- await waitFor(() => expect(screen.getAllByText('Email is required')).toHaveLength(1));
162
- // And the message is NOT inside the control the widget rendered.
163
- expect(controlOf('email')).not.toHaveTextContent('Email is required');
164
- });
165
-
166
- it('a widget that does NOT read `error` is still marked invalid, by the Slot', async () => {
167
- // The counterpart that makes the diagnosis precise: `TextField` never
168
- // computed its own `aria-invalid`, so `<FormControl>`'s Slot value reached
169
- // its input untouched and it announced invalid fields correctly all along.
170
- // Only the seven widgets that DID compute one were broken — they wrote
171
- // theirs after the props spread, so `!!undefined` won.
172
- //
173
- // Pinned because the obvious "cleanup" here is to stop forwarding `error`
174
- // to widgets that do not declare a use for it. That cannot be known (a
175
- // third-party widget is exactly the case the contract exists for), and
176
- // withholding it would recreate declared-≠-delivered one widget at a time.
177
- ComponentRegistry.register('plaintext', TextField as any, { namespace: 'field', skipFallback: true });
178
- renderForm({ name: 'title', label: 'Title', type: 'plaintext', required: true }, '');
179
-
180
- submit();
181
-
182
- await waitFor(() => expect(controlOf('title')).toHaveAttribute('aria-invalid', 'true'));
183
- });
184
-
185
- it('clears the invalid state when the field becomes valid again', async () => {
186
- renderForm({ name: 'email', label: 'Email', type: 'email', required: true }, '');
187
-
188
- submit();
189
- await waitFor(() => expect(controlOf('email')).toHaveAttribute('aria-invalid', 'true'));
190
-
191
- fireEvent.change(controlOf('email'), { target: { value: 'a@example.com' } });
192
-
193
- await waitFor(() => expect(controlOf('email')).toHaveAttribute('aria-invalid', 'false'));
194
- });
195
- });
196
-
197
- describe('field:select announces validation state on its TRIGGER (objectui#3306)', () => {
198
- // Radix `Select.Root` renders no DOM element and silently drops every prop
199
- // it does not recognise. The widget used to spread its DOM pass-through onto
200
- // Root, so `aria-invalid` / `aria-describedby` / `aria-required` all
201
- // vanished: a failed required select showed the red message while assistive
202
- // tech was told nothing. The real control is the focusable
203
- // `<button role="combobox">` the trigger renders — that is where every
204
- // assertion below reads from, never a wrapper.
205
- //
206
- // `field:select` (not bare `select`) because `select` is a
207
- // BUILTIN_FIELD_TYPE in the form renderer and never reaches the registry —
208
- // same reason as `field:textarea` above.
209
- const OPTIONS = [
210
- { label: 'Alpha', value: 'alpha' },
211
- { label: 'Beta', value: 'beta' },
212
- ];
213
-
214
- const selectField = {
215
- name: 'choice',
216
- label: 'Choice',
217
- type: 'field:select',
218
- required: true,
219
- options: OPTIONS,
220
- };
221
-
222
- /** The trigger button, by role — fails loudly if it stops being a combobox. */
223
- function trigger(): Element {
224
- const el = document.querySelector('[data-field="choice"] [role="combobox"]');
225
- if (!el) throw new Error('no select trigger rendered for field "choice"');
226
- return el;
227
- }
228
-
229
- it('a required select is aria-invalid only AFTER validation fails, on the trigger', async () => {
230
- renderForm(selectField, null);
231
-
232
- // The explicit "false" half is load-bearing, same as the widgets above: a
233
- // valid field SAYS it is valid rather than staying mute.
234
- expect(trigger()).toHaveAttribute('aria-invalid', 'false');
235
-
236
- submit();
237
-
238
- // Prove the failure actually rendered before reading aria off it — an
239
- // error variant that silently produces no error tests nothing.
240
- await waitFor(() =>
241
- expect(screen.getAllByText('Choice is required')).toHaveLength(1),
242
- );
243
- expect(trigger()).toHaveAttribute('aria-invalid', 'true');
244
- });
245
-
246
- it('carries aria-required on the trigger — the state channel of objectui#3290', () => {
247
- renderForm(selectField, null);
248
-
249
- // Root would swallow it; the trigger is "the control" for a Radix select.
250
- expect(trigger()).toHaveAttribute('aria-required', 'true');
251
- });
252
-
253
- it('aria-describedby on the trigger references the rendered error message', async () => {
254
- renderForm(selectField, null);
255
-
256
- submit();
257
-
258
- const message = await screen.findByText('Choice is required');
259
- // The association is only real if the id chain closes: the trigger must
260
- // point at the exact element carrying the message text.
261
- expect(message.id).not.toBe('');
262
- const describedBy = trigger().getAttribute('aria-describedby') ?? '';
263
- expect(describedBy.split(/\s+/)).toContain(message.id);
264
- });
265
- });