@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,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
- });