@object-ui/core 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 (58) hide show
  1. package/CHANGELOG.md +1224 -0
  2. package/README.md +2 -12
  3. package/dist/actions/ActionEngine.d.ts +42 -25
  4. package/dist/actions/ActionEngine.js +66 -52
  5. package/dist/actions/ActionRunner.d.ts +199 -21
  6. package/dist/actions/ActionRunner.js +332 -16
  7. package/dist/actions/actionKeys.d.ts +96 -20
  8. package/dist/actions/actionKeys.js +157 -18
  9. package/dist/data-scope/ViewDataProvider.d.ts +37 -12
  10. package/dist/data-scope/ViewDataProvider.js +83 -5
  11. package/dist/data-scope/element-data-source.d.ts +113 -0
  12. package/dist/data-scope/element-data-source.js +216 -0
  13. package/dist/data-scope/index.d.ts +2 -1
  14. package/dist/data-scope/index.js +1 -0
  15. package/dist/evaluator/declaredPredicate.d.ts +119 -0
  16. package/dist/evaluator/declaredPredicate.js +152 -0
  17. package/dist/evaluator/fieldRules.d.ts +16 -0
  18. package/dist/evaluator/fieldRules.js +51 -16
  19. package/dist/evaluator/index.d.ts +1 -0
  20. package/dist/evaluator/index.js +1 -0
  21. package/dist/evaluator/listConditional.d.ts +5 -2
  22. package/dist/evaluator/listConditional.js +58 -16
  23. package/dist/evaluator/predicateInput.d.ts +51 -3
  24. package/dist/evaluator/predicateInput.js +62 -5
  25. package/dist/index.d.ts +4 -1
  26. package/dist/index.js +14 -1
  27. package/dist/protocols/DndProtocol.d.ts +42 -1
  28. package/dist/protocols/KeyboardProtocol.d.ts +31 -1
  29. package/dist/protocols/SharingProtocol.d.ts +11 -1
  30. package/dist/registry/Registry.d.ts +24 -0
  31. package/dist/theme/ThemeEngine.d.ts +0 -8
  32. package/dist/theme/ThemeEngine.js +22 -103
  33. package/dist/theme/index.d.ts +1 -1
  34. package/dist/theme/index.js +7 -1
  35. package/dist/types/index.d.ts +42 -8
  36. package/dist/utils/chart-series.d.ts +258 -5
  37. package/dist/utils/chart-series.js +423 -20
  38. package/dist/utils/compare-to.d.ts +40 -13
  39. package/dist/utils/compare-to.js +4 -32
  40. package/dist/utils/dashboard-filters.d.ts +54 -8
  41. package/dist/utils/dashboard-filters.js +261 -12
  42. package/dist/utils/dataset-format.d.ts +73 -14
  43. package/dist/utils/dataset-format.js +130 -9
  44. package/dist/utils/dataset-pivot.d.ts +28 -7
  45. package/dist/utils/dataset-pivot.js +27 -6
  46. package/dist/utils/dom-props.d.ts +137 -0
  47. package/dist/utils/dom-props.js +153 -0
  48. package/dist/utils/normalize-list-view.d.ts +26 -6
  49. package/dist/utils/normalize-list-view.js +30 -8
  50. package/dist/utils/number-display.d.ts +151 -0
  51. package/dist/utils/number-display.js +71 -0
  52. package/dist/utils/resolve-view-id.d.ts +31 -0
  53. package/dist/utils/resolve-view-id.js +45 -0
  54. package/dist/utils/sort-query.d.ts +55 -0
  55. package/dist/utils/sort-query.js +35 -0
  56. package/package.json +5 -5
  57. package/dist/utils/merge-views-into-objects.d.ts +0 -19
  58. package/dist/utils/merge-views-into-objects.js +0 -39
@@ -5,6 +5,7 @@
5
5
  * This source code is licensed under the MIT license found in the
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
+ import { formatDisplayNumber } from './number-display.js';
8
9
  /**
9
10
  * Scale a stored `percent`-field value to its DISPLAY magnitude.
10
11
  *
@@ -16,10 +17,54 @@
16
17
  * percent cell renderer (`formatPercent` in `@object-ui/fields`) and the dataset
17
18
  * measure formatter ({@link formatMeasure}) so a percent renders identically as
18
19
  * a row value and as an aggregated metric — the two surfaces can never drift.
20
+ *
21
+ * ⚠️ That last sentence was briefly FALSE, and objectui#4576 is what made it
22
+ * true again. Sharing the SCALING was never enough on its own: between #4553
23
+ * and #4576 the cell renderer got the locale's percent CONVENTION from `Intl`
24
+ * while {@link formatMeasure} appended a literal '%', so a German session read
25
+ * `1.234,5 %` in a list cell and `1.234,5%` in a dashboard measure — the same
26
+ * number, scaled identically, rendered under two conventions. Both ends now go
27
+ * through the locale's own percent affix. If a third surface ever needs percent
28
+ * display, it takes BOTH halves from here — the scaling AND the convention —
29
+ * or this promise breaks again in the same place.
19
30
  */
20
31
  export function percentDisplayValue(value) {
21
32
  return value > -1 && value < 1 ? value * 100 : value;
22
33
  }
34
+ /**
35
+ * Format one number in the DISPLAY locale, surviving a malformed locale tag.
36
+ *
37
+ * THE DUPLICATE IS GONE (objectui#4576). This used to be a hand-mirrored copy
38
+ * of the `try`/`catch` retry inside `formatDisplayNumber` — the ONE
39
+ * number-display formatter — kept in step by a comment at each end because a
40
+ * PACKAGE BOUNDARY stood between them: `formatDisplayNumber` lived in
41
+ * `@object-ui/i18n`, which depends on `i18next`/`react-i18next` and
42
+ * peer-depends on React, and this package is the React-free engine. #4566
43
+ * measured the option mapping LOSSLESS across 32,760 combinations and recorded
44
+ * the duplication rather than crossing that boundary; #4576 removed the
45
+ * boundary instead, by moving the pure function DOWN into this package
46
+ * (`./number-display.ts`), where `@object-ui/i18n` now re-exports it.
47
+ *
48
+ * So this is a thin ADAPTER, not a second implementation: it converts the
49
+ * `Intl`-shaped option bag these call sites already speak into
50
+ * {@link DisplayNumberFormatOptions}, and every policy decision — grouping,
51
+ * the malformed-locale retry, the percent convention — is made in the one
52
+ * home. The retry is load-bearing rather than defensive: before #4566 these
53
+ * sites passed a literal `undefined`, which never throws, but a THREADED tag
54
+ * can be malformed and a bare `Intl.NumberFormat('en_US', …)` throws
55
+ * `RangeError` (measured — underscore instead of hyphen is the likeliest
56
+ * tenant-config typo), which would take the whole widget down. A bad
57
+ * `currency` still throws out of both attempts, which is what lets
58
+ * {@link formatMeasure}'s own `catch` fall through to plain-number formatting.
59
+ *
60
+ * ⚠️ `scale` is deliberately never passed. A measure carries no field `scale`
61
+ * (its decimals come from a numeral format PATTERN), so the grouping policy
62
+ * keyed on it must not fire here — which is exactly why #4566 measured the two
63
+ * implementations byte-identical across all 32,760 combinations.
64
+ */
65
+ function formatNumberInLocale(value, locale, options) {
66
+ return formatDisplayNumber(value, { ...options, locale });
67
+ }
23
68
  /**
24
69
  * Format a MEASURE value. Currency comes from the field's declared `currency`
25
70
  * (locale-correct symbol via `Intl`), NOT from a "$" baked into the format
@@ -27,8 +72,40 @@ export function percentDisplayValue(value) {
27
72
  * never a misleading "$". The numeral `format` hint (e.g. "0,0", "0.0%")
28
73
  * controls grouping / decimals / percent; it can't be baked into the row value
29
74
  * server-side (the same number feeds charts), so it is applied here.
75
+ *
76
+ * `locale` is the BCP-47 tag of the active display locale — in React, whatever
77
+ * `useDisplayLocale()` returns. It is optional and LAST so every existing call
78
+ * keeps compiling, and omitting it reproduces the previous output byte for
79
+ * byte (the old code passed a literal `undefined` to `Intl`, which is exactly
80
+ * what an omitted argument passes now).
81
+ *
82
+ * Before objectui#4566 there was no way to pass one: all three formatting sites
83
+ * here hard-coded `undefined`, which is neither of the repo's two locale
84
+ * channels — it is the MACHINE's locale. A German session read a KPI as
85
+ * `1,234.5` beside a grid cell rendering the same number as `1.234,5`, and
86
+ * inverted separators read as a different number, not an unstyled one.
87
+ *
88
+ * ── This routes through `formatDisplayNumber` (objectui#4576) ──
89
+ * It did not always. #4566 measured the option mapping across 32,760
90
+ * value × format × currency × percentScale × locale combinations and found it
91
+ * LOSSLESS — routing changes no byte of output, because the policy layer
92
+ * `formatDisplayNumber` adds over plain `Intl` is grouping suppression keyed on
93
+ * a field's declared `scale`, and a measure has no `scale` to feed it (decimals
94
+ * here come from a numeral format PATTERN, so grouping stays on and the two
95
+ * agree everywhere). What blocked it was the PACKAGE BOUNDARY: the formatter
96
+ * lived in `@object-ui/i18n`, which depends on `i18next`/`react-i18next` and
97
+ * peer-depends on React, while this package is the React-free engine (AGENTS.md
98
+ * §3: "No UI-lib deps. Logic only.") and a runtime dependency of React-FREE
99
+ * consumers — the `object-ui` VS Code extension and `@object-ui/data-objectstack`.
100
+ *
101
+ * #4576 removed the boundary rather than working around it: `formatDisplayNumber`
102
+ * is pure, so it moved DOWN into this package (`./number-display.ts`) and
103
+ * `@object-ui/i18n` re-exports it. The duplicate implementation this file used
104
+ * to carry is gone, and with it the drift that duplicate produced — see the
105
+ * percent note in the body, and {@link percentDisplayValue}'s promise, which
106
+ * is true again.
30
107
  */
31
- export function formatMeasure(v, format, currency, percentScale) {
108
+ export function formatMeasure(v, format, currency, percentScale, locale) {
32
109
  if (v == null)
33
110
  return '—';
34
111
  if (typeof v !== 'number')
@@ -36,20 +113,33 @@ export function formatMeasure(v, format, currency, percentScale) {
36
113
  const decimals = format ? (format.split('.')[1]?.match(/0/g)?.length ?? 0) : undefined;
37
114
  if (currency) {
38
115
  try {
39
- return new Intl.NumberFormat(undefined, {
40
- style: 'currency',
116
+ // No `style: 'currency'` here: `formatDisplayNumber` derives that from the
117
+ // presence of `currency` itself, which is also what keeps its "money
118
+ // always groups" policy in one place instead of two.
119
+ return formatNumberInLocale(v, locale, {
41
120
  currency,
42
121
  minimumFractionDigits: decimals ?? 0,
43
122
  maximumFractionDigits: decimals ?? 2,
44
- }).format(v);
123
+ });
45
124
  }
46
125
  catch {
47
126
  // Unknown currency code → fall through to plain number formatting.
127
+ // Still reachable with a locale threaded: `formatNumberInLocale` retries
128
+ // without the LOCALE, and a bad currency throws out of that retry too.
48
129
  }
49
130
  }
50
131
  if (!format) {
51
132
  // No format hint → preserve the plain rendering (integers verbatim).
52
- return Number.isInteger(v) ? String(v) : v.toLocaleString(undefined, { maximumFractionDigits: 2 });
133
+ //
134
+ // The integer branch stays a bare `String(v)` and is deliberately NOT
135
+ // localized: it is the one form here that renders no separator and no
136
+ // decimal mark, so there is nothing for a locale to change — and routing it
137
+ // through `Intl` WOULD change it, in two ways this card is not about
138
+ // (measured): a locale with its own numbering system would re-digit it
139
+ // (`ar-EG` 1234 → an Arabic-Indic spelling), and `1e21` would expand from
140
+ // `1e+21` to its 22 digits. Only the fractional branch below ever produced
141
+ // locale-dependent text, and that is the site #4566 fixes.
142
+ return Number.isInteger(v) ? String(v) : formatNumberInLocale(v, locale, { maximumFractionDigits: 2 });
53
143
  }
54
144
  const isPercent = format.includes('%');
55
145
  // A legacy "$" literal in the format string is still honored (explicit author
@@ -68,18 +158,49 @@ export function formatMeasure(v, format, currency, percentScale) {
68
158
  const display = isPercent
69
159
  ? (percentScale ? (percentScale === 'fraction' ? v * 100 : v) : percentDisplayValue(v))
70
160
  : v;
71
- const body = display.toLocaleString(undefined, { minimumFractionDigits: decimals ?? 0, maximumFractionDigits: decimals ?? 0 });
72
- return `${legacyDollar}${body}${isPercent ? '%' : ''}`;
161
+ // The percent sign is the LOCALE's, not a literal '%' (objectui#4576).
162
+ //
163
+ // Until #4576 this appended a hard-coded '%' to a decimal-formatted body, so
164
+ // a German session read `1.234,5%` from a dashboard measure beside
165
+ // `1.234,5 %` (no-break space — the German percent convention) from a list
166
+ // cell showing the SAME number, because `formatPercent` had gone through
167
+ // `Intl` since #4553. That contradicted {@link percentDisplayValue}'s own
168
+ // promise that the two surfaces "can never drift": the SCALING had stopped
169
+ // drifting, the CONVENTION had started. Measured to differ in de, fr, es, ru,
170
+ // sv, cs, fi (no-break space), tr (the sign moves to the FRONT: `%1.234,5`)
171
+ // and ar (its own sign plus U+061C); en, ja, zh were already identical.
172
+ //
173
+ // `style: 'percentPoints'` is what closes it, and the choice of route is
174
+ // measured, not incidental. `display` is already in percentage POINTS, while
175
+ // `Intl`'s `style: 'percent'` wants a fraction — so routing through that (the
176
+ // way `formatPercent` does) would mean dividing by 100 for `Intl` to multiply
177
+ // straight back, and that round trip is lossy at rounding TIES: 27,581 of
178
+ // 1,200,013 ordinary-magnitude en-US forms move (`0.175` at 2 decimals goes
179
+ // from `0.18%` to `0.17%`), plus `MAX_SAFE_INTEGER` and everything from 1e23
180
+ // up. Formatting the points directly with the percent UNIT was measured to
181
+ // produce a byte-identical percent affix to `style: 'percent'` across all 171
182
+ // locale tags tested, while moving ZERO of those 1,200,013 en forms — the
183
+ // same convention by a route that does not touch the value.
184
+ const body = formatNumberInLocale(display, locale, {
185
+ style: isPercent ? 'percentPoints' : 'decimal',
186
+ minimumFractionDigits: decimals ?? 0,
187
+ maximumFractionDigits: decimals ?? 0,
188
+ });
189
+ return `${legacyDollar}${body}`;
73
190
  }
74
191
  /**
75
192
  * Format a non-measure (dimension / label) value — the server already resolves
76
193
  * dimension display labels, so this only tidies numbers and nulls.
194
+ *
195
+ * `locale` follows {@link formatMeasure}: optional, last, and omitting it
196
+ * reproduces the previous machine-locale output byte for byte. Integers stay
197
+ * verbatim here for the same measured reason as there.
77
198
  */
78
- export function formatDimensionValue(v) {
199
+ export function formatDimensionValue(v, locale) {
79
200
  if (v == null)
80
201
  return '—';
81
202
  if (typeof v === 'number') {
82
- return Number.isInteger(v) ? String(v) : v.toLocaleString(undefined, { maximumFractionDigits: 2 });
203
+ return Number.isInteger(v) ? String(v) : formatNumberInLocale(v, locale, { maximumFractionDigits: 2 });
83
204
  }
84
205
  return String(v);
85
206
  }
@@ -37,19 +37,40 @@
37
37
  *
38
38
  * Pure (no React / i18n), like its `dataset-format` neighbour.
39
39
  *
40
- * Known residual, tracked separately in objectstack#5666: callers encode a
41
- * null/undefined dimension value as a placeholder string, so it still collides
42
- * with a value that literally equals that placeholder. That is a property of
43
- * the placeholder, not of the encoding below.
40
+ * The empty-value encoding closed the last instance of the same assumption
41
+ * (objectui#4056): callers used to spell a null/undefined dimension value as
42
+ * the STRING `'∅'` before encoding it, so a row whose value literally IS that
43
+ * character shared a bucket with a row whose value is absent — the placeholder
44
+ * reintroduced, one level up, exactly the "no value contains this character"
45
+ * assumption the encoder below had just removed. An empty value is now JSON
46
+ * `null`, which `JSON.stringify` renders as a bare `null` no string can spell,
47
+ * and `pivotDimensionValue` owns that normalization so no caller has to hold a
48
+ * placeholder literal of its own.
44
49
  */
45
50
  /**
46
- * Encode a pivot BUCKET id from its dimension values.
51
+ * Normalize ONE raw dimension value for `pivotBucketId`: an absent value (null
52
+ * or undefined) becomes JSON `null`, everything else its string form.
53
+ *
54
+ * This lives here rather than at each call site because a placeholder spelled
55
+ * by the caller is a placeholder that can collide: `String(v ?? '∅')` put an
56
+ * ordinary string into the tuple, so `null` and the character `'∅'` encoded
57
+ * identically (objectui#4056). `null` is not a string, so nothing a dimension
58
+ * can hold encodes to it — the same reason the tuple is JSON rather than a
59
+ * delimiter join.
60
+ */
61
+ export declare const pivotDimensionValue: (value: unknown) => string | null;
62
+ /**
63
+ * Encode a pivot BUCKET id from its dimension values, each already normalized
64
+ * by `pivotDimensionValue` (empty ⇒ `null`).
47
65
  *
48
66
  * Axis-neutral on purpose: a DOWN bucket and an ACROSS bucket are the same kind
49
67
  * of thing (a dimension-value tuple), and a cross-tab with multiple across
50
- * dimensions collides on that axis just as readily as on the down axis.
68
+ * dimensions collides on that axis just as readily as on the down axis. That
69
+ * holds for a SINGLE-value across bucket too — a bare value is a one-element
70
+ * tuple, and spelling it as the raw string instead is what left the dashboard's
71
+ * column ids on a second, colliding encoding after the row ids were fixed.
51
72
  */
52
- export declare const pivotBucketId: (dimensionValues: string[]) => string;
73
+ export declare const pivotBucketId: (dimensionValues: Array<string | null>) => string;
53
74
  /**
54
75
  * Encode the cell key for a (down bucket, across bucket) pair — the key of the
55
76
  * map a renderer reads to place a measure and to resolve a drill-through.
@@ -37,17 +37,38 @@
37
37
  *
38
38
  * Pure (no React / i18n), like its `dataset-format` neighbour.
39
39
  *
40
- * Known residual, tracked separately in objectstack#5666: callers encode a
41
- * null/undefined dimension value as a placeholder string, so it still collides
42
- * with a value that literally equals that placeholder. That is a property of
43
- * the placeholder, not of the encoding below.
40
+ * The empty-value encoding closed the last instance of the same assumption
41
+ * (objectui#4056): callers used to spell a null/undefined dimension value as
42
+ * the STRING `'∅'` before encoding it, so a row whose value literally IS that
43
+ * character shared a bucket with a row whose value is absent — the placeholder
44
+ * reintroduced, one level up, exactly the "no value contains this character"
45
+ * assumption the encoder below had just removed. An empty value is now JSON
46
+ * `null`, which `JSON.stringify` renders as a bare `null` no string can spell,
47
+ * and `pivotDimensionValue` owns that normalization so no caller has to hold a
48
+ * placeholder literal of its own.
44
49
  */
45
50
  /**
46
- * Encode a pivot BUCKET id from its dimension values.
51
+ * Normalize ONE raw dimension value for `pivotBucketId`: an absent value (null
52
+ * or undefined) becomes JSON `null`, everything else its string form.
53
+ *
54
+ * This lives here rather than at each call site because a placeholder spelled
55
+ * by the caller is a placeholder that can collide: `String(v ?? '∅')` put an
56
+ * ordinary string into the tuple, so `null` and the character `'∅'` encoded
57
+ * identically (objectui#4056). `null` is not a string, so nothing a dimension
58
+ * can hold encodes to it — the same reason the tuple is JSON rather than a
59
+ * delimiter join.
60
+ */
61
+ export const pivotDimensionValue = (value) => value == null ? null : String(value);
62
+ /**
63
+ * Encode a pivot BUCKET id from its dimension values, each already normalized
64
+ * by `pivotDimensionValue` (empty ⇒ `null`).
47
65
  *
48
66
  * Axis-neutral on purpose: a DOWN bucket and an ACROSS bucket are the same kind
49
67
  * of thing (a dimension-value tuple), and a cross-tab with multiple across
50
- * dimensions collides on that axis just as readily as on the down axis.
68
+ * dimensions collides on that axis just as readily as on the down axis. That
69
+ * holds for a SINGLE-value across bucket too — a bare value is a one-element
70
+ * tuple, and spelling it as the raw string instead is what left the dashboard's
71
+ * column ids on a second, colliding encoding after the row ids were fixed.
51
72
  */
52
73
  export const pivotBucketId = (dimensionValues) => JSON.stringify(dimensionValues);
53
74
  /**
@@ -0,0 +1,137 @@
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
+ * The RUNTIME EXECUTOR of the SDUI widget prop contract's DOM pass-through
10
+ * (objectui#4425 phase 2, promoting objectui#3291's whitelist from
11
+ * `packages/fields` to every registered SDUI widget).
12
+ *
13
+ * ## The defect this closes
14
+ *
15
+ * `SchemaRenderer` hands a registered component the authored node's own keys,
16
+ * the contents of its `props` container, the ARIA it resolved, and the host's
17
+ * trailing props — then a widget ending in a bare `{...props}` spread puts all
18
+ * of it on a DOM element. React passes unknown lowercase attributes through in
19
+ * complete silence and stringifies object values, so the failure is invisible:
20
+ *
21
+ * ```
22
+ * <div id="canary-node" name="canary_node" bind="data.revenue"
23
+ * events="[object Object]" arialabel="Canary label"
24
+ * datasource="[object Object]" props="[object Object]" …>
25
+ * ```
26
+ *
27
+ * That is one real measurement, from `plugin-chatbot:chatbot` on the sweep gate
28
+ * (`packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx`).
29
+ *
30
+ * ## Why a whitelist, and not a list of keys to drop
31
+ *
32
+ * Measured, not assumed. objectui#4357/PR #4428 answered the same defect in
33
+ * `plugin-dashboard` with a deny-list: it closed every one of the seven keys it
34
+ * enumerated, and the sweep still found the OPEN TAIL leaking on the same two
35
+ * components — `zzcanary`, `reference_to`, an authored `props: { colorVariant }`
36
+ * — because a deny-list can only name what already exists. The set of keys an
37
+ * author may put on a node is unbounded; the set a widget may put on an element
38
+ * is declared. A deny-list bounded by enumeration cannot be finished; a
39
+ * whitelist bounded by declaration can.
40
+ *
41
+ * ## The set, and why each key is in it
42
+ *
43
+ * Every key here is either injected by `SchemaRenderer` for the whole SDUI
44
+ * contract, or a GLOBAL HTML attribute legal on whatever element a widget
45
+ * renders — this helper cannot know that element, so nothing element-specific
46
+ * belongs here:
47
+ *
48
+ * - `id` — global; `SchemaRenderer` carries the node's id.
49
+ * - `className` — global (`class`); the styling channel AGENTS.md #3 requires
50
+ * every widget to expose, and the carrier of ADR-0065's scope class.
51
+ * - `role` — the third member of `@objectstack/spec`'s `AriaPropsSchema`,
52
+ * resolved and injected by `SchemaRenderer.resolveAriaProps` alongside
53
+ * `aria-label` / `aria-describedby`. Withholding it would make an authored
54
+ * `role` type-check, read as supported, and silently never arrive — the
55
+ * DECLARED-BUT-NOT-DELIVERED failure this repo treats as first class.
56
+ * - `tabIndex`, `autoFocus` — global HTML attributes.
57
+ * - `onClick`, `onBlur`, `onFocus` — React synthetic handlers, which never
58
+ * become attributes at all.
59
+ *
60
+ * Plus the two OPEN families, matched by prefix so no per-attribute list is
61
+ * needed: `aria-*` (the resolved ARIA above — note the camelCase `ariaLabel` /
62
+ * `ariaDescribedBy` the author wrote do NOT match, which is the point: they are
63
+ * meaningless to assistive technology and the resolver already emitted the
64
+ * hyphenated forms) and `data-*` (open in HTML too, and how `data-obj-id` /
65
+ * `data-obj-type` / the debug attributes reach the element).
66
+ *
67
+ * ## Deliberately NOT here
68
+ *
69
+ * `name` and `disabled` are legal only on FORM CONTROLS, not on the container a
70
+ * typical SDUI widget renders — `name` on a `<div>` is exactly one of the 14
71
+ * leaked attributes this closes. `@object-ui/fields` declares both, because
72
+ * every widget there renders a control and its contract
73
+ * (`FieldWidgetComponentProps`) says so; that package keeps its own declared key
74
+ * list and calls {@link pickDomProps} with it. One MECHANISM, two DECLARATIONS,
75
+ * each bound to the contract it executes — not two judges.
76
+ *
77
+ * A semantic prop is CONSUMED, never forwarded: a widget that can be disabled
78
+ * reads `disabled` and applies it (see `plugin-chatbot`'s registrations), which
79
+ * is what keeps `disabledOn` working without putting `disabled="true"` on a
80
+ * `<div>`.
81
+ *
82
+ * Deliberate DOM pass-through beyond this set stays available the objectui#4435
83
+ * way — DECLARE it (`interface Props extends HTMLAttributes<HTMLDivElement>`)
84
+ * and forward it by name. Do not reopen the spread (AGENTS.md #0.1: fix the
85
+ * contract, never widen the consumer).
86
+ */
87
+ export declare const SDUI_DOM_PASS_THROUGH_KEYS: readonly ["id", "className", "role", "tabIndex", "autoFocus", "onClick", "onBlur", "onFocus"];
88
+ /** A key {@link toDomProps} forwards by name (the open families are prefixes). */
89
+ export type SduiDomPassThroughKey = (typeof SDUI_DOM_PASS_THROUGH_KEYS)[number];
90
+ /**
91
+ * `aria-*` and `data-*` are open families — open in HTML, and open in the
92
+ * widget contract — so they are matched by prefix rather than enumerated.
93
+ *
94
+ * Note what this does NOT match: `dataSource`. The injected data-source ADAPTER
95
+ * is not a `data-` attribute, and a prefix test that accepted it would put
96
+ * `datasource="[object Object]"` on the element of every data-bound widget —
97
+ * the one leak a schema-only measurement cannot see, because it only appears
98
+ * when a host really supplies an adapter (objectui#4428).
99
+ */
100
+ export declare function isOpenDomAttributeFamily(key: string): boolean;
101
+ /** The subset of `P` a whitelist of `K` forwards, with each key's declared type. */
102
+ export type DomProps<P, K extends string = SduiDomPassThroughKey> = Pick<P, Extract<keyof P, K>> & {
103
+ [Key in Extract<keyof P, `data-${string}` | `aria-${string}`>]: P[Key];
104
+ };
105
+ /**
106
+ * The MECHANISM: keep the keys a caller's contract declares as DOM-safe plus
107
+ * the two open families, drop everything else.
108
+ *
109
+ * Exported for the one caller whose contract declares a different set —
110
+ * `@object-ui/fields`, whose widgets render form controls and whose
111
+ * `FieldWidgetComponentProps` therefore declares `name` and `disabled` as DOM
112
+ * pass-through (objectui#3291). Everything else should call {@link toDomProps},
113
+ * which is this function with the SDUI contract's own set already applied.
114
+ */
115
+ export declare function pickDomProps<P extends object, K extends string>(props: P, keys: readonly K[]): DomProps<P, K>;
116
+ /**
117
+ * Keep only what may legitimately become a DOM attribute on a registered SDUI
118
+ * widget's host element, and drop everything else — the injected `schema`, the
119
+ * data-source adapter, the authored node's metadata, the `props` container, and
120
+ * any extra key an author put on the node.
121
+ *
122
+ * Replaces the bare `{...props}` spread in a widget registration:
123
+ *
124
+ * ```tsx
125
+ * // before — forwards whatever `SchemaRenderer` handed it
126
+ * ({ schema, className, ...props }) => <Chatbot className={className} {...props} />
127
+ *
128
+ * // after
129
+ * ({ schema, className, ...props }) => (
130
+ * <Chatbot {...toDomProps(props)} className={className} />
131
+ * )
132
+ * ```
133
+ *
134
+ * Semantic props a widget INTERPRETS (`disabled`, and everything it reads off
135
+ * `schema`) are read as before; this function only governs what gets spread.
136
+ */
137
+ export declare function toDomProps<P extends object>(props: P): DomProps<P>;
@@ -0,0 +1,153 @@
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
+ * The RUNTIME EXECUTOR of the SDUI widget prop contract's DOM pass-through
10
+ * (objectui#4425 phase 2, promoting objectui#3291's whitelist from
11
+ * `packages/fields` to every registered SDUI widget).
12
+ *
13
+ * ## The defect this closes
14
+ *
15
+ * `SchemaRenderer` hands a registered component the authored node's own keys,
16
+ * the contents of its `props` container, the ARIA it resolved, and the host's
17
+ * trailing props — then a widget ending in a bare `{...props}` spread puts all
18
+ * of it on a DOM element. React passes unknown lowercase attributes through in
19
+ * complete silence and stringifies object values, so the failure is invisible:
20
+ *
21
+ * ```
22
+ * <div id="canary-node" name="canary_node" bind="data.revenue"
23
+ * events="[object Object]" arialabel="Canary label"
24
+ * datasource="[object Object]" props="[object Object]" …>
25
+ * ```
26
+ *
27
+ * That is one real measurement, from `plugin-chatbot:chatbot` on the sweep gate
28
+ * (`packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx`).
29
+ *
30
+ * ## Why a whitelist, and not a list of keys to drop
31
+ *
32
+ * Measured, not assumed. objectui#4357/PR #4428 answered the same defect in
33
+ * `plugin-dashboard` with a deny-list: it closed every one of the seven keys it
34
+ * enumerated, and the sweep still found the OPEN TAIL leaking on the same two
35
+ * components — `zzcanary`, `reference_to`, an authored `props: { colorVariant }`
36
+ * — because a deny-list can only name what already exists. The set of keys an
37
+ * author may put on a node is unbounded; the set a widget may put on an element
38
+ * is declared. A deny-list bounded by enumeration cannot be finished; a
39
+ * whitelist bounded by declaration can.
40
+ *
41
+ * ## The set, and why each key is in it
42
+ *
43
+ * Every key here is either injected by `SchemaRenderer` for the whole SDUI
44
+ * contract, or a GLOBAL HTML attribute legal on whatever element a widget
45
+ * renders — this helper cannot know that element, so nothing element-specific
46
+ * belongs here:
47
+ *
48
+ * - `id` — global; `SchemaRenderer` carries the node's id.
49
+ * - `className` — global (`class`); the styling channel AGENTS.md #3 requires
50
+ * every widget to expose, and the carrier of ADR-0065's scope class.
51
+ * - `role` — the third member of `@objectstack/spec`'s `AriaPropsSchema`,
52
+ * resolved and injected by `SchemaRenderer.resolveAriaProps` alongside
53
+ * `aria-label` / `aria-describedby`. Withholding it would make an authored
54
+ * `role` type-check, read as supported, and silently never arrive — the
55
+ * DECLARED-BUT-NOT-DELIVERED failure this repo treats as first class.
56
+ * - `tabIndex`, `autoFocus` — global HTML attributes.
57
+ * - `onClick`, `onBlur`, `onFocus` — React synthetic handlers, which never
58
+ * become attributes at all.
59
+ *
60
+ * Plus the two OPEN families, matched by prefix so no per-attribute list is
61
+ * needed: `aria-*` (the resolved ARIA above — note the camelCase `ariaLabel` /
62
+ * `ariaDescribedBy` the author wrote do NOT match, which is the point: they are
63
+ * meaningless to assistive technology and the resolver already emitted the
64
+ * hyphenated forms) and `data-*` (open in HTML too, and how `data-obj-id` /
65
+ * `data-obj-type` / the debug attributes reach the element).
66
+ *
67
+ * ## Deliberately NOT here
68
+ *
69
+ * `name` and `disabled` are legal only on FORM CONTROLS, not on the container a
70
+ * typical SDUI widget renders — `name` on a `<div>` is exactly one of the 14
71
+ * leaked attributes this closes. `@object-ui/fields` declares both, because
72
+ * every widget there renders a control and its contract
73
+ * (`FieldWidgetComponentProps`) says so; that package keeps its own declared key
74
+ * list and calls {@link pickDomProps} with it. One MECHANISM, two DECLARATIONS,
75
+ * each bound to the contract it executes — not two judges.
76
+ *
77
+ * A semantic prop is CONSUMED, never forwarded: a widget that can be disabled
78
+ * reads `disabled` and applies it (see `plugin-chatbot`'s registrations), which
79
+ * is what keeps `disabledOn` working without putting `disabled="true"` on a
80
+ * `<div>`.
81
+ *
82
+ * Deliberate DOM pass-through beyond this set stays available the objectui#4435
83
+ * way — DECLARE it (`interface Props extends HTMLAttributes<HTMLDivElement>`)
84
+ * and forward it by name. Do not reopen the spread (AGENTS.md #0.1: fix the
85
+ * contract, never widen the consumer).
86
+ */
87
+ export const SDUI_DOM_PASS_THROUGH_KEYS = [
88
+ 'id',
89
+ 'className',
90
+ 'role',
91
+ 'tabIndex',
92
+ 'autoFocus',
93
+ 'onClick',
94
+ 'onBlur',
95
+ 'onFocus',
96
+ ];
97
+ /**
98
+ * `aria-*` and `data-*` are open families — open in HTML, and open in the
99
+ * widget contract — so they are matched by prefix rather than enumerated.
100
+ *
101
+ * Note what this does NOT match: `dataSource`. The injected data-source ADAPTER
102
+ * is not a `data-` attribute, and a prefix test that accepted it would put
103
+ * `datasource="[object Object]"` on the element of every data-bound widget —
104
+ * the one leak a schema-only measurement cannot see, because it only appears
105
+ * when a host really supplies an adapter (objectui#4428).
106
+ */
107
+ export function isOpenDomAttributeFamily(key) {
108
+ return key.startsWith('data-') || key.startsWith('aria-');
109
+ }
110
+ /**
111
+ * The MECHANISM: keep the keys a caller's contract declares as DOM-safe plus
112
+ * the two open families, drop everything else.
113
+ *
114
+ * Exported for the one caller whose contract declares a different set —
115
+ * `@object-ui/fields`, whose widgets render form controls and whose
116
+ * `FieldWidgetComponentProps` therefore declares `name` and `disabled` as DOM
117
+ * pass-through (objectui#3291). Everything else should call {@link toDomProps},
118
+ * which is this function with the SDUI contract's own set already applied.
119
+ */
120
+ export function pickDomProps(props, keys) {
121
+ const allowed = new Set(keys);
122
+ const domProps = {};
123
+ for (const key of Object.keys(props)) {
124
+ if (allowed.has(key) || isOpenDomAttributeFamily(key)) {
125
+ domProps[key] = props[key];
126
+ }
127
+ }
128
+ return domProps;
129
+ }
130
+ /**
131
+ * Keep only what may legitimately become a DOM attribute on a registered SDUI
132
+ * widget's host element, and drop everything else — the injected `schema`, the
133
+ * data-source adapter, the authored node's metadata, the `props` container, and
134
+ * any extra key an author put on the node.
135
+ *
136
+ * Replaces the bare `{...props}` spread in a widget registration:
137
+ *
138
+ * ```tsx
139
+ * // before — forwards whatever `SchemaRenderer` handed it
140
+ * ({ schema, className, ...props }) => <Chatbot className={className} {...props} />
141
+ *
142
+ * // after
143
+ * ({ schema, className, ...props }) => (
144
+ * <Chatbot {...toDomProps(props)} className={className} />
145
+ * )
146
+ * ```
147
+ *
148
+ * Semantic props a widget INTERPRETS (`disabled`, and everything it reads off
149
+ * `schema`) are read as before; this function only governs what gets spread.
150
+ */
151
+ export function toDomProps(props) {
152
+ return pickDomProps(props, SDUI_DOM_PASS_THROUGH_KEYS);
153
+ }
@@ -35,13 +35,33 @@ export declare const DENSITY_MODE_TO_ROW_HEIGHT: Record<DensityMode, RowHeight>;
35
35
  */
36
36
  export declare const ROW_HEIGHT_TO_DENSITY_MODE: Record<RowHeight, DensityMode>;
37
37
  /**
38
- * Tolerant reader for {@link ROW_HEIGHT_TO_DENSITY_MODE}. View metadata is
39
- * user-authored, so `rowHeight` is not guaranteed to be one of the five spec
40
- * values — an unknown one lands on `comfortable` rather than rendering an
41
- * undefined density. Keeping the fallback here means every surface collapses
42
- * the same way.
38
+ * Runtime reader for {@link ROW_HEIGHT_TO_DENSITY_MODE}: the spec's five row
39
+ * heights narrowed onto the renderer's three densities, and **nothing else**.
40
+ *
41
+ * The parameter stays `unknown` because this is the boundary user-authored view
42
+ * metadata actually crosses — `ListViewSchema.rowHeight` is statically a
43
+ * `RowHeight`, but the value arrives from stored view definitions TypeScript
44
+ * never saw (`ObjectView`: `viewDef.rowHeight ?? listSchema.rowHeight`). The
45
+ * type-level half of the guarantee is the `Record<RowHeight, …>` on the table
46
+ * above, which fails the build when the spec grows a row height.
47
+ *
48
+ * An off-spec value gets NO density (objectui#4440). It used to be coerced to
49
+ * `comfortable`, which is the opposite of what `@object-ui/react`'s spec bridge
50
+ * answers for the same string after objectui#4352 — one metadata-driven system
51
+ * holding two answers for one input. AGENTS.md #0.1 decides which one survives:
52
+ * a renderer-side rehabilitation of off-spec metadata is a second de-facto
53
+ * contract, and one strict contract beats N. The producer is where a bad
54
+ * `rowHeight` gets fixed.
55
+ *
56
+ * Callers apply their own "nothing was said" default to `undefined`, so an
57
+ * off-spec row height now renders exactly like an absent one — `'compact'` in
58
+ * both `ListView` and `ObjectGrid`.
59
+ *
60
+ * `hasOwnProperty`, not `in`: `in` walks the prototype chain, so `'toString'`
61
+ * used to come back as `Object.prototype.toString` — a FUNCTION returned from
62
+ * something typed `DensityMode`.
43
63
  */
44
- export declare function rowHeightToDensityMode(rowHeight: unknown): DensityMode;
64
+ export declare function rowHeightToDensityMode(rowHeight: unknown): DensityMode | undefined;
45
65
  /**
46
66
  * ObjectUI's `list-view` node historically used a different vocabulary from
47
67
  * `@objectstack/spec` for the same concepts (`fields` where the spec says