@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,287 +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
- * `TextAreaField`'s character counter announces itself in the session locale —
11
- * objectui#3406.
12
- *
13
- * The counter block is rendered only when the field declares `maxLength`, and
14
- * what a sighted user sees (`{n}/{max}`) is digits, so nothing about this was
15
- * visible on screen. The block carries `aria-live="polite"`, and its accessible
16
- * name was the English literal `Character count: ${n} of ${max}` — so a zh/ja/ar
17
- * session had an English sentence read out on every keystroke, and only screen
18
- * reader users could perceive it.
19
- *
20
- * Unlike objectui#3404 (keys existed, this path did not consume them), no
21
- * `characterCount` key existed in any of the ten packs. `fields.textarea.characterCount`
22
- * is new here, in all ten, with `{{count}}` / `{{max}}`.
23
- *
24
- * ## What the cases assert, and why in this shape
25
- *
26
- * - **`en` is a NO-OP pin, not a defect reproducer.** The default in
27
- * `FIELD_DEFAULTS` is byte-identical to the literal it replaced, so the
28
- * English case was green before this change too. It is here to prove the
29
- * swap did not alter English rendering — only a positive assertion sees that
30
- * direction. Its exact expected string is repeated verbatim in
31
- * `TextAreaField.characterCount.no-provider.test.tsx`; together the two pin
32
- * pack-default equality across a boundary the packages cannot import across.
33
- * - **Non-`en` positive AND English-literal negative, together.** A re-inlined
34
- * literal sitting next to a translated sibling still satisfies a
35
- * positive-only assertion.
36
- * - **`ja` carries the load-bearing ORDER check.** Its translation puts the cap
37
- * BEFORE the count ("of {{max}} characters, {{count}}"). An implementation
38
- * that assembled the sentence from parts in code — "label + count + separator +
39
- * max" — cannot produce that order, so it passes a zh-only check by luck and
40
- * fails here. This is the case that justifies one interpolated key over a
41
- * per-part assembly.
42
- * - **`ru` and `ar` are the plural-resolution check.** `count` is not an
43
- * ordinary interpolation variable to i18next: passing it activates plural
44
- * lookup (`key_one` / `key_few` / `key_many` / …) BEFORE the base key. Both
45
- * packs declare the base key only, and both languages have multi-form plural
46
- * rules, so these two prove the base-key fallback actually resolves rather
47
- * than leaving the user with a raw key. `{{count}}` is the spelling this
48
- * repo already uses for counts (`table.selected`, `lookup.recordCount`), and
49
- * this pins that it keeps working for a pack with no plural forms declared.
50
- * - **Recompute-on-input.** The name is derived per render. Pinned so a future
51
- * memo cannot freeze it at the mount-time length while the visible digits
52
- * move on.
53
- *
54
- * ## objectui#3408 — same key, different place, plus a sibling
55
- *
56
- * The sentence is no longer an `aria-live` region's `aria-label`. It is the
57
- * textarea's accessible DESCRIPTION (`aria-describedby`), read once on focus
58
- * instead of re-announced on every keystroke, and the cases above resolve it
59
- * through that association. The expected strings did not change: the ten packs
60
- * still serve them, and this file still guards the same drift (backfilled
61
- * English, a code-assembled sentence that cannot reproduce ja's word order, a
62
- * raw key surfacing where plural lookup fails).
63
- *
64
- * `fields.textarea.charactersRemaining` is the sibling #3408 added — the only
65
- * thing the widget still SPEAKS while typing, and therefore the one the
66
- * English-backfill risk now applies to. Its cases sit at the bottom, with fake
67
- * timers, because reaching it requires crossing the near-limit threshold and
68
- * then pausing.
69
- */
70
- import React from 'react';
71
- import { describe, it, expect, vi, afterEach } from 'vitest';
72
- import { render, screen, fireEvent, act } from '@testing-library/react';
73
- import '@testing-library/jest-dom';
74
- import { I18nProvider } from '@object-ui/i18n';
75
-
76
- import { TextAreaField } from '../TextAreaField';
77
- import type { FieldMetadata } from '@object-ui/types';
78
-
79
- const textareaField = (extra: Record<string, unknown> = {}) =>
80
- ({ name: 'notes', type: 'textarea', ...extra }) as unknown as FieldMetadata;
81
-
82
- function renderIn(language: string, element: React.ReactElement) {
83
- return render(
84
- <I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
85
- {element}
86
- </I18nProvider>,
87
- );
88
- }
89
-
90
- /**
91
- * Where the sentence lives moved in objectui#3408: it was the `aria-label` of
92
- * the visible counter, which was ALSO the `aria-live` region — so the whole
93
- * sentence was re-announced on every keystroke. It is now the textarea's
94
- * accessible DESCRIPTION, read once when focus lands.
95
- *
96
- * These helpers therefore resolve it the way an assistive technology does —
97
- * follow `aria-describedby`, concatenate the referenced nodes — instead of
98
- * reading an attribute off a node found by selector. That keeps the
99
- * ASSOCIATION under assertion here too, and it is why the cases below did not
100
- * need their expected strings changed: the same ten-pack key still produces
101
- * them, in a place a screen reader reaches without typing.
102
- *
103
- * The digits stay queryable separately (`visibleCounter`) because several
104
- * cases assert that the visible half is unchanged. The behaviour half of
105
- * #3408 — how often the live region speaks — is pinned in
106
- * `TextAreaField.characterCount.announcements.test.tsx`.
107
- */
108
- const visibleCounter = () => document.querySelector('[data-testid="textarea-character-count"]');
109
- const describedText = () => {
110
- const box = screen.getByRole('textbox');
111
- const ids = (box.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean);
112
- return ids.map((id) => document.getElementById(id)?.textContent ?? '').join(' ').trim();
113
- };
114
-
115
- describe('TextAreaField character counter is translated (objectui#3406)', () => {
116
- it('renders the English sentence under an en provider', () => {
117
- renderIn(
118
- 'en',
119
- <TextAreaField value="hello" onChange={vi.fn()} field={textareaField({ maxLength: 200 })} />,
120
- );
121
-
122
- // Byte-identical to the literal this replaced, so `en` is a no-op change.
123
- expect(describedText()).toBe('Character count: 5 of 200');
124
- expect(visibleCounter()).toHaveTextContent('5/200');
125
- });
126
-
127
- it('renders the sentence in Chinese under a zh provider', () => {
128
- renderIn(
129
- 'zh',
130
- <TextAreaField value="hello" onChange={vi.fn()} field={textareaField({ maxLength: 200 })} />,
131
- );
132
-
133
- expect(describedText()).toBe('已输入 5 个字符,最多 200 个');
134
- // The literal, asserted absent — a re-inlined one would still satisfy a
135
- // positive-only check on a sibling case.
136
- expect(describedText()).not.toMatch(/character count/i);
137
- // The digits stay language-independent; only the accessible name moved.
138
- expect(visibleCounter()).toHaveTextContent('5/200');
139
- });
140
-
141
- it('renders the sentence in Japanese, with the cap interpolated BEFORE the count', () => {
142
- renderIn(
143
- 'ja',
144
- <TextAreaField value="hello" onChange={vi.fn()} field={textareaField({ maxLength: 200 })} />,
145
- );
146
-
147
- // `{{max}}` precedes `{{count}}` in this pack. Code-side concatenation
148
- // cannot produce this order — that is the point of the assertion.
149
- expect(describedText()).toBe('文字数: 200 文字中 5 文字');
150
- expect(describedText()!.indexOf('200')).toBeLessThan(describedText()!.indexOf('5'));
151
- expect(describedText()).not.toMatch(/character count/i);
152
- });
153
-
154
- it('resolves the base key under ru, whose plural rules i18next consults first', () => {
155
- renderIn(
156
- 'ru',
157
- <TextAreaField value="hello" onChange={vi.fn()} field={textareaField({ maxLength: 200 })} />,
158
- );
159
-
160
- expect(describedText()).toBe('Количество символов: 5 из 200');
161
- // The failure this guards: plural lookup missing and no base-key fallback
162
- // would surface the raw key instead of a sentence.
163
- expect(describedText()).not.toContain('fields.textarea');
164
- });
165
-
166
- it('resolves the base key under ar as well', () => {
167
- renderIn(
168
- 'ar',
169
- <TextAreaField value="hello" onChange={vi.fn()} field={textareaField({ maxLength: 200 })} />,
170
- );
171
-
172
- expect(describedText()).toBe('عدد الأحرف: 5 من 200');
173
- expect(describedText()).not.toContain('fields.textarea');
174
- });
175
-
176
- it('recomputes the spoken count as the user types', () => {
177
- // Derived per render, not memoized at mount. A frozen name would keep
178
- // announcing "5" while the visible digits read 11.
179
- const Host = () => {
180
- const [v, setV] = React.useState('hello');
181
- return <TextAreaField value={v} onChange={setV} field={textareaField({ maxLength: 200 })} />;
182
- };
183
- renderIn('zh', <Host />);
184
- expect(describedText()).toBe('已输入 5 个字符,最多 200 个');
185
-
186
- fireEvent.change(screen.getByRole('textbox'), { target: { value: 'hello world' } });
187
-
188
- expect(describedText()).toBe('已输入 11 个字符,最多 200 个');
189
- expect(visibleCounter()).toHaveTextContent('11/200');
190
- });
191
-
192
- it('keeps reading the legacy snake_case max_length spelling', () => {
193
- // The dual-read predates this change (framework#1878 §3). Keying the
194
- // sentence must not narrow which spelling reaches it.
195
- renderIn(
196
- 'zh',
197
- <TextAreaField value="hello" onChange={vi.fn()} field={textareaField({ max_length: 80 })} />,
198
- );
199
-
200
- expect(describedText()).toBe('已输入 5 个字符,最多 80 个');
201
- });
202
-
203
- it('renders no counter at all when the field declares no maxLength', () => {
204
- // The block is `{maxLength && …}`. Translating it must not make it
205
- // unconditional — an always-present live region would announce on every
206
- // keystroke in every long-text field in the app.
207
- renderIn('zh', <TextAreaField value="hello" onChange={vi.fn()} field={textareaField()} />);
208
-
209
- expect(visibleCounter()).toBeNull();
210
- });
211
-
212
- it('renders no counter in the readonly branch', () => {
213
- // The translation hook had to move ABOVE the readonly early return
214
- // (rules-of-hooks). This pins that the hoist changed nothing a user sees
215
- // on that branch.
216
- renderIn(
217
- 'zh',
218
- <TextAreaField
219
- value="hello"
220
- onChange={vi.fn()}
221
- readonly
222
- field={textareaField({ maxLength: 200 })}
223
- />,
224
- );
225
-
226
- expect(visibleCounter()).toBeNull();
227
- expect(screen.getByText('hello')).toBeInTheDocument();
228
- });
229
- });
230
-
231
- /**
232
- * The near-limit warning — the sentence that IS spoken mid-typing
233
- * (objectui#3408). Reaching it needs the value inside the last 10% / 20
234
- * characters of the cap AND a pause, so these render already-near-full values
235
- * and advance fake timers rather than typing 490 characters.
236
- */
237
- describe('TextAreaField near-limit warning is translated (objectui#3408)', () => {
238
- afterEach(() => vi.useRealTimers());
239
-
240
- // Scoped to its own container and unmounted before returning: some cases
241
- // call this twice, and a document-wide query would read the FIRST mount's
242
- // region — green for the previous language, not the one under test.
243
- const nearLimit = (language: string) => {
244
- vi.useFakeTimers();
245
- const { container, unmount } = renderIn(
246
- language,
247
- <TextAreaField value={'x'.repeat(190)} onChange={vi.fn()} field={textareaField({ maxLength: 200 })} />,
248
- );
249
- act(() => { vi.advanceTimersByTime(1000); });
250
- const spoken = container.querySelector('[aria-live="polite"]')?.textContent ?? '';
251
- unmount();
252
- vi.useRealTimers();
253
- return spoken;
254
- };
255
-
256
- it('renders the English sentence under an en provider', () => {
257
- expect(nearLimit('en')).toBe('Characters remaining: 10');
258
- });
259
-
260
- it('renders the sentence in Chinese under a zh provider', () => {
261
- expect(nearLimit('zh')).toBe('还可输入 10 个字符');
262
- // A backfilled English string is invisible at runtime — the whole defect
263
- // class is English reaching a non-English screen reader.
264
- expect(nearLimit('zh')).not.toMatch(/characters remaining/i);
265
- });
266
-
267
- it('renders the sentence in Japanese', () => {
268
- expect(nearLimit('ja')).toBe('残り 10 文字');
269
- });
270
-
271
- it('resolves the base key under ru and ar, whose plural rules i18next consults first', () => {
272
- // Same mechanism as `characterCount` above: `count` sends i18next to
273
- // `key_one` / `key_few` / … before the base key, and these packs declare
274
- // the base key only. A raw key reaching a screen-reader-only string is
275
- // invisible to everyone who could report it.
276
- expect(nearLimit('ru')).toBe('Осталось символов: 10');
277
- expect(nearLimit('ar')).toBe('الأحرف المتبقية: 10');
278
- expect(nearLimit('ru')).not.toContain('fields.textarea');
279
- expect(nearLimit('ar')).not.toContain('fields.textarea');
280
- });
281
- });
282
-
283
- // The provider-LESS leg lives in `TextAreaField.characterCount.no-provider.test.tsx`.
284
- // It cannot share this file: mounting `I18nProvider` installs its instance as
285
- // react-i18next's GLOBAL default, so after any case above there is no "no
286
- // provider" state left in this module graph to observe (measured in
287
- // objectui#3404 — see that file's header).
@@ -1,160 +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
- * `TextAreaField`'s character counter with NO `I18nProvider` mounted
11
- * (objectui#3406) — standalone/embedded hosts, and most of this package's own
12
- * bare-render tests.
13
- *
14
- * ## Why this leg constrains the implementation
15
- *
16
- * Routing an accessible name through i18n must not turn it into a raw key
17
- * where nothing is configured — for a screen-reader-only string that failure
18
- * is invisible to everyone who could report it. Bare `useObjectTranslation()`
19
- * with no provider returns the key verbatim AND drops interpolation, which is
20
- * strictly worse than the English literal being replaced (measured on the
21
- * pre-change tree in objectui#3404, same hook, same package). So the widget
22
- * consumes `useFieldTranslation()` — `createSafeTranslation` plus English
23
- * defaults, which does its own `{{k}}` substitution.
24
- *
25
- * The expected sentence below is repeated verbatim in the `en`-provider case
26
- * of `TextAreaField.characterCount.i18n.test.tsx`. `packages/i18n` cannot
27
- * import `packages/fields` (the dependency runs the other way), so this pair of
28
- * literals is what pins pack and code default to the same bytes — and both to
29
- * the literal that existed before this change.
30
- *
31
- * ## Why a separate FILE and not another `describe`
32
- *
33
- * Mounting `I18nProvider` calls `initReactI18next`, which installs that
34
- * instance as react-i18next's GLOBAL default. After any sibling case has done
35
- * so there is no "no provider" state left in the module graph, and these
36
- * assertions would read that leaked instance instead of the fallback — green
37
- * for the wrong reason. Measured in objectui#3404: cases written inside the
38
- * provider file stayed GREEN under a reverse check that correctly reddened a
39
- * separate file. vitest's per-file isolation is what makes the observation
40
- * honest. Same split, same reason as
41
- * `FullscreenFieldEditor.no-provider.test.tsx` and
42
- * `TagsField.placeholder.no-provider.test.tsx`.
43
- */
44
- import { describe, it, expect, vi, afterEach } from 'vitest';
45
- import { render, screen, act } from '@testing-library/react';
46
- import '@testing-library/jest-dom';
47
-
48
- import { TextAreaField } from '../TextAreaField';
49
- import type { FieldMetadata } from '@object-ui/types';
50
-
51
- const textareaField = (extra: Record<string, unknown> = {}) =>
52
- ({ name: 'notes', type: 'textarea', ...extra }) as unknown as FieldMetadata;
53
-
54
- const visibleCounter = () => document.querySelector('[data-testid="textarea-character-count"]');
55
- const statusRegion = () => document.querySelector('[aria-live="polite"]');
56
-
57
- /**
58
- * objectui#3408 moved the sentence off the live region's `aria-label` and onto
59
- * the textarea's accessible DESCRIPTION. Resolved here the way an assistive
60
- * technology resolves it — follow `aria-describedby` — so the association is
61
- * under assertion alongside the copy.
62
- */
63
- const counterLabel = () => {
64
- const box = screen.getByRole('textbox');
65
- const ids = (box.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean);
66
- return ids.map((id) => document.getElementById(id)?.textContent ?? '').join(' ').trim();
67
- };
68
-
69
- describe('character counter — English fallback survives with no i18n configured (objectui#3406)', () => {
70
- afterEach(() => vi.useRealTimers());
71
-
72
- it('renders the English default, not a raw key', () => {
73
- render(
74
- <TextAreaField value="hello" onChange={vi.fn()} field={textareaField({ maxLength: 200 })} />,
75
- );
76
-
77
- // Byte-identical to the literal this change replaced.
78
- expect(counterLabel()).toBe('Character count: 5 of 200');
79
- // The measured failure mode, stated directly — a key reaching a user.
80
- expect(counterLabel()).not.toMatch(/fields\.textarea/);
81
- });
82
-
83
- it('substitutes BOTH placeholders in the fallback path', () => {
84
- // `createSafeTranslation`'s fallback runs its own `{{k}}` replacement.
85
- // Bare `useObjectTranslation` would leave the sentence unsubstituted
86
- // (in fact would not return a sentence at all).
87
- render(
88
- <TextAreaField value="hi" onChange={vi.fn()} field={textareaField({ maxLength: 40 })} />,
89
- );
90
-
91
- expect(counterLabel()).toBe('Character count: 2 of 40');
92
- expect(counterLabel()).not.toContain('{{');
93
- });
94
-
95
- it('announces zero for an empty value rather than a hole in the sentence', () => {
96
- // `0` is falsy, so an interpolation that guarded on truthiness would drop
97
- // it and speak "Character count: of 40" — the one input length where the
98
- // substitution can fail on its own.
99
- //
100
- // Deliberately `''` and not `undefined`: `FieldWidgetComponentProps`
101
- // declares `value: T`, non-optional. The widget's `value || ''` guard
102
- // predates this change and covers a JS host that ignores the type; pinning
103
- // an out-of-contract shape here would be the lenient-consumer move
104
- // AGENTS.md #0.1 rules out, and is not what this issue is about.
105
- render(<TextAreaField value="" onChange={vi.fn()} field={textareaField({ maxLength: 40 })} />);
106
-
107
- expect(counterLabel()).toBe('Character count: 0 of 40');
108
- });
109
-
110
- it('keeps a live region, now SEPARATE from the visible digits', () => {
111
- // objectui#3406 pinned `aria-live="polite"` on the counter itself and said
112
- // so explicitly: the per-keystroke re-announce was a behaviour question,
113
- // filed separately, deliberately untouched. objectui#3408 is that filing,
114
- // and this pin is updated in the direction it decided — the live region
115
- // still exists (that attribute is why anything is ever spoken) but it is a
116
- // visually-hidden node of its own, and the digits it used to share an
117
- // element with are now `aria-hidden`.
118
- render(
119
- <TextAreaField value="hello" onChange={vi.fn()} field={textareaField({ maxLength: 200 })} />,
120
- );
121
-
122
- expect(statusRegion()).toHaveAttribute('aria-live', 'polite');
123
- expect(statusRegion()).not.toBe(visibleCounter());
124
- expect(visibleCounter()).toHaveAttribute('aria-hidden', 'true');
125
- expect(visibleCounter()).not.toHaveAttribute('aria-live');
126
- });
127
-
128
- it('renders the English near-limit warning too, not a raw key', () => {
129
- // The second half of the fallback contract: `charactersRemaining` is the
130
- // only sentence the widget still speaks while typing, so a key leaking
131
- // here is the same invisible failure — heard by exactly the users who
132
- // cannot see that it is a key. 190 of 200 is inside the warning band; the
133
- // 1s pause is what releases it.
134
- vi.useFakeTimers();
135
- render(
136
- <TextAreaField
137
- value={'x'.repeat(190)}
138
- onChange={vi.fn()}
139
- field={textareaField({ maxLength: 200 })}
140
- />,
141
- );
142
- act(() => { vi.advanceTimersByTime(1000); });
143
-
144
- expect(statusRegion()).toHaveTextContent('Characters remaining: 10');
145
- expect(statusRegion()!.textContent).not.toMatch(/fields\.textarea/);
146
- expect(statusRegion()!.textContent).not.toContain('{{');
147
- });
148
-
149
- it('the code-shipped default carries no CJK (Commandment #-1)', () => {
150
- // With no provider mounted, whatever renders here came from CODE
151
- // (FIELD_DEFAULTS). Escaped ranges on purpose: a literal CJK class would
152
- // itself violate the commandment this asserts (same shape as
153
- // `FullscreenFieldEditor.no-provider.test.tsx`).
154
- render(
155
- <TextAreaField value="hello" onChange={vi.fn()} field={textareaField({ maxLength: 200 })} />,
156
- );
157
-
158
- expect(counterLabel()).not.toMatch(/[\u3000-\u30ff\u4e00-\u9fff]/);
159
- });
160
- });
@@ -1,161 +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
- * The fullscreen dialog's character-count description follows the session
11
- * language — objectui#3417.
12
- *
13
- * The sentence is the SAME `fields.textarea.characterCount` key the inline
14
- * surface has used since objectui#3406, and reusing it rather than minting a
15
- * `fields.textarea.fullscreenCharacterCount` twin is the point: one form-level
16
- * setting (`ObjectFormSchema.mobile.fullscreenLongText`) projects onto both
17
- * surfaces of the same field, so two copies of this copy could only drift, and
18
- * a twin would mean editing ten locale packs to say what they already say.
19
- * (Same reasoning, same shape, as objectui#3404's decision to have
20
- * `FullscreenFieldEditor` consume the existing `form.fullscreen.*` keys.)
21
- *
22
- * ## Why this needs its own cases at all
23
- *
24
- * `TextAreaField.characterCount.i18n.test.tsx` already pins the key and its ten
25
- * packs — including the load-bearing `ja` word-order case, which no code-side
26
- * concatenation can satisfy. What it cannot see is whether the FULLSCREEN
27
- * surface routes through `t()` or re-inlines an English literal on the way to a
28
- * node only a screen reader reads. A footer that renders
29
- * `Character count: 5 of 500` in a zh session looks correct to everyone who
30
- * could file the bug. So: a positive translated assertion AND the English
31
- * literal asserted absent, on the fullscreen surface specifically.
32
- *
33
- * `ja` is carried over for the same reason it exists on the inline side — its
34
- * pack puts the cap BEFORE the count, so an implementation that assembled the
35
- * sentence from parts passes a zh-only check by luck and fails here.
36
- *
37
- * The provider-LESS leg lives in `TextAreaField.fullscreenCharacterCount.test.tsx`
38
- * and cannot share this file: mounting `I18nProvider` installs its instance as
39
- * react-i18next's GLOBAL default, so after any case below there is no "no
40
- * provider" state left in this module graph to observe (measured in
41
- * objectui#3404 — cases written inside the provider file stayed GREEN under a
42
- * reverse check that correctly reddened a separate file).
43
- */
44
-
45
- import React from 'react';
46
- import { describe, it, expect, vi } from 'vitest';
47
- import { render, screen, fireEvent } from '@testing-library/react';
48
- import '@testing-library/jest-dom';
49
- import { I18nProvider } from '@object-ui/i18n';
50
-
51
- import { TextAreaField } from '../TextAreaField';
52
- import type { FieldMetadata } from '@object-ui/types';
53
-
54
- const fieldMeta = (extra: Record<string, unknown> = {}) =>
55
- ({
56
- name: 'description',
57
- label: 'Description',
58
- type: 'textarea',
59
- mobile_fullscreen: true,
60
- ...extra,
61
- }) as unknown as FieldMetadata;
62
-
63
- /**
64
- * Render, open the dialog, and return the dialog textarea's accessible
65
- * DESCRIPTION resolved the way an assistive technology resolves it — follow
66
- * `aria-describedby`, concatenate the referenced nodes. Scoped to its own mount
67
- * and unmounted before returning: every case here renders a different language,
68
- * and a document-wide read after a second mount would report the FIRST one.
69
- */
70
- const fullscreenDescription = (language: string, maxLength = 200, value = 'hello') => {
71
- const { unmount } = render(
72
- <I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
73
- <TextAreaField value={value} onChange={vi.fn()} field={fieldMeta({ maxLength })} />
74
- </I18nProvider>,
75
- );
76
- fireEvent.click(screen.getByTestId('textarea-fullscreen-toggle'));
77
-
78
- const input = screen.getByTestId('textarea-fullscreen-input');
79
- const ids = (input.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean);
80
- const text = ids
81
- .map((id) => document.getElementById(id)?.textContent ?? '')
82
- .join(' ')
83
- .trim();
84
- const digits =
85
- document.querySelector('[data-testid="textarea-fullscreen-character-count"]')?.textContent ??
86
- null;
87
-
88
- unmount();
89
- return { text, digits };
90
- };
91
-
92
- describe('the fullscreen counter description is translated (objectui#3417)', () => {
93
- it('renders the English sentence under an en provider', () => {
94
- // A no-op pin: `FIELD_DEFAULTS` and the `en` pack are byte-identical, so
95
- // this is here to prove routing through `t()` did not change English
96
- // rendering. Only a positive assertion sees that direction.
97
- const { text, digits } = fullscreenDescription('en');
98
-
99
- expect(text).toBe('Character count: 5 of 200');
100
- expect(digits).toBe('5/200');
101
- });
102
-
103
- it('renders the sentence in Chinese under a zh provider', () => {
104
- const { text, digits } = fullscreenDescription('zh');
105
-
106
- expect(text).toBe('已输入 5 个字符,最多 200 个');
107
- // The literal, asserted absent — a re-inlined one would still satisfy the
108
- // positive assertion on a sibling case, and in a screen-reader-only string
109
- // nobody who can see it is affected.
110
- expect(text).not.toMatch(/character count/i);
111
- // The digits stay language-independent; only the description is keyed.
112
- expect(digits).toBe('5/200');
113
- });
114
-
115
- it('renders the sentence in Japanese, with the cap interpolated BEFORE the count', () => {
116
- // `{{max}}` precedes `{{count}}` in this pack. Code-side concatenation
117
- // cannot produce this order — that is the point of the assertion.
118
- const { text } = fullscreenDescription('ja');
119
-
120
- expect(text).toBe('文字数: 200 文字中 5 文字');
121
- expect(text.indexOf('200')).toBeLessThan(text.indexOf('5'));
122
- expect(text).not.toMatch(/character count/i);
123
- });
124
-
125
- it('resolves the base key under ru, whose plural rules i18next consults first', () => {
126
- // `count` sends i18next to `key_one` / `key_few` / … BEFORE the base key,
127
- // and these packs declare the base key only. A raw key reaching a
128
- // screen-reader-only string is invisible to everyone who could report it.
129
- const { text } = fullscreenDescription('ru');
130
-
131
- expect(text).toBe('Количество символов: 5 из 200');
132
- expect(text).not.toContain('fields.textarea');
133
- });
134
-
135
- it('recomputes the sentence as the DRAFT changes, not the committed value', () => {
136
- // Derived per render from the draft. A memo frozen at open time would keep
137
- // describing the value the user opened the dialog with while the digits
138
- // beside it moved on.
139
- render(
140
- <I18nProvider config={{ defaultLanguage: 'zh', detectBrowserLanguage: false }}>
141
- <TextAreaField value="hello" onChange={vi.fn()} field={fieldMeta({ maxLength: 200 })} />
142
- </I18nProvider>,
143
- );
144
- fireEvent.click(screen.getByTestId('textarea-fullscreen-toggle'));
145
-
146
- const input = screen.getByTestId('textarea-fullscreen-input');
147
- const describedText = () => {
148
- const ids = (input.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean);
149
- return ids
150
- .map((id) => document.getElementById(id)?.textContent ?? '')
151
- .join(' ')
152
- .trim();
153
- };
154
-
155
- expect(describedText()).toBe('已输入 5 个字符,最多 200 个');
156
-
157
- fireEvent.change(input, { target: { value: 'hello world' } });
158
-
159
- expect(describedText()).toBe('已输入 11 个字符,最多 200 个');
160
- });
161
- });