@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.
- package/CHANGELOG.md +1224 -0
- package/README.md +2 -12
- package/dist/actions/ActionEngine.d.ts +42 -25
- package/dist/actions/ActionEngine.js +66 -52
- package/dist/actions/ActionRunner.d.ts +199 -21
- package/dist/actions/ActionRunner.js +332 -16
- package/dist/actions/actionKeys.d.ts +96 -20
- package/dist/actions/actionKeys.js +157 -18
- package/dist/data-scope/ViewDataProvider.d.ts +37 -12
- package/dist/data-scope/ViewDataProvider.js +83 -5
- package/dist/data-scope/element-data-source.d.ts +113 -0
- package/dist/data-scope/element-data-source.js +216 -0
- package/dist/data-scope/index.d.ts +2 -1
- package/dist/data-scope/index.js +1 -0
- package/dist/evaluator/declaredPredicate.d.ts +119 -0
- package/dist/evaluator/declaredPredicate.js +152 -0
- package/dist/evaluator/fieldRules.d.ts +16 -0
- package/dist/evaluator/fieldRules.js +51 -16
- package/dist/evaluator/index.d.ts +1 -0
- package/dist/evaluator/index.js +1 -0
- package/dist/evaluator/listConditional.d.ts +5 -2
- package/dist/evaluator/listConditional.js +58 -16
- package/dist/evaluator/predicateInput.d.ts +51 -3
- package/dist/evaluator/predicateInput.js +62 -5
- package/dist/index.d.ts +4 -1
- package/dist/index.js +14 -1
- package/dist/protocols/DndProtocol.d.ts +42 -1
- package/dist/protocols/KeyboardProtocol.d.ts +31 -1
- package/dist/protocols/SharingProtocol.d.ts +11 -1
- package/dist/registry/Registry.d.ts +24 -0
- package/dist/theme/ThemeEngine.d.ts +0 -8
- package/dist/theme/ThemeEngine.js +22 -103
- package/dist/theme/index.d.ts +1 -1
- package/dist/theme/index.js +7 -1
- package/dist/types/index.d.ts +42 -8
- package/dist/utils/chart-series.d.ts +258 -5
- package/dist/utils/chart-series.js +423 -20
- package/dist/utils/compare-to.d.ts +40 -13
- package/dist/utils/compare-to.js +4 -32
- package/dist/utils/dashboard-filters.d.ts +54 -8
- package/dist/utils/dashboard-filters.js +261 -12
- package/dist/utils/dataset-format.d.ts +73 -14
- package/dist/utils/dataset-format.js +130 -9
- package/dist/utils/dataset-pivot.d.ts +28 -7
- package/dist/utils/dataset-pivot.js +27 -6
- package/dist/utils/dom-props.d.ts +137 -0
- package/dist/utils/dom-props.js +153 -0
- package/dist/utils/normalize-list-view.d.ts +26 -6
- package/dist/utils/normalize-list-view.js +30 -8
- package/dist/utils/number-display.d.ts +151 -0
- package/dist/utils/number-display.js +71 -0
- package/dist/utils/resolve-view-id.d.ts +31 -0
- package/dist/utils/resolve-view-id.js +45 -0
- package/dist/utils/sort-query.d.ts +55 -0
- package/dist/utils/sort-query.js +35 -0
- package/package.json +5 -5
- package/dist/utils/merge-views-into-objects.d.ts +0 -19
- 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
|
-
|
|
40
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
|
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
|
-
*
|
|
41
|
-
* null/undefined dimension value as
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
41
|
-
* null/undefined dimension value as
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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
|