@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,346 +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
- * What a screen reader can find out about the character cap INSIDE the
11
- * fullscreen long-text dialog — objectui#3417.
12
- *
13
- * ## The state this replaces
14
- *
15
- * objectui#3408 gave the inline counter three nodes. The fullscreen dialog's
16
- * counter, handed to `FullscreenFieldEditor` as a `footer`, stayed what it had
17
- * been since the feature shipped:
18
- *
19
- * ```jsx
20
- * footer={(draft) => maxLength
21
- * ? <span className="…">{draft.length}/{maxLength}</span>
22
- * : null}
23
- * ```
24
- *
25
- * Digits, and nothing else. No accessible name, no `aria-describedby` tying it
26
- * to the dialog's `textarea`, nothing `aria-live`. Browse mode read "5 slash
27
- * 500" if it happened to sweep the footer; focusing the input said nothing at
28
- * all. Same field, same cap, same user as the inline surface — and the
29
- * fullscreen branch was at zero.
30
- *
31
- * Reachability is not exotic: `ObjectForm` stamps `mobile_fullscreen` onto
32
- * EVERY long-text field when `ObjectFormSchema.mobile.fullscreenLongText` is
33
- * set, so on a phone with that setting on, every capped long-text field in the
34
- * form is this case.
35
- *
36
- * ## What is pinned here, and what is deliberately NOT
37
- *
38
- * The dialog gets two of the three nodes — `aria-hidden` digits and a
39
- * visually-hidden DESCRIPTION reached through the textarea's
40
- * `aria-describedby`. It gets NO live region, and that absence is asserted
41
- * rather than left to chance (`no aria-live node inside the dialog`): a modal
42
- * the user opened deliberately to write at length should not also interrupt
43
- * them, the description already delivers the cap on focus, and the inline
44
- * surface's live region stays mounted behind the overlay — a second one would
45
- * put two in one document.
46
- *
47
- * That assertion is therefore GREEN on `origin/main` too, and is expected to
48
- * be. It is not evidence of the fix; it is the pin that the fix did not
49
- * overreach. The cases that change direction are the describedby/description
50
- * ones, which are red on `origin/main` for the plainest possible reason —
51
- * `aria-describedby` is absent and there is no description node to point at.
52
- *
53
- * ## Why this file mounts no `I18nProvider`
54
- *
55
- * Two reasons, and they are the same reason. Mounting `I18nProvider` calls
56
- * `initReactI18next`, which installs that instance as react-i18next's GLOBAL
57
- * default — after any sibling case has done so there is no "no provider" state
58
- * left in this module graph to observe, which is why
59
- * `TextAreaField.characterCount.no-provider.test.tsx` and
60
- * `FullscreenFieldEditor.no-provider.test.tsx` are separate FILES rather than
61
- * separate `describe`s. Keeping this file provider-less makes its English
62
- * expectations a genuine assertion about the code-shipped `FIELD_DEFAULTS`
63
- * fallback on the fullscreen path — a raw key surfacing in a
64
- * screen-reader-only string is invisible to everyone who could report it. The
65
- * locale leg lives in `TextAreaField.fullscreenCharacterCount.i18n.test.tsx`.
66
- */
67
-
68
- import React from 'react';
69
- import { describe, it, expect, vi } from 'vitest';
70
- import { render, screen, fireEvent } from '@testing-library/react';
71
- import '@testing-library/jest-dom';
72
-
73
- import { TextAreaField } from '../TextAreaField';
74
- import type { FieldMetadata } from '@object-ui/types';
75
-
76
- const fieldMeta = (extra: Record<string, unknown> = {}) =>
77
- ({
78
- name: 'description',
79
- label: 'Description',
80
- type: 'textarea',
81
- mobile_fullscreen: true,
82
- ...extra,
83
- }) as unknown as FieldMetadata;
84
-
85
- const dialog = () => screen.getByTestId('textarea-fullscreen-dialog');
86
- const fullscreenInput = () =>
87
- screen.getByTestId('textarea-fullscreen-input') as HTMLTextAreaElement;
88
- const fullscreenCounter = () =>
89
- document.querySelector('[data-testid="textarea-fullscreen-character-count"]');
90
- const inlineCounter = () => document.querySelector('[data-testid="textarea-character-count"]');
91
-
92
- /**
93
- * Resolve an element's accessible DESCRIPTION the way an assistive technology
94
- * does — follow `aria-describedby`, in order, and concatenate the referenced
95
- * nodes' text. Asserting on this rather than on a node found by test id is
96
- * what puts "the association actually works" under assertion: a description
97
- * node nothing references is a node no screen reader will ever reach, and that
98
- * is precisely the failure a footer-shaped counter invites.
99
- */
100
- const describedText = (el: HTMLElement) => {
101
- const ids = (el.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean);
102
- return ids
103
- .map((id) => document.getElementById(id)?.textContent ?? '')
104
- .join(' ')
105
- .trim();
106
- };
107
-
108
- /** A host that owns the value, so a committed edit really changes it. */
109
- function Host({
110
- initial = '',
111
- maxLength,
112
- metaExtra = {},
113
- }: {
114
- initial?: string;
115
- maxLength?: number;
116
- metaExtra?: Record<string, unknown>;
117
- }) {
118
- const [v, setV] = React.useState(initial);
119
- return (
120
- <TextAreaField value={v} onChange={setV} field={fieldMeta({ maxLength, ...metaExtra })} />
121
- );
122
- }
123
-
124
- const openDialog = () => fireEvent.click(screen.getByTestId('textarea-fullscreen-toggle'));
125
-
126
- describe('the fullscreen dialog counter is reachable from the input (objectui#3417)', () => {
127
- it('describes the dialog textarea, so focus announces the cap once', () => {
128
- // Measured on `origin/main`: the dialog's textarea carried no
129
- // `aria-describedby` at all, so this is the assertion that flips.
130
- render(<Host maxLength={500} initial="hello" />);
131
- openDialog();
132
-
133
- expect(fullscreenInput()).toHaveAttribute('aria-describedby');
134
- expect(describedText(fullscreenInput())).toBe('Character count: 5 of 500');
135
- });
136
-
137
- it('keeps the visible digits out of the accessibility tree', () => {
138
- // The digits are a glance affordance. Unhidden they make a screen reader
139
- // read "5 slash 500" on top of the description that says it in words —
140
- // which is exactly what browse mode did on the old bare-span footer.
141
- render(<Host maxLength={500} initial="hello" />);
142
- openDialog();
143
-
144
- expect(fullscreenCounter()).toHaveAttribute('aria-hidden', 'true');
145
- expect(fullscreenCounter()).toHaveTextContent('5/500');
146
- expect(fullscreenCounter()).not.toHaveAttribute('aria-live');
147
- });
148
-
149
- it('counts the DRAFT, in the digits and in the description together', () => {
150
- // The dialog edits a local draft that is not written back until "Done"
151
- // (`FullscreenFieldEditor`). A description sourced from the committed
152
- // value instead would describe the field the user is no longer looking at,
153
- // and would silently disagree with the digits beside it — so both are
154
- // asserted from the same edit.
155
- render(<Host maxLength={500} initial="hello" />);
156
- openDialog();
157
-
158
- fireEvent.change(fullscreenInput(), { target: { value: 'hello from fullscreen' } });
159
-
160
- expect(fullscreenCounter()).toHaveTextContent('21/500');
161
- expect(describedText(fullscreenInput())).toBe('Character count: 21 of 500');
162
- });
163
-
164
- it('substitutes both placeholders from the code-shipped fallback', () => {
165
- // No provider is mounted, so whatever renders here came from
166
- // `FIELD_DEFAULTS` via `createSafeTranslation`. The failure this guards is
167
- // a raw key or an unsubstituted `{{max}}` reaching a screen-reader-only
168
- // string, where nobody who can see it is affected and nobody affected can
169
- // see it.
170
- render(<Host maxLength={40} initial="hi" />);
171
- openDialog();
172
-
173
- expect(describedText(fullscreenInput())).toBe('Character count: 2 of 40');
174
- expect(describedText(fullscreenInput())).not.toMatch(/fields\.textarea/);
175
- expect(describedText(fullscreenInput())).not.toContain('{{');
176
- });
177
-
178
- it('announces zero for an empty draft rather than a hole in the sentence', () => {
179
- // `0` is falsy — the one input length at which a truthiness-guarded
180
- // interpolation drops the number and speaks "Character count: of 40".
181
- render(<Host maxLength={40} />);
182
- openDialog();
183
-
184
- expect(describedText(fullscreenInput())).toBe('Character count: 0 of 40');
185
- });
186
-
187
- it('the code-shipped default carries no CJK (Commandment #-1)', () => {
188
- // Escaped ranges on purpose: a literal CJK character class would itself
189
- // violate the commandment this asserts (same shape as
190
- // `FullscreenFieldEditor.no-provider.test.tsx`).
191
- render(<Host maxLength={200} initial="hello" />);
192
- openDialog();
193
-
194
- // The positive assertion FIRST, and not decoration: a negative match on a
195
- // sentence that does not exist is satisfied by the empty string, so
196
- // without this line the case is green on `origin/main` for the one reason
197
- // that proves nothing.
198
- expect(describedText(fullscreenInput())).toBe('Character count: 5 of 200');
199
- expect(describedText(fullscreenInput())).not.toMatch(/[\u3000-\u30ff\u4e00-\u9fff]/);
200
- });
201
-
202
- it('reads the legacy snake_case max_length spelling here too', () => {
203
- // The dual-read predates all of this (framework#1878 §3). Sharing the
204
- // counting UI across surfaces must not narrow which spelling reaches the
205
- // fullscreen one.
206
- render(<Host metaExtra={{ max_length: 80 }} initial="hello" />);
207
- openDialog();
208
-
209
- expect(fullscreenCounter()).toHaveTextContent('5/80');
210
- expect(describedText(fullscreenInput())).toBe('Character count: 5 of 80');
211
- });
212
- });
213
-
214
- describe('the fullscreen dialog stays SILENT while typing (objectui#3417)', () => {
215
- it('renders no aria-live node inside the dialog', () => {
216
- // The ruling on #3417, pinned as an absence. GREEN on `origin/main` too —
217
- // there was nothing accessible in that footer at all — so this case is not
218
- // evidence of the fix, it is the guard against the fix overreaching into a
219
- // second live region. `[aria-live]` unqualified, because "assertive"
220
- // instead of "polite" would be worse, not compliant.
221
- render(<Host maxLength={100} initial={'x'.repeat(95)} />);
222
- openDialog();
223
-
224
- // Deep inside the warning band (5 of 100 left): if a status region existed
225
- // on this surface, this is the state that would fill it.
226
- expect(dialog().querySelector('[aria-live]')).toBeNull();
227
- expect(dialog().querySelector('[aria-atomic]')).toBeNull();
228
- });
229
-
230
- it('grows no live region as the draft crosses into the near-limit band', () => {
231
- render(<Host maxLength={100} initial="short" />);
232
- openDialog();
233
-
234
- fireEvent.change(fullscreenInput(), { target: { value: 'x'.repeat(99) } });
235
-
236
- expect(fullscreenCounter()).toHaveTextContent('99/100');
237
- expect(dialog().querySelector('[aria-live]')).toBeNull();
238
- });
239
- });
240
-
241
- describe('the two surfaces are wired independently (objectui#3417)', () => {
242
- it('gives the inline and fullscreen descriptions distinct ids', () => {
243
- // One shared id would point both textareas at whichever sentence rendered
244
- // last — and the two diverge the moment the user types in the dialog,
245
- // which is the whole reason the dialog holds a draft.
246
- render(<Host maxLength={500} initial="hello" />);
247
- const inline = screen.getAllByRole('textbox')[0] as HTMLTextAreaElement;
248
- const inlineDescribedBy = inline.getAttribute('aria-describedby');
249
- openDialog();
250
- const fullscreenDescribedBy = fullscreenInput().getAttribute('aria-describedby');
251
-
252
- // BOTH asserted present before they are compared. `not.toBe` alone is
253
- // satisfied by `null !== 'some-id'`, which is exactly the `origin/main`
254
- // state this case is supposed to reject — the trap of an assertion that
255
- // passes because nothing was produced.
256
- expect(inlineDescribedBy).not.toBeNull();
257
- expect(fullscreenDescribedBy).not.toBeNull();
258
- expect(fullscreenDescribedBy).not.toBe(inlineDescribedBy);
259
- // And each resolves to its own sentence, not to the other's node.
260
- expect(describedText(fullscreenInput())).toBe('Character count: 5 of 500');
261
- });
262
-
263
- it('leaves the inline description on the committed value while the draft moves', () => {
264
- // The draft is local until "Done". Both descriptions are live at once (the
265
- // inline one behind the overlay), so a shared source would make one of
266
- // them a lie.
267
- render(<Host maxLength={500} initial="hello" />);
268
- const inline = screen.getAllByRole('textbox')[0] as HTMLTextAreaElement;
269
- openDialog();
270
-
271
- fireEvent.change(fullscreenInput(), { target: { value: 'hello from fullscreen' } });
272
-
273
- expect(describedText(fullscreenInput())).toBe('Character count: 21 of 500');
274
- expect(describedText(inline)).toBe('Character count: 5 of 500');
275
- expect(inlineCounter()).toHaveTextContent('5/500');
276
- });
277
-
278
- it('lets the committed edit flow back to the inline counter on Done', () => {
279
- // End to end, because a counter that describes a draft nobody commits
280
- // would satisfy every assertion above.
281
- render(<Host maxLength={500} initial="hello" />);
282
- openDialog();
283
-
284
- fireEvent.change(fullscreenInput(), { target: { value: 'hello from fullscreen' } });
285
- fireEvent.click(screen.getByTestId('textarea-fullscreen-save'));
286
-
287
- expect(inlineCounter()).toHaveTextContent('21/500');
288
- const inline = screen.getAllByRole('textbox')[0] as HTMLTextAreaElement;
289
- expect(describedText(inline)).toBe('Character count: 21 of 500');
290
- });
291
-
292
- it('keeps the inline surface at three nodes, live region included', () => {
293
- // The extraction must not have cost the inline surface anything. Its own
294
- // pins live in the objectui#3408 files and are untouched; this is the
295
- // cross-check from the fullscreen side, taken with the dialog OPEN so that
296
- // "there is one live region in the document, and it is the inline one" is
297
- // asserted as a whole.
298
- render(<Host maxLength={100} initial={'x'.repeat(95)} />);
299
- openDialog();
300
-
301
- const liveRegions = document.querySelectorAll('[aria-live="polite"]');
302
- expect(liveRegions).toHaveLength(1);
303
- expect(dialog().contains(liveRegions[0])).toBe(false);
304
- expect(inlineCounter()).toHaveAttribute('aria-hidden', 'true');
305
- });
306
- });
307
-
308
- describe('a field with no cap gets no fullscreen counter (objectui#3417)', () => {
309
- it('renders neither digits nor a description node', () => {
310
- // `{maxLength && …}` on both surfaces. Sharing the component must not make
311
- // either one unconditional.
312
- render(<Host maxLength={undefined} />);
313
- openDialog();
314
-
315
- expect(fullscreenCounter()).toBeNull();
316
- expect(inlineCounter()).toBeNull();
317
- });
318
-
319
- it('leaves the dialog textarea`s aria-describedby unset', () => {
320
- // An `aria-describedby` naming an id that does not exist is worse than
321
- // none: some assistive technologies fall back to reading the id string.
322
- render(<Host maxLength={undefined} />);
323
- openDialog();
324
-
325
- expect(fullscreenInput()).not.toHaveAttribute('aria-describedby');
326
- });
327
-
328
- it('renders no counter on either surface when the field is readonly', () => {
329
- // The readonly branch early-returns a display before any of this is
330
- // computed, so there is no dialog to open at all. Pinned as a
331
- // non-regression of the hook hoist.
332
- render(
333
- <TextAreaField
334
- value="hello"
335
- onChange={vi.fn()}
336
- readonly
337
- field={fieldMeta({ maxLength: 500 })}
338
- />,
339
- );
340
-
341
- expect(inlineCounter()).toBeNull();
342
- expect(fullscreenCounter()).toBeNull();
343
- expect(screen.queryByTestId('textarea-fullscreen-toggle')).not.toBeInTheDocument();
344
- expect(document.querySelector('[aria-live="polite"]')).toBeNull();
345
- });
346
- });
@@ -1,246 +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
- * A DISABLED `TextAreaField` cannot be edited through its fullscreen dialog —
11
- * objectui#3402.
12
- *
13
- * `disabled` used to land on the inline `<Textarea>` and nowhere else:
14
- * `showFullscreenButton` did not consult it, the call site did not forward it,
15
- * and `FullscreenFieldEditor` did not declare it. Measured on `origin/main`
16
- * with a probe equivalent to the cases below (the issue itself carried only
17
- * static evidence, so the premise was reproduced dynamically before any fix):
18
- *
19
- * PROBE textarea: inline disabled = true
20
- * PROBE textarea: toggle present = true, toggle disabled = false
21
- * PROBE textarea: dialog opened = true
22
- * PROBE textarea: dialog input disabled = false
23
- * PROBE textarea: onChange calls = [["EDITED WHILE DISABLED"]]
24
- *
25
- * That last line is the defect: a disabled field's value changed. The state is
26
- * nastier than the read-only one precisely because it LOOKS right — the inline
27
- * control is greyed out, and the only way around it is the button sitting on
28
- * top of it.
29
- *
30
- * This is the registered-path half of the same family #3400/PR #3401 fixed on
31
- * the built-in `form.tsx` path, and the two are deliberately pinned to the same
32
- * behaviours so one form-level setting keeps producing one behaviour:
33
- *
34
- * - `readonly` → no affordance at all. Untouched here: this widget's
35
- * early-return renders a read-only display before the button is computed.
36
- * Pinned below as a NON-REGRESSION, because the fix must not have moved it.
37
- * - `disabled` → the button stays but is inert, and the dialog locks on its
38
- * own. `disabled` is not only a static flag: the form renderer computes it
39
- * as `disabled || fieldDisabled || isSubmitting || …`, so it can flip to
40
- * true while the dialog is already open, at which point the button is no
41
- * longer the gate.
42
- *
43
- * Reachability is not exotic. `ObjectForm` stamps `mobile_fullscreen: true`
44
- * onto EVERY long-text field when `ObjectFormSchema.mobile.fullscreenLongText`
45
- * is set, without consulting readonly/disabled, and `stripRegisteredFieldProps`
46
- * does not strip `disabled` from what registered widgets receive.
47
- */
48
-
49
- import { describe, it, expect, vi } from 'vitest';
50
- import { render, screen, fireEvent } from '@testing-library/react';
51
- import '@testing-library/jest-dom';
52
-
53
- import { TextAreaField } from '../TextAreaField';
54
- import type { FieldMetadata } from '@object-ui/types';
55
-
56
- const fieldMeta = (extra: Record<string, unknown> = {}) =>
57
- ({
58
- name: 'description',
59
- label: 'Description',
60
- type: 'textarea',
61
- mobile_fullscreen: true,
62
- ...extra,
63
- }) as unknown as FieldMetadata;
64
-
65
- const toggle = () =>
66
- screen.queryByTestId('textarea-fullscreen-toggle') as HTMLButtonElement | null;
67
- const dialogInput = () =>
68
- screen.queryByTestId('textarea-fullscreen-input') as HTMLTextAreaElement | null;
69
- const saveButton = () =>
70
- screen.queryByTestId('textarea-fullscreen-save') as HTMLButtonElement | null;
71
-
72
- describe('TextAreaField fullscreen — disabled keeps the affordance but makes it inert (objectui#3402)', () => {
73
- it('keeps the expand button and disables it', () => {
74
- render(
75
- <TextAreaField value="hello" onChange={() => {}} field={fieldMeta()} disabled />,
76
- );
77
-
78
- // The affordance stays: `disabled` means "not interactive, muted", which is
79
- // a different state from `readonly`'s "shown plainly" (no button at all).
80
- expect(toggle()).not.toBeNull();
81
- expect(toggle()!.disabled).toBe(true);
82
- });
83
-
84
- it('does not open the dialog when that button is clicked anyway', () => {
85
- // The attribute stops a real user. The handler's own `if (disabled) return`
86
- // is pinned separately here because the attribute is not the only thing
87
- // that can be lost — a `pointer-events` rule and the attribute both live in
88
- // a className/props soup that refactors touch.
89
- render(
90
- <TextAreaField value="hello" onChange={() => {}} field={fieldMeta()} disabled />,
91
- );
92
-
93
- fireEvent.click(toggle()!);
94
-
95
- expect(screen.queryByTestId('textarea-fullscreen-dialog')).not.toBeInTheDocument();
96
- expect(dialogInput()).toBeNull();
97
- });
98
-
99
- it('still disables the inline control', () => {
100
- // The one exit that always held. Kept so a fix that moved `disabled` onto
101
- // the dialog and off the inline textarea would be caught.
102
- render(
103
- <TextAreaField value="hello" onChange={() => {}} field={fieldMeta()} disabled />,
104
- );
105
-
106
- const inline = screen.getAllByRole('textbox')[0] as HTMLTextAreaElement;
107
- expect(inline.disabled).toBe(true);
108
- });
109
-
110
- it('never commits a value while disabled', () => {
111
- const onChange = vi.fn();
112
- render(
113
- <TextAreaField value="hello" onChange={onChange} field={fieldMeta()} disabled />,
114
- );
115
-
116
- fireEvent.click(toggle()!);
117
-
118
- // The measured defect, in one assertion: the probe above got
119
- // `[["EDITED WHILE DISABLED"]]` here.
120
- expect(onChange).not.toHaveBeenCalled();
121
- });
122
- });
123
-
124
- describe('TextAreaField fullscreen — the dialog holds on its own when disabled arrives mid-edit (objectui#3402)', () => {
125
- /**
126
- * Open the dialog while editable, then flip `disabled` under it — the
127
- * `isSubmitting` case. This widget never sees `isSubmitting`; it sees
128
- * `disabled`, which the form renderer has already OR-ed it into, so the honest
129
- * widget-level reproduction is the prop flipping true while the dialog is
130
- * open, which is exactly what a submit does to it. (The form-level
131
- * composition itself is pinned on the built-in path by #3400/PR #3401.)
132
- */
133
- function openThenDisable(onChange: () => void) {
134
- const { rerender } = render(
135
- <TextAreaField value="hello" onChange={onChange} field={fieldMeta()} />,
136
- );
137
- // Opened while the field is still editable — the legitimate state.
138
- fireEvent.click(toggle()!);
139
- expect(dialogInput()).not.toBeNull();
140
-
141
- // Submit starts. The dialog is still on screen; the button is no longer
142
- // the gate.
143
- rerender(
144
- <TextAreaField value="hello" onChange={onChange} field={fieldMeta()} disabled />,
145
- );
146
- }
147
-
148
- it('locks the dialog controls', () => {
149
- openThenDisable(vi.fn());
150
-
151
- expect(dialogInput()).not.toBeNull();
152
- expect(dialogInput()!.disabled).toBe(true);
153
- expect(saveButton()!.disabled).toBe(true);
154
- });
155
-
156
- /**
157
- * Deliberately a SEPARATE test from the attribute assertions above, not a tail
158
- * on them. Sharing one test made this assertion unreachable whenever an
159
- * attribute assertion failed — i.e. it could only ever run in the world where
160
- * the native attribute was already doing the work, which is no pin at all.
161
- * Split, it is `commitFullscreen`'s own `if (!disabled)` guard that answers
162
- * when the attribute is gone. Verified by removing each in turn: drop the
163
- * `disabled` attribute from "Done" and this test stays green (the guard
164
- * refuses); drop the guard as well and it turns red with
165
- * `onChange` called with "EDITED WHILE SUBMITTING".
166
- */
167
- it('refuses the write-back even when "Done" is driven directly', () => {
168
- const onChange = vi.fn();
169
- openThenDisable(onChange);
170
-
171
- fireEvent.change(dialogInput()!, { target: { value: 'EDITED WHILE SUBMITTING' } });
172
- fireEvent.click(saveButton()!);
173
-
174
- expect(onChange).not.toHaveBeenCalled();
175
- });
176
-
177
- it('still lets the user out of the dialog', () => {
178
- // Cancel and Esc must stay live in every state, or a submit in flight traps
179
- // the user inside a modal they can no longer use.
180
- const onChange = vi.fn();
181
- const { rerender } = render(
182
- <TextAreaField value="hello" onChange={onChange} field={fieldMeta()} />,
183
- );
184
-
185
- fireEvent.click(toggle()!);
186
- rerender(
187
- <TextAreaField value="hello" onChange={onChange} field={fieldMeta()} disabled />,
188
- );
189
-
190
- fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
191
-
192
- expect(screen.queryByTestId('textarea-fullscreen-dialog')).not.toBeInTheDocument();
193
- expect(onChange).not.toHaveBeenCalled();
194
- });
195
- });
196
-
197
- describe('TextAreaField fullscreen — the states the fix must not have moved (objectui#3402)', () => {
198
- it('still edits fullscreen and commits when the field is editable', () => {
199
- // The control case for every "does not" above: without it they would all
200
- // pass just as well on a widget that renders no affordance at all.
201
- const onChange = vi.fn();
202
- render(<TextAreaField value="hello" onChange={onChange} field={fieldMeta()} />);
203
-
204
- expect(toggle()!.disabled).toBe(false);
205
- fireEvent.click(toggle()!);
206
-
207
- const input = dialogInput()!;
208
- expect(input.disabled).toBe(false);
209
- expect(saveButton()!.disabled).toBe(false);
210
-
211
- fireEvent.change(input, { target: { value: 'hello from fullscreen' } });
212
- fireEvent.click(saveButton()!);
213
-
214
- expect(onChange).toHaveBeenCalledWith('hello from fullscreen');
215
- });
216
-
217
- it('still renders no affordance at all in readonly mode', () => {
218
- // The early return this change deliberately did NOT touch: `readonly`
219
- // returns a read-only display before `showFullscreenButton` is computed,
220
- // which is why readonly was never part of this defect.
221
- render(
222
- <TextAreaField value="hello" onChange={() => {}} readonly field={fieldMeta()} />,
223
- );
224
-
225
- expect(toggle()).toBeNull();
226
- expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
227
- expect(screen.getByText('hello')).toBeInTheDocument();
228
- });
229
-
230
- it('still renders no affordance when the metadata does not opt in, disabled or not', () => {
231
- const { rerender } = render(
232
- <TextAreaField value="hello" onChange={() => {}} field={fieldMeta({ mobile_fullscreen: false })} />,
233
- );
234
- expect(toggle()).toBeNull();
235
-
236
- rerender(
237
- <TextAreaField
238
- value="hello"
239
- onChange={() => {}}
240
- field={fieldMeta({ mobile_fullscreen: false })}
241
- disabled
242
- />,
243
- );
244
- expect(toggle()).toBeNull();
245
- });
246
- });