@object-ui/fields 17.2.0 → 17.5.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 (212) hide show
  1. package/CHANGELOG.md +1533 -0
  2. package/README.md +1 -12
  3. package/dist/FieldEditWidget.d.ts +7 -1
  4. package/dist/currency.d.ts +46 -0
  5. package/dist/field-type-alias.d.ts +19 -1
  6. package/dist/index.cjs +5 -5
  7. package/dist/index.css +633 -0
  8. package/dist/index.d.ts +52 -12
  9. package/dist/index.js +5167 -4759
  10. package/dist/widgets/AddressField.d.ts +4 -12
  11. package/dist/widgets/AvatarField.d.ts +1 -1
  12. package/dist/widgets/CharacterCount.d.ts +56 -0
  13. package/dist/widgets/CheckboxesField.d.ts +1 -1
  14. package/dist/widgets/CodeField.d.ts +1 -1
  15. package/dist/widgets/ColorField.d.ts +1 -1
  16. package/dist/widgets/CurrencyField.d.ts +1 -1
  17. package/dist/widgets/EmailField.d.ts +1 -1
  18. package/dist/widgets/FileField.d.ts +1 -1
  19. package/dist/widgets/FilterConditionField.d.ts +1 -1
  20. package/dist/widgets/FullscreenFieldEditor.d.ts +45 -0
  21. package/dist/widgets/GeolocationField.d.ts +1 -1
  22. package/dist/widgets/GridField.d.ts +12 -2
  23. package/dist/widgets/ImageField.d.ts +1 -1
  24. package/dist/widgets/LocationField.d.ts +1 -1
  25. package/dist/widgets/LookupField.d.ts +1 -1
  26. package/dist/widgets/MultiSelectField.d.ts +1 -1
  27. package/dist/widgets/ObjectField.d.ts +1 -1
  28. package/dist/widgets/ObjectRefField.d.ts +1 -1
  29. package/dist/widgets/OptionsEmptyState.d.ts +59 -0
  30. package/dist/widgets/PercentField.d.ts +1 -1
  31. package/dist/widgets/PhoneField.d.ts +1 -1
  32. package/dist/widgets/QRCodeField.d.ts +1 -1
  33. package/dist/widgets/RadioField.d.ts +1 -1
  34. package/dist/widgets/RatingField.d.ts +1 -1
  35. package/dist/widgets/RecipientPickerField.d.ts +1 -1
  36. package/dist/widgets/RecordPickerDialog.d.ts +49 -7
  37. package/dist/widgets/RichTextField.d.ts +19 -1
  38. package/dist/widgets/SelectField.d.ts +25 -5
  39. package/dist/widgets/TagsField.d.ts +1 -1
  40. package/dist/widgets/TextAreaField.d.ts +44 -5
  41. package/dist/widgets/UrlField.d.ts +1 -1
  42. package/dist/widgets/address-format.d.ts +68 -0
  43. package/dist/widgets/file-size-guard.d.ts +41 -0
  44. package/dist/widgets/nativeDateValue.d.ts +37 -0
  45. package/dist/widgets/toDomProps.d.ts +111 -0
  46. package/dist/widgets/toHostGroupProps.d.ts +91 -0
  47. package/dist/widgets/types.d.ts +128 -33
  48. package/dist/widgets/useRecordQuery.d.ts +7 -1
  49. package/dist/withFieldCarrier.d.ts +56 -0
  50. package/package.json +20 -16
  51. package/src/FieldEditWidget.tsx +17 -1
  52. package/src/__tests__/AddressCellRenderer.test.tsx +166 -0
  53. package/src/__tests__/CurrencyField.minorUnits.test.tsx +191 -0
  54. package/src/__tests__/CurrencyField.symbol.test.tsx +198 -0
  55. package/src/__tests__/DateCellRenderer.test.tsx +9 -0
  56. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +146 -0
  57. package/src/__tests__/PercentCellRenderer.locale.test.tsx +152 -0
  58. package/src/__tests__/PercentCellRenderer.test.tsx +115 -0
  59. package/src/__tests__/boolean-label-association-e2e.test.tsx +298 -0
  60. package/src/__tests__/capability-multiselect-retired.test.ts +109 -0
  61. package/src/__tests__/cell-truncation.test.tsx +96 -0
  62. package/src/__tests__/composite-group-label-e2e.test.tsx +436 -0
  63. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +403 -0
  64. package/src/__tests__/date-formatter-residue-4272.test.ts +120 -0
  65. package/src/__tests__/date-locale-channel.test.tsx +305 -0
  66. package/src/__tests__/field-carrier-sdui.test.tsx +169 -0
  67. package/src/__tests__/formatCurrency.centsDigit.test.tsx +158 -0
  68. package/src/__tests__/formatCurrency.minorUnits.test.tsx +221 -0
  69. package/src/__tests__/group-labelling-declaration.test.ts +148 -0
  70. package/src/__tests__/lookupCellRecordLink.test.tsx +171 -0
  71. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +115 -0
  72. package/src/__tests__/percent-formatter-locale-4553.test.ts +147 -0
  73. package/src/__tests__/percent-tie-halfup-4590.test.ts +224 -0
  74. package/src/__tests__/select-label-association-e2e.test.tsx +199 -0
  75. package/src/__tests__/spec-symbol-batch7.test.ts +29 -15
  76. package/src/__tests__/validation-feedback.test.tsx +22 -13
  77. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +265 -0
  78. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +338 -0
  79. package/src/__tests__/widget-dom-leak-e2e.test.tsx +357 -0
  80. package/src/__tests__/widget-props-contract.test.tsx +22 -9
  81. package/src/complex-widgets.test.tsx +22 -6
  82. package/src/currency.ts +130 -0
  83. package/src/datetime-widgets.test.tsx +94 -2
  84. package/src/field-type-alias.multiple.test.ts +81 -0
  85. package/src/field-type-alias.ts +58 -2
  86. package/src/index.css +44 -0
  87. package/src/index.tsx +558 -268
  88. package/src/standard-widgets.test.tsx +8 -1
  89. package/src/widgets/AddressField.postalCode.test.tsx +257 -0
  90. package/src/widgets/AddressField.tsx +107 -38
  91. package/src/widgets/AddressField.uniqueIds.test.tsx +93 -0
  92. package/src/widgets/AvatarField.tsx +9 -2
  93. package/src/widgets/BooleanField.tsx +44 -10
  94. package/src/widgets/CapabilityMultiSelectField.tsx +17 -5
  95. package/src/widgets/CharacterCount.tsx +263 -0
  96. package/src/widgets/CheckboxesField.tsx +66 -16
  97. package/src/widgets/CodeField.tsx +9 -2
  98. package/src/widgets/ColorField.tsx +11 -2
  99. package/src/widgets/CurrencyField.tsx +60 -19
  100. package/src/widgets/DateField.tsx +13 -4
  101. package/src/widgets/DateTimeField.tsx +14 -5
  102. package/src/widgets/EmailField.tsx +5 -5
  103. package/src/widgets/FileField.tsx +39 -11
  104. package/src/widgets/FilterConditionField.tsx +11 -0
  105. package/src/widgets/FormulaField.tsx +7 -2
  106. package/src/widgets/FullscreenFieldEditor.tsx +60 -0
  107. package/src/widgets/GeolocationField.tsx +49 -8
  108. package/src/widgets/GeolocationField.uniqueIds.test.tsx +88 -0
  109. package/src/widgets/GridField.test.tsx +226 -0
  110. package/src/widgets/GridField.tsx +115 -11
  111. package/src/widgets/ImageField.maxSize.test.tsx +154 -0
  112. package/src/widgets/ImageField.tsx +63 -6
  113. package/src/widgets/LocationField.tsx +9 -2
  114. package/src/widgets/LookupField.gateHintLabel.test.tsx +135 -0
  115. package/src/widgets/LookupField.hydration.test.tsx +1 -1
  116. package/src/widgets/LookupField.tsx +49 -10
  117. package/src/widgets/MasterDetailField.tsx +1 -1
  118. package/src/widgets/MultiSelectField.tsx +86 -16
  119. package/src/widgets/NumberField.tsx +3 -3
  120. package/src/widgets/ObjectField.tsx +17 -8
  121. package/src/widgets/ObjectRefField.tsx +10 -0
  122. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +88 -0
  123. package/src/widgets/OptionsEmptyState.test.tsx +169 -0
  124. package/src/widgets/OptionsEmptyState.tsx +99 -0
  125. package/src/widgets/PasswordField.tsx +3 -3
  126. package/src/widgets/PeoplePicker.tsx +7 -1
  127. package/src/widgets/PercentField.tsx +5 -4
  128. package/src/widgets/PhoneField.tsx +5 -5
  129. package/src/widgets/QRCodeField.tsx +9 -2
  130. package/src/widgets/RadioField.tsx +58 -16
  131. package/src/widgets/RatingField.tsx +32 -4
  132. package/src/widgets/RecipientPickerField.tsx +18 -0
  133. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +191 -0
  134. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +124 -0
  135. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +611 -0
  136. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +112 -0
  137. package/src/widgets/RecordPickerDialog.tsx +235 -21
  138. package/src/widgets/RichTextField.tsx +151 -24
  139. package/src/widgets/SelectField.tsx +84 -22
  140. package/src/widgets/SliderField.tsx +1 -1
  141. package/src/widgets/SummaryField.tsx +1 -1
  142. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +68 -0
  143. package/src/widgets/TagsField.placeholder.test.tsx +95 -0
  144. package/src/widgets/TagsField.tsx +23 -2
  145. package/src/widgets/TextAreaField.tsx +171 -93
  146. package/src/widgets/TextField.tsx +3 -3
  147. package/src/widgets/TimeField.tsx +2 -2
  148. package/src/widgets/UrlField.tsx +5 -5
  149. package/src/widgets/UserField.tsx +4 -3
  150. package/src/widgets/VectorField.tsx +1 -1
  151. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +25 -1
  152. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +248 -0
  153. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +116 -0
  154. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +244 -0
  155. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +195 -0
  156. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +417 -0
  157. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +287 -0
  158. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +160 -0
  159. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +161 -0
  160. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +346 -0
  161. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +246 -0
  162. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +175 -0
  163. package/src/widgets/address-format.ts +120 -0
  164. package/src/widgets/file-size-guard.ts +55 -0
  165. package/src/widgets/nativeDateValue.test.ts +115 -0
  166. package/src/widgets/nativeDateValue.ts +112 -0
  167. package/src/widgets/optionWidgets.gatedOptions.test.tsx +267 -0
  168. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +150 -0
  169. package/src/widgets/toDomProps.ts +204 -0
  170. package/src/widgets/toHostGroupProps.ts +102 -0
  171. package/src/widgets/types.ts +135 -35
  172. package/src/widgets/useFieldTranslation.ts +46 -4
  173. package/src/widgets/useRecordQuery.ts +30 -3
  174. package/src/withFieldCarrier.tsx +75 -0
  175. package/dist/FieldEditWidget.test.d.ts +0 -8
  176. package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
  177. package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
  178. package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
  179. package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
  180. package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
  181. package/dist/__tests__/registry-collision.test.d.ts +0 -0
  182. package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
  183. package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
  184. package/dist/__tests__/validation-feedback.test.d.ts +0 -0
  185. package/dist/__tests__/widget-props-contract.test.d.ts +0 -8
  186. package/dist/coerce-safe-value.test.d.ts +0 -1
  187. package/dist/complex-widgets.test.d.ts +0 -1
  188. package/dist/datetime-widgets.test.d.ts +0 -1
  189. package/dist/field-type-coverage.test.d.ts +0 -1
  190. package/dist/standard-widgets.test.d.ts +0 -0
  191. package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
  192. package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
  193. package/dist/widgets/GridField.test.d.ts +0 -1
  194. package/dist/widgets/ImageLightbox.test.d.ts +0 -1
  195. package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
  196. package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
  197. package/dist/widgets/LookupField.idField.test.d.ts +0 -1
  198. package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
  199. package/dist/widgets/PeoplePicker.test.d.ts +0 -8
  200. package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
  201. package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
  202. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  203. package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
  204. package/dist/widgets/UserFieldPicker.test.d.ts +0 -8
  205. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
  206. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
  207. package/dist/widgets/deriveLookupColumns.test.d.ts +0 -1
  208. package/dist/widgets/file-value.test.d.ts +0 -1
  209. package/dist/widgets/personDisplay.test.d.ts +0 -8
  210. package/dist/widgets/recentLookups.test.d.ts +0 -1
  211. package/dist/widgets/useRecordQuery.test.d.ts +0 -8
  212. package/dist/widgets/useUploadingSignal.test.d.ts +0 -1
@@ -0,0 +1,357 @@
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
+ * GATE: no field widget may leak a non-DOM prop onto a DOM element
11
+ * (objectui#3291).
12
+ *
13
+ * Every registered field widget is rendered through BOTH hosts, and every
14
+ * attribute of every element it produced is checked against what HTML actually
15
+ * defines. An attribute nobody can explain is a leak.
16
+ *
17
+ * ## What this catches that a unit test cannot
18
+ *
19
+ * The leak was never in one widget. It was structural: a widget spread
20
+ * `{...props}` onto its control, and the hosts hand a widget more than the DOM
21
+ * can take. Measured on `origin/main`, a real form, a real widget:
22
+ *
23
+ * ```
24
+ * <input placeholder="PH-f" zzcanary="CANARY-STR" zzcanaryobj="[object Object]"
25
+ * zzcanarynum="42" id="…" type="text" value="" name="f">
26
+ * ```
27
+ *
28
+ * `zzcanaryobj="[object Object]"` is an ordinary key an author wrote on the
29
+ * field config. That is why the fix is a WHITELIST (`toDomProps`) and why this
30
+ * test plants canaries rather than checking a list of known-bad names: a
31
+ * blacklist of today's renderer props would pass all three canaries above.
32
+ *
33
+ * ## Four things that silently defeat a test like this
34
+ *
35
+ * 1. **React warnings prove nothing.** React 19 passes an all-lowercase
36
+ * unknown attribute through in COMPLETE silence. In the audit sweep the
37
+ * only warning came from a camelCase canary — which was written to the DOM
38
+ * anyway. So this walks real DOM attributes and never listens for console
39
+ * output.
40
+ * 2. **Both hosts, or the result is a false pass.** The form renderer strips a
41
+ * known set before forwarding; `SchemaRenderer` spreads the whole authored
42
+ * node with NO strip layer, so it leaks strictly more (`label` is only
43
+ * visible there). On that path the widget's own spread is the only defence.
44
+ * 3. **An error variant that produces no error tests nothing.** `required` +
45
+ * a `false` boolean does NOT fail here — this repo made `required` a
46
+ * presence check, so `false` is a value (cloud#972). The audit's first pass
47
+ * under-reported the `error` leak by one widget for exactly this reason.
48
+ * Every error variant therefore ASSERTS the message rendered before it
49
+ * scans; a variant that silently produces no error fails the test.
50
+ * 4. **This repo runs happy-dom, not jsdom** (`vitest.config.mts`), whose IDL
51
+ * coverage has real gaps — `select[size]`, `option[label]`, `textarea[wrap]`
52
+ * and `col[span]` are all standard HTML that happy-dom does not reflect.
53
+ * See `@object-ui/test-support`'s `dom-leak-judge` for how the judge is
54
+ * built, and its `HAPPY_DOM_IDL_GAPS` for each measured exception and its
55
+ * reason. Every one of those four was found BY a calibration fixture, not
56
+ * by guesswork.
57
+ *
58
+ * ## The judge proves itself — next to the judge, not here
59
+ *
60
+ * `isKnownAttribute` / `findLeaks` / `leakReport` are imported from
61
+ * `@object-ui/test-support`, which is where they now live for BOTH DOM-leak
62
+ * gates (objectui#4434). They used to be defined in this file and copied into
63
+ * `packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx`, and the
64
+ * two copies had already drifted.
65
+ *
66
+ * The calibration fixtures moved with them: standard markup that must yield
67
+ * ZERO findings, and markup with planted fake attributes that must ALL be
68
+ * found, now unioned from both gates' fixtures and run once in
69
+ * `packages/test-support/src/__tests__/dom-leak-judge.test.tsx`. When a
70
+ * happy-dom upgrade changes IDL coverage, that fails loudly instead of this
71
+ * sweep going quietly blind — and it now fails in ONE place instead of needing
72
+ * to be discovered twice.
73
+ *
74
+ * ## Deliberate coverage boundary
75
+ *
76
+ * Popovers are NOT opened. Radix needs pointer-capture APIs happy-dom does not
77
+ * implement. Every widget's props spread lands on its inline control (the
78
+ * trigger for a picker), which always renders — so the spread site IS covered,
79
+ * but content that exists only inside an open dropdown is NOT scanned by this
80
+ * test.
81
+ *
82
+ * Widgets are registered from STATIC imports wrapped in `withFieldCarrier`,
83
+ * never via `registerAllFields()`, which wraps every loader in `React.lazy` —
84
+ * an unbounded module load inside a bounded `waitFor` is this repo's known
85
+ * flake generator (AGENTS.md 测试纪律 / objectui#3010).
86
+ */
87
+
88
+ import type { ComponentType } from 'react';
89
+ import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest';
90
+ import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
91
+ import '@testing-library/jest-dom';
92
+ import { ComponentRegistry } from '@object-ui/core';
93
+ // Module scope: pulls in the form renderer's registration side effect.
94
+ import '@object-ui/components';
95
+ import { SchemaRenderer } from '@object-ui/react';
96
+ // The attribute judge, shared with `packages/app-shell`'s sweep gate
97
+ // (objectui#4434). It used to be defined in this file and copied into that one;
98
+ // its calibration fixtures now live next to it and prove it for both gates.
99
+ // `@object-ui/test-support` is private and never published — see its README.
100
+ import { findLeaks, leakReport } from '@object-ui/test-support';
101
+
102
+ import { withFieldCarrier } from '../withFieldCarrier';
103
+ import { FORM_FIELD_TYPES } from '../index';
104
+
105
+ import { TextField } from '../widgets/TextField';
106
+ import { TextAreaField } from '../widgets/TextAreaField';
107
+ import { NumberField } from '../widgets/NumberField';
108
+ import { BooleanField } from '../widgets/BooleanField';
109
+ import { SelectField } from '../widgets/SelectField';
110
+ import { DateField } from '../widgets/DateField';
111
+ import { DateTimeField } from '../widgets/DateTimeField';
112
+ import { TimeField } from '../widgets/TimeField';
113
+ import { EmailField } from '../widgets/EmailField';
114
+ import { PhoneField } from '../widgets/PhoneField';
115
+ import { UrlField } from '../widgets/UrlField';
116
+ import { MultiSelectField } from '../widgets/MultiSelectField';
117
+ import { RadioField } from '../widgets/RadioField';
118
+ import { CheckboxesField } from '../widgets/CheckboxesField';
119
+ import { TagsField } from '../widgets/TagsField';
120
+ import { CurrencyField } from '../widgets/CurrencyField';
121
+ import { PercentField } from '../widgets/PercentField';
122
+ import { PasswordField } from '../widgets/PasswordField';
123
+ import { RichTextField } from '../widgets/RichTextField';
124
+ import { LookupField } from '../widgets/LookupField';
125
+ import { FileField } from '../widgets/FileField';
126
+ import { ImageField } from '../widgets/ImageField';
127
+ import { LocationField } from '../widgets/LocationField';
128
+ import { FormulaField } from '../widgets/FormulaField';
129
+ import { SummaryField } from '../widgets/SummaryField';
130
+ import { AutoNumberField } from '../widgets/AutoNumberField';
131
+ import { UserField } from '../widgets/UserField';
132
+ import { ObjectField } from '../widgets/ObjectField';
133
+ import { VectorField } from '../widgets/VectorField';
134
+ import { GridField } from '../widgets/GridField';
135
+ import { ColorField } from '../widgets/ColorField';
136
+ import { SliderField } from '../widgets/SliderField';
137
+ import { RatingField } from '../widgets/RatingField';
138
+ import { CodeField } from '../widgets/CodeField';
139
+ import { AvatarField } from '../widgets/AvatarField';
140
+ import { AddressField } from '../widgets/AddressField';
141
+ import { GeolocationField } from '../widgets/GeolocationField';
142
+ import { SignatureField } from '../widgets/SignatureField';
143
+ import { QRCodeField } from '../widgets/QRCodeField';
144
+ import { ObjectRefField } from '../widgets/ObjectRefField';
145
+ import { FilterConditionField } from '../widgets/FilterConditionField';
146
+ import { RecipientPickerField } from '../widgets/RecipientPickerField';
147
+
148
+ /* ════════════════════════════════════════════════════════════════════════════
149
+ * Every registered field widget, both hosts
150
+ * ══════════════════════════════════════════════════════════════════════════ */
151
+
152
+ /**
153
+ * Static components for the widget map's keys. Kept as one object so the
154
+ * parity assertion below can prove it covers the whole registry: a NEW field
155
+ * type added to `fieldWidgetMap` without a line here fails loudly rather than
156
+ * quietly going unscanned.
157
+ */
158
+ const WIDGETS: Record<string, ComponentType<any>> = {
159
+ text: TextField,
160
+ textarea: TextAreaField,
161
+ number: NumberField,
162
+ boolean: BooleanField,
163
+ select: SelectField,
164
+ date: DateField,
165
+ datetime: DateTimeField,
166
+ time: TimeField,
167
+ email: EmailField,
168
+ phone: PhoneField,
169
+ url: UrlField,
170
+ multiselect: MultiSelectField,
171
+ radio: RadioField,
172
+ checkboxes: CheckboxesField,
173
+ tags: TagsField,
174
+ currency: CurrencyField,
175
+ percent: PercentField,
176
+ password: PasswordField,
177
+ markdown: RichTextField,
178
+ html: RichTextField,
179
+ richtext: RichTextField,
180
+ lookup: LookupField,
181
+ master_detail: LookupField,
182
+ file: FileField,
183
+ image: ImageField,
184
+ location: LocationField,
185
+ formula: FormulaField,
186
+ summary: SummaryField,
187
+ auto_number: AutoNumberField,
188
+ user: UserField,
189
+ owner: UserField,
190
+ object: ObjectField,
191
+ vector: VectorField,
192
+ grid: GridField,
193
+ color: ColorField,
194
+ slider: SliderField,
195
+ rating: RatingField,
196
+ code: CodeField,
197
+ avatar: AvatarField,
198
+ address: AddressField,
199
+ geolocation: GeolocationField,
200
+ signature: SignatureField,
201
+ qrcode: QRCodeField,
202
+ 'object-ref': ObjectRefField,
203
+ 'filter-condition': FilterConditionField,
204
+ 'recipient-picker': RecipientPickerField,
205
+ };
206
+
207
+ /** Option widgets render an "unfillable" placeholder unless offered a list. */
208
+ const OPTION_TYPES = new Set(['select', 'multiselect', 'radio', 'checkboxes', 'tags']);
209
+ const OPTIONS = [
210
+ { label: 'Alpha', value: 'alpha' },
211
+ { label: 'Beta', value: 'beta' },
212
+ ];
213
+
214
+ /**
215
+ * The value each variant starts from: `null` is MISSING for the required check
216
+ * this repo implements (presence, not truthiness — `false` and `0` are values,
217
+ * cloud#972), so one value drives a real validation failure for every type,
218
+ * including `boolean`. Trap 3 above is why that matters, and why every error
219
+ * variant asserts the message before it scans.
220
+ */
221
+ const MISSING_VALUE = null;
222
+
223
+ /** Author-written extras — the open tail that a blacklist cannot close. */
224
+ const AUTHORED_EXTRAS = {
225
+ zzcanary: 'CANARY-STR',
226
+ zzcanaryobj: { nested: true },
227
+ zzcanarynum: 42,
228
+ zzcanaryCamel: 'CANARY-CAMEL',
229
+ reference_to: 'contacts',
230
+ };
231
+
232
+ function fieldConfig(type: string, extras: Record<string, unknown> = {}) {
233
+ return {
234
+ name: 'f',
235
+ label: 'F',
236
+ type: `field:${type}`,
237
+ ...(OPTION_TYPES.has(type) ? { options: OPTIONS } : {}),
238
+ ...extras,
239
+ };
240
+ }
241
+
242
+ function renderForm(field: Record<string, unknown>, required: boolean) {
243
+ const Form = ComponentRegistry.get('form')!;
244
+ return render(
245
+ <Form
246
+ schema={{
247
+ type: 'form',
248
+ mode: 'create',
249
+ showSubmit: true,
250
+ showCancel: false,
251
+ submitLabel: 'Create',
252
+ defaultValues: { f: MISSING_VALUE },
253
+ fields: [{ ...field, required }],
254
+ onSubmit: () => {},
255
+ }}
256
+ />,
257
+ );
258
+ }
259
+
260
+ /**
261
+ * The form row. Waiting on `[data-field]` (emitted by `FormItem` for EVERY
262
+ * field) rather than widget-specific copy keeps the wait condition identical
263
+ * for all 46 widgets and independent of what any one of them renders.
264
+ */
265
+ async function formRow(): Promise<Element> {
266
+ return waitFor(() => {
267
+ const row = document.querySelector('[data-field="f"]');
268
+ if (!row) throw new Error('field row never rendered');
269
+ return row;
270
+ });
271
+ }
272
+
273
+ beforeAll(() => {
274
+ for (const [type, Widget] of Object.entries(WIDGETS)) {
275
+ // `withFieldCarrier` is the real registration seam (objectui#3233) — going
276
+ // around it would test a widget in a shape no host ever produces.
277
+ ComponentRegistry.register(type, withFieldCarrier(Widget) as any, {
278
+ namespace: 'field',
279
+ skipFallback: true,
280
+ });
281
+ }
282
+ }, 60000);
283
+
284
+ beforeEach(() => {
285
+ if (!(Element.prototype as any).scrollIntoView) {
286
+ (Element.prototype as any).scrollIntoView = () => {};
287
+ }
288
+ });
289
+
290
+ afterEach(() => {
291
+ cleanup();
292
+ vi.restoreAllMocks();
293
+ });
294
+
295
+ describe('no field widget leaks non-DOM props to the DOM (objectui#3291)', () => {
296
+ it('covers every field type the form can render', () => {
297
+ // The gate's reach. Without this, adding a widget adds an unscanned widget.
298
+ expect(Object.keys(WIDGETS).sort()).toEqual([...FORM_FIELD_TYPES].sort());
299
+ });
300
+
301
+ const types = Object.keys(WIDGETS);
302
+
303
+ it.each(types)('field:%s — form path, plain field', async (type) => {
304
+ renderForm(fieldConfig(type), false);
305
+ const row = await formRow();
306
+ expect(leakReport(`field:${type} [form/plain]`, findLeaks(row))).toBe('');
307
+ });
308
+
309
+ it.each(types)('field:%s — form path, author-written extra keys', async (type) => {
310
+ // The largest measured leak source: the form renderer destructures a fixed set
311
+ // of known keys and forwards the rest verbatim, so anything else an author
312
+ // put on the field config arrives at the widget as a prop.
313
+ renderForm(fieldConfig(type, AUTHORED_EXTRAS), false);
314
+ const row = await formRow();
315
+ expect(leakReport(`field:${type} [form/authored-extras]`, findLeaks(row))).toBe('');
316
+ });
317
+
318
+ it.each(types)('field:%s — form path, after a real validation failure', async (type) => {
319
+ renderForm(fieldConfig(type), true);
320
+ const row = await formRow();
321
+
322
+ fireEvent.click(screen.getByRole('button', { name: /create/i }));
323
+
324
+ // Trap 3: scan only once the error is PROVEN to exist. A variant that
325
+ // silently produced none would otherwise pass while testing nothing.
326
+ await waitFor(() => {
327
+ expect(document.querySelector('[data-field="f"]')?.textContent ?? '').toContain(
328
+ 'is required',
329
+ );
330
+ });
331
+
332
+ expect(
333
+ leakReport(`field:${type} [form/validation-error]`, findLeaks(await formRow())),
334
+ ).toBe('');
335
+ });
336
+
337
+ it.each(types)('field:%s — SDUI path, plain node', async (type) => {
338
+ // Strictly wider than the form path: `SchemaRenderer` spreads the whole
339
+ // authored node as props with no strip layer, so the widget's own spread
340
+ // is the only defence here.
341
+ const { container } = render(
342
+ <SchemaRenderer schema={{ ...fieldConfig(type), type: `field:${type}` } as any} />,
343
+ );
344
+ await waitFor(() => expect(container.firstElementChild).toBeTruthy());
345
+ expect(leakReport(`field:${type} [sdui/plain]`, findLeaks(container))).toBe('');
346
+ });
347
+
348
+ it.each(types)('field:%s — SDUI path, author-written extra keys', async (type) => {
349
+ const { container } = render(
350
+ <SchemaRenderer
351
+ schema={{ ...fieldConfig(type, AUTHORED_EXTRAS), type: `field:${type}` } as any}
352
+ />,
353
+ );
354
+ await waitFor(() => expect(container.firstElementChild).toBeTruthy());
355
+ expect(leakReport(`field:${type} [sdui/authored-extras]`, findLeaks(container))).toBe('');
356
+ });
357
+ });
@@ -14,8 +14,9 @@
14
14
  * one (objectstack#4075). Three consequences, one test each below:
15
15
  *
16
16
  * 1. a key the type does not declare is a compile error, not a silent `any`
17
- * — which is what makes the `error` / `errorMessage` divergence
18
- * (objectui#3222) decidable by the compiler at all;
17
+ * — which is what let objectui#3222 rename the validation slot to the
18
+ * spec's `error` with the compiler, not grep, proving no call site was
19
+ * missed;
19
20
  * 2. a MISSPELLED prop (`readOnly` for `readonly`) is rejected;
20
21
  * 3. the pass-through keys hosts genuinely forward still type-check, and
21
22
  * still reach the rendered control at runtime — closing the type must not
@@ -52,13 +53,15 @@ describe('FieldWidgetComponentProps is closed (objectui#3221)', () => {
52
53
  it('rejects keys it does not declare', () => {
53
54
  const props = {} as FieldWidgetComponentProps<string>;
54
55
 
55
- // The spec's `FieldWidgetPropsSchema` declares both; this type declares
56
- // neither. Reading them used to give `any` and `undefined` forever.
57
- // Whether to ADD them is objectui#3222 — deliberately not decided here.
56
+ // `required` stays out on purpose (objectui#3222): the required marker has
57
+ // exactly one author, the form renderer's `<FormLabel>`, and handing the
58
+ // flag to widgets invites a second one. Its sibling `error` went the other
59
+ // way — the spec declares it, so the slot below adopted the spec's name and
60
+ // the form renderer now produces it.
58
61
  // @ts-expect-error `required` is not part of this contract
59
62
  void props.required;
60
- // @ts-expect-error `error` is not part of this contract — this type's slot is `errorMessage`
61
- void props.error;
63
+ const message: string | undefined = props.error;
64
+ void message;
62
65
 
63
66
  // …and the class of bug the index signature hid best: a typo.
64
67
  // @ts-expect-error the prop is `readonly`, not React's DOM `readOnly`
@@ -66,6 +69,14 @@ describe('FieldWidgetComponentProps is closed (objectui#3221)', () => {
66
69
  // @ts-expect-error the prop is `onChange`
67
70
  void props.onchange;
68
71
 
72
+ // The second metadata carrier is gone (objectui#3233). `schema` was a
73
+ // DECLARED key here through #3221 — one concept under two spellings, which
74
+ // is how ~30 widgets ended up resolving `field || schema`. v17 converged it
75
+ // at the producers, so reading it is now a compile error rather than a
76
+ // silent second contract for the next widget author to discover.
77
+ // @ts-expect-error `schema` was retired from the widget contract in v17
78
+ void props.schema;
79
+
69
80
  expect(true).toBe(true);
70
81
  });
71
82
 
@@ -76,9 +87,11 @@ describe('FieldWidgetComponentProps is closed (objectui#3221)', () => {
76
87
  const passThrough: FieldWidgetComponentProps<string> = {
77
88
  value: '',
78
89
  onChange: () => {},
90
+ // The single metadata carrier (objectui#3233) — every host produces this
91
+ // one key: the form renderer (`renderFieldComponent`), the inline-edit
92
+ // hosts, and the registry adapter that maps `SchemaRenderer`'s SDUI node
93
+ // onto it.
79
94
  field,
80
- // form renderer (`renderFieldComponent`)
81
- schema: field,
82
95
  dataSource: {},
83
96
  dependentValues: { country: 'cn' },
84
97
  dependsOn: ['country'],
@@ -191,9 +191,18 @@ describe('Complex & Relationship Widgets', () => {
191
191
  expect(mockDataSource.find).toHaveBeenCalledTimes(1);
192
192
  });
193
193
 
194
- // Type in search
194
+ // Type in search. U+2026 now, and it comes from a PACK VALUE:
195
+ // objectui#4375 retired the `common.search` + '...' concatenation
196
+ // this used to pin, so the placeholder is `table.search`
197
+ // — read through `useFieldTranslation`, whose no-provider defaults map
198
+ // serves the `en` byte `Search…` in this test. It therefore moves with
199
+ // the pack from here on, and is covered by objectui#3878's glyph pin.
200
+ // `common.search` itself is retired from the ten packs and from this
201
+ // package's defaults map (objectui#4392); naming it bare rather than
202
+ // spelling a t() call around it is what keeps that retirement's
203
+ // revival pin readable — see common-search-retired-4392.test.ts.
195
204
  await act(async () => {
196
- fireEvent.change(screen.getByPlaceholderText('Search...'), {
205
+ fireEvent.change(screen.getByPlaceholderText('Search…'), {
197
206
  target: { value: 'acme' },
198
207
  });
199
208
  });
@@ -462,7 +471,9 @@ describe('Complex & Relationship Widgets', () => {
462
471
  expect(screen.getByText('Alpha')).toBeInTheDocument();
463
472
  });
464
473
 
465
- const searchInput = screen.getByPlaceholderText('Search...');
474
+ // U+2026 from the `table.search` pack value — see the note on the
475
+ // `$search` test above (objectui#4375).
476
+ const searchInput = screen.getByPlaceholderText('Search…');
466
477
 
467
478
  // Arrow down twice: -1 → 0 (Alpha) → 1 (Beta)
468
479
  await act(async () => {
@@ -480,9 +491,14 @@ describe('Complex & Relationship Widgets', () => {
480
491
  expect(onChange).toHaveBeenCalledWith('2');
481
492
  });
482
493
 
483
- it('resolves reference_to from nested field.field (createFieldRenderer wrapper)', async () => {
484
- // Simulates how createFieldRenderer wraps the field: the real metadata
485
- // (reference_to, reference_field, etc.) is nested inside field.field.
494
+ it('resolves reference_to from nested field.field (form metadata slot)', async () => {
495
+ // Simulates the `field.field` nesting the FORM renderer hands widgets:
496
+ // `renderFieldComponent` passes `field: field.field || field`, where
497
+ // `.field` is the declared metadata slot (objectui#3090) holding the
498
+ // real metadata (reference_to, reference_field, …). The test used to
499
+ // credit this shape to the docs-demo `createFieldRenderer` wrapper,
500
+ // which never produced it and no longer exists (objectui#3910); the
501
+ // live producer is the form path, so the coverage stands.
486
502
  const onChange = vi.fn();
487
503
  mockDataSource.find.mockResolvedValue({
488
504
  data: [
package/src/currency.ts CHANGED
@@ -12,3 +12,133 @@
12
12
  * every field/cell renderer that already depends on it.
13
13
  */
14
14
  export { resolveFieldCurrency } from '@object-ui/i18n';
15
+
16
+ /**
17
+ * Fallback width for a currency whose digit count cannot be resolved: the
18
+ * literal both call sites used unconditionally before objectui#4361. It is
19
+ * reached only when there is no ISO code to ask about (an invalid code, or a
20
+ * runtime with no currency data at all), never as a policy.
21
+ */
22
+ const DEFAULT_FRACTION_DIGITS = 2;
23
+
24
+ /**
25
+ * ISO 4217 minor-unit width, memoized per currency code.
26
+ *
27
+ * The probe is deliberately made with NO locale. Measured across
28
+ * en-US / de-DE / ja-JP / ar-KW / zh-CN / fr-FR / pl-PL / es-ES and the runtime
29
+ * default, every locale answers the same digit count for the same code (USD 2,
30
+ * JPY 0, KWD 3, BHD 3, CLP 0, ISK 0) — the count comes from CLDR's
31
+ * `currencyData`, which is keyed by the currency, not by who is reading it.
32
+ * Dropping the locale from the probe therefore loses nothing and removes a
33
+ * failure mode: a malformed locale tag makes `Intl.NumberFormat` throw
34
+ * `RangeError: Incorrect locale information provided`, which would turn a bad
35
+ * LOCALE into a wrong CURRENCY width. It also makes the code the whole cache
36
+ * key.
37
+ */
38
+ const fractionDigitsByCurrency = new Map<string, number>();
39
+
40
+ /**
41
+ * The number of fraction digits ISO 4217 gives `currency` — 0 for JPY/KRW/CLP,
42
+ * 2 for USD/EUR/CNY, 3 for KWD/BHD/OMR/TND — as ICU reports it.
43
+ *
44
+ * Returns {@link DEFAULT_FRACTION_DIGITS} when the code is not a currency code
45
+ * at all: `Intl` throws `RangeError: Invalid currency code` for those, and the
46
+ * throw must not escape, or a bad code would change the width used by the
47
+ * callers' own bad-currency fallbacks (objectui#4332 pinned those strings). A
48
+ * code that is well-formed but unknown to CLDR (e.g. `ZZZ`) does not throw —
49
+ * ICU answers 2 for anything outside its table, which is the same default.
50
+ *
51
+ * Memoized because the currency cell renderer runs once per grid cell: measured
52
+ * at 24.3us per uncached probe against 0.02us cached (200k iterations, node 22),
53
+ * so a 500-row grid pays ~12ms per render pass for a value that cannot change.
54
+ */
55
+ export function currencyFractionDigits(currency: string): number {
56
+ const cached = fractionDigitsByCurrency.get(currency);
57
+ if (cached !== undefined) return cached;
58
+ let digits = DEFAULT_FRACTION_DIGITS;
59
+ try {
60
+ // `maximumFractionDigits` is typed `number | undefined` on
61
+ // `ResolvedNumberFormatOptions` — it is absent when a format resolves with
62
+ // significant-digit rounding instead of fraction-digit rounding, which a
63
+ // bare `style: 'currency'` never does. Coalesced rather than asserted so
64
+ // the value is a number by construction and not by claim.
65
+ digits =
66
+ new Intl.NumberFormat(undefined, { style: 'currency', currency }).resolvedOptions()
67
+ .maximumFractionDigits ?? DEFAULT_FRACTION_DIGITS;
68
+ } catch {
69
+ // Not a currency code — keep the historical width and let the caller's own
70
+ // formatting attempt surface the bad code.
71
+ }
72
+ fractionDigitsByCurrency.set(currency, digits);
73
+ return digits;
74
+ }
75
+
76
+ /**
77
+ * Display symbols, memoized per **(locale, currency)** pair.
78
+ *
79
+ * The pair, not the code — and this is the one place this helper deliberately
80
+ * parts company with {@link currencyFractionDigits} above. The digit count is
81
+ * locale-independent (measured across eight locales, see that map's note), so
82
+ * its probe drops the locale and keys the cache on the code alone. The SYMBOL
83
+ * is genuinely locale-dependent, measured on the same runtime:
84
+ *
85
+ * - `USD` → `$` at `en` / `en-US` / `de-DE` / `ja-JP`, `US$` at `en-GB` and
86
+ * `zh-CN`, `$US` at `fr-FR`;
87
+ * - `JPY` → U+00A5 at `en`, the FULLWIDTH U+FFE5 at `ja-JP`, `JP¥` at
88
+ * `en-GB`, and the bare code `JPY` at `fr-FR`.
89
+ *
90
+ * Copying the code-only cache key would therefore let whichever locale asked
91
+ * first answer for every locale after it, for the life of the process — a
92
+ * silent wrong answer rather than a crash, so it is pinned by test.
93
+ *
94
+ * `|` is safe as the key separator: it appears in neither a BCP-47 tag nor an
95
+ * ISO 4217 code, so no two distinct pairs can collide on one key.
96
+ */
97
+ const symbolByLocaleAndCurrency = new Map<string, string>();
98
+
99
+ /**
100
+ * The string ICU renders in place of `currency` for a reader in `locale` — `$`
101
+ * for USD at `en`, `€` for EUR, U+00A5 for JPY — taken from the `currency` part
102
+ * of the very format the display path already uses.
103
+ *
104
+ * This is the ONE channel for the symbol (objectui#4414). It is not an
105
+ * independent opinion about how a currency should look: it is the same
106
+ * `Intl.NumberFormat(locale, { style: 'currency', currency })` that
107
+ * `formatDisplayNumber` formats amounts with, read one part at a time. So a
108
+ * widget that renders an amount in one mode and a bare symbol in another cannot
109
+ * disagree with itself — which is exactly what the hand-written
110
+ * `currency === 'USD' ? '$' : currency` ternary did, showing `JPY` beside a
111
+ * readonly `¥1,235`.
112
+ *
113
+ * Falls back to `currency` itself — the code as given — in the three cases
114
+ * where ICU cannot answer, because a bare code is a legitimate adornment (it is
115
+ * what ICU itself returns for KWD/BHD/CHF/ISK, and what
116
+ * `formatAmount`'s own bad-code branch renders beside it):
117
+ *
118
+ * 1. an invalid currency code — `Intl` throws `RangeError: Invalid currency
119
+ * code`;
120
+ * 2. a malformed locale tag (`en_US` with an underscore, say) — `Intl` throws
121
+ * `RangeError: Incorrect locale information provided`. `currencyFractionDigits`
122
+ * dodged this failure mode by dropping the locale from its probe; this
123
+ * helper needs the locale, so it catches instead. Neither throw may escape:
124
+ * this runs during render, so an escaped `RangeError` is a blank widget.
125
+ * 3. no `currency` part in the output at all — not observed for
126
+ * `style: 'currency'`, but coalesced rather than asserted.
127
+ */
128
+ export function currencySymbol(currency: string, locale: string): string {
129
+ const key = `${locale}|${currency}`;
130
+ const cached = symbolByLocaleAndCurrency.get(key);
131
+ if (cached !== undefined) return cached;
132
+ let symbol = currency;
133
+ try {
134
+ // The formatted value is irrelevant — only the `currency` part is read — so
135
+ // the cheapest deterministic input is used.
136
+ const parts = new Intl.NumberFormat(locale, { style: 'currency', currency }).formatToParts(0);
137
+ symbol = parts.find((part) => part.type === 'currency')?.value ?? currency;
138
+ } catch {
139
+ // Bad code or bad locale — keep the code, and let the caller's own
140
+ // formatting attempt surface the real problem.
141
+ }
142
+ symbolByLocaleAndCurrency.set(key, symbol);
143
+ return symbol;
144
+ }