@object-ui/fields 17.3.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 (156) hide show
  1. package/CHANGELOG.md +941 -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 +4 -4
  7. package/dist/index.css +633 -0
  8. package/dist/index.d.ts +48 -12
  9. package/dist/index.js +5026 -4933
  10. package/dist/widgets/AddressField.d.ts +3 -11
  11. package/dist/widgets/FullscreenFieldEditor.d.ts +42 -152
  12. package/dist/widgets/GridField.d.ts +12 -2
  13. package/dist/widgets/OptionsEmptyState.d.ts +15 -1
  14. package/dist/widgets/RecordPickerDialog.d.ts +32 -6
  15. package/dist/widgets/SelectField.d.ts +25 -5
  16. package/dist/widgets/address-format.d.ts +68 -0
  17. package/dist/widgets/file-size-guard.d.ts +41 -0
  18. package/dist/widgets/nativeDateValue.d.ts +37 -0
  19. package/dist/widgets/toDomProps.d.ts +30 -10
  20. package/dist/widgets/toHostGroupProps.d.ts +91 -0
  21. package/dist/widgets/useRecordQuery.d.ts +7 -1
  22. package/package.json +17 -13
  23. package/src/FieldEditWidget.tsx +17 -1
  24. package/src/__tests__/AddressCellRenderer.test.tsx +166 -0
  25. package/src/__tests__/CurrencyField.minorUnits.test.tsx +191 -0
  26. package/src/__tests__/CurrencyField.symbol.test.tsx +198 -0
  27. package/src/__tests__/DateCellRenderer.test.tsx +9 -0
  28. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +146 -0
  29. package/src/__tests__/PercentCellRenderer.locale.test.tsx +152 -0
  30. package/src/__tests__/PercentCellRenderer.test.tsx +115 -0
  31. package/src/__tests__/boolean-label-association-e2e.test.tsx +298 -0
  32. package/src/__tests__/capability-multiselect-retired.test.ts +109 -0
  33. package/src/__tests__/composite-group-label-e2e.test.tsx +436 -0
  34. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +403 -0
  35. package/src/__tests__/date-formatter-residue-4272.test.ts +120 -0
  36. package/src/__tests__/date-locale-channel.test.tsx +305 -0
  37. package/src/__tests__/formatCurrency.centsDigit.test.tsx +158 -0
  38. package/src/__tests__/formatCurrency.minorUnits.test.tsx +221 -0
  39. package/src/__tests__/group-labelling-declaration.test.ts +148 -0
  40. package/src/__tests__/lookupCellRecordLink.test.tsx +171 -0
  41. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +115 -0
  42. package/src/__tests__/percent-formatter-locale-4553.test.ts +147 -0
  43. package/src/__tests__/percent-tie-halfup-4590.test.ts +224 -0
  44. package/src/__tests__/select-label-association-e2e.test.tsx +199 -0
  45. package/src/__tests__/widget-dom-leak-e2e.test.tsx +29 -270
  46. package/src/complex-widgets.test.tsx +22 -6
  47. package/src/currency.ts +130 -0
  48. package/src/datetime-widgets.test.tsx +94 -2
  49. package/src/field-type-alias.multiple.test.ts +81 -0
  50. package/src/field-type-alias.ts +58 -2
  51. package/src/index.css +44 -0
  52. package/src/index.tsx +489 -247
  53. package/src/standard-widgets.test.tsx +8 -1
  54. package/src/widgets/AddressField.postalCode.test.tsx +257 -0
  55. package/src/widgets/AddressField.tsx +79 -29
  56. package/src/widgets/AddressField.uniqueIds.test.tsx +2 -1
  57. package/src/widgets/BooleanField.tsx +41 -7
  58. package/src/widgets/CapabilityMultiSelectField.tsx +17 -5
  59. package/src/widgets/CheckboxesField.tsx +35 -2
  60. package/src/widgets/CurrencyField.tsx +55 -15
  61. package/src/widgets/DateField.tsx +11 -2
  62. package/src/widgets/DateTimeField.tsx +12 -3
  63. package/src/widgets/FileField.tsx +27 -9
  64. package/src/widgets/FormulaField.tsx +6 -1
  65. package/src/widgets/FullscreenFieldEditor.tsx +47 -258
  66. package/src/widgets/GeolocationField.tsx +29 -3
  67. package/src/widgets/GridField.test.tsx +226 -0
  68. package/src/widgets/GridField.tsx +114 -10
  69. package/src/widgets/ImageField.maxSize.test.tsx +154 -0
  70. package/src/widgets/ImageField.tsx +54 -4
  71. package/src/widgets/LookupField.hydration.test.tsx +1 -1
  72. package/src/widgets/LookupField.tsx +8 -4
  73. package/src/widgets/MultiSelectField.tsx +56 -2
  74. package/src/widgets/OptionsEmptyState.tsx +16 -0
  75. package/src/widgets/RadioField.tsx +35 -3
  76. package/src/widgets/RatingField.tsx +15 -2
  77. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +191 -0
  78. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +124 -0
  79. package/src/widgets/RecordPickerDialog.tsx +64 -15
  80. package/src/widgets/RichTextField.tsx +1 -1
  81. package/src/widgets/SelectField.tsx +36 -5
  82. package/src/widgets/UserField.tsx +3 -2
  83. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +25 -1
  84. package/src/widgets/address-format.ts +120 -0
  85. package/src/widgets/file-size-guard.ts +55 -0
  86. package/src/widgets/nativeDateValue.test.ts +115 -0
  87. package/src/widgets/nativeDateValue.ts +112 -0
  88. package/src/widgets/optionWidgets.gatedOptions.test.tsx +267 -0
  89. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +150 -0
  90. package/src/widgets/toDomProps.ts +48 -19
  91. package/src/widgets/toHostGroupProps.ts +102 -0
  92. package/src/widgets/useFieldTranslation.ts +15 -33
  93. package/src/widgets/useRecordQuery.ts +30 -3
  94. package/dist/FieldEditWidget.test.d.ts +0 -8
  95. package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
  96. package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
  97. package/dist/__tests__/cell-truncation.test.d.ts +0 -0
  98. package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
  99. package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
  100. package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
  101. package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
  102. package/dist/__tests__/registry-collision.test.d.ts +0 -0
  103. package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
  104. package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
  105. package/dist/__tests__/validation-feedback.test.d.ts +0 -0
  106. package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
  107. package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
  108. package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
  109. package/dist/__tests__/widget-props-contract.test.d.ts +0 -8
  110. package/dist/coerce-safe-value.test.d.ts +0 -1
  111. package/dist/complex-widgets.test.d.ts +0 -1
  112. package/dist/datetime-widgets.test.d.ts +0 -1
  113. package/dist/field-type-coverage.test.d.ts +0 -1
  114. package/dist/standard-widgets.test.d.ts +0 -0
  115. package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
  116. package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
  117. package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
  118. package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
  119. package/dist/widgets/GridField.test.d.ts +0 -1
  120. package/dist/widgets/ImageLightbox.test.d.ts +0 -1
  121. package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
  122. package/dist/widgets/LookupField.gateHintLabel.test.d.ts +0 -8
  123. package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
  124. package/dist/widgets/LookupField.idField.test.d.ts +0 -1
  125. package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
  126. package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
  127. package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
  128. package/dist/widgets/PeoplePicker.test.d.ts +0 -8
  129. package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
  130. package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
  131. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  132. package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +0 -8
  133. package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +0 -8
  134. package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
  135. package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
  136. package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
  137. package/dist/widgets/UserFieldPicker.test.d.ts +0 -8
  138. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
  139. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
  140. package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
  141. package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
  142. package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
  143. package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
  144. package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
  145. package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
  146. package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
  147. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
  148. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
  149. package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
  150. package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
  151. package/dist/widgets/deriveLookupColumns.test.d.ts +0 -1
  152. package/dist/widgets/file-value.test.d.ts +0 -1
  153. package/dist/widgets/personDisplay.test.d.ts +0 -8
  154. package/dist/widgets/recentLookups.test.d.ts +0 -1
  155. package/dist/widgets/useRecordQuery.test.d.ts +0 -8
  156. package/dist/widgets/useUploadingSignal.test.d.ts +0 -1
@@ -50,17 +50,26 @@
50
50
  * 4. **This repo runs happy-dom, not jsdom** (`vitest.config.mts`), whose IDL
51
51
  * coverage has real gaps — `select[size]`, `option[label]`, `textarea[wrap]`
52
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
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
56
  * by guesswork.
57
57
  *
58
- * ## The judge proves itself
58
+ * ## The judge proves itself — next to the judge, not here
59
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.
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.
64
73
  *
65
74
  * ## Deliberate coverage boundary
66
75
  *
@@ -84,6 +93,11 @@ import { ComponentRegistry } from '@object-ui/core';
84
93
  // Module scope: pulls in the form renderer's registration side effect.
85
94
  import '@object-ui/components';
86
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';
87
101
 
88
102
  import { withFieldCarrier } from '../withFieldCarrier';
89
103
  import { FORM_FIELD_TYPES } from '../index';
@@ -131,263 +145,6 @@ import { ObjectRefField } from '../widgets/ObjectRefField';
131
145
  import { FilterConditionField } from '../widgets/FilterConditionField';
132
146
  import { RecipientPickerField } from '../widgets/RecipientPickerField';
133
147
 
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
148
  /* ════════════════════════════════════════════════════════════════════════════
392
149
  * Every registered field widget, both hosts
393
150
  * ══════════════════════════════════════════════════════════════════════════ */
@@ -546,7 +303,7 @@ describe('no field widget leaks non-DOM props to the DOM (objectui#3291)', () =>
546
303
  it.each(types)('field:%s — form path, plain field', async (type) => {
547
304
  renderForm(fieldConfig(type), false);
548
305
  const row = await formRow();
549
- expect(leakReport(type, 'form/plain', findLeaks(row))).toBe('');
306
+ expect(leakReport(`field:${type} [form/plain]`, findLeaks(row))).toBe('');
550
307
  });
551
308
 
552
309
  it.each(types)('field:%s — form path, author-written extra keys', async (type) => {
@@ -555,7 +312,7 @@ describe('no field widget leaks non-DOM props to the DOM (objectui#3291)', () =>
555
312
  // put on the field config arrives at the widget as a prop.
556
313
  renderForm(fieldConfig(type, AUTHORED_EXTRAS), false);
557
314
  const row = await formRow();
558
- expect(leakReport(type, 'form/authored-extras', findLeaks(row))).toBe('');
315
+ expect(leakReport(`field:${type} [form/authored-extras]`, findLeaks(row))).toBe('');
559
316
  });
560
317
 
561
318
  it.each(types)('field:%s — form path, after a real validation failure', async (type) => {
@@ -572,7 +329,9 @@ describe('no field widget leaks non-DOM props to the DOM (objectui#3291)', () =>
572
329
  );
573
330
  });
574
331
 
575
- expect(leakReport(type, 'form/validation-error', findLeaks(await formRow()))).toBe('');
332
+ expect(
333
+ leakReport(`field:${type} [form/validation-error]`, findLeaks(await formRow())),
334
+ ).toBe('');
576
335
  });
577
336
 
578
337
  it.each(types)('field:%s — SDUI path, plain node', async (type) => {
@@ -583,7 +342,7 @@ describe('no field widget leaks non-DOM props to the DOM (objectui#3291)', () =>
583
342
  <SchemaRenderer schema={{ ...fieldConfig(type), type: `field:${type}` } as any} />,
584
343
  );
585
344
  await waitFor(() => expect(container.firstElementChild).toBeTruthy());
586
- expect(leakReport(type, 'sdui/plain', findLeaks(container))).toBe('');
345
+ expect(leakReport(`field:${type} [sdui/plain]`, findLeaks(container))).toBe('');
587
346
  });
588
347
 
589
348
  it.each(types)('field:%s — SDUI path, author-written extra keys', async (type) => {
@@ -593,6 +352,6 @@ describe('no field widget leaks non-DOM props to the DOM (objectui#3291)', () =>
593
352
  />,
594
353
  );
595
354
  await waitFor(() => expect(container.firstElementChild).toBeTruthy());
596
- expect(leakReport(type, 'sdui/authored-extras', findLeaks(container))).toBe('');
355
+ expect(leakReport(`field:${type} [sdui/authored-extras]`, findLeaks(container))).toBe('');
597
356
  });
598
357
  });
@@ -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
+ }
@@ -1,3 +1,4 @@
1
+ import React from 'react';
1
2
  import { render, screen, fireEvent } from '@testing-library/react';
2
3
  import { describe, it, expect, vi } from 'vitest';
3
4
  import { DateField } from './widgets/DateField';
@@ -54,10 +55,15 @@ describe('Date/Time Widgets', () => {
54
55
  });
55
56
 
56
57
  it('calls onChange when value changes', () => {
57
- const { container } = render(<DateTimeField {...baseProps} />);
58
+ const onChange = vi.fn();
59
+ const { container } = render(<DateTimeField {...baseProps} onChange={onChange} />);
58
60
  const input = container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
59
61
  fireEvent.change(input, { target: { value: '2023-01-01T12:00' } });
60
- expect(baseProps.onChange).toHaveBeenCalledWith('2023-01-01T12:00');
62
+ // The control speaks zone-less local wall clock; form state holds
63
+ // ISO, the shape the API hands back and `toDateTimeInputValue`
64
+ // reads (objectui#3127). Storing the naive string instead would put
65
+ // read and write on different timezone bases.
66
+ expect(onChange).toHaveBeenCalledWith(new Date('2023-01-01T12:00').toISOString());
61
67
  });
62
68
 
63
69
  it('renders formatted datetime in readonly mode', () => {
@@ -71,6 +77,92 @@ describe('Date/Time Widgets', () => {
71
77
  });
72
78
  });
73
79
 
80
+ /**
81
+ * objectui#3127 — the record's stored ISO value reached the native control
82
+ * verbatim, and `<input type="date">` / `<input type="datetime-local">`
83
+ * reject anything but their one exact shape SILENTLY: the attribute lands
84
+ * in the DOM, `.value` reads back `''`, and the user sees an empty box on a
85
+ * field that has a value. `.value` (not `getAttribute('value')`) is
86
+ * therefore the only assertion that can see this bug at all.
87
+ */
88
+ describe('existing values round-trip through the native controls (#3127)', () => {
89
+ // The seeded showcase value. Whole minutes and no sub-second part, so
90
+ // the control (which has minute resolution) can represent it exactly
91
+ // and the round trip below is lossless in every timezone.
92
+ const storedIso = '2026-06-17T14:30:00.000Z';
93
+
94
+ it('DateTimeField shows a stored ISO instant instead of an empty box', () => {
95
+ const { container } = render(<DateTimeField {...baseProps} value={storedIso} />);
96
+ const input = container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
97
+
98
+ expect(input.value).not.toBe('');
99
+ expect(input.value).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
100
+ // The shown wall clock denotes the SAME instant as the record —
101
+ // asserted this way so the test holds in every timezone, which a
102
+ // hardcoded "2026-06-17T22:30" would not.
103
+ expect(new Date(input.value).getTime()).toBe(new Date(storedIso).getTime());
104
+ });
105
+
106
+ it('DateTimeField writes back on the same basis it reads', () => {
107
+ // Read and write must share one timezone basis. Displaying local
108
+ // while storing the naive local string is the worse bug: the value
109
+ // would silently drift by the viewer's offset on every edit.
110
+ const onChange = vi.fn();
111
+ const { container } = render(
112
+ <DateTimeField {...baseProps} onChange={onChange} value={storedIso} />
113
+ );
114
+ const input = container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
115
+
116
+ fireEvent.change(input, { target: { value: '2026-06-18T09:15' } });
117
+
118
+ const written = onChange.mock.calls[0][0];
119
+ // Zone-QUALIFIED, which the control's own output is not. Storing
120
+ // the raw `2026-06-18T09:15` is what puts the write on a different
121
+ // basis from the read: the server has no way to know whose 09:15
122
+ // it is, and resolves it to a different instant than the one the
123
+ // user picked.
124
+ expect(written).toMatch(/(Z|[+-]\d{2}:\d{2})$/);
125
+ expect(new Date(written).getTime()).toBe(new Date('2026-06-18T09:15').getTime());
126
+ });
127
+
128
+ it('DateTimeField returning to the shown minute restores the exact stored value', () => {
129
+ // Guards the drift this fix could otherwise introduce: if the two
130
+ // conversions disagreed by the viewer's UTC offset, navigating away
131
+ // from a value and back would land somewhere else, and every open →
132
+ // fiddle → undo would quietly move the record.
133
+ // Held in real state, because a `vi.fn()` onChange would leave the
134
+ // control pinned to its initial prop and React would swallow the
135
+ // second edit as a no-op — the test would pass without exercising
136
+ // anything.
137
+ const seen: string[] = [];
138
+ const Host = () => {
139
+ const [v, setV] = React.useState<string>(storedIso);
140
+ seen.push(v);
141
+ return <DateTimeField {...baseProps} value={v} onChange={setV as any} />;
142
+ };
143
+ const { container } = render(<Host />);
144
+ const input = container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
145
+ const shown = input.value;
146
+
147
+ fireEvent.change(input, { target: { value: '2026-06-18T09:15' } });
148
+ fireEvent.change(input, { target: { value: shown } });
149
+
150
+ expect(seen[seen.length - 1]).toBe(storedIso);
151
+ });
152
+
153
+ it('DateField shows the stored calendar day, ISO-shaped or not', () => {
154
+ // Both spellings the API may hand back for a `date` field. The day
155
+ // must survive verbatim — re-parsing the bare form as UTC midnight
156
+ // would move it to the 16th west of Greenwich.
157
+ for (const stored of ['2026-06-17', '2026-06-17T00:00:00.000Z']) {
158
+ const { container, unmount } = render(<DateField {...baseProps} value={stored} />);
159
+ const input = container.querySelector('input[type="date"]') as HTMLInputElement;
160
+ expect(input.value).toBe('2026-06-17');
161
+ unmount();
162
+ }
163
+ });
164
+ });
165
+
74
166
  describe('TimeField', () => {
75
167
  it('renders time input in edit mode', () => {
76
168
  const { container } = render(<TimeField {...baseProps} />);