@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,263 +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
- import React, { useEffect, useState } from 'react';
10
- import { useFieldTranslation } from './useFieldTranslation';
11
-
12
- /**
13
- * The character counter a capped long-text field renders, in the GOV.UK
14
- * character-count shape — extracted from `TextAreaField` so that widget's TWO
15
- * editing surfaces (the inline textarea and the fullscreen dialog) render the
16
- * SAME counting UI instead of two hand-written copies (objectui#3417).
17
- *
18
- * ## Why this is shared rather than copied
19
- *
20
- * objectui#3408 gave the inline counter three nodes — decorative digits, an
21
- * accessible description, a gated status region. The fullscreen dialog's
22
- * counter, passed to `FullscreenFieldEditor` as a `footer`, stayed what it had
23
- * always been: a bare `{n}/{max}` `span` with no accessible name, no
24
- * `aria-describedby` association with the dialog's textarea, and nothing
25
- * `aria-live`. Same field, same cap, same user — and a screen reader in the
26
- * fullscreen dialog got nothing at all, while browse mode read "5 slash 500".
27
- *
28
- * That is the failure mode this package keeps paying for (objectui#3301: the
29
- * fullscreen flag reached `RichTextField` for as long as the feature existed
30
- * and that widget never read it). A second hand-written copy of a counting UI
31
- * is the same failure with an extra step: it drifts, and nothing reports the
32
- * drift. So the counter has ONE implementation and each surface renders it.
33
- *
34
- * ## The three nodes, and why the third is a prop
35
- *
36
- * 1. the visible `{n}/{max}` digits, `aria-hidden` — a glance affordance;
37
- * 2. a visually-hidden DESCRIPTION carrying the translated sentence. The host
38
- * owns {@link CharacterCountProps.descriptionId} and MUST name it in the
39
- * edited control's `aria-describedby`, which is what makes the sentence
40
- * reachable on focus rather than only in browse mode;
41
- * 3. a visually-hidden `aria-live="polite"` STATUS region, silent until the
42
- * value is inside the last 10% / 20 characters of the cap and updated only
43
- * after the typist pauses.
44
- *
45
- * Node 3 is opt-in per surface ({@link CharacterCountProps.announceNearLimit}),
46
- * required rather than defaulted, because "should this surface interrupt the
47
- * user while they type?" is a real decision and neither answer should be
48
- * reached by leaving a prop off. The fullscreen dialog answers no: it is a
49
- * modal the user opened deliberately to write at length, the description
50
- * already tells them the cap on focus, and the dialog's own textarea carries
51
- * the same native `maxLength` stop as the inline one. The inline surface
52
- * answers yes, unchanged from objectui#3408.
53
- */
54
-
55
- /**
56
- * How long the typist must pause before the counter's status region is allowed
57
- * to change (objectui#3408). The GOV.UK Design System character-count component
58
- * uses the same shape and the same order of magnitude.
59
- *
60
- * The number this replaces was effectively 0: the counter WAS the live region,
61
- * so every keystroke re-rendered it and every re-render was an announcement.
62
- * Measured on `origin/main`, zh session, `maxLength: 500`, typing a 52-character
63
- * sentence one character at a time: 52 keystrokes produced 52 distinct
64
- * announcements totalling 979 spoken characters — ~19x the text the user was
65
- * trying to write, each one interrupting the screen reader's echo of what they
66
- * had just typed.
67
- */
68
- const COUNTER_STATUS_DEBOUNCE_MS = 1000;
69
-
70
- /** Announce inside the last 10% of the cap … */
71
- const COUNTER_STATUS_REMAINING_RATIO = 0.1;
72
- /** … or the last 20 characters — whichever the typist reaches FIRST. */
73
- const COUNTER_STATUS_MIN_REMAINING = 20;
74
-
75
- /**
76
- * The remaining-character count at or below which the status region speaks.
77
- *
78
- * "10% remaining OR 20 characters remaining, whichever comes first" is a
79
- * disjunction, and because `remaining` only ever counts DOWN as the user types,
80
- * the branch that fires first is simply the LARGER of the two — hence `max`.
81
- * A 500-character cap starts warning at 50 remaining; a 100-character cap at 20
82
- * (10% of it would be 10, which the typist would reach later, not sooner).
83
- *
84
- * A cap smaller than {@link COUNTER_STATUS_MIN_REMAINING} is therefore "near
85
- * the limit" from the first keystroke, which is correct: on a 10-character
86
- * field every character genuinely is one of the last few. The debounce still
87
- * bounds it to one announcement per pause.
88
- */
89
- function counterStatusThreshold(maxLength: number): number {
90
- return Math.max(
91
- Math.ceil(maxLength * COUNTER_STATUS_REMAINING_RATIO),
92
- COUNTER_STATUS_MIN_REMAINING,
93
- );
94
- }
95
-
96
- export interface CharacterCountProps {
97
- /**
98
- * Length of the text this surface is counting. The inline surface passes the
99
- * committed value's length; the fullscreen dialog passes the DRAFT's, which
100
- * is the number that is true of what the user is looking at.
101
- */
102
- length: number;
103
- /**
104
- * The declared cap. Callers render this component only when the field
105
- * declares one, so it is non-optional here — a counter with no cap to count
106
- * against has nothing to say and must not be mounted at all (an unconditional
107
- * live region in every long-text field is the objectui#3408 defect back
108
- * again).
109
- */
110
- maxLength: number;
111
- /**
112
- * The description node's `id`, minted by the host from `useId()`.
113
- *
114
- * The host MUST also name it in the edited control's `aria-describedby` —
115
- * this component renders the sentence, only the host knows which element is
116
- * being described, and a description nothing references is a node no screen
117
- * reader will ever reach. That association is asserted from the OUTSIDE (the
118
- * tests resolve `aria-describedby` and concatenate the referenced nodes' text,
119
- * exactly as an assistive technology does) rather than trusted here.
120
- *
121
- * Two surfaces of one widget therefore need two ids: the dialog's draft and
122
- * the inline value diverge the moment the user types in the dialog, so one
123
- * shared id would point both controls at whichever sentence rendered last.
124
- */
125
- descriptionId: string;
126
- /**
127
- * Render node 3, the threshold-gated debounced `aria-live` status region.
128
- *
129
- * Required on purpose — see the component doc above. `false` removes the
130
- * region from the DOM entirely (not merely empties it), which is the only
131
- * form of "no live region" a test can tell apart from "a live region that
132
- * happens to be silent right now".
133
- */
134
- announceNearLimit: boolean;
135
- /**
136
- * Classes for the visible digits. Required because placement is entirely the
137
- * surface's business — inline pins them to the textarea's bottom-right
138
- * corner, the dialog lays them out as a footer item — and a default borrowed
139
- * from either one would position the other wrongly while looking deliberate.
140
- */
141
- className: string;
142
- /**
143
- * `data-testid` for the visible digits, namespaced per surface
144
- * (`textarea-character-count`, `textarea-fullscreen-character-count`) so a
145
- * test that finds digits can say WHICH surface it found them on. Same
146
- * convention, same reason, as `FullscreenFieldEditor`'s `testIdPrefix`.
147
- */
148
- testId: string;
149
- }
150
-
151
- export function CharacterCount({
152
- length,
153
- maxLength,
154
- descriptionId,
155
- announceNearLimit,
156
- className,
157
- testId,
158
- }: CharacterCountProps) {
159
- const { t } = useFieldTranslation();
160
-
161
- /**
162
- * The counter sentence as a DESCRIPTION (objectui#3408). Reached through the
163
- * edited control's `aria-describedby`, so focusing it says "12 characters,
164
- * 500 max" once and then shuts up. Before this the cap was announced only as
165
- * a side effect of typing — focus told a screen reader user nothing at all
166
- * that a sighted user could read off the corner of the box.
167
- */
168
- const description = t('fields.textarea.characterCount', { count: length, max: maxLength });
169
-
170
- /**
171
- * The near-limit warning, gated. Silent for the whole comfortable middle of
172
- * the field — a count nobody is close to is not news — and phrased as what
173
- * is LEFT rather than what has been typed, because that is the number the
174
- * user is about to act on. Over-long values (a cap lowered after the record
175
- * was saved) clamp to 0: the textarea's own `maxLength` blocks further
176
- * input, so "0 left" is the true actionable state, and the description above
177
- * still carries the honest `503 of 500`.
178
- *
179
- * Short-circuited by `announceNearLimit` rather than only hidden at render
180
- * time, so a surface that renders no status region also schedules no timer —
181
- * a debounce firing into a region that does not exist is a state update with
182
- * no consumer, and in tests an `act()` warning with no cause.
183
- */
184
- const pendingStatus =
185
- announceNearLimit && maxLength - length <= counterStatusThreshold(maxLength)
186
- ? t('fields.textarea.charactersRemaining', { count: Math.max(maxLength - length, 0) })
187
- : '';
188
-
189
- /**
190
- * The last sentence a PAUSE settled on. Only ever written by the timer —
191
- * never cleared synchronously — so this effect adds no cascading render on
192
- * the keystrokes it is busy staying quiet through
193
- * (`react-hooks/set-state-in-effect`).
194
- */
195
- const [settledStatus, setSettledStatus] = useState('');
196
-
197
- useEffect(() => {
198
- if (!pendingStatus) return;
199
- const timer = setTimeout(() => setSettledStatus(pendingStatus), COUNTER_STATUS_DEBOUNCE_MS);
200
- // Every keystroke cancels the previous pending announcement, so a typist
201
- // who never pauses is never interrupted. The dependency is the SENTENCE,
202
- // not the length: re-typing back to the same count produces the same string
203
- // and therefore no DOM change and no second announcement.
204
- return () => clearTimeout(timer);
205
- }, [pendingStatus]);
206
-
207
- /**
208
- * Speak the settled sentence only while it is still TRUE — i.e. while it is
209
- * the one the current value would produce. Everything else renders empty,
210
- * which costs no speech (emptying a live region announces nothing), and that
211
- * is what makes leaving the warning band silent IMMEDIATELY rather than a
212
- * second later.
213
- *
214
- * The obvious alternative, `pendingStatus ? settledStatus : ''`, is wrong in
215
- * one specific way: delete back out of the band and type into it again, and
216
- * the region re-announces the STALE count from before the excursion a full
217
- * second before the timer corrects it. Announcing a wrong number is worse
218
- * than announcing a right one twice, which is the bounded, sub-second,
219
- * correct-content cost of the comparison below. Pinned by `never
220
- * re-announces the STALE count when the user types back into the band`.
221
- */
222
- const status = settledStatus === pendingStatus ? pendingStatus : '';
223
-
224
- return (
225
- <>
226
- {/*
227
- The VISIBLE counter, and visible ONLY (objectui#3408). It used to be
228
- three things at once: the digits a sighted user glances at, the carrier
229
- of the translated sentence (objectui#3406), and the live region itself —
230
- so the sentence was re-announced on every single keystroke. Split into
231
- the three nodes here, this one is decorative: `aria-hidden` keeps a
232
- screen reader from reading "5 slash 200" on top of the description that
233
- says it properly.
234
- */}
235
- <div className={className} aria-hidden="true" data-testid={testId}>
236
- {length}/{maxLength}
237
- </div>
238
-
239
- {/*
240
- The DESCRIPTION. Referenced by the edited control's `aria-describedby`,
241
- never announced on its own — a screen reader reads it when focus lands
242
- on the control and not again. Visually hidden because the digits above
243
- already say it to the eye.
244
- */}
245
- <span id={descriptionId} className="sr-only">
246
- {description}
247
- </span>
248
-
249
- {/*
250
- The STATUS region, on the surfaces that opted in. Rendered
251
- unconditionally there and starting EMPTY on purpose: a live region has
252
- to be in the DOM before its content changes or the first change is not
253
- announced at all. `aria-atomic` so the whole sentence is spoken rather
254
- than the digits that differ from last time.
255
- */}
256
- {announceNearLimit && (
257
- <span className="sr-only" aria-live="polite" aria-atomic="true">
258
- {status}
259
- </span>
260
- )}
261
- </>
262
- );
263
- }
@@ -1,143 +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
- * Cascading / role-gated `checkboxes` options (#2715 follow-up) — parity with
11
- * `MultiSelectField` (ADR-0058 / #2284).
12
- *
13
- * Exercises the observable widget behaviour: the dependency gate (a "select the
14
- * parent first" empty-state) and the per-element cascade clear — a selection
15
- * dropped from the array when the offered set no longer includes it, while
16
- * still-valid selections are kept. The per-option filtering itself is unit-tested
17
- * in `@object-ui/core` (`optionRules.test.ts`); here we prove the checkbox list
18
- * wires `dependentValues` + predicate scope into that resolver via the shared
19
- * `useCascadingOptions` hook.
20
- */
21
- import { describe, it, expect, vi, beforeEach } from 'vitest';
22
- import React from 'react';
23
- import { render, screen } from '@testing-library/react';
24
- import '@testing-library/jest-dom';
25
- import { PredicateScopeProvider } from '@object-ui/react';
26
- import { CheckboxesField } from './CheckboxesField';
27
-
28
- const provinceField = {
29
- name: 'provinces',
30
- type: 'checkboxes',
31
- dependsOn: 'country',
32
- options: [
33
- { label: 'Zhejiang', value: 'zj', visibleWhen: "record.country == 'cn'" },
34
- { label: 'California', value: 'ca', visibleWhen: "record.country == 'us'" },
35
- ],
36
- } as any;
37
-
38
- beforeEach(() => {
39
- vi.clearAllMocks();
40
- });
41
-
42
- describe('CheckboxesField — dependency gating (#2715)', () => {
43
- it('gates with a "select parent first" hint while the controlling field is empty', () => {
44
- render(
45
- <CheckboxesField
46
- value={[]}
47
- onChange={vi.fn()}
48
- field={provinceField}
49
- {...({ name: 'provinces', dependentValues: {} } as any)}
50
- />,
51
- );
52
- const gate = screen.getByTestId('checkboxes-empty-provinces');
53
- expect(gate).toHaveTextContent(/select country first/i);
54
- // Gated → no checkboxes offered.
55
- expect(screen.queryAllByRole('checkbox')).toHaveLength(0);
56
- });
57
-
58
- it('unlocks the checkboxes once the controlling field is set', () => {
59
- render(
60
- <CheckboxesField
61
- value={[]}
62
- onChange={vi.fn()}
63
- field={provinceField}
64
- {...({ name: 'provinces', dependentValues: { country: 'cn' } } as any)}
65
- />,
66
- );
67
- expect(screen.queryByTestId('checkboxes-empty-provinces')).not.toBeInTheDocument();
68
- // Only the `cn` option is offered.
69
- expect(screen.getByRole('checkbox', { name: 'Zhejiang' })).toBeInTheDocument();
70
- expect(screen.queryByRole('checkbox', { name: 'California' })).not.toBeInTheDocument();
71
- });
72
- });
73
-
74
- describe('CheckboxesField — cascade clear (#2715)', () => {
75
- it('drops only the selections the parent change no longer offers', () => {
76
- const onChange = vi.fn();
77
- render(
78
- <CheckboxesField
79
- value={['zj', 'ca']}
80
- onChange={onChange}
81
- field={provinceField}
82
- {...({ name: 'provinces', dependentValues: { country: 'cn' } } as any)}
83
- />,
84
- );
85
- // 'ca' is a US province — under country=cn it is not offered, so it is
86
- // pruned; the still-valid 'zj' is kept.
87
- expect(onChange).toHaveBeenCalledWith(['zj']);
88
- });
89
-
90
- it('keeps selections that are all still offered', () => {
91
- const onChange = vi.fn();
92
- render(
93
- <CheckboxesField
94
- value={['zj']}
95
- onChange={onChange}
96
- field={provinceField}
97
- {...({ name: 'provinces', dependentValues: { country: 'cn' } } as any)}
98
- />,
99
- );
100
- expect(onChange).not.toHaveBeenCalled();
101
- });
102
- });
103
-
104
- describe('CheckboxesField — role / context gating (#2715)', () => {
105
- const tierField = {
106
- name: 'tiers',
107
- type: 'checkboxes',
108
- options: [
109
- { label: 'Standard', value: 'standard' },
110
- { label: 'Admin only', value: 'admin_only', visibleWhen: "'admin' in current_user.positions" },
111
- ],
112
- } as any;
113
-
114
- it('prunes an admin-only value for a non-admin (offered set excludes it)', () => {
115
- const onChange = vi.fn();
116
- render(
117
- <PredicateScopeProvider scope={{ current_user: { positions: ['sales'] } }}>
118
- <CheckboxesField
119
- value={['standard', 'admin_only']}
120
- onChange={onChange}
121
- field={tierField}
122
- {...({ name: 'tiers', dependentValues: {} } as any)}
123
- />
124
- </PredicateScopeProvider>,
125
- );
126
- expect(onChange).toHaveBeenCalledWith(['standard']);
127
- });
128
-
129
- it('keeps an admin-only value for an admin', () => {
130
- const onChange = vi.fn();
131
- render(
132
- <PredicateScopeProvider scope={{ current_user: { positions: ['admin'] } }}>
133
- <CheckboxesField
134
- value={['standard', 'admin_only']}
135
- onChange={onChange}
136
- field={tierField}
137
- {...({ name: 'tiers', dependentValues: {} } as any)}
138
- />
139
- </PredicateScopeProvider>,
140
- );
141
- expect(onChange).not.toHaveBeenCalled();
142
- });
143
- });
@@ -1,136 +0,0 @@
1
- import React, { useId, useEffect } from 'react';
2
- import { Checkbox, Label, EmptyValue, Badge } from '@object-ui/components';
3
- import type { OptionLike } from '@object-ui/core';
4
- import { FieldWidgetComponentProps } from './types';
5
- import { toDomProps } from './toDomProps';
6
- import { OptionsEmptyState } from './OptionsEmptyState';
7
- import { useCascadingOptions } from './useCascadingOptions';
8
-
9
- type Option = OptionLike;
10
-
11
- /**
12
- * CheckboxesField - select zero or more values from a fixed option set, rendered
13
- * as a list of checkboxes. The stored value is a string[]. Used for the
14
- * `checkboxes` field type.
15
- *
16
- * Options support the same per-option `visibleWhen` cascading + `dependsOn`
17
- * gating as `MultiSelectField` (ADR-0058 / #2715), resolved through the shared
18
- * {@link useCascadingOptions} hook: the offered boxes narrow against the live
19
- * form record + `current_user`, the control is gated behind a "select the parent
20
- * first" hint while a dependency is empty, and selections no longer offered
21
- * (parent changed / predicate flipped) are pruned from the array.
22
- */
23
- export function CheckboxesField({
24
- value,
25
- onChange,
26
- field,
27
- readonly,
28
- className,
29
- dependentValues,
30
- dependsOn: dependsOnProp,
31
- emptyHint,
32
- dataSource: _dataSource,
33
- error,
34
- ...props
35
- }: FieldWidgetComponentProps<string[]>) {
36
- const config = field as any;
37
- const rawOptions: Option[] = config?.options || [];
38
- const selected: string[] = Array.isArray(value) ? value : value == null ? [] : [value as unknown as string];
39
- const groupId = useId();
40
- const fieldName = props.name || config?.name || props.id || '';
41
-
42
- const dependsOn = config?.dependsOn ?? dependsOnProp;
43
- const { options, gated, dependsOnFields } = useCascadingOptions<Option>(
44
- rawOptions,
45
- dependsOn,
46
- dependentValues,
47
- );
48
-
49
- // Cascade clear: prune selected values the offered set no longer includes
50
- // (parent changed / predicate flipped), keeping the ones still valid — the
51
- // per-element clear the multi-value case needs (cf. the scalar select/radio).
52
- useEffect(() => {
53
- if (readonly) return;
54
- if (selected.length === 0) return;
55
- const stillOffered = selected.filter((v) => options.some((o) => o.value === v));
56
- if (stillOffered.length !== selected.length) onChange(stillOffered);
57
- // eslint-disable-next-line react-hooks/exhaustive-deps
58
- }, [options, gated]);
59
-
60
- if (readonly) {
61
- if (selected.length === 0) return <EmptyValue />;
62
- return (
63
- <div className="flex flex-wrap gap-1">
64
- {selected.map((v) => {
65
- // Label from the raw set so a stored value hidden by `visibleWhen`
66
- // still renders its label rather than a bare id.
67
- const opt = rawOptions.find((o) => o.value === v);
68
- return <Badge key={v} variant="outline">{opt?.label || v}</Badge>;
69
- })}
70
- </div>
71
- );
72
- }
73
-
74
- // No offered options is unfillable — surface a legible state instead of an
75
- // empty checkbox list: the host's `emptyHint` when it computed one, else this
76
- // widget's own translated copy. Shared with the select / multiselect / radio
77
- // so the four cannot drift again (objectui#3231).
78
- if (options.length === 0) {
79
- return (
80
- <OptionsEmptyState
81
- emptyHint={emptyHint}
82
- gated={gated}
83
- dependsOnFields={dependsOnFields}
84
- testId={fieldName ? `checkboxes-empty-${fieldName}` : undefined}
85
- className="min-h-9"
86
- />
87
- );
88
- }
89
-
90
- const toggle = (v: string, checked: boolean) => {
91
- const next = checked ? [...selected.filter((x) => x !== v), v] : selected.filter((x) => x !== v);
92
- onChange(next);
93
- };
94
-
95
- // DOM pass-through (objectui#3318): the container carries the form
96
- // renderer's id / aria-describedby, but NOT its `aria-invalid` — a plain
97
- // wrapper div is not where assistive tech reads the invalid state. That
98
- // state goes onto every focusable checkbox below (`checkbox` is an
99
- // aria-invalid-supporting role), computed from the published `error` slot
100
- // (#3222). The per-item ids below stay authoritative for their labels.
101
- // `name` is withheld too: it is only DOM-legal on form controls, and on
102
- // this div it is exactly the leak #3291 sweeps for.
103
- const {
104
- 'aria-invalid': _hostAriaInvalid,
105
- name: _domName,
106
- ...groupDomProps
107
- } = toDomProps(props);
108
-
109
- return (
110
- <div
111
- {...groupDomProps}
112
- className={className}
113
- data-testid={fieldName ? `checkboxes-${fieldName}` : undefined}
114
- >
115
- {options.map((opt) => {
116
- // Multi-value fields store string arrays — stringify the (possibly
117
- // numeric, #3090-widened) authored value at this boundary.
118
- const value = String(opt.value);
119
- const id = `${groupId}-${value}`;
120
- return (
121
- <div key={value} className="flex items-center space-x-2 py-0.5">
122
- <Checkbox
123
- id={id}
124
- checked={selected.includes(value)}
125
- onCheckedChange={(checked) => toggle(value, !!checked)}
126
- disabled={props.disabled}
127
- aria-invalid={!!error}
128
- data-testid={`checkboxes-option-${value}`}
129
- />
130
- <Label htmlFor={id} className="font-normal">{opt.label}</Label>
131
- </div>
132
- );
133
- })}
134
- </div>
135
- );
136
- }
@@ -1,41 +0,0 @@
1
- import React from 'react';
2
- import { Textarea, cn, EmptyValue } from '@object-ui/components';
3
- import { FieldWidgetComponentProps } from './types';
4
- import { toDomProps } from './toDomProps';
5
-
6
- /**
7
- * Code field widget - provides a code editor with syntax highlighting
8
- * Uses a simple textarea with monospace font
9
- * For advanced code editing, use the @object-ui/plugin-editor component
10
- */
11
- export function CodeField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
12
- const config = field;
13
- // Get code-specific configuration from field metadata
14
- const language = (config as any)?.language ?? 'javascript';
15
-
16
- if (readonly) {
17
- return (
18
- <pre className={cn("text-sm bg-muted p-2 rounded overflow-x-auto border", props.className)}>
19
- <code>{value || <EmptyValue />}</code>
20
- </pre>
21
- );
22
- }
23
-
24
- return (
25
- <Textarea
26
- // DOM pass-through onto the real focusable control (objectui#3318).
27
- {...toDomProps(props)}
28
- value={value || ''}
29
- onChange={(e) => onChange(e.target.value)}
30
- placeholder={config?.placeholder || `// Write ${language} code here...`}
31
- disabled={readonly || props.disabled}
32
- className={cn("font-mono text-sm", props.className)}
33
- rows={12}
34
- spellCheck={false}
35
- // AFTER the spread so this widget's own computation wins: `error` is
36
- // the published validation slot (#3222), `!!undefined` → explicit
37
- // "false".
38
- aria-invalid={!!error}
39
- />
40
- );
41
- }
@@ -1,53 +0,0 @@
1
- import React from 'react';
2
- import { Input, EmptyValue } from '@object-ui/components';
3
- import { FieldWidgetComponentProps } from './types';
4
- import { toDomProps } from './toDomProps';
5
-
6
- /**
7
- * Color field widget - provides a color picker input
8
- * Supports hex color values (e.g., #ff0000)
9
- */
10
- export function ColorField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
11
- const colorField = field as any;
12
-
13
- if (readonly) {
14
- return (
15
- <div className="flex items-center gap-2">
16
- <div
17
- className="w-6 h-6 rounded border border-input"
18
- style={{ backgroundColor: value || '#000000' }}
19
- />
20
- <span className="text-sm">{value || <EmptyValue />}</span>
21
- </div>
22
- );
23
- }
24
-
25
- return (
26
- <div className="flex items-center gap-2">
27
- <input
28
- type="color"
29
- value={value || '#000000'}
30
- onChange={(e) => onChange(e.target.value)}
31
- disabled={readonly || props.disabled}
32
- className="w-10 h-10 rounded border border-input cursor-pointer"
33
- // Both focusable halves of this widget announce the same validation
34
- // state (objectui#3318).
35
- aria-invalid={!!error}
36
- />
37
- <Input
38
- // DOM pass-through onto the primary text control (objectui#3318): the
39
- // whitelist spread carries the form renderer's id / aria-describedby.
40
- {...toDomProps(props)}
41
- type="text"
42
- value={value || ''}
43
- onChange={(e) => onChange(e.target.value)}
44
- placeholder={colorField?.placeholder || '#000000'}
45
- disabled={readonly || props.disabled}
46
- className={props.className}
47
- pattern="^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$"
48
- // AFTER the spread so this widget's own computation wins (#3222).
49
- aria-invalid={!!error}
50
- />
51
- </div>
52
- );
53
- }