@object-ui/fields 17.2.0 → 17.3.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 (142) hide show
  1. package/CHANGELOG.md +592 -0
  2. package/dist/__tests__/cell-truncation.test.d.ts +0 -0
  3. package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
  4. package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
  5. package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
  6. package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
  7. package/dist/index.cjs +5 -5
  8. package/dist/index.d.ts +4 -0
  9. package/dist/index.js +2881 -2566
  10. package/dist/widgets/AddressField.d.ts +1 -1
  11. package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
  12. package/dist/widgets/AvatarField.d.ts +1 -1
  13. package/dist/widgets/CharacterCount.d.ts +56 -0
  14. package/dist/widgets/CheckboxesField.d.ts +1 -1
  15. package/dist/widgets/CodeField.d.ts +1 -1
  16. package/dist/widgets/ColorField.d.ts +1 -1
  17. package/dist/widgets/CurrencyField.d.ts +1 -1
  18. package/dist/widgets/EmailField.d.ts +1 -1
  19. package/dist/widgets/FileField.d.ts +1 -1
  20. package/dist/widgets/FilterConditionField.d.ts +1 -1
  21. package/dist/widgets/FullscreenFieldEditor.d.ts +155 -0
  22. package/dist/widgets/GeolocationField.d.ts +1 -1
  23. package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
  24. package/dist/widgets/ImageField.d.ts +1 -1
  25. package/dist/widgets/LocationField.d.ts +1 -1
  26. package/dist/widgets/LookupField.d.ts +1 -1
  27. package/dist/widgets/LookupField.gateHintLabel.test.d.ts +8 -0
  28. package/dist/widgets/MultiSelectField.d.ts +1 -1
  29. package/dist/widgets/ObjectField.d.ts +1 -1
  30. package/dist/widgets/ObjectRefField.d.ts +1 -1
  31. package/dist/widgets/OptionsEmptyState.d.ts +45 -0
  32. package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
  33. package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
  34. package/dist/widgets/PercentField.d.ts +1 -1
  35. package/dist/widgets/PhoneField.d.ts +1 -1
  36. package/dist/widgets/QRCodeField.d.ts +1 -1
  37. package/dist/widgets/RadioField.d.ts +1 -1
  38. package/dist/widgets/RatingField.d.ts +1 -1
  39. package/dist/widgets/RecipientPickerField.d.ts +1 -1
  40. package/dist/widgets/RecordPickerDialog.d.ts +17 -1
  41. package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +8 -0
  42. package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +8 -0
  43. package/dist/widgets/RichTextField.d.ts +19 -1
  44. package/dist/widgets/TagsField.d.ts +1 -1
  45. package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
  46. package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
  47. package/dist/widgets/TextAreaField.d.ts +44 -5
  48. package/dist/widgets/UrlField.d.ts +1 -1
  49. package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
  50. package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
  51. package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
  52. package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
  53. package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
  54. package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
  55. package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
  56. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
  57. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
  58. package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
  59. package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
  60. package/dist/widgets/toDomProps.d.ts +91 -0
  61. package/dist/widgets/types.d.ts +128 -33
  62. package/dist/withFieldCarrier.d.ts +56 -0
  63. package/package.json +13 -13
  64. package/src/__tests__/cell-truncation.test.tsx +96 -0
  65. package/src/__tests__/field-carrier-sdui.test.tsx +169 -0
  66. package/src/__tests__/spec-symbol-batch7.test.ts +29 -15
  67. package/src/__tests__/validation-feedback.test.tsx +22 -13
  68. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +265 -0
  69. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +338 -0
  70. package/src/__tests__/widget-dom-leak-e2e.test.tsx +598 -0
  71. package/src/__tests__/widget-props-contract.test.tsx +22 -9
  72. package/src/index.tsx +74 -26
  73. package/src/widgets/AddressField.tsx +31 -12
  74. package/src/widgets/AddressField.uniqueIds.test.tsx +92 -0
  75. package/src/widgets/AvatarField.tsx +9 -2
  76. package/src/widgets/BooleanField.tsx +3 -3
  77. package/src/widgets/CharacterCount.tsx +263 -0
  78. package/src/widgets/CheckboxesField.tsx +31 -14
  79. package/src/widgets/CodeField.tsx +9 -2
  80. package/src/widgets/ColorField.tsx +11 -2
  81. package/src/widgets/CurrencyField.tsx +5 -4
  82. package/src/widgets/DateField.tsx +2 -2
  83. package/src/widgets/DateTimeField.tsx +2 -2
  84. package/src/widgets/EmailField.tsx +5 -5
  85. package/src/widgets/FileField.tsx +13 -3
  86. package/src/widgets/FilterConditionField.tsx +11 -0
  87. package/src/widgets/FormulaField.tsx +1 -1
  88. package/src/widgets/FullscreenFieldEditor.tsx +271 -0
  89. package/src/widgets/GeolocationField.tsx +21 -6
  90. package/src/widgets/GeolocationField.uniqueIds.test.tsx +88 -0
  91. package/src/widgets/GridField.tsx +1 -1
  92. package/src/widgets/ImageField.tsx +9 -2
  93. package/src/widgets/LocationField.tsx +9 -2
  94. package/src/widgets/LookupField.gateHintLabel.test.tsx +135 -0
  95. package/src/widgets/LookupField.tsx +41 -6
  96. package/src/widgets/MasterDetailField.tsx +1 -1
  97. package/src/widgets/MultiSelectField.tsx +30 -14
  98. package/src/widgets/NumberField.tsx +3 -3
  99. package/src/widgets/ObjectField.tsx +17 -8
  100. package/src/widgets/ObjectRefField.tsx +10 -0
  101. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +88 -0
  102. package/src/widgets/OptionsEmptyState.test.tsx +169 -0
  103. package/src/widgets/OptionsEmptyState.tsx +83 -0
  104. package/src/widgets/PasswordField.tsx +3 -3
  105. package/src/widgets/PeoplePicker.tsx +7 -1
  106. package/src/widgets/PercentField.tsx +5 -4
  107. package/src/widgets/PhoneField.tsx +5 -5
  108. package/src/widgets/QRCodeField.tsx +9 -2
  109. package/src/widgets/RadioField.tsx +24 -14
  110. package/src/widgets/RatingField.tsx +18 -3
  111. package/src/widgets/RecipientPickerField.tsx +18 -0
  112. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +611 -0
  113. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +112 -0
  114. package/src/widgets/RecordPickerDialog.tsx +173 -8
  115. package/src/widgets/RichTextField.tsx +151 -24
  116. package/src/widgets/SelectField.tsx +48 -17
  117. package/src/widgets/SliderField.tsx +1 -1
  118. package/src/widgets/SummaryField.tsx +1 -1
  119. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +68 -0
  120. package/src/widgets/TagsField.placeholder.test.tsx +95 -0
  121. package/src/widgets/TagsField.tsx +23 -2
  122. package/src/widgets/TextAreaField.tsx +171 -93
  123. package/src/widgets/TextField.tsx +3 -3
  124. package/src/widgets/TimeField.tsx +2 -2
  125. package/src/widgets/UrlField.tsx +5 -5
  126. package/src/widgets/UserField.tsx +1 -1
  127. package/src/widgets/VectorField.tsx +1 -1
  128. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +248 -0
  129. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +116 -0
  130. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +244 -0
  131. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +195 -0
  132. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +417 -0
  133. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +287 -0
  134. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +160 -0
  135. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +161 -0
  136. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +346 -0
  137. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +246 -0
  138. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +175 -0
  139. package/src/widgets/toDomProps.ts +175 -0
  140. package/src/widgets/types.ts +135 -35
  141. package/src/widgets/useFieldTranslation.ts +60 -0
  142. package/src/withFieldCarrier.tsx +75 -0
@@ -0,0 +1,598 @@
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 {@link isKnownAttribute} for how the judge is built, and
54
+ * {@link HAPPY_DOM_IDL_GAPS} for each measured exception and its reason.
55
+ * Every one of those four was found BY the calibration fixture below, not
56
+ * by guesswork.
57
+ *
58
+ * ## The judge proves itself
59
+ *
60
+ * Two fixtures run before the sweep: standard markup that must yield ZERO
61
+ * findings, and markup with planted fake attributes that must ALL be found.
62
+ * When a happy-dom upgrade changes IDL coverage, those fail loudly instead of
63
+ * the sweep going quietly blind.
64
+ *
65
+ * ## Deliberate coverage boundary
66
+ *
67
+ * Popovers are NOT opened. Radix needs pointer-capture APIs happy-dom does not
68
+ * implement. Every widget's props spread lands on its inline control (the
69
+ * trigger for a picker), which always renders — so the spread site IS covered,
70
+ * but content that exists only inside an open dropdown is NOT scanned by this
71
+ * test.
72
+ *
73
+ * Widgets are registered from STATIC imports wrapped in `withFieldCarrier`,
74
+ * never via `registerAllFields()`, which wraps every loader in `React.lazy` —
75
+ * an unbounded module load inside a bounded `waitFor` is this repo's known
76
+ * flake generator (AGENTS.md 测试纪律 / objectui#3010).
77
+ */
78
+
79
+ import type { ComponentType } from 'react';
80
+ import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest';
81
+ import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
82
+ import '@testing-library/jest-dom';
83
+ import { ComponentRegistry } from '@object-ui/core';
84
+ // Module scope: pulls in the form renderer's registration side effect.
85
+ import '@object-ui/components';
86
+ import { SchemaRenderer } from '@object-ui/react';
87
+
88
+ import { withFieldCarrier } from '../withFieldCarrier';
89
+ import { FORM_FIELD_TYPES } from '../index';
90
+
91
+ import { TextField } from '../widgets/TextField';
92
+ import { TextAreaField } from '../widgets/TextAreaField';
93
+ import { NumberField } from '../widgets/NumberField';
94
+ import { BooleanField } from '../widgets/BooleanField';
95
+ import { SelectField } from '../widgets/SelectField';
96
+ import { DateField } from '../widgets/DateField';
97
+ import { DateTimeField } from '../widgets/DateTimeField';
98
+ import { TimeField } from '../widgets/TimeField';
99
+ import { EmailField } from '../widgets/EmailField';
100
+ import { PhoneField } from '../widgets/PhoneField';
101
+ import { UrlField } from '../widgets/UrlField';
102
+ import { MultiSelectField } from '../widgets/MultiSelectField';
103
+ import { RadioField } from '../widgets/RadioField';
104
+ import { CheckboxesField } from '../widgets/CheckboxesField';
105
+ import { TagsField } from '../widgets/TagsField';
106
+ import { CurrencyField } from '../widgets/CurrencyField';
107
+ import { PercentField } from '../widgets/PercentField';
108
+ import { PasswordField } from '../widgets/PasswordField';
109
+ import { RichTextField } from '../widgets/RichTextField';
110
+ import { LookupField } from '../widgets/LookupField';
111
+ import { FileField } from '../widgets/FileField';
112
+ import { ImageField } from '../widgets/ImageField';
113
+ import { LocationField } from '../widgets/LocationField';
114
+ import { FormulaField } from '../widgets/FormulaField';
115
+ import { SummaryField } from '../widgets/SummaryField';
116
+ import { AutoNumberField } from '../widgets/AutoNumberField';
117
+ import { UserField } from '../widgets/UserField';
118
+ import { ObjectField } from '../widgets/ObjectField';
119
+ import { VectorField } from '../widgets/VectorField';
120
+ import { GridField } from '../widgets/GridField';
121
+ import { ColorField } from '../widgets/ColorField';
122
+ import { SliderField } from '../widgets/SliderField';
123
+ import { RatingField } from '../widgets/RatingField';
124
+ import { CodeField } from '../widgets/CodeField';
125
+ import { AvatarField } from '../widgets/AvatarField';
126
+ import { AddressField } from '../widgets/AddressField';
127
+ import { GeolocationField } from '../widgets/GeolocationField';
128
+ import { SignatureField } from '../widgets/SignatureField';
129
+ import { QRCodeField } from '../widgets/QRCodeField';
130
+ import { ObjectRefField } from '../widgets/ObjectRefField';
131
+ import { FilterConditionField } from '../widgets/FilterConditionField';
132
+ import { RecipientPickerField } from '../widgets/RecipientPickerField';
133
+
134
+ /* ════════════════════════════════════════════════════════════════════════════
135
+ * The judge: is this attribute one HTML actually defines?
136
+ * ══════════════════════════════════════════════════════════════════════════ */
137
+
138
+ /** Open families. `data-*` is the one open family the widget contract declares. */
139
+ const OPEN_PREFIXES = [
140
+ 'data-',
141
+ 'aria-',
142
+ // `cmdk-root` / `cmdk-input` / `cmdk-list` … are marks the cmdk library puts
143
+ // on ITS OWN DOM. Not prop pass-through, and present on every cmdk-based
144
+ // picker (`object-ref`, `lookup`, …) the moment it renders.
145
+ 'cmdk-',
146
+ ];
147
+
148
+ /**
149
+ * Global HTML attributes. Most are also IDL properties and would be caught by
150
+ * the reflection check below; they are listed because a missing IDL for a
151
+ * genuinely global attribute would otherwise read as a leak.
152
+ */
153
+ const GLOBAL_HTML_ATTRIBUTES = new Set([
154
+ 'id', 'class', 'style', 'title', 'lang', 'dir', 'hidden', 'tabindex', 'role',
155
+ 'slot', 'part', 'exportparts', 'itemid', 'itemprop', 'itemref', 'itemscope',
156
+ 'itemtype', 'translate', 'draggable', 'spellcheck', 'autocapitalize',
157
+ 'autocorrect', 'contenteditable', 'enterkeyhint', 'inputmode', 'accesskey',
158
+ 'nonce', 'is', 'popover', 'inert', 'autofocus',
159
+ ]);
160
+
161
+ /**
162
+ * Attributes whose IDL property is spelled differently enough that the
163
+ * case-insensitive reflection match below cannot find them.
164
+ */
165
+ const ATTRIBUTE_TO_IDL_ALIAS: Record<string, string> = {
166
+ 'class': 'className',
167
+ 'for': 'htmlFor',
168
+ 'accept-charset': 'acceptCharset',
169
+ 'http-equiv': 'httpEquiv',
170
+ };
171
+
172
+ /**
173
+ * MEASURED gaps in happy-dom's IDL, kept deliberately tiny — each entry is an
174
+ * attribute HTML defines that happy-dom's element does not reflect as a
175
+ * property, so reflection alone would report it as a leak.
176
+ */
177
+ const HAPPY_DOM_IDL_GAPS: Record<string, Set<string>> = {
178
+ // `HTMLSelectElement.size` is standard; happy-dom does not define it.
179
+ select: new Set(['size']),
180
+ // `HTMLOptionElement.label` is standard; happy-dom does not define it.
181
+ option: new Set(['label']),
182
+ // `HTMLTextAreaElement.wrap` is standard; happy-dom does not define it.
183
+ textarea: new Set(['wrap']),
184
+ // `HTMLTableColElement.span` is standard; happy-dom does not define it.
185
+ col: new Set(['span']),
186
+ colgroup: new Set(['span']),
187
+ };
188
+
189
+ /**
190
+ * SVG needs its own list: the reflection trick does NOT hold for SVG under
191
+ * happy-dom (`SVGElement` reflects almost nothing), and lucide icons put a
192
+ * fixed set of presentation attributes on every icon they render.
193
+ */
194
+ const SVG_ATTRIBUTES = new Set([
195
+ 'xmlns', 'xmlns:xlink', 'version', 'viewbox', 'preserveaspectratio',
196
+ 'width', 'height', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'cx', 'cy', 'r', 'rx',
197
+ 'ry', 'd', 'points', 'transform', 'fill', 'fill-rule', 'fill-opacity',
198
+ 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin',
199
+ 'stroke-dasharray', 'stroke-dashoffset', 'stroke-opacity', 'opacity',
200
+ 'clip-path', 'clip-rule', 'mask', 'offset', 'stop-color', 'stop-opacity',
201
+ 'gradientunits', 'gradienttransform', 'patternunits', 'text-anchor',
202
+ 'dominant-baseline', 'font-size', 'font-family', 'font-weight', 'vector-effect',
203
+ 'shape-rendering', 'focusable', 'overflow', 'color',
204
+ ]);
205
+
206
+ /** Lowercased IDL property names on a tag's prototype chain, cached per tag. */
207
+ const idlCache = new Map<string, Set<string>>();
208
+
209
+ function idlPropertiesFor(tagName: string): Set<string> {
210
+ const tag = tagName.toLowerCase();
211
+ const cached = idlCache.get(tag);
212
+ if (cached) return cached;
213
+
214
+ const names = new Set<string>();
215
+ const element = document.createElement(tag);
216
+ for (const own of Object.getOwnPropertyNames(element)) names.add(own.toLowerCase());
217
+ for (
218
+ let proto = Object.getPrototypeOf(element);
219
+ proto && proto !== Object.prototype;
220
+ proto = Object.getPrototypeOf(proto)
221
+ ) {
222
+ for (const name of Object.getOwnPropertyNames(proto)) names.add(name.toLowerCase());
223
+ }
224
+ idlCache.set(tag, names);
225
+ return names;
226
+ }
227
+
228
+ /**
229
+ * The rule: an attribute is legitimate when HTML/SVG defines it for that
230
+ * element, or when it belongs to an open family.
231
+ *
232
+ * The reflection check ("does the element's prototype chain carry a property
233
+ * with this name, case-insensitively?") is what makes this maintainable — it
234
+ * covers `readonly→readOnly`, `maxlength→maxLength`, `colspan→colSpan` and
235
+ * every other per-tag attribute automatically, instead of a hand-kept table
236
+ * per element type that would rot.
237
+ */
238
+ function isKnownAttribute(element: Element, attribute: string): boolean {
239
+ const name = attribute.toLowerCase();
240
+
241
+ if (OPEN_PREFIXES.some((prefix) => name.startsWith(prefix))) return true;
242
+
243
+ // Inline event handlers (`onclick`) reflect as IDL properties on every
244
+ // element; React never emits them as attributes, so reaching one means a
245
+ // handler-shaped prop was stringified onto the DOM. Treat as a leak.
246
+ if (name.startsWith('on')) return false;
247
+
248
+ const tag = element.tagName.toLowerCase();
249
+
250
+ if (element.namespaceURI === 'http://www.w3.org/2000/svg') {
251
+ return SVG_ATTRIBUTES.has(name) || GLOBAL_HTML_ATTRIBUTES.has(name);
252
+ }
253
+
254
+ if (GLOBAL_HTML_ATTRIBUTES.has(name)) return true;
255
+ if (HAPPY_DOM_IDL_GAPS[tag]?.has(name)) return true;
256
+
257
+ const idl = idlPropertiesFor(tag);
258
+ const alias = ATTRIBUTE_TO_IDL_ALIAS[name];
259
+ if (alias && idl.has(alias.toLowerCase())) return true;
260
+ return idl.has(name);
261
+ }
262
+
263
+ interface Leak {
264
+ tag: string;
265
+ attribute: string;
266
+ value: string;
267
+ outerHTML: string;
268
+ }
269
+
270
+ /** Every unexplained attribute on `root` and its descendants. */
271
+ function findLeaks(root: Element): Leak[] {
272
+ const leaks: Leak[] = [];
273
+ const elements: Element[] = [root, ...Array.from(root.querySelectorAll('*'))];
274
+ for (const element of elements) {
275
+ for (const attribute of Array.from(element.attributes)) {
276
+ if (isKnownAttribute(element, attribute.name)) continue;
277
+ leaks.push({
278
+ tag: element.tagName.toLowerCase(),
279
+ attribute: attribute.name,
280
+ value: attribute.value,
281
+ outerHTML: element.outerHTML.slice(0, 400),
282
+ });
283
+ }
284
+ }
285
+ return leaks;
286
+ }
287
+
288
+ /**
289
+ * Renders the finding as the assertion's "actual" value, so a failure names
290
+ * the widget, the element, the attribute, its value and the markup. A 46-widget
291
+ * sweep failing as `expected [] to equal [ …47 items ]` is unusable.
292
+ */
293
+ function leakReport(widgetType: string, variant: string, leaks: Leak[]): string {
294
+ if (leaks.length === 0) return '';
295
+ const lines = leaks.map(
296
+ (leak) =>
297
+ ` <${leak.tag}> leaked ${leak.attribute}="${leak.value}"\n` +
298
+ ` in: ${leak.outerHTML}`,
299
+ );
300
+ return (
301
+ `field:${widgetType} [${variant}] leaked ${leaks.length} non-DOM ` +
302
+ `attribute(s):\n${lines.join('\n')}`
303
+ );
304
+ }
305
+
306
+ /* ════════════════════════════════════════════════════════════════════════════
307
+ * The judge proves itself, BEFORE it is trusted on 46 widgets
308
+ * ══════════════════════════════════════════════════════════════════════════ */
309
+
310
+ /**
311
+ * Ordinary, correct markup. Every attribute here is one HTML defines, several
312
+ * chosen precisely because their IDL name differs from the attribute
313
+ * (`readonly`/`maxlength`/`colspan`/`class`/`for`), plus the two happy-dom IDL
314
+ * gaps (`select[size]`, `option[label]`) and a cmdk mark.
315
+ */
316
+ const CLEAN_FIXTURE = `
317
+ <div id="root" class="a b" title="t" role="group" tabindex="0" hidden
318
+ data-testid="x" aria-label="l" data-state="open">
319
+ <input type="text" name="n" value="v" placeholder="p" readonly maxlength="5"
320
+ minlength="1" required autofocus autocomplete="off" inputmode="text"
321
+ step="1" min="0" max="9" pattern=".*" list="dl" />
322
+ <textarea rows="3" cols="4" wrap="soft" readonly></textarea>
323
+ <select size="2" multiple><option label="L" value="v" selected></option></select>
324
+ <button type="button" disabled formnovalidate value="on">b</button>
325
+ <a href="#" target="_blank" rel="noopener" download hreflang="en"></a>
326
+ <label for="root">l</label>
327
+ <img src="s.png" alt="a" width="10" height="10" loading="lazy" decoding="async" />
328
+ <table><colgroup><col span="2" /></colgroup><tbody><tr>
329
+ <td colspan="2" rowspan="1" headers="h"></td></tr></tbody></table>
330
+ <div cmdk-root=""><div cmdk-list=""></div></div>
331
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"
332
+ fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
333
+ stroke-linejoin="round" class="icon"><path d="M0 0h24v24H0z" /></svg>
334
+ </div>
335
+ `;
336
+
337
+ /** Every planted attribute here MUST be reported. */
338
+ const PLANTED_LEAKS: ReadonlyArray<readonly [string, string]> = [
339
+ // The renderer-only props that reached the DOM in the audit.
340
+ ['schema', '[object Object]'],
341
+ ['error', 'Title is required'],
342
+ ['emptyhint', 'Select country first'],
343
+ ['datasource', '[object Object]'],
344
+ ['dependentvalues', '[object Object]'],
345
+ ['dependson', 'country'],
346
+ ['inputtype', 'text'],
347
+ ['compact', 'true'],
348
+ ['onselectrecord', 'function'],
349
+ // The SDUI-only extra.
350
+ ['label', 'Title'],
351
+ // The open tail: arbitrary keys an author wrote on the field config.
352
+ ['zzcanary', 'CANARY-STR'],
353
+ ['zzcanaryobj', '[object Object]'],
354
+ ['zzcanarynum', '42'],
355
+ ['zzcanarycamel', 'CANARY-CAMEL'],
356
+ ['reference_to', 'contacts'],
357
+ ];
358
+
359
+ describe('the leak judge is calibrated (objectui#3291)', () => {
360
+ it('reports NOTHING on standard markup — no false positives', () => {
361
+ const host = document.createElement('div');
362
+ host.innerHTML = CLEAN_FIXTURE;
363
+ document.body.appendChild(host);
364
+ try {
365
+ const leaks = findLeaks(host);
366
+ expect(
367
+ leaks.map((l) => `<${l.tag}> ${l.attribute}="${l.value}"`).join('\n'),
368
+ ).toBe('');
369
+ } finally {
370
+ host.remove();
371
+ }
372
+ });
373
+
374
+ it('reports EVERY planted fake attribute — no false negatives', () => {
375
+ const host = document.createElement('div');
376
+ const planted = PLANTED_LEAKS.map(([name, value]) => `${name}="${value}"`).join(' ');
377
+ // On an <input>, so nothing can be excused by a permissive container.
378
+ host.innerHTML = `<input type="text" name="n" ${planted} />`;
379
+ document.body.appendChild(host);
380
+ try {
381
+ const found = new Set(findLeaks(host).map((leak) => leak.attribute));
382
+ const missed = PLANTED_LEAKS.map(([name]) => name).filter((name) => !found.has(name));
383
+ expect(missed).toEqual([]);
384
+ expect(found.size).toBe(PLANTED_LEAKS.length);
385
+ } finally {
386
+ host.remove();
387
+ }
388
+ });
389
+ });
390
+
391
+ /* ════════════════════════════════════════════════════════════════════════════
392
+ * Every registered field widget, both hosts
393
+ * ══════════════════════════════════════════════════════════════════════════ */
394
+
395
+ /**
396
+ * Static components for the widget map's keys. Kept as one object so the
397
+ * parity assertion below can prove it covers the whole registry: a NEW field
398
+ * type added to `fieldWidgetMap` without a line here fails loudly rather than
399
+ * quietly going unscanned.
400
+ */
401
+ const WIDGETS: Record<string, ComponentType<any>> = {
402
+ text: TextField,
403
+ textarea: TextAreaField,
404
+ number: NumberField,
405
+ boolean: BooleanField,
406
+ select: SelectField,
407
+ date: DateField,
408
+ datetime: DateTimeField,
409
+ time: TimeField,
410
+ email: EmailField,
411
+ phone: PhoneField,
412
+ url: UrlField,
413
+ multiselect: MultiSelectField,
414
+ radio: RadioField,
415
+ checkboxes: CheckboxesField,
416
+ tags: TagsField,
417
+ currency: CurrencyField,
418
+ percent: PercentField,
419
+ password: PasswordField,
420
+ markdown: RichTextField,
421
+ html: RichTextField,
422
+ richtext: RichTextField,
423
+ lookup: LookupField,
424
+ master_detail: LookupField,
425
+ file: FileField,
426
+ image: ImageField,
427
+ location: LocationField,
428
+ formula: FormulaField,
429
+ summary: SummaryField,
430
+ auto_number: AutoNumberField,
431
+ user: UserField,
432
+ owner: UserField,
433
+ object: ObjectField,
434
+ vector: VectorField,
435
+ grid: GridField,
436
+ color: ColorField,
437
+ slider: SliderField,
438
+ rating: RatingField,
439
+ code: CodeField,
440
+ avatar: AvatarField,
441
+ address: AddressField,
442
+ geolocation: GeolocationField,
443
+ signature: SignatureField,
444
+ qrcode: QRCodeField,
445
+ 'object-ref': ObjectRefField,
446
+ 'filter-condition': FilterConditionField,
447
+ 'recipient-picker': RecipientPickerField,
448
+ };
449
+
450
+ /** Option widgets render an "unfillable" placeholder unless offered a list. */
451
+ const OPTION_TYPES = new Set(['select', 'multiselect', 'radio', 'checkboxes', 'tags']);
452
+ const OPTIONS = [
453
+ { label: 'Alpha', value: 'alpha' },
454
+ { label: 'Beta', value: 'beta' },
455
+ ];
456
+
457
+ /**
458
+ * The value each variant starts from: `null` is MISSING for the required check
459
+ * this repo implements (presence, not truthiness — `false` and `0` are values,
460
+ * cloud#972), so one value drives a real validation failure for every type,
461
+ * including `boolean`. Trap 3 above is why that matters, and why every error
462
+ * variant asserts the message before it scans.
463
+ */
464
+ const MISSING_VALUE = null;
465
+
466
+ /** Author-written extras — the open tail that a blacklist cannot close. */
467
+ const AUTHORED_EXTRAS = {
468
+ zzcanary: 'CANARY-STR',
469
+ zzcanaryobj: { nested: true },
470
+ zzcanarynum: 42,
471
+ zzcanaryCamel: 'CANARY-CAMEL',
472
+ reference_to: 'contacts',
473
+ };
474
+
475
+ function fieldConfig(type: string, extras: Record<string, unknown> = {}) {
476
+ return {
477
+ name: 'f',
478
+ label: 'F',
479
+ type: `field:${type}`,
480
+ ...(OPTION_TYPES.has(type) ? { options: OPTIONS } : {}),
481
+ ...extras,
482
+ };
483
+ }
484
+
485
+ function renderForm(field: Record<string, unknown>, required: boolean) {
486
+ const Form = ComponentRegistry.get('form')!;
487
+ return render(
488
+ <Form
489
+ schema={{
490
+ type: 'form',
491
+ mode: 'create',
492
+ showSubmit: true,
493
+ showCancel: false,
494
+ submitLabel: 'Create',
495
+ defaultValues: { f: MISSING_VALUE },
496
+ fields: [{ ...field, required }],
497
+ onSubmit: () => {},
498
+ }}
499
+ />,
500
+ );
501
+ }
502
+
503
+ /**
504
+ * The form row. Waiting on `[data-field]` (emitted by `FormItem` for EVERY
505
+ * field) rather than widget-specific copy keeps the wait condition identical
506
+ * for all 46 widgets and independent of what any one of them renders.
507
+ */
508
+ async function formRow(): Promise<Element> {
509
+ return waitFor(() => {
510
+ const row = document.querySelector('[data-field="f"]');
511
+ if (!row) throw new Error('field row never rendered');
512
+ return row;
513
+ });
514
+ }
515
+
516
+ beforeAll(() => {
517
+ for (const [type, Widget] of Object.entries(WIDGETS)) {
518
+ // `withFieldCarrier` is the real registration seam (objectui#3233) — going
519
+ // around it would test a widget in a shape no host ever produces.
520
+ ComponentRegistry.register(type, withFieldCarrier(Widget) as any, {
521
+ namespace: 'field',
522
+ skipFallback: true,
523
+ });
524
+ }
525
+ }, 60000);
526
+
527
+ beforeEach(() => {
528
+ if (!(Element.prototype as any).scrollIntoView) {
529
+ (Element.prototype as any).scrollIntoView = () => {};
530
+ }
531
+ });
532
+
533
+ afterEach(() => {
534
+ cleanup();
535
+ vi.restoreAllMocks();
536
+ });
537
+
538
+ describe('no field widget leaks non-DOM props to the DOM (objectui#3291)', () => {
539
+ it('covers every field type the form can render', () => {
540
+ // The gate's reach. Without this, adding a widget adds an unscanned widget.
541
+ expect(Object.keys(WIDGETS).sort()).toEqual([...FORM_FIELD_TYPES].sort());
542
+ });
543
+
544
+ const types = Object.keys(WIDGETS);
545
+
546
+ it.each(types)('field:%s — form path, plain field', async (type) => {
547
+ renderForm(fieldConfig(type), false);
548
+ const row = await formRow();
549
+ expect(leakReport(type, 'form/plain', findLeaks(row))).toBe('');
550
+ });
551
+
552
+ it.each(types)('field:%s — form path, author-written extra keys', async (type) => {
553
+ // The largest measured leak source: the form renderer destructures a fixed set
554
+ // of known keys and forwards the rest verbatim, so anything else an author
555
+ // put on the field config arrives at the widget as a prop.
556
+ renderForm(fieldConfig(type, AUTHORED_EXTRAS), false);
557
+ const row = await formRow();
558
+ expect(leakReport(type, 'form/authored-extras', findLeaks(row))).toBe('');
559
+ });
560
+
561
+ it.each(types)('field:%s — form path, after a real validation failure', async (type) => {
562
+ renderForm(fieldConfig(type), true);
563
+ const row = await formRow();
564
+
565
+ fireEvent.click(screen.getByRole('button', { name: /create/i }));
566
+
567
+ // Trap 3: scan only once the error is PROVEN to exist. A variant that
568
+ // silently produced none would otherwise pass while testing nothing.
569
+ await waitFor(() => {
570
+ expect(document.querySelector('[data-field="f"]')?.textContent ?? '').toContain(
571
+ 'is required',
572
+ );
573
+ });
574
+
575
+ expect(leakReport(type, 'form/validation-error', findLeaks(await formRow()))).toBe('');
576
+ });
577
+
578
+ it.each(types)('field:%s — SDUI path, plain node', async (type) => {
579
+ // Strictly wider than the form path: `SchemaRenderer` spreads the whole
580
+ // authored node as props with no strip layer, so the widget's own spread
581
+ // is the only defence here.
582
+ const { container } = render(
583
+ <SchemaRenderer schema={{ ...fieldConfig(type), type: `field:${type}` } as any} />,
584
+ );
585
+ await waitFor(() => expect(container.firstElementChild).toBeTruthy());
586
+ expect(leakReport(type, 'sdui/plain', findLeaks(container))).toBe('');
587
+ });
588
+
589
+ it.each(types)('field:%s — SDUI path, author-written extra keys', async (type) => {
590
+ const { container } = render(
591
+ <SchemaRenderer
592
+ schema={{ ...fieldConfig(type, AUTHORED_EXTRAS), type: `field:${type}` } as any}
593
+ />,
594
+ );
595
+ await waitFor(() => expect(container.firstElementChild).toBeTruthy());
596
+ expect(leakReport(type, 'sdui/authored-extras', findLeaks(container))).toBe('');
597
+ });
598
+ });
@@ -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'],