@object-ui/fields 17.3.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 (265) hide show
  1. package/CHANGELOG.md +1655 -0
  2. package/README.md +1 -12
  3. package/dist/FieldEditWidget.d.ts +23 -3
  4. package/dist/currency.d.ts +46 -0
  5. package/dist/field-type-alias.d.ts +35 -1
  6. package/dist/index.cjs +4 -4
  7. package/dist/index.css +651 -0
  8. package/dist/index.d.ts +474 -85
  9. package/dist/index.js +5418 -5023
  10. package/dist/widgets/AddressField.d.ts +4 -12
  11. package/dist/widgets/AutoNumberField.d.ts +1 -1
  12. package/dist/widgets/AvatarField.d.ts +1 -1
  13. package/dist/widgets/BooleanField.d.ts +1 -1
  14. package/dist/widgets/CapabilityMultiSelectField.d.ts +1 -1
  15. package/dist/widgets/CheckboxesField.d.ts +1 -1
  16. package/dist/widgets/CodeField.d.ts +1 -1
  17. package/dist/widgets/ColorField.d.ts +1 -1
  18. package/dist/widgets/CurrencyField.d.ts +1 -1
  19. package/dist/widgets/DateField.d.ts +1 -1
  20. package/dist/widgets/DateTimeField.d.ts +1 -1
  21. package/dist/widgets/EmailField.d.ts +1 -1
  22. package/dist/widgets/FileField.d.ts +18 -2
  23. package/dist/widgets/FilterConditionField.d.ts +28 -1
  24. package/dist/widgets/FormulaField.d.ts +1 -1
  25. package/dist/widgets/FullscreenFieldEditor.d.ts +49 -152
  26. package/dist/widgets/GeolocationField.d.ts +1 -1
  27. package/dist/widgets/GridField.d.ts +68 -6
  28. package/dist/widgets/ImageField.d.ts +1 -1
  29. package/dist/widgets/LocationField.d.ts +1 -1
  30. package/dist/widgets/LookupField.d.ts +1 -1
  31. package/dist/widgets/MasterDetailField.d.ts +1 -1
  32. package/dist/widgets/MultiSelectField.d.ts +1 -1
  33. package/dist/widgets/NumberField.d.ts +1 -1
  34. package/dist/widgets/ObjectField.d.ts +1 -1
  35. package/dist/widgets/ObjectRefField.d.ts +1 -1
  36. package/dist/widgets/OptionsEmptyState.d.ts +21 -1
  37. package/dist/widgets/PasswordField.d.ts +1 -1
  38. package/dist/widgets/PercentField.d.ts +1 -1
  39. package/dist/widgets/PhoneField.d.ts +1 -1
  40. package/dist/widgets/QRCodeField.d.ts +1 -1
  41. package/dist/widgets/RadioField.d.ts +1 -1
  42. package/dist/widgets/RatingField.d.ts +1 -1
  43. package/dist/widgets/RecipientPickerField.d.ts +1 -1
  44. package/dist/widgets/RecordPickerDialog.d.ts +34 -10
  45. package/dist/widgets/RichTextField.d.ts +37 -1
  46. package/dist/widgets/SelectField.d.ts +26 -6
  47. package/dist/widgets/SignatureField.d.ts +32 -2
  48. package/dist/widgets/SliderField.d.ts +37 -2
  49. package/dist/widgets/SummaryField.d.ts +1 -1
  50. package/dist/widgets/TagsField.d.ts +1 -1
  51. package/dist/widgets/TextAreaField.d.ts +6 -2
  52. package/dist/widgets/TextField.d.ts +1 -1
  53. package/dist/widgets/TimeField.d.ts +1 -1
  54. package/dist/widgets/UrlField.d.ts +1 -1
  55. package/dist/widgets/UserField.d.ts +1 -1
  56. package/dist/widgets/VectorField.d.ts +1 -1
  57. package/dist/widgets/_cell-renderer-bridge.d.ts +1 -1
  58. package/dist/widgets/address-format.d.ts +88 -0
  59. package/dist/widgets/file-size-guard.d.ts +41 -0
  60. package/dist/widgets/lookupColumnDisplay.d.ts +93 -0
  61. package/dist/widgets/nativeDateValue.d.ts +37 -0
  62. package/dist/widgets/richTextDisplay.d.ts +80 -0
  63. package/dist/widgets/toDomProps.d.ts +30 -10
  64. package/dist/widgets/toHostGroupProps.d.ts +189 -0
  65. package/dist/widgets/types.d.ts +8 -3
  66. package/dist/widgets/useRecordQuery.d.ts +7 -1
  67. package/package.json +17 -14
  68. package/dist/FieldEditWidget.test.d.ts +0 -8
  69. package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
  70. package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
  71. package/dist/__tests__/cell-truncation.test.d.ts +0 -0
  72. package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
  73. package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
  74. package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
  75. package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
  76. package/dist/__tests__/registry-collision.test.d.ts +0 -0
  77. package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
  78. package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
  79. package/dist/__tests__/validation-feedback.test.d.ts +0 -0
  80. package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
  81. package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
  82. package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
  83. package/dist/__tests__/widget-props-contract.test.d.ts +0 -8
  84. package/dist/coerce-safe-value.test.d.ts +0 -1
  85. package/dist/complex-widgets.test.d.ts +0 -1
  86. package/dist/datetime-widgets.test.d.ts +0 -1
  87. package/dist/field-type-coverage.test.d.ts +0 -1
  88. package/dist/standard-widgets.test.d.ts +0 -0
  89. package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
  90. package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
  91. package/dist/widgets/CharacterCount.d.ts +0 -56
  92. package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
  93. package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
  94. package/dist/widgets/GridField.test.d.ts +0 -1
  95. package/dist/widgets/ImageLightbox.test.d.ts +0 -1
  96. package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
  97. package/dist/widgets/LookupField.gateHintLabel.test.d.ts +0 -8
  98. package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
  99. package/dist/widgets/LookupField.idField.test.d.ts +0 -1
  100. package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
  101. package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
  102. package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
  103. package/dist/widgets/PeoplePicker.test.d.ts +0 -8
  104. package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
  105. package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
  106. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  107. package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +0 -8
  108. package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +0 -8
  109. package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
  110. package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
  111. package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
  112. package/dist/widgets/UserFieldPicker.test.d.ts +0 -8
  113. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
  114. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
  115. package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
  116. package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
  117. package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
  118. package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
  119. package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
  120. package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
  121. package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
  122. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
  123. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
  124. package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
  125. package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
  126. package/dist/widgets/deriveLookupColumns.test.d.ts +0 -1
  127. package/dist/widgets/file-value.test.d.ts +0 -1
  128. package/dist/widgets/personDisplay.test.d.ts +0 -8
  129. package/dist/widgets/recentLookups.test.d.ts +0 -1
  130. package/dist/widgets/useRecordQuery.test.d.ts +0 -8
  131. package/dist/widgets/useUploadingSignal.test.d.ts +0 -1
  132. package/src/FieldEditWidget.test.ts +0 -133
  133. package/src/FieldEditWidget.tsx +0 -191
  134. package/src/__tests__/DateCellRenderer.test.tsx +0 -73
  135. package/src/__tests__/NumberCellRenderer.test.tsx +0 -60
  136. package/src/__tests__/cell-truncation.test.tsx +0 -96
  137. package/src/__tests__/field-carrier-sdui.test.tsx +0 -169
  138. package/src/__tests__/lookupCellDisplayField.test.tsx +0 -107
  139. package/src/__tests__/lookupCellDisplayName.test.tsx +0 -119
  140. package/src/__tests__/lookupCellReferenceKey.test.tsx +0 -57
  141. package/src/__tests__/registry-collision.test.ts +0 -91
  142. package/src/__tests__/resolveFieldCurrency.test.ts +0 -20
  143. package/src/__tests__/spec-symbol-batch7.test.ts +0 -126
  144. package/src/__tests__/validation-feedback.test.tsx +0 -361
  145. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +0 -265
  146. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +0 -338
  147. package/src/__tests__/widget-dom-leak-e2e.test.tsx +0 -598
  148. package/src/__tests__/widget-props-contract.test.tsx +0 -142
  149. package/src/coerce-safe-value.test.ts +0 -34
  150. package/src/complex-widgets.test.tsx +0 -908
  151. package/src/currency.ts +0 -14
  152. package/src/datetime-widgets.test.tsx +0 -93
  153. package/src/field-type-alias.ts +0 -100
  154. package/src/field-type-coverage.test.ts +0 -67
  155. package/src/index.tsx +0 -2503
  156. package/src/standard-widgets.test.tsx +0 -357
  157. package/src/widgets/AddressField.tsx +0 -132
  158. package/src/widgets/AddressField.uniqueIds.test.tsx +0 -92
  159. package/src/widgets/AutoNumberField.tsx +0 -18
  160. package/src/widgets/AvatarField.tsx +0 -117
  161. package/src/widgets/BooleanField.tsx +0 -52
  162. package/src/widgets/CapabilityMultiSelectField.test.tsx +0 -168
  163. package/src/widgets/CapabilityMultiSelectField.tsx +0 -229
  164. package/src/widgets/CharacterCount.tsx +0 -263
  165. package/src/widgets/CheckboxesField.cascade.test.tsx +0 -143
  166. package/src/widgets/CheckboxesField.tsx +0 -136
  167. package/src/widgets/CodeField.tsx +0 -41
  168. package/src/widgets/ColorField.tsx +0 -53
  169. package/src/widgets/CurrencyField.tsx +0 -95
  170. package/src/widgets/DateField.tsx +0 -31
  171. package/src/widgets/DateTimeField.tsx +0 -37
  172. package/src/widgets/EmailField.tsx +0 -37
  173. package/src/widgets/FileField.tsx +0 -494
  174. package/src/widgets/FilterConditionField.tsx +0 -454
  175. package/src/widgets/FormulaField.tsx +0 -33
  176. package/src/widgets/FullscreenFieldEditor.tsx +0 -271
  177. package/src/widgets/GeolocationField.tsx +0 -167
  178. package/src/widgets/GeolocationField.uniqueIds.test.tsx +0 -88
  179. package/src/widgets/GridField.test.tsx +0 -406
  180. package/src/widgets/GridField.tsx +0 -966
  181. package/src/widgets/ImageCropperDialog.tsx +0 -245
  182. package/src/widgets/ImageField.tsx +0 -255
  183. package/src/widgets/ImageLightbox.test.tsx +0 -52
  184. package/src/widgets/ImageLightbox.tsx +0 -108
  185. package/src/widgets/LocationField.tsx +0 -57
  186. package/src/widgets/LookupField.dependsOn.test.tsx +0 -233
  187. package/src/widgets/LookupField.gateHintLabel.test.tsx +0 -135
  188. package/src/widgets/LookupField.hydration.test.tsx +0 -136
  189. package/src/widgets/LookupField.idField.test.tsx +0 -61
  190. package/src/widgets/LookupField.tsx +0 -1291
  191. package/src/widgets/MarkdownContent.tsx +0 -19
  192. package/src/widgets/MasterDetailField.tsx +0 -131
  193. package/src/widgets/MultiSelectField.cascade.test.tsx +0 -143
  194. package/src/widgets/MultiSelectField.tsx +0 -142
  195. package/src/widgets/NumberField.tsx +0 -49
  196. package/src/widgets/ObjectField.tsx +0 -94
  197. package/src/widgets/ObjectRefField.tsx +0 -121
  198. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +0 -88
  199. package/src/widgets/OptionsEmptyState.test.tsx +0 -169
  200. package/src/widgets/OptionsEmptyState.tsx +0 -83
  201. package/src/widgets/PasswordField.tsx +0 -49
  202. package/src/widgets/PeoplePicker.test.tsx +0 -360
  203. package/src/widgets/PeoplePicker.tsx +0 -579
  204. package/src/widgets/PeoplePickerComponents.test.tsx +0 -104
  205. package/src/widgets/PercentField.tsx +0 -95
  206. package/src/widgets/PersonRow.tsx +0 -117
  207. package/src/widgets/PhoneField.tsx +0 -37
  208. package/src/widgets/QRCodeField.tsx +0 -103
  209. package/src/widgets/RadioField.cascade.test.tsx +0 -141
  210. package/src/widgets/RadioField.tsx +0 -111
  211. package/src/widgets/RatingField.tsx +0 -81
  212. package/src/widgets/RecipientPickerField.test.tsx +0 -157
  213. package/src/widgets/RecipientPickerField.tsx +0 -188
  214. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +0 -611
  215. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +0 -112
  216. package/src/widgets/RecordPickerDialog.tsx +0 -1316
  217. package/src/widgets/RichTextField.tsx +0 -176
  218. package/src/widgets/SelectField.cascade.test.tsx +0 -138
  219. package/src/widgets/SelectField.tsx +0 -162
  220. package/src/widgets/SelectionTray.tsx +0 -110
  221. package/src/widgets/SignatureField.tsx +0 -140
  222. package/src/widgets/SliderField.tsx +0 -39
  223. package/src/widgets/SummaryField.tsx +0 -31
  224. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +0 -68
  225. package/src/widgets/TagsField.placeholder.test.tsx +0 -95
  226. package/src/widgets/TagsField.tsx +0 -88
  227. package/src/widgets/TextAreaField.tsx +0 -213
  228. package/src/widgets/TextField.tsx +0 -45
  229. package/src/widgets/TimeField.tsx +0 -31
  230. package/src/widgets/UrlField.tsx +0 -46
  231. package/src/widgets/UserField.tsx +0 -58
  232. package/src/widgets/UserFieldPicker.test.tsx +0 -78
  233. package/src/widgets/VectorField.tsx +0 -29
  234. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +0 -66
  235. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +0 -142
  236. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +0 -248
  237. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +0 -116
  238. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +0 -244
  239. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +0 -195
  240. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +0 -417
  241. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +0 -287
  242. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +0 -160
  243. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +0 -161
  244. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +0 -346
  245. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +0 -246
  246. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +0 -175
  247. package/src/widgets/_cell-renderer-bridge.ts +0 -19
  248. package/src/widgets/deriveLookupColumns.test.ts +0 -112
  249. package/src/widgets/deriveLookupColumns.ts +0 -108
  250. package/src/widgets/file-value.test.ts +0 -230
  251. package/src/widgets/file-value.ts +0 -246
  252. package/src/widgets/openNativePicker.ts +0 -18
  253. package/src/widgets/personDisplay.test.ts +0 -113
  254. package/src/widgets/personDisplay.ts +0 -92
  255. package/src/widgets/recentLookups.test.ts +0 -45
  256. package/src/widgets/recentLookups.ts +0 -47
  257. package/src/widgets/toDomProps.ts +0 -175
  258. package/src/widgets/types.ts +0 -242
  259. package/src/widgets/useCascadingOptions.ts +0 -46
  260. package/src/widgets/useFieldTranslation.ts +0 -150
  261. package/src/widgets/useRecordQuery.test.ts +0 -174
  262. package/src/widgets/useRecordQuery.ts +0 -264
  263. package/src/widgets/useUploadingSignal.test.tsx +0 -62
  264. package/src/widgets/useUploadingSignal.ts +0 -20
  265. 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
- });