inkbridge 0.1.0-beta.21 → 0.1.0-beta.23
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/README.md +29 -0
- package/code.js +15 -15
- package/manifest.json +1 -2
- package/package.json +40 -22
- package/scanner/border-dash-pattern-regression.ts +163 -0
- package/scanner/child-sizing-matrix-regression.ts +9 -0
- package/scanner/cli.ts +21 -5
- package/scanner/component-scanner.ts +1333 -77
- package/scanner/conditional-map-branch-regression.ts +180 -0
- package/scanner/css-token-reader.ts +66 -5
- package/scanner/dialog-content-gate-regression.ts +195 -0
- package/scanner/expression-evaluator-regression.ts +432 -0
- package/scanner/framework-adapter-shadcn-regression.ts +157 -1
- package/scanner/hidden-check-drift-regression.ts +125 -0
- package/scanner/horizontal-text-shrink-regression.ts +230 -0
- package/scanner/imported-array-map-regression.ts +195 -0
- package/scanner/inline-flex-regression.ts +5 -0
- package/scanner/intrinsic-sizing-regression.ts +333 -0
- package/scanner/portal-class-strip-regression.ts +109 -0
- package/scanner/responsive-hidden-inline-regression.ts +226 -0
- package/scanner/responsive-opt-in-regression.ts +212 -0
- package/scanner/select-root-flatten-regression.ts +314 -0
- package/scanner/space-between-single-child-regression.ts +163 -0
- package/scanner/story-args-resolution-regression.ts +311 -0
- package/scanner/story-dimensioning-regression.ts +76 -1
- package/scanner/style-map.ts +57 -0
- package/scanner/table-column-alignment-regression.ts +355 -0
- package/scanner/ternary-fragment-branch-regression.ts +196 -0
- package/scanner/text-truncate-regression.ts +481 -0
- package/scanner/types.ts +13 -0
- package/src/components/component-gen.ts +21 -38
- package/src/design-system/cva-master.ts +11 -18
- package/src/design-system/design-system.ts +36 -7
- package/src/design-system/frame-stabilizers.ts +109 -12
- package/src/design-system/preview-builder.ts +38 -0
- package/src/design-system/selectable-state.ts +8 -1
- package/src/design-system/story-builder.ts +62 -32
- package/src/design-system/story-dimensioning.ts +14 -3
- package/src/design-system/tag-predicates.ts +8 -0
- package/src/design-system/typography.ts +26 -0
- package/src/design-system/ui-builder.ts +188 -60
- package/src/effects/icon-builder.ts +8 -0
- package/src/framework-adapters/shadcn.ts +113 -0
- package/src/github/github.ts +22 -4
- package/src/layout/index.ts +4 -0
- package/src/layout/intrinsic-applier.ts +105 -0
- package/src/layout/intrinsic-sizing.ts +183 -0
- package/src/layout/layout-parser.ts +36 -0
- package/src/layout/parser/layout-mode.ts +14 -4
- package/src/layout/table-layout.ts +271 -0
- package/src/layout/text-truncate-pass.ts +151 -0
- package/src/layout/width-solver.ts +63 -17
- package/src/main.ts +37 -124
- package/src/plugin/config.ts +21 -0
- package/src/plugin/packs/pack-provider.ts +20 -4
- package/src/plugin/packs/packs.ts +14 -0
- package/src/render-engine-version.ts +1 -1
- package/src/tailwind/jsx-utils.ts +39 -0
- package/src/tailwind/node-ir.ts +8 -1
- package/src/tailwind/responsive-analyzer.ts +57 -3
- package/src/tailwind/tailwind.ts +344 -51
- package/src/text/index.ts +1 -0
- package/src/text/inline-text.ts +112 -12
- package/src/text/text-builder.ts +2 -2
- package/src/text/text-truncate.ts +101 -0
- package/src/tokens/tokens.ts +107 -16
- package/src/tokens/variables.ts +203 -46
- package/templates/scan-components-route.ts +8 -0
- package/ui.html +144 -43
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import * as fs from 'node:fs';
|
|
4
|
+
import { ComponentScanner } from './component-scanner';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Regression: when a `.map()` iteration is the truthy (or falsy) branch
|
|
8
|
+
* of a JSX ternary, `pushChosenJsxBranch` must dispatch to
|
|
9
|
+
* `expandMapCall` instead of falling back to `resolveExpressionValue`
|
|
10
|
+
* (which doesn't know how to iterate `.map`).
|
|
11
|
+
*
|
|
12
|
+
* Bug shape that prompted this fix (greenhouse-app `RecentTradesCard`):
|
|
13
|
+
*
|
|
14
|
+
* <TableBody>
|
|
15
|
+
* {rows.length ? (
|
|
16
|
+
* rows.map((row) => <TableRow key={row.id}>...</TableRow>)
|
|
17
|
+
* ) : (
|
|
18
|
+
* <EmptyRow />
|
|
19
|
+
* )}
|
|
20
|
+
* </TableBody>
|
|
21
|
+
*
|
|
22
|
+
* Without this fix the scanner picked the truthy branch correctly
|
|
23
|
+
* (rows.length resolves to a positive number), then silently dropped the
|
|
24
|
+
* CallExpression and rendered an empty `<tbody>` in Figma. The fix adds
|
|
25
|
+
* a `Node.isCallExpression(chosen)` case in `pushChosenJsxBranch` that
|
|
26
|
+
* routes `.map(...)` through `expandMapCall`.
|
|
27
|
+
*
|
|
28
|
+
* Bare `arr.map(...)` calls directly inside JSX (no ternary wrapper)
|
|
29
|
+
* already worked via the `Node.isCallExpression(expr)` branch in
|
|
30
|
+
* `buildJsxTree`. This fixture exists specifically to lock in the
|
|
31
|
+
* conditional-branch case.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
interface JsxNodeLike {
|
|
35
|
+
type: 'element' | 'text';
|
|
36
|
+
tagName?: string;
|
|
37
|
+
content?: string;
|
|
38
|
+
props?: Record<string, unknown>;
|
|
39
|
+
children?: JsxNodeLike[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface ScannedShape {
|
|
43
|
+
name: string;
|
|
44
|
+
stories?: Array<{ name: string; jsxTree?: JsxNodeLike }>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const FIXTURE_DIR = path.resolve(
|
|
48
|
+
process.cwd(),
|
|
49
|
+
'tools/figma-plugin/scanner/__fixtures__/conditional-map-branch'
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
function findAll(node: JsxNodeLike | undefined, tag: string, out: JsxNodeLike[] = []): JsxNodeLike[] {
|
|
53
|
+
if (!node || node.type !== 'element') return out;
|
|
54
|
+
if (node.tagName === tag) out.push(node);
|
|
55
|
+
for (const c of node.children || []) findAll(c, tag, out);
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
function findOne(node: JsxNodeLike | undefined, tag: string): JsxNodeLike | null {
|
|
59
|
+
if (!node || node.type !== 'element') return null;
|
|
60
|
+
if (node.tagName === tag) return node;
|
|
61
|
+
for (const c of node.children || []) {
|
|
62
|
+
const f = findOne(c, tag);
|
|
63
|
+
if (f) return f;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function writeFixtures(): void {
|
|
69
|
+
fs.mkdirSync(FIXTURE_DIR, { recursive: true });
|
|
70
|
+
fs.writeFileSync(
|
|
71
|
+
path.join(FIXTURE_DIR, 'TradesList.tsx'),
|
|
72
|
+
`
|
|
73
|
+
export interface TradeRow {
|
|
74
|
+
id: string;
|
|
75
|
+
label: string;
|
|
76
|
+
}
|
|
77
|
+
export function TradesList({ rows }: { rows: TradeRow[] }) {
|
|
78
|
+
return (
|
|
79
|
+
<div>
|
|
80
|
+
<h1>Trades</h1>
|
|
81
|
+
{rows.length ? (
|
|
82
|
+
rows.map((row) => (
|
|
83
|
+
<div data-row-id={row.id}>{row.label}</div>
|
|
84
|
+
))
|
|
85
|
+
) : (
|
|
86
|
+
<div data-role="empty">No trades</div>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
`,
|
|
92
|
+
'utf-8'
|
|
93
|
+
);
|
|
94
|
+
fs.writeFileSync(
|
|
95
|
+
path.join(FIXTURE_DIR, 'TradesList.stories.tsx'),
|
|
96
|
+
`
|
|
97
|
+
import { TradesList } from "./TradesList";
|
|
98
|
+
const meta = { component: TradesList };
|
|
99
|
+
export default meta;
|
|
100
|
+
|
|
101
|
+
// Inline array literal in args + ternary-with-map: exercises the
|
|
102
|
+
// pushChosenJsxBranch CallExpression case.
|
|
103
|
+
export const WithRows = {
|
|
104
|
+
args: {
|
|
105
|
+
rows: [
|
|
106
|
+
{ id: "a", label: "Alpha" },
|
|
107
|
+
{ id: "b", label: "Beta" },
|
|
108
|
+
{ id: "c", label: "Gamma" },
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Empty rows → ternary picks the falsy branch (the empty-state div).
|
|
114
|
+
// Exercises the JsxElement case in pushChosenJsxBranch, which always
|
|
115
|
+
// worked but is the natural counterpart to assert here.
|
|
116
|
+
export const Empty = {
|
|
117
|
+
args: { rows: [] },
|
|
118
|
+
};
|
|
119
|
+
`,
|
|
120
|
+
'utf-8'
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function cleanup(): void {
|
|
125
|
+
try { fs.rmSync(FIXTURE_DIR, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function run(): Promise<void> {
|
|
129
|
+
writeFixtures();
|
|
130
|
+
const scanner = new ComponentScanner({
|
|
131
|
+
componentPaths: [FIXTURE_DIR],
|
|
132
|
+
filePattern: '*.tsx',
|
|
133
|
+
exclude: [],
|
|
134
|
+
});
|
|
135
|
+
const results = (await scanner.scanAll()) as unknown as ScannedShape[];
|
|
136
|
+
const def = results.find((r) => r.name === 'TradesList');
|
|
137
|
+
assert.ok(def, 'TradesList component must be scanned');
|
|
138
|
+
const byName = (n: string) => (def.stories || []).find((s) => s.name === n);
|
|
139
|
+
|
|
140
|
+
// -------------------------------------------------------------------------
|
|
141
|
+
// WithRows: the truthy branch of `rows.length ? rows.map(...) : <Empty/>`
|
|
142
|
+
// must expand the .map and emit one <div data-row-id> per row.
|
|
143
|
+
// -------------------------------------------------------------------------
|
|
144
|
+
{
|
|
145
|
+
const story = byName('WithRows');
|
|
146
|
+
assert.ok(story?.jsxTree, 'WithRows story must have a jsxTree');
|
|
147
|
+
const rows = findAll(story.jsxTree, 'div').filter((n) => n.props?.['data-row-id'] != null);
|
|
148
|
+
assert.equal(rows.length, 3, `expected 3 row divs from rows.map, got ${rows.length}`);
|
|
149
|
+
const ids = rows.map((r) => r.props?.['data-row-id']).join(',');
|
|
150
|
+
assert.equal(ids, 'a,b,c', `row order/ids must round-trip, got "${ids}"`);
|
|
151
|
+
const empty = findOne(story.jsxTree, 'div');
|
|
152
|
+
// The empty-state div has data-role="empty" — must NOT be emitted when
|
|
153
|
+
// rows are present.
|
|
154
|
+
const emptyDiv = findAll(story.jsxTree, 'div').find((n) => n.props?.['data-role'] === 'empty');
|
|
155
|
+
assert.equal(emptyDiv, undefined, 'empty-state div must not appear when rows are populated');
|
|
156
|
+
assert.ok(empty, 'sanity: root div renders');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// -------------------------------------------------------------------------
|
|
160
|
+
// Empty: the falsy branch picks the empty-state div (JsxElement path,
|
|
161
|
+
// which already worked — this asserts symmetry).
|
|
162
|
+
// -------------------------------------------------------------------------
|
|
163
|
+
{
|
|
164
|
+
const story = byName('Empty');
|
|
165
|
+
assert.ok(story?.jsxTree, 'Empty story must have a jsxTree');
|
|
166
|
+
const emptyDiv = findAll(story.jsxTree, 'div').find((n) => n.props?.['data-role'] === 'empty');
|
|
167
|
+
assert.ok(emptyDiv, 'empty-state div must appear when rows is empty');
|
|
168
|
+
const rowDivs = findAll(story.jsxTree, 'div').filter((n) => n.props?.['data-row-id'] != null);
|
|
169
|
+
assert.equal(rowDivs.length, 0, 'no row divs when rows is empty');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
cleanup();
|
|
173
|
+
console.log('conditional-map-branch-regression: PASS (2 cases)');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
run().catch((err) => {
|
|
177
|
+
cleanup();
|
|
178
|
+
console.error(err);
|
|
179
|
+
process.exit(1);
|
|
180
|
+
});
|
|
@@ -240,7 +240,19 @@ export function resolveCssTokenPathFromImports(filePath: string, visited: Set<st
|
|
|
240
240
|
return absolute;
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
-
|
|
243
|
+
interface ReadCssOptions {
|
|
244
|
+
/**
|
|
245
|
+
* When true, `@import` directives that resolve via `node_modules`
|
|
246
|
+
* (bare specifiers like `"tailwindcss"` / `"tw-animate-css"`) are
|
|
247
|
+
* NOT expanded — only consumer-owned files (relative paths) get
|
|
248
|
+
* inlined. Used by the consumer-only scan path so the Design Tokens
|
|
249
|
+
* panel can show ONLY what the consumer wrote, without Tailwind's
|
|
250
|
+
* defaults leaking in.
|
|
251
|
+
*/
|
|
252
|
+
skipBareImports?: boolean;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function readCssWithImports(filePath: string, visited: Set<string> = new Set(), opts: ReadCssOptions = {}): string {
|
|
244
256
|
const absolute = path.resolve(filePath);
|
|
245
257
|
if (visited.has(absolute)) return '';
|
|
246
258
|
visited.add(absolute);
|
|
@@ -262,19 +274,46 @@ function readCssWithImports(filePath: string, visited: Set<string> = new Set()):
|
|
|
262
274
|
const parts: string[] = [];
|
|
263
275
|
for (const node of root.nodes || []) {
|
|
264
276
|
if (node.type === 'atrule' && ((node as AtRule).name || '').toLowerCase() === 'import') {
|
|
265
|
-
const
|
|
277
|
+
const params = (node as AtRule).params || '';
|
|
278
|
+
const specifier = extractImportSpecifier(params) || '';
|
|
279
|
+
const isBare = specifier && !specifier.startsWith('./') && !specifier.startsWith('../') && !path.isAbsolute(specifier);
|
|
280
|
+
if (opts.skipBareImports && isBare) {
|
|
281
|
+
// Drop the import entirely — don't expand and don't preserve the
|
|
282
|
+
// unresolved @import line either (the walker would otherwise see
|
|
283
|
+
// a parameterless atrule with no body).
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
const imported = resolveImportedCssPath(absolute, params);
|
|
266
287
|
if (imported) {
|
|
267
|
-
const importedCss = readCssWithImports(imported, visited);
|
|
288
|
+
const importedCss = readCssWithImports(imported, visited, opts);
|
|
268
289
|
if (importedCss.trim()) parts.push(importedCss);
|
|
269
290
|
continue;
|
|
270
291
|
}
|
|
271
292
|
}
|
|
272
|
-
parts.push(node
|
|
293
|
+
parts.push(serializeNodeWithTerminator(node));
|
|
273
294
|
}
|
|
274
295
|
|
|
275
296
|
return parts.join('\n');
|
|
276
297
|
}
|
|
277
298
|
|
|
299
|
+
/**
|
|
300
|
+
* Serialize a postcss node for concatenation. Bare at-rules (no block) need
|
|
301
|
+
* an explicit terminating `;` — postcss's `node.toString()` omits trailing
|
|
302
|
+
* semicolons since they're whitespace-style raws, but concatenating two
|
|
303
|
+
* unterminated bare at-rules (e.g. `@plugin "x"\n@custom-variant y\n@theme
|
|
304
|
+
* inline {...}`) collides them into a single rule whose params swallow
|
|
305
|
+
* everything after. Greenhouse-app's `@theme inline { --font-* }` block
|
|
306
|
+
* was being eaten as params of the preceding `@plugin` at-rule before
|
|
307
|
+
* this fix, so its font / colour overrides never reached the walker.
|
|
308
|
+
*/
|
|
309
|
+
function serializeNodeWithTerminator(node: postcss.Node): string {
|
|
310
|
+
const text = node.toString();
|
|
311
|
+
if (node.type !== 'atrule') return text;
|
|
312
|
+
const at = node as AtRule;
|
|
313
|
+
if (at.nodes) return text; // has a {} block — already terminated
|
|
314
|
+
return text.trimEnd().endsWith(';') ? text : text + ';';
|
|
315
|
+
}
|
|
316
|
+
|
|
278
317
|
export function discoverCssTokenPath(projectRoot: string, explicitPath?: string): string | null {
|
|
279
318
|
if (explicitPath && explicitPath.trim()) {
|
|
280
319
|
return discoverFilePath(projectRoot, explicitPath.trim());
|
|
@@ -567,6 +606,28 @@ function parseDtcgTokenMap(
|
|
|
567
606
|
}
|
|
568
607
|
|
|
569
608
|
export function readTokenSourceMap(options: ReadTokenSourceOptions): ScannedTokenMap {
|
|
609
|
+
return readTokenSourceMapInternal(options, {});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Like `readTokenSourceMap`, but only walks tokens the consumer actually
|
|
614
|
+
* wrote in their own files. `@import` directives that resolve via
|
|
615
|
+
* `node_modules` (Tailwind, tw-animate-css, …) are dropped from the
|
|
616
|
+
* concat instead of expanded, so the resulting map omits Tailwind's
|
|
617
|
+
* defaults (`--font-serif`, `--text-sm`, the default spacing scale,
|
|
618
|
+
* etc.). Used by the design-tokens panel to show only the consumer's
|
|
619
|
+
* overrides — runtime token resolution still uses the full map from
|
|
620
|
+
* `readTokenSourceMap` so utilities like `text-sm` keep working.
|
|
621
|
+
*
|
|
622
|
+
* Returns an empty map when the consumer mode is DTCG-only — DTCG
|
|
623
|
+
* files are by definition consumer-owned, so the full scan IS already
|
|
624
|
+
* the consumer-only scan.
|
|
625
|
+
*/
|
|
626
|
+
export function readConsumerOwnedTokenMap(options: ReadTokenSourceOptions): ScannedTokenMap {
|
|
627
|
+
return readTokenSourceMapInternal(options, { skipBareImports: true });
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function readTokenSourceMapInternal(options: ReadTokenSourceOptions, readOpts: ReadCssOptions): ScannedTokenMap {
|
|
570
631
|
const projectRoot = path.resolve(options.projectRoot);
|
|
571
632
|
const requestedMode: TokenSourceMode = options.tokenSourceMode === 'dtcg' ? 'dtcg' : 'css';
|
|
572
633
|
const cssPath = discoverCssTokenPath(projectRoot, options.cssTokenPath);
|
|
@@ -581,7 +642,7 @@ export function readTokenSourceMap(options: ReadTokenSourceOptions): ScannedToke
|
|
|
581
642
|
|
|
582
643
|
// css mode: CSS preferred; fall back to DTCG if no CSS file found, then embedded.
|
|
583
644
|
if (cssPath) {
|
|
584
|
-
const cssText = readCssWithImports(cssPath);
|
|
645
|
+
const cssText = readCssWithImports(cssPath, new Set(), readOpts);
|
|
585
646
|
// Use the file that actually contains the declarations as source so the
|
|
586
647
|
// write-back path (PR/patch) targets the right file even when globals.css
|
|
587
648
|
// delegates token definitions to an imported file like tokens.css.
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { ComponentScanner } from './component-scanner';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Regression: every `DialogContent` / `DialogPrimitive.Content` in a
|
|
6
|
+
* scanned JSX tree gets an `__dialogRootIsOpen` data prop derived
|
|
7
|
+
* from the nearest enclosing `<Dialog>` / `<DialogPrimitive.Root>`'s
|
|
8
|
+
* `open` / `defaultOpen` props. The render-time gate in
|
|
9
|
+
* `ui-builder.ts` uses this annotation to suppress nested closed
|
|
10
|
+
* dialogs so they don't bleed inline next to their parent.
|
|
11
|
+
*
|
|
12
|
+
* Real-world trigger: greenhouse-app `IncreasePositionModal` has a
|
|
13
|
+
* help-info `<Dialog>` nested inside its `<DialogHeader>`. The outer
|
|
14
|
+
* Dialog receives `open={true}` from the story, the inner has no
|
|
15
|
+
* explicit `open`. Before the gate, both DialogContents rendered —
|
|
16
|
+
* the inner "Open position / Overview" content appeared as a sliver
|
|
17
|
+
* floating top-right of the main modal in the sm preview, and as
|
|
18
|
+
* vertical-letter-per-line description text in the base preview.
|
|
19
|
+
*
|
|
20
|
+
* Why the annotation is required (instead of inheriting context at
|
|
21
|
+
* render time): the outer DialogContent is portal-extracted at scan
|
|
22
|
+
* time, so by the time `buildFigmaNode` reaches it the wrapping
|
|
23
|
+
* Dialog Root is no longer on its ancestor chain. Baking the open
|
|
24
|
+
* state into a data prop at scan time keeps the gate universal
|
|
25
|
+
* regardless of which portal path the render takes.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
interface TestScannerView {
|
|
29
|
+
transformJsxTree: (tree: unknown) => unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const scanner = new ComponentScanner({
|
|
33
|
+
componentPaths: [],
|
|
34
|
+
filePattern: '*.tsx',
|
|
35
|
+
exclude: [],
|
|
36
|
+
}) as unknown as TestScannerView;
|
|
37
|
+
|
|
38
|
+
interface NodeLike {
|
|
39
|
+
type: 'element' | 'text';
|
|
40
|
+
tagName?: string;
|
|
41
|
+
isComponent?: boolean;
|
|
42
|
+
props?: Record<string, string>;
|
|
43
|
+
children?: NodeLike[];
|
|
44
|
+
content?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function element(
|
|
48
|
+
tagName: string,
|
|
49
|
+
props: Record<string, string> = {},
|
|
50
|
+
children: NodeLike[] = [],
|
|
51
|
+
): NodeLike {
|
|
52
|
+
return {
|
|
53
|
+
type: 'element',
|
|
54
|
+
tagName,
|
|
55
|
+
isComponent: /^[A-Z]/.test(tagName),
|
|
56
|
+
props,
|
|
57
|
+
children,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function findElements(node: NodeLike, tagName: string, out: NodeLike[] = []): NodeLike[] {
|
|
62
|
+
if (!node || node.type !== 'element') return out;
|
|
63
|
+
if (node.tagName === tagName) out.push(node);
|
|
64
|
+
for (const child of node.children || []) findElements(child, tagName, out);
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const transform = (tree: NodeLike): NodeLike =>
|
|
69
|
+
scanner.transformJsxTree(tree as unknown) as NodeLike;
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Case 1: outer Dialog open=true, nested Dialog has no open prop.
|
|
73
|
+
// Outer content gets isOpen=true, nested content gets isOpen=false.
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
{
|
|
77
|
+
const tree = element('div', {}, [
|
|
78
|
+
element('Dialog', { open: 'true' }, [
|
|
79
|
+
element('DialogContent', {}, [
|
|
80
|
+
element('DialogHeader', {}, [
|
|
81
|
+
element('DialogTitle', {}, []),
|
|
82
|
+
element('Dialog', {}, [ // nested, no open
|
|
83
|
+
element('DialogTrigger', {}, []),
|
|
84
|
+
element('DialogContent', {}, [
|
|
85
|
+
element('p', {}, []),
|
|
86
|
+
]),
|
|
87
|
+
]),
|
|
88
|
+
]),
|
|
89
|
+
]),
|
|
90
|
+
]),
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
const result = transform(tree);
|
|
94
|
+
const contents = findElements(result, 'DialogContent');
|
|
95
|
+
assert.equal(contents.length, 2, 'both DialogContents preserved in the tree');
|
|
96
|
+
assert.equal(
|
|
97
|
+
contents[0].props?.__dialogRootIsOpen,
|
|
98
|
+
'true',
|
|
99
|
+
'outer DialogContent annotated with isOpen=true from the open prop',
|
|
100
|
+
);
|
|
101
|
+
assert.equal(
|
|
102
|
+
contents[1].props?.__dialogRootIsOpen,
|
|
103
|
+
'false',
|
|
104
|
+
'nested DialogContent annotated with isOpen=false — gate will suppress it at render time',
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Case 2: defaultOpen also counts as open
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
{
|
|
113
|
+
const tree = element('div', {}, [
|
|
114
|
+
element('Dialog', { defaultOpen: 'true' }, [
|
|
115
|
+
element('DialogContent', {}, []),
|
|
116
|
+
]),
|
|
117
|
+
]);
|
|
118
|
+
const result = transform(tree);
|
|
119
|
+
const contents = findElements(result, 'DialogContent');
|
|
120
|
+
assert.equal(
|
|
121
|
+
contents[0].props?.__dialogRootIsOpen,
|
|
122
|
+
'true',
|
|
123
|
+
'defaultOpen=true propagates as isOpen — covers stories that pre-open the modal',
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Case 3: DialogPrimitive.Root / DialogPrimitive.Content alias works the same
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
{
|
|
132
|
+
const tree = element('div', {}, [
|
|
133
|
+
element('DialogPrimitive.Root', { open: 'true' }, [
|
|
134
|
+
element('DialogPrimitive.Content', {}, []),
|
|
135
|
+
]),
|
|
136
|
+
]);
|
|
137
|
+
const result = transform(tree);
|
|
138
|
+
const contents = findElements(result, 'DialogPrimitive.Content');
|
|
139
|
+
assert.equal(
|
|
140
|
+
contents[0].props?.__dialogRootIsOpen,
|
|
141
|
+
'true',
|
|
142
|
+
'Primitive.Root + Primitive.Content combo is annotated the same way',
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Case 4: deeply nested Dialog — each Content gets the nearest Root's state
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
{
|
|
151
|
+
const tree = element('div', {}, [
|
|
152
|
+
element('Dialog', { open: 'true' }, [
|
|
153
|
+
element('DialogContent', { 'data-marker': 'outer' }, [
|
|
154
|
+
element('Dialog', { defaultOpen: 'true' }, [
|
|
155
|
+
element('DialogContent', { 'data-marker': 'middle' }, [
|
|
156
|
+
element('Dialog', {}, [
|
|
157
|
+
element('DialogContent', { 'data-marker': 'inner' }, []),
|
|
158
|
+
]),
|
|
159
|
+
]),
|
|
160
|
+
]),
|
|
161
|
+
]),
|
|
162
|
+
]),
|
|
163
|
+
]);
|
|
164
|
+
const result = transform(tree);
|
|
165
|
+
const contents = findElements(result, 'DialogContent');
|
|
166
|
+
const byMarker = new Map(contents.map((c) => [c.props?.['data-marker'], c]));
|
|
167
|
+
assert.equal(byMarker.get('outer')?.props?.__dialogRootIsOpen, 'true', 'outermost: open=true');
|
|
168
|
+
assert.equal(byMarker.get('middle')?.props?.__dialogRootIsOpen, 'true', 'middle: defaultOpen=true');
|
|
169
|
+
assert.equal(
|
|
170
|
+
byMarker.get('inner')?.props?.__dialogRootIsOpen,
|
|
171
|
+
'false',
|
|
172
|
+
'innermost: no open prop → falsy annotation — gate suppresses it independent of ancestor state',
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// Case 5: DialogContent NOT inside any Dialog Root — annotation absent.
|
|
178
|
+
// The render-time gate falls through (no suppression) since this is the
|
|
179
|
+
// portal-extracted shape that other stories rely on.
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
{
|
|
183
|
+
const tree = element('div', {}, [
|
|
184
|
+
element('DialogContent', {}, []),
|
|
185
|
+
]);
|
|
186
|
+
const result = transform(tree);
|
|
187
|
+
const contents = findElements(result, 'DialogContent');
|
|
188
|
+
assert.equal(
|
|
189
|
+
contents[0].props?.__dialogRootIsOpen,
|
|
190
|
+
undefined,
|
|
191
|
+
'DialogContent with no Dialog ancestor stays unannotated → renderer falls back to render',
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
console.log('dialog-content-gate-regression: PASS');
|