@object-ui/fields 17.5.0 → 17.6.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 (229) hide show
  1. package/CHANGELOG.md +714 -0
  2. package/dist/FieldEditWidget.d.ts +16 -2
  3. package/dist/field-type-alias.d.ts +16 -0
  4. package/dist/index.cjs +4 -4
  5. package/dist/index.css +27 -9
  6. package/dist/index.d.ts +425 -72
  7. package/dist/index.js +2530 -2228
  8. package/dist/widgets/AddressField.d.ts +3 -3
  9. package/dist/widgets/AutoNumberField.d.ts +1 -1
  10. package/dist/widgets/AvatarField.d.ts +1 -1
  11. package/dist/widgets/BooleanField.d.ts +1 -1
  12. package/dist/widgets/CapabilityMultiSelectField.d.ts +1 -1
  13. package/dist/widgets/CheckboxesField.d.ts +1 -1
  14. package/dist/widgets/CodeField.d.ts +1 -1
  15. package/dist/widgets/ColorField.d.ts +1 -1
  16. package/dist/widgets/CurrencyField.d.ts +1 -1
  17. package/dist/widgets/DateField.d.ts +1 -1
  18. package/dist/widgets/DateTimeField.d.ts +1 -1
  19. package/dist/widgets/EmailField.d.ts +1 -1
  20. package/dist/widgets/FileField.d.ts +18 -2
  21. package/dist/widgets/FilterConditionField.d.ts +28 -1
  22. package/dist/widgets/FormulaField.d.ts +1 -1
  23. package/dist/widgets/FullscreenFieldEditor.d.ts +9 -2
  24. package/dist/widgets/GeolocationField.d.ts +1 -1
  25. package/dist/widgets/GridField.d.ts +56 -4
  26. package/dist/widgets/ImageField.d.ts +1 -1
  27. package/dist/widgets/LocationField.d.ts +1 -1
  28. package/dist/widgets/LookupField.d.ts +1 -1
  29. package/dist/widgets/MasterDetailField.d.ts +1 -1
  30. package/dist/widgets/MultiSelectField.d.ts +1 -1
  31. package/dist/widgets/NumberField.d.ts +1 -1
  32. package/dist/widgets/ObjectField.d.ts +1 -1
  33. package/dist/widgets/ObjectRefField.d.ts +1 -1
  34. package/dist/widgets/OptionsEmptyState.d.ts +13 -7
  35. package/dist/widgets/PasswordField.d.ts +1 -1
  36. package/dist/widgets/PercentField.d.ts +1 -1
  37. package/dist/widgets/PhoneField.d.ts +1 -1
  38. package/dist/widgets/QRCodeField.d.ts +1 -1
  39. package/dist/widgets/RadioField.d.ts +1 -1
  40. package/dist/widgets/RatingField.d.ts +1 -1
  41. package/dist/widgets/RecipientPickerField.d.ts +1 -1
  42. package/dist/widgets/RecordPickerDialog.d.ts +2 -4
  43. package/dist/widgets/RichTextField.d.ts +37 -1
  44. package/dist/widgets/SelectField.d.ts +1 -1
  45. package/dist/widgets/SignatureField.d.ts +32 -2
  46. package/dist/widgets/SliderField.d.ts +37 -2
  47. package/dist/widgets/SummaryField.d.ts +1 -1
  48. package/dist/widgets/TagsField.d.ts +1 -1
  49. package/dist/widgets/TextAreaField.d.ts +6 -2
  50. package/dist/widgets/TextField.d.ts +1 -1
  51. package/dist/widgets/TimeField.d.ts +1 -1
  52. package/dist/widgets/UrlField.d.ts +1 -1
  53. package/dist/widgets/UserField.d.ts +1 -1
  54. package/dist/widgets/VectorField.d.ts +1 -1
  55. package/dist/widgets/_cell-renderer-bridge.d.ts +1 -1
  56. package/dist/widgets/address-format.d.ts +27 -7
  57. package/dist/widgets/lookupColumnDisplay.d.ts +93 -0
  58. package/dist/widgets/richTextDisplay.d.ts +80 -0
  59. package/dist/widgets/toHostGroupProps.d.ts +119 -21
  60. package/dist/widgets/types.d.ts +8 -3
  61. package/package.json +9 -10
  62. package/dist/widgets/CharacterCount.d.ts +0 -56
  63. package/src/FieldEditWidget.test.ts +0 -133
  64. package/src/FieldEditWidget.tsx +0 -207
  65. package/src/__tests__/AddressCellRenderer.test.tsx +0 -166
  66. package/src/__tests__/CurrencyField.minorUnits.test.tsx +0 -191
  67. package/src/__tests__/CurrencyField.symbol.test.tsx +0 -198
  68. package/src/__tests__/DateCellRenderer.test.tsx +0 -82
  69. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +0 -146
  70. package/src/__tests__/NumberCellRenderer.test.tsx +0 -60
  71. package/src/__tests__/PercentCellRenderer.locale.test.tsx +0 -152
  72. package/src/__tests__/PercentCellRenderer.test.tsx +0 -115
  73. package/src/__tests__/boolean-label-association-e2e.test.tsx +0 -298
  74. package/src/__tests__/capability-multiselect-retired.test.ts +0 -109
  75. package/src/__tests__/cell-truncation.test.tsx +0 -96
  76. package/src/__tests__/composite-group-label-e2e.test.tsx +0 -436
  77. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +0 -403
  78. package/src/__tests__/date-formatter-residue-4272.test.ts +0 -120
  79. package/src/__tests__/date-locale-channel.test.tsx +0 -305
  80. package/src/__tests__/field-carrier-sdui.test.tsx +0 -169
  81. package/src/__tests__/formatCurrency.centsDigit.test.tsx +0 -158
  82. package/src/__tests__/formatCurrency.minorUnits.test.tsx +0 -221
  83. package/src/__tests__/group-labelling-declaration.test.ts +0 -148
  84. package/src/__tests__/lookupCellDisplayField.test.tsx +0 -107
  85. package/src/__tests__/lookupCellDisplayName.test.tsx +0 -119
  86. package/src/__tests__/lookupCellRecordLink.test.tsx +0 -171
  87. package/src/__tests__/lookupCellReferenceKey.test.tsx +0 -57
  88. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +0 -115
  89. package/src/__tests__/percent-formatter-locale-4553.test.ts +0 -147
  90. package/src/__tests__/percent-tie-halfup-4590.test.ts +0 -224
  91. package/src/__tests__/registry-collision.test.ts +0 -91
  92. package/src/__tests__/resolveFieldCurrency.test.ts +0 -20
  93. package/src/__tests__/select-label-association-e2e.test.tsx +0 -199
  94. package/src/__tests__/spec-symbol-batch7.test.ts +0 -126
  95. package/src/__tests__/validation-feedback.test.tsx +0 -361
  96. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +0 -265
  97. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +0 -338
  98. package/src/__tests__/widget-dom-leak-e2e.test.tsx +0 -357
  99. package/src/__tests__/widget-props-contract.test.tsx +0 -142
  100. package/src/coerce-safe-value.test.ts +0 -34
  101. package/src/complex-widgets.test.tsx +0 -924
  102. package/src/currency.ts +0 -144
  103. package/src/datetime-widgets.test.tsx +0 -185
  104. package/src/field-type-alias.multiple.test.ts +0 -81
  105. package/src/field-type-alias.ts +0 -156
  106. package/src/field-type-coverage.test.ts +0 -67
  107. package/src/index.css +0 -44
  108. package/src/index.tsx +0 -2745
  109. package/src/standard-widgets.test.tsx +0 -364
  110. package/src/widgets/AddressField.postalCode.test.tsx +0 -257
  111. package/src/widgets/AddressField.tsx +0 -182
  112. package/src/widgets/AddressField.uniqueIds.test.tsx +0 -93
  113. package/src/widgets/AutoNumberField.tsx +0 -18
  114. package/src/widgets/AvatarField.tsx +0 -117
  115. package/src/widgets/BooleanField.tsx +0 -86
  116. package/src/widgets/CapabilityMultiSelectField.test.tsx +0 -168
  117. package/src/widgets/CapabilityMultiSelectField.tsx +0 -241
  118. package/src/widgets/CharacterCount.tsx +0 -263
  119. package/src/widgets/CheckboxesField.cascade.test.tsx +0 -143
  120. package/src/widgets/CheckboxesField.tsx +0 -169
  121. package/src/widgets/CodeField.tsx +0 -41
  122. package/src/widgets/ColorField.tsx +0 -53
  123. package/src/widgets/CurrencyField.tsx +0 -135
  124. package/src/widgets/DateField.tsx +0 -40
  125. package/src/widgets/DateTimeField.tsx +0 -46
  126. package/src/widgets/EmailField.tsx +0 -37
  127. package/src/widgets/FileField.tsx +0 -512
  128. package/src/widgets/FilterConditionField.tsx +0 -454
  129. package/src/widgets/FormulaField.tsx +0 -38
  130. package/src/widgets/FullscreenFieldEditor.tsx +0 -60
  131. package/src/widgets/GeolocationField.tsx +0 -193
  132. package/src/widgets/GeolocationField.uniqueIds.test.tsx +0 -88
  133. package/src/widgets/GridField.test.tsx +0 -632
  134. package/src/widgets/GridField.tsx +0 -1070
  135. package/src/widgets/ImageCropperDialog.tsx +0 -245
  136. package/src/widgets/ImageField.maxSize.test.tsx +0 -154
  137. package/src/widgets/ImageField.tsx +0 -305
  138. package/src/widgets/ImageLightbox.test.tsx +0 -52
  139. package/src/widgets/ImageLightbox.tsx +0 -108
  140. package/src/widgets/LocationField.tsx +0 -57
  141. package/src/widgets/LookupField.dependsOn.test.tsx +0 -233
  142. package/src/widgets/LookupField.gateHintLabel.test.tsx +0 -135
  143. package/src/widgets/LookupField.hydration.test.tsx +0 -136
  144. package/src/widgets/LookupField.idField.test.tsx +0 -61
  145. package/src/widgets/LookupField.tsx +0 -1295
  146. package/src/widgets/MarkdownContent.tsx +0 -19
  147. package/src/widgets/MasterDetailField.tsx +0 -131
  148. package/src/widgets/MultiSelectField.cascade.test.tsx +0 -143
  149. package/src/widgets/MultiSelectField.tsx +0 -196
  150. package/src/widgets/NumberField.tsx +0 -49
  151. package/src/widgets/ObjectField.tsx +0 -94
  152. package/src/widgets/ObjectRefField.tsx +0 -121
  153. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +0 -88
  154. package/src/widgets/OptionsEmptyState.test.tsx +0 -169
  155. package/src/widgets/OptionsEmptyState.tsx +0 -99
  156. package/src/widgets/PasswordField.tsx +0 -49
  157. package/src/widgets/PeoplePicker.test.tsx +0 -360
  158. package/src/widgets/PeoplePicker.tsx +0 -579
  159. package/src/widgets/PeoplePickerComponents.test.tsx +0 -104
  160. package/src/widgets/PercentField.tsx +0 -95
  161. package/src/widgets/PersonRow.tsx +0 -117
  162. package/src/widgets/PhoneField.tsx +0 -37
  163. package/src/widgets/QRCodeField.tsx +0 -103
  164. package/src/widgets/RadioField.cascade.test.tsx +0 -141
  165. package/src/widgets/RadioField.tsx +0 -143
  166. package/src/widgets/RatingField.tsx +0 -94
  167. package/src/widgets/RecipientPickerField.test.tsx +0 -157
  168. package/src/widgets/RecipientPickerField.tsx +0 -188
  169. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +0 -191
  170. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +0 -124
  171. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +0 -611
  172. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +0 -112
  173. package/src/widgets/RecordPickerDialog.tsx +0 -1365
  174. package/src/widgets/RichTextField.tsx +0 -176
  175. package/src/widgets/SelectField.cascade.test.tsx +0 -138
  176. package/src/widgets/SelectField.tsx +0 -193
  177. package/src/widgets/SelectionTray.tsx +0 -110
  178. package/src/widgets/SignatureField.tsx +0 -140
  179. package/src/widgets/SliderField.tsx +0 -39
  180. package/src/widgets/SummaryField.tsx +0 -31
  181. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +0 -68
  182. package/src/widgets/TagsField.placeholder.test.tsx +0 -95
  183. package/src/widgets/TagsField.tsx +0 -88
  184. package/src/widgets/TextAreaField.tsx +0 -213
  185. package/src/widgets/TextField.tsx +0 -45
  186. package/src/widgets/TimeField.tsx +0 -31
  187. package/src/widgets/UrlField.tsx +0 -46
  188. package/src/widgets/UserField.tsx +0 -59
  189. package/src/widgets/UserFieldPicker.test.tsx +0 -78
  190. package/src/widgets/VectorField.tsx +0 -29
  191. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +0 -66
  192. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +0 -166
  193. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +0 -248
  194. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +0 -116
  195. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +0 -244
  196. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +0 -195
  197. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +0 -417
  198. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +0 -287
  199. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +0 -160
  200. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +0 -161
  201. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +0 -346
  202. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +0 -246
  203. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +0 -175
  204. package/src/widgets/_cell-renderer-bridge.ts +0 -19
  205. package/src/widgets/address-format.ts +0 -120
  206. package/src/widgets/deriveLookupColumns.test.ts +0 -112
  207. package/src/widgets/deriveLookupColumns.ts +0 -108
  208. package/src/widgets/file-size-guard.ts +0 -55
  209. package/src/widgets/file-value.test.ts +0 -230
  210. package/src/widgets/file-value.ts +0 -246
  211. package/src/widgets/nativeDateValue.test.ts +0 -115
  212. package/src/widgets/nativeDateValue.ts +0 -112
  213. package/src/widgets/openNativePicker.ts +0 -18
  214. package/src/widgets/optionWidgets.gatedOptions.test.tsx +0 -267
  215. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +0 -150
  216. package/src/widgets/personDisplay.test.ts +0 -113
  217. package/src/widgets/personDisplay.ts +0 -92
  218. package/src/widgets/recentLookups.test.ts +0 -45
  219. package/src/widgets/recentLookups.ts +0 -47
  220. package/src/widgets/toDomProps.ts +0 -204
  221. package/src/widgets/toHostGroupProps.ts +0 -102
  222. package/src/widgets/types.ts +0 -242
  223. package/src/widgets/useCascadingOptions.ts +0 -46
  224. package/src/widgets/useFieldTranslation.ts +0 -132
  225. package/src/widgets/useRecordQuery.test.ts +0 -174
  226. package/src/widgets/useRecordQuery.ts +0 -291
  227. package/src/widgets/useUploadingSignal.test.tsx +0 -62
  228. package/src/widgets/useUploadingSignal.ts +0 -20
  229. package/src/withFieldCarrier.tsx +0 -75
@@ -1,195 +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
- * `RichTextField` honours `mobile_fullscreen` (objectui#3301).
11
- *
12
- * `ObjectFormSchema.mobile.fullscreenLongText` has always promised, in its own
13
- * JSDoc, that "textarea/rich-text get an expand button", and `ObjectForm` has
14
- * always stamped `mobile_fullscreen` onto `field:markdown` / `field:html`
15
- * fields to deliver it. This widget — the one both of those types resolve to —
16
- * simply never read the flag. Producer present, consumer absent: the flag
17
- * arrived at the widget and nothing happened, with nothing anywhere reporting
18
- * that half the documented feature was inert.
19
- *
20
- * So the assertions below are about the READ and the interaction it drives.
21
- * The sibling file `TextAreaField.mobileFullscreen.test.tsx` pins the same
22
- * contract for the other widget; both now go through the shared
23
- * `FullscreenFieldEditor`, and the pair is what keeps one form-level setting
24
- * producing one behaviour. Divergence in either widget fails one of the two.
25
- */
26
-
27
- import { describe, it, expect, vi } from 'vitest';
28
- import { render, screen, fireEvent } from '@testing-library/react';
29
- import '@testing-library/jest-dom';
30
-
31
- import { RichTextField } from '../RichTextField';
32
- import type { FieldWidgetComponentProps } from '../types';
33
- import type { FieldMetadata } from '@object-ui/types';
34
-
35
- const fieldMeta = (extra: Record<string, unknown> = {}) =>
36
- ({
37
- name: 'notes',
38
- label: 'Notes',
39
- type: 'markdown',
40
- ...extra,
41
- }) as unknown as FieldMetadata;
42
-
43
- describe('RichTextField mobile fullscreen — the metadata flag is the only source', () => {
44
- it('renders the expand affordance when the field metadata sets mobile_fullscreen', () => {
45
- render(
46
- <RichTextField
47
- value="# hello"
48
- onChange={() => {}}
49
- field={fieldMeta({ mobile_fullscreen: true })}
50
- />,
51
- );
52
-
53
- expect(screen.getByTestId('richtext-fullscreen-toggle')).toBeInTheDocument();
54
- });
55
-
56
- it('renders no affordance when the metadata does not opt in', () => {
57
- render(<RichTextField value="# hello" onChange={() => {}} field={fieldMeta()} />);
58
-
59
- expect(screen.queryByTestId('richtext-fullscreen-toggle')).not.toBeInTheDocument();
60
- expect(screen.queryByTestId('richtext-fullscreen-dialog')).not.toBeInTheDocument();
61
- });
62
-
63
- it('opens the fullscreen dialog and commits the edited draft', () => {
64
- const onChange = vi.fn();
65
- render(
66
- <RichTextField
67
- value="# hello"
68
- onChange={onChange}
69
- field={fieldMeta({ mobile_fullscreen: true })}
70
- />,
71
- );
72
-
73
- // Closed until the affordance is used — the dialog is not just mounted.
74
- expect(screen.queryByTestId('richtext-fullscreen-dialog')).not.toBeInTheDocument();
75
-
76
- fireEvent.click(screen.getByTestId('richtext-fullscreen-toggle'));
77
- expect(screen.getByTestId('richtext-fullscreen-dialog')).toBeInTheDocument();
78
-
79
- const dialogInput = screen.getByTestId('richtext-fullscreen-input');
80
- expect(dialogInput).toHaveValue('# hello');
81
-
82
- fireEvent.change(dialogInput, { target: { value: '# hello from fullscreen' } });
83
- // The draft is local until "Done" — the host is not notified per keystroke,
84
- // so a react-hook-form field is not marked dirty by an edit the user may
85
- // still cancel.
86
- expect(onChange).not.toHaveBeenCalled();
87
-
88
- fireEvent.click(screen.getByTestId('richtext-fullscreen-save'));
89
- expect(onChange).toHaveBeenCalledWith('# hello from fullscreen');
90
- });
91
-
92
- it('discards the draft on Cancel', () => {
93
- const onChange = vi.fn();
94
- render(
95
- <RichTextField
96
- value="# hello"
97
- onChange={onChange}
98
- field={fieldMeta({ mobile_fullscreen: true })}
99
- />,
100
- );
101
-
102
- fireEvent.click(screen.getByTestId('richtext-fullscreen-toggle'));
103
- fireEvent.change(screen.getByTestId('richtext-fullscreen-input'), {
104
- target: { value: 'thrown away' },
105
- });
106
- fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
107
-
108
- expect(onChange).not.toHaveBeenCalled();
109
- expect(screen.queryByTestId('richtext-fullscreen-dialog')).not.toBeInTheDocument();
110
-
111
- // …and re-opening seeds from the COMMITTED value, not from the abandoned
112
- // draft. Holding the draft across opens would silently resurrect an edit
113
- // the user explicitly cancelled.
114
- fireEvent.click(screen.getByTestId('richtext-fullscreen-toggle'));
115
- expect(screen.getByTestId('richtext-fullscreen-input')).toHaveValue('# hello');
116
- });
117
-
118
- it('puts the REAL editor in the dialog, not a degraded copy of it', () => {
119
- // The acceptance criterion that the dialog holds the rich editor itself:
120
- // the same surface renders in both positions (same format indicator, same
121
- // editor), so whatever this widget's editor grows into, the fullscreen
122
- // view gets it too rather than drifting into a bare textarea.
123
- render(
124
- <RichTextField
125
- value=""
126
- onChange={() => {}}
127
- field={fieldMeta({ mobile_fullscreen: true, format: 'html', placeholder: 'Write HTML' })}
128
- />,
129
- );
130
-
131
- expect(screen.getAllByText('Format: html')).toHaveLength(1);
132
-
133
- fireEvent.click(screen.getByTestId('richtext-fullscreen-toggle'));
134
-
135
- // Inline + dialog: the indicator and the placeholder are present twice.
136
- expect(screen.getAllByText('Format: html')).toHaveLength(2);
137
- expect(screen.getAllByPlaceholderText('Write HTML')).toHaveLength(2);
138
- expect(screen.getByTestId('richtext-fullscreen-input')).toBeEnabled();
139
- });
140
-
141
- it('titles the dialog and the affordance with the field label', () => {
142
- render(
143
- <RichTextField
144
- value=""
145
- onChange={() => {}}
146
- field={fieldMeta({ mobile_fullscreen: true, label: 'Release notes' })}
147
- />,
148
- );
149
-
150
- expect(
151
- screen.getByRole('button', { name: 'Edit Release notes fullscreen' }),
152
- ).toBeInTheDocument();
153
-
154
- fireEvent.click(screen.getByTestId('richtext-fullscreen-toggle'));
155
- expect(screen.getByRole('heading', { name: 'Release notes' })).toBeInTheDocument();
156
- });
157
-
158
- it('renders no affordance at all in readonly mode', () => {
159
- render(
160
- <RichTextField
161
- value="# hello"
162
- onChange={() => {}}
163
- readonly
164
- field={fieldMeta({ mobile_fullscreen: true })}
165
- />,
166
- );
167
-
168
- expect(screen.queryByTestId('richtext-fullscreen-toggle')).not.toBeInTheDocument();
169
- });
170
- });
171
-
172
- describe('RichTextField mobile fullscreen — no second carrier', () => {
173
- it('ignores the flag on prop spellings a host might invent', () => {
174
- // Same convergence `TextAreaField` went through in objectui#3232/#3233:
175
- // the metadata is the ONE carrier. A host passing the flag as a prop must
176
- // get nothing — a tolerant `??` chain here is exactly where a misspelled
177
- // AI-authored flag would hide and look supported.
178
- const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
179
- try {
180
- const hostProps = {
181
- mobileFullscreen: true,
182
- mobile_fullscreen: true,
183
- schema: fieldMeta({ mobile_fullscreen: true }),
184
- } as unknown as Partial<FieldWidgetComponentProps<string>>;
185
-
186
- render(
187
- <RichTextField value="" onChange={() => {}} field={fieldMeta()} {...hostProps} />,
188
- );
189
-
190
- expect(screen.queryByTestId('richtext-fullscreen-toggle')).not.toBeInTheDocument();
191
- } finally {
192
- consoleError.mockRestore();
193
- }
194
- });
195
- });
@@ -1,417 +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
- * HOW OFTEN the textarea character counter speaks, and what a screen reader can
11
- * find out without typing at all — objectui#3408.
12
- *
13
- * objectui#3406 keyed the counter's sentence and deliberately changed no
14
- * behaviour. This file is the behaviour half.
15
- *
16
- * ## The measurement this replaces
17
- *
18
- * Probe run against `origin/main` (zh session, `maxLength: 500`, the sentence
19
- * "Follow up with the customer about the renewal quote." typed one character at
20
- * a time — reproduced verbatim by `speaks nothing across the issue's 52
21
- * keystroke probe` below):
22
- *
23
- * ```json
24
- * { "keystrokes": 52, "announcementsFired": 52, "distinctAnnouncements": 52,
25
- * "totalCharsSpoken": 979, "regionIsAriaHidden": null,
26
- * "textareaDescribedBy": null }
27
- * ```
28
- *
29
- * 979 spoken characters for 52 typed ones — ~19x — every one of them cutting
30
- * off the screen reader's echo of the letter the user had just pressed. The
31
- * counter element was simultaneously the visible digits, the translated
32
- * sentence and the live region, so "re-render" and "announce" were the same
33
- * event.
34
- *
35
- * ## The shape being pinned (GOV.UK character-count, three nodes)
36
- *
37
- * 1. the visible `{n}/{max}` digits, `aria-hidden` — decorative;
38
- * 2. a visually-hidden DESCRIPTION carrying the full sentence, reached through
39
- * the textarea's `aria-describedby` — read once on focus;
40
- * 3. a visually-hidden `aria-live="polite"` STATUS region, silent until the
41
- * value is inside the last 10% / 20 characters of the cap, and updated only
42
- * after the typist pauses.
43
- *
44
- * ## Why fake timers
45
- *
46
- * The debounce is 1000ms. Ten real pauses would add ten seconds of wall clock
47
- * to every CI run and would still be a race under load (AGENTS.md's flaky-test
48
- * section). `vi.useFakeTimers()` makes the pause an assertion instead of a
49
- * wait: `advanceTimersByTime(999)` proving silence is a statement no sleep can
50
- * make.
51
- *
52
- * ## Counting rule used throughout
53
- *
54
- * A screen reader speaks when a live region's content CHANGES to something
55
- * non-empty. Emptying it says nothing. `announcementsOf()` below counts exactly
56
- * that, so the numbers here are comparable to the probe's.
57
- */
58
- import React from 'react';
59
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
60
- import { render, screen, fireEvent, act } from '@testing-library/react';
61
- import '@testing-library/jest-dom';
62
- import { I18nProvider } from '@object-ui/i18n';
63
-
64
- import { TextAreaField } from '../TextAreaField';
65
- import type { FieldWidgetComponentProps } from '../types';
66
- import type { FieldMetadata } from '@object-ui/types';
67
-
68
- const DEBOUNCE_MS = 1000;
69
-
70
- const textareaField = (extra: Record<string, unknown> = {}) =>
71
- ({ name: 'notes', type: 'textarea', ...extra }) as unknown as FieldMetadata;
72
-
73
- /** The `aria-live` node — now the ONLY one in this widget, by construction. */
74
- const statusRegion = () => document.querySelector('[aria-live="polite"]');
75
- const statusText = () => statusRegion()?.textContent ?? null;
76
- const visibleCounter = () => document.querySelector('[data-testid="textarea-character-count"]');
77
- const textarea = () => screen.getByRole('textbox');
78
-
79
- /**
80
- * Resolve the textarea's accessible DESCRIPTION the way an assistive technology
81
- * does — follow `aria-describedby`, in order, and concatenate the referenced
82
- * nodes' text. Asserting on this rather than on a node found by test id is what
83
- * makes "the association actually works" part of the assertion.
84
- */
85
- const describedText = () => {
86
- const ids = (textarea().getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean);
87
- return ids.map((id) => document.getElementById(id)?.textContent ?? '').join(' ').trim();
88
- };
89
-
90
- /** A host that owns the value, so `fireEvent.change` really is a keystroke. */
91
- // `maxLength` has NO default on purpose: a default would swallow the
92
- // no-cap cases, which pass it as `undefined`.
93
- function Host({
94
- initial = '',
95
- maxLength,
96
- ...rest
97
- }: { initial?: string; maxLength?: number } & Partial<FieldWidgetComponentProps<string>>) {
98
- const [v, setV] = React.useState(initial);
99
- return (
100
- <TextAreaField value={v} onChange={setV} field={textareaField({ maxLength })} {...rest} />
101
- );
102
- }
103
-
104
- const renderIn = (language: string, element: React.ReactElement) =>
105
- render(
106
- <I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
107
- {element}
108
- </I18nProvider>,
109
- );
110
-
111
- const pause = (ms = DEBOUNCE_MS) => act(() => { vi.advanceTimersByTime(ms); });
112
-
113
- /**
114
- * Type `text` one character at a time onto `prefix`, sampling the status region
115
- * after every keystroke, and return every distinct non-empty sentence it was
116
- * changed to. `pauseEvery` = how many keystrokes between 1s pauses (0 = the
117
- * issue's probe: continuous typing, never pausing).
118
- */
119
- function announcementsOf(prefix: string, text: string, pauseEvery = 0): string[] {
120
- const spoken: string[] = [];
121
- let last = statusText();
122
- const record = () => {
123
- const now = statusText();
124
- if (now !== last) {
125
- if (now) spoken.push(now);
126
- last = now;
127
- }
128
- };
129
- for (let i = 1; i <= text.length; i++) {
130
- fireEvent.change(textarea(), { target: { value: prefix + text.slice(0, i) } });
131
- record();
132
- if (pauseEvery && i % pauseEvery === 0) {
133
- pause();
134
- record();
135
- }
136
- }
137
- return spoken;
138
- }
139
-
140
- beforeEach(() => vi.useFakeTimers());
141
- afterEach(() => vi.useRealTimers());
142
-
143
- describe('the counter no longer narrates every keystroke (objectui#3408)', () => {
144
- const SENTENCE = 'Follow up with the customer about the renewal quote.';
145
-
146
- it('speaks nothing across the issue`s 52-keystroke probe', () => {
147
- // The probe, verbatim: zh, maxLength 500, 52 characters typed one at a
148
- // time. Measured before this change: 52 announcements / 979 characters.
149
- expect(SENTENCE).toHaveLength(52);
150
- renderIn('zh', <Host maxLength={500} />);
151
-
152
- const spoken = announcementsOf('', SENTENCE);
153
-
154
- expect(spoken).toEqual([]);
155
- expect(spoken.reduce((n, s) => n + s.length, 0)).toBe(0);
156
- // The value really was typed — otherwise the silence above proves nothing.
157
- expect(visibleCounter()).toHaveTextContent('52/500');
158
- });
159
-
160
- it('stays silent even if the typist pauses a full second after EVERY keystroke', () => {
161
- // The pessimistic variant, and the one that separates the two mechanisms:
162
- // with a flush after every character the debounce can suppress nothing, so
163
- // anything still silent here is silent because of the THRESHOLD. 52 of 500
164
- // used leaves 448 remaining, nowhere near the last 50.
165
- renderIn('zh', <Host maxLength={500} />);
166
-
167
- expect(announcementsOf('', SENTENCE, 1)).toEqual([]);
168
- });
169
-
170
- it('speaks a single-digit number of times over a run that ends AT the cap', () => {
171
- // The realistic worst case: the same 52 keystrokes, but landing exactly on
172
- // a 500-character cap, with a pause every 10 characters. Every pause from
173
- // here on is inside the warning band, so this is the run that produces the
174
- // most speech the design allows — and it is still 5, against the 52 the
175
- // probe measured for a run that never came near the limit at all.
176
- renderIn('zh', <Host maxLength={500} initial={'x'.repeat(448)} />);
177
-
178
- const spoken = announcementsOf('x'.repeat(448), SENTENCE, 10);
179
-
180
- expect(spoken.length).toBeLessThan(10);
181
- expect(spoken).toEqual([
182
- '还可输入 42 个字符',
183
- '还可输入 32 个字符',
184
- '还可输入 22 个字符',
185
- '还可输入 12 个字符',
186
- '还可输入 2 个字符',
187
- ]);
188
- });
189
- });
190
-
191
- describe('the status region is debounced (objectui#3408)', () => {
192
- it('says nothing until the typing stops', () => {
193
- // maxLength 100 → the warning band is the last 20 characters, so every
194
- // keystroke below is inside it and only the PAUSE is holding speech back.
195
- renderIn('en', <Host maxLength={100} initial={'x'.repeat(80)} />);
196
- expect(statusText()).toBe('');
197
-
198
- announcementsOf('x'.repeat(80), 'abcde');
199
-
200
- // Five keystrokes inside the warning band, still nothing spoken.
201
- expect(statusText()).toBe('');
202
- pause(DEBOUNCE_MS - 1);
203
- // 999ms of quiet is not a pause. Asserting the near-miss is the difference
204
- // between pinning a debounce and pinning "it eventually says something".
205
- expect(statusText()).toBe('');
206
-
207
- pause(1);
208
- expect(statusText()).toBe('Characters remaining: 15');
209
- });
210
-
211
- it('collapses a burst into ONE announcement, not one per keystroke', () => {
212
- renderIn('en', <Host maxLength={100} initial={'x'.repeat(80)} />);
213
-
214
- const spoken = announcementsOf('x'.repeat(80), 'abcdefghij');
215
- pause();
216
-
217
- expect(spoken).toEqual([]);
218
- expect(statusText()).toBe('Characters remaining: 10');
219
- });
220
-
221
- it('does not re-announce a count the user typed their way back to', () => {
222
- // Type one character, pause, delete it, pause. The sentence is identical
223
- // both times, so the DOM does not change and nothing is spoken twice.
224
- renderIn('en', <Host maxLength={100} initial={'x'.repeat(85)} />);
225
- pause();
226
- expect(statusText()).toBe('Characters remaining: 15');
227
-
228
- fireEvent.change(textarea(), { target: { value: 'x'.repeat(86) } });
229
- pause();
230
- expect(statusText()).toBe('Characters remaining: 14');
231
-
232
- fireEvent.change(textarea(), { target: { value: 'x'.repeat(85) } });
233
- pause();
234
- expect(statusText()).toBe('Characters remaining: 15');
235
- });
236
- });
237
-
238
- describe('the status region is threshold-gated (objectui#3408)', () => {
239
- it('is silent one character OUTSIDE the band and speaks one character inside it', () => {
240
- // 500-character cap → 10% is 50, which the typist reaches before the flat
241
- // 20, so 50 is the boundary. Both directions asserted: a gate that never
242
- // opens and a gate that is always open both pass a one-sided check.
243
- const { unmount } = renderIn('en', <Host maxLength={500} initial={'x'.repeat(449)} />);
244
- pause();
245
- expect(statusText()).toBe('');
246
- unmount();
247
-
248
- renderIn('en', <Host maxLength={500} initial={'x'.repeat(450)} />);
249
- pause();
250
- expect(statusText()).toBe('Characters remaining: 50');
251
- });
252
-
253
- it('falls back to the flat 20 characters when 10% of the cap is smaller', () => {
254
- // 100-character cap: 10% is 10, but 20 remaining arrives FIRST, so 20 is
255
- // the boundary. This is the case that pins `max()` — take `min()` and the
256
- // warning arrives ten characters too late, which no assertion on the 500
257
- // case above can see.
258
- const { unmount } = renderIn('en', <Host maxLength={100} initial={'x'.repeat(79)} />);
259
- pause();
260
- expect(statusText()).toBe('');
261
- unmount();
262
-
263
- renderIn('en', <Host maxLength={100} initial={'x'.repeat(80)} />);
264
- pause();
265
- expect(statusText()).toBe('Characters remaining: 20');
266
- });
267
-
268
- it('goes quiet again — immediately — when the user deletes back out of the band', () => {
269
- renderIn('en', <Host maxLength={500} initial={'x'.repeat(460)} />);
270
- pause();
271
- expect(statusText()).toBe('Characters remaining: 40');
272
-
273
- fireEvent.change(textarea(), { target: { value: 'x'.repeat(400) } });
274
-
275
- // No pause needed: emptying a live region announces nothing, so silence is
276
- // free and should not wait a second to take effect.
277
- expect(statusText()).toBe('');
278
- });
279
-
280
- it('never re-announces the STALE count when the user types back into the band', () => {
281
- // The excursion case. Reach the band, be announced, delete far out of it,
282
- // then type back in at a different length. The naive "show the last settled
283
- // sentence whenever we are in the band" renders the count from BEFORE the
284
- // excursion for a full second before the timer corrects it — a wrong
285
- // number, spoken, which is worse than the silence it replaces.
286
- renderIn('en', <Host maxLength={500} initial={'x'.repeat(460)} />);
287
- pause();
288
- expect(statusText()).toBe('Characters remaining: 40');
289
-
290
- fireEvent.change(textarea(), { target: { value: 'x'.repeat(300) } });
291
- expect(statusText()).toBe('');
292
-
293
- fireEvent.change(textarea(), { target: { value: 'x'.repeat(455) } });
294
- // The moment of the bug: 45 is the truth, 40 is what was cached.
295
- expect(statusText()).toBe('');
296
- pause();
297
- expect(statusText()).toBe('Characters remaining: 45');
298
- });
299
-
300
- it('clamps an over-long value to zero remaining rather than counting backwards', () => {
301
- // Reachable when a cap is lowered after the record was saved. "-3 remaining"
302
- // is not a sentence, and the textarea's own maxLength blocks further input,
303
- // so 0 is the true actionable number. The full, honest count stays in the
304
- // description.
305
- renderIn('en', <Host maxLength={500} initial={'x'.repeat(503)} />);
306
- pause();
307
-
308
- expect(statusText()).toBe('Characters remaining: 0');
309
- expect(describedText()).toBe('Character count: 503 of 500');
310
- });
311
-
312
- it('treats a very small cap as near-limit from the first keystroke', () => {
313
- // 10-character cap: the flat 20 exceeds the whole field, so every state is
314
- // "nearly full". That is correct, and the debounce still bounds it to one
315
- // announcement per pause.
316
- renderIn('en', <Host maxLength={10} initial="ab" />);
317
- pause();
318
-
319
- expect(statusText()).toBe('Characters remaining: 8');
320
- });
321
- });
322
-
323
- describe('the counter is reachable without typing (objectui#3408)', () => {
324
- it('describes the textarea, so focus announces the cap once', () => {
325
- // Measured before this change: `textareaDescribedBy: null` — a screen
326
- // reader user learned there was a limit only by bumping into it.
327
- renderIn('en', <Host maxLength={500} initial="hello" />);
328
-
329
- expect(textarea()).toHaveAttribute('aria-describedby');
330
- expect(describedText()).toBe('Character count: 5 of 500');
331
- });
332
-
333
- it('APPENDS to the host`s aria-describedby instead of replacing it', () => {
334
- // `<FormControl>` is a Radix Slot: it hands the control an
335
- // `aria-describedby` naming the field description and error message, and
336
- // it arrives here through `toDomProps`. Clobbering it would trade "no cap
337
- // announced" for "no ERROR announced".
338
- render(
339
- <>
340
- <span id="host-desc">Internal notes only</span>
341
- <Host maxLength={500} initial="hello" aria-describedby="host-desc" />
342
- </>,
343
- );
344
-
345
- const ids = textarea().getAttribute('aria-describedby')!.split(' ');
346
- expect(ids[0]).toBe('host-desc');
347
- expect(ids).toHaveLength(2);
348
- expect(describedText()).toBe('Internal notes only Character count: 5 of 500');
349
- });
350
-
351
- it('keeps the visible digits out of the accessibility tree', () => {
352
- // Measured before: `regionIsAriaHidden: null`. The digits are a glance
353
- // affordance; unhidden they make a screen reader read "5 slash 500" on top
354
- // of the description that says it in words.
355
- renderIn('en', <Host maxLength={500} initial="hello" />);
356
-
357
- expect(visibleCounter()).toHaveAttribute('aria-hidden', 'true');
358
- expect(visibleCounter()).toHaveTextContent('5/500');
359
- expect(visibleCounter()).not.toHaveAttribute('aria-live');
360
- });
361
-
362
- it('renders the live region EMPTY at mount rather than conditionally', () => {
363
- // A live region inserted at the same moment as its text is not reliably
364
- // announced — it has to be in the DOM first. `{status && <span …>}` would
365
- // pass every "is it silent?" assertion above and break the one case the
366
- // whole gate exists to serve.
367
- renderIn('en', <Host maxLength={500} />);
368
-
369
- expect(statusRegion()).not.toBeNull();
370
- expect(statusText()).toBe('');
371
- });
372
-
373
- it('adds neither node when the field declares no cap', () => {
374
- render(<Host maxLength={undefined} />);
375
-
376
- expect(statusRegion()).toBeNull();
377
- expect(visibleCounter()).toBeNull();
378
- expect(textarea()).not.toHaveAttribute('aria-describedby');
379
- });
380
-
381
- it('leaves a host describedby alone when there is no cap to describe', () => {
382
- render(
383
- <>
384
- <span id="host-desc">Internal notes only</span>
385
- <Host maxLength={undefined} aria-describedby="host-desc" />
386
- </>,
387
- );
388
-
389
- expect(textarea()).toHaveAttribute('aria-describedby', 'host-desc');
390
- });
391
-
392
- it('gives two textareas on one form distinct description ids', () => {
393
- // The ids come from `useId()`, per instance. Shared ids would point both
394
- // fields at whichever description rendered last.
395
- render(
396
- <>
397
- <Host maxLength={500} initial="a" />
398
- <Host maxLength={300} initial="bb" />
399
- </>,
400
- );
401
-
402
- const [first, second] = screen.getAllByRole('textbox');
403
- const idOf = (el: HTMLElement) => el.getAttribute('aria-describedby');
404
- expect(idOf(first)).not.toBe(idOf(second));
405
- expect(document.getElementById(idOf(first)!)!.textContent).toBe('Character count: 1 of 500');
406
- expect(document.getElementById(idOf(second)!)!.textContent).toBe('Character count: 2 of 300');
407
- });
408
-
409
- it('renders no counter, no status region and no description when readonly', () => {
410
- renderIn('en', <TextAreaField value="hello" onChange={vi.fn()} readonly field={textareaField({ maxLength: 500 })} />);
411
-
412
- expect(statusRegion()).toBeNull();
413
- expect(visibleCounter()).toBeNull();
414
- expect(screen.queryByRole('textbox')).toBeNull();
415
- expect(screen.getByText('hello')).toBeInTheDocument();
416
- });
417
- });