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,212 @@
|
|
|
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
|
+
(globalThis as unknown as { figma: unknown }).figma = {
|
|
7
|
+
notify: () => undefined,
|
|
8
|
+
showUI: () => undefined,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
import { shouldRenderResponsiveForStory } from '../src/design-system/preview-builder';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Locks in the responsive-frame opt-in / opt-out flow:
|
|
15
|
+
*
|
|
16
|
+
* 1. **Scanner side** — `parameters.inkbridge.responsive` on a story
|
|
17
|
+
* export round-trips to `StoryInfo.responsive` (`true`, `false`, or
|
|
18
|
+
* omitted). The scanner reads it via the ObjectLiteralExpression
|
|
19
|
+
* property chain `parameters → inkbridge → responsive`.
|
|
20
|
+
*
|
|
21
|
+
* 2. **Plugin side** — `shouldRenderResponsiveForStory(def, story,
|
|
22
|
+
* storyIndex)` enforces the policy:
|
|
23
|
+
* - explicit `story.responsive === true` → always render
|
|
24
|
+
* - explicit `story.responsive === false` → never render
|
|
25
|
+
* - unset → render only for the canonical Default story (name
|
|
26
|
+
* contains "default") or, if no Default story exists, only for
|
|
27
|
+
* the first story (storyIndex === 0).
|
|
28
|
+
*
|
|
29
|
+
* Together this lets consumers cut frame-explosion: variant stories
|
|
30
|
+
* like `Loading` / `WithError` get a single non-responsive frame by
|
|
31
|
+
* default, and any story can opt in/out via Storybook parameters.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
interface StoryShape {
|
|
35
|
+
name: string;
|
|
36
|
+
responsive?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface ScannedShape {
|
|
40
|
+
name: string;
|
|
41
|
+
stories?: StoryShape[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const FIXTURE_DIR = path.resolve(
|
|
45
|
+
process.cwd(),
|
|
46
|
+
'tools/figma-plugin/scanner/__fixtures__/responsive-opt-in'
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
function writeFixtures(): void {
|
|
50
|
+
fs.mkdirSync(FIXTURE_DIR, { recursive: true });
|
|
51
|
+
fs.writeFileSync(
|
|
52
|
+
path.join(FIXTURE_DIR, 'Card.tsx'),
|
|
53
|
+
`
|
|
54
|
+
export function Card({ label }: { label: string }) {
|
|
55
|
+
return <div className="flex flex-col gap-2 sm:flex-row md:gap-4">{label}</div>;
|
|
56
|
+
}
|
|
57
|
+
`,
|
|
58
|
+
'utf-8'
|
|
59
|
+
);
|
|
60
|
+
fs.writeFileSync(
|
|
61
|
+
path.join(FIXTURE_DIR, 'Card.stories.tsx'),
|
|
62
|
+
`
|
|
63
|
+
import { Card } from "./Card";
|
|
64
|
+
const meta = { component: Card };
|
|
65
|
+
export default meta;
|
|
66
|
+
|
|
67
|
+
// No parameters — falls through to default policy.
|
|
68
|
+
export const Default = {
|
|
69
|
+
args: { label: "default" },
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Explicit opt-in via parameters.inkbridge.responsive = true.
|
|
73
|
+
export const WithItems = {
|
|
74
|
+
args: { label: "with items" },
|
|
75
|
+
parameters: { inkbridge: { responsive: true } },
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// Explicit opt-out (suppress even if Default).
|
|
79
|
+
export const Suppressed = {
|
|
80
|
+
args: { label: "suppressed" },
|
|
81
|
+
parameters: { inkbridge: { responsive: false } },
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// No flag, not Default — should not render responsive by default.
|
|
85
|
+
export const Loading = {
|
|
86
|
+
args: { label: "loading" },
|
|
87
|
+
};
|
|
88
|
+
`,
|
|
89
|
+
'utf-8'
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function cleanupFixtures(): void {
|
|
94
|
+
try { fs.rmSync(FIXTURE_DIR, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function run(): Promise<void> {
|
|
98
|
+
writeFixtures();
|
|
99
|
+
|
|
100
|
+
const scanner = new ComponentScanner({
|
|
101
|
+
componentPaths: [FIXTURE_DIR],
|
|
102
|
+
filePattern: '*.tsx',
|
|
103
|
+
exclude: [],
|
|
104
|
+
});
|
|
105
|
+
const results = (await scanner.scanAll()) as unknown as ScannedShape[];
|
|
106
|
+
const card = results.find((r) => r.name === 'Card');
|
|
107
|
+
assert.ok(card, `Card must be scanned; got: ${results.map((r) => r.name).join(', ')}`);
|
|
108
|
+
const stories = card.stories || [];
|
|
109
|
+
const byName = (n: string) => stories.find((s) => s.name === n);
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------
|
|
112
|
+
// Scanner round-trip
|
|
113
|
+
// ---------------------------------------------------------------------
|
|
114
|
+
{
|
|
115
|
+
const def = byName('Default');
|
|
116
|
+
assert.ok(def, 'Default story must exist');
|
|
117
|
+
assert.equal(def.responsive, undefined, 'Default: parameters absent → responsive undefined');
|
|
118
|
+
}
|
|
119
|
+
{
|
|
120
|
+
const wi = byName('WithItems');
|
|
121
|
+
assert.ok(wi, 'WithItems story must exist');
|
|
122
|
+
assert.equal(wi.responsive, true, 'WithItems: parameters.inkbridge.responsive=true → true');
|
|
123
|
+
}
|
|
124
|
+
{
|
|
125
|
+
const sup = byName('Suppressed');
|
|
126
|
+
assert.ok(sup, 'Suppressed story must exist');
|
|
127
|
+
assert.equal(sup.responsive, false, 'Suppressed: parameters.inkbridge.responsive=false → false');
|
|
128
|
+
}
|
|
129
|
+
{
|
|
130
|
+
const loading = byName('Loading');
|
|
131
|
+
assert.ok(loading, 'Loading story must exist');
|
|
132
|
+
assert.equal(loading.responsive, undefined, 'Loading: parameters absent → undefined');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------
|
|
136
|
+
// Gate helper — uses the scanned StoryInfo shape directly.
|
|
137
|
+
// ---------------------------------------------------------------------
|
|
138
|
+
// Reuse the same def from the scanner so we test against the real
|
|
139
|
+
// shape, not a hand-rolled mock.
|
|
140
|
+
const def = card;
|
|
141
|
+
const get = (name: string) => stories.findIndex((s) => s.name === name);
|
|
142
|
+
|
|
143
|
+
assert.equal(
|
|
144
|
+
shouldRenderResponsiveForStory(def, byName('Default'), get('Default')),
|
|
145
|
+
true,
|
|
146
|
+
'Default story: no flag, name=Default → render responsive',
|
|
147
|
+
);
|
|
148
|
+
assert.equal(
|
|
149
|
+
shouldRenderResponsiveForStory(def, byName('WithItems'), get('WithItems')),
|
|
150
|
+
true,
|
|
151
|
+
'WithItems: explicit responsive=true → render',
|
|
152
|
+
);
|
|
153
|
+
assert.equal(
|
|
154
|
+
shouldRenderResponsiveForStory(def, byName('Suppressed'), get('Suppressed')),
|
|
155
|
+
false,
|
|
156
|
+
'Suppressed: explicit responsive=false → suppress even if it would otherwise render',
|
|
157
|
+
);
|
|
158
|
+
assert.equal(
|
|
159
|
+
shouldRenderResponsiveForStory(def, byName('Loading'), get('Loading')),
|
|
160
|
+
false,
|
|
161
|
+
'Loading: no flag, not Default, Default exists in sibling stories → suppress',
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------
|
|
165
|
+
// First-story fallback when no Default story exists.
|
|
166
|
+
// Synthetic def: two stories named X, Y. Y has no flag, X is first.
|
|
167
|
+
// ---------------------------------------------------------------------
|
|
168
|
+
{
|
|
169
|
+
const syntheticDef = { stories: [{ name: 'X' }, { name: 'Y' }] };
|
|
170
|
+
assert.equal(
|
|
171
|
+
shouldRenderResponsiveForStory(syntheticDef, syntheticDef.stories[0], 0),
|
|
172
|
+
true,
|
|
173
|
+
'first story (no Default sibling): renders responsive',
|
|
174
|
+
);
|
|
175
|
+
assert.equal(
|
|
176
|
+
shouldRenderResponsiveForStory(syntheticDef, syntheticDef.stories[1], 1),
|
|
177
|
+
false,
|
|
178
|
+
'non-first story (no Default sibling): suppress',
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------------
|
|
183
|
+
// Explicit flag wins over policy in both directions.
|
|
184
|
+
// ---------------------------------------------------------------------
|
|
185
|
+
{
|
|
186
|
+
const syntheticDef = {
|
|
187
|
+
stories: [
|
|
188
|
+
{ name: 'Default', responsive: false },
|
|
189
|
+
{ name: 'Variant', responsive: true },
|
|
190
|
+
],
|
|
191
|
+
};
|
|
192
|
+
assert.equal(
|
|
193
|
+
shouldRenderResponsiveForStory(syntheticDef, syntheticDef.stories[0], 0),
|
|
194
|
+
false,
|
|
195
|
+
'Default with explicit false: suppress (opt-out wins over name-based default)',
|
|
196
|
+
);
|
|
197
|
+
assert.equal(
|
|
198
|
+
shouldRenderResponsiveForStory(syntheticDef, syntheticDef.stories[1], 1),
|
|
199
|
+
true,
|
|
200
|
+
'Variant with explicit true: render (opt-in wins over default policy)',
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
cleanupFixtures();
|
|
205
|
+
console.log('responsive-opt-in-regression: PASS (scanner round-trip + gate helper)');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
run().catch((err) => {
|
|
209
|
+
cleanupFixtures();
|
|
210
|
+
console.error(err);
|
|
211
|
+
process.exit(1);
|
|
212
|
+
});
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { ComponentScanner } from './component-scanner';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Regression: `<Select value="x">` / `<SelectPrimitive.Root>` wrappers
|
|
7
|
+
* with no className are flattened away by the scanner. The wrapper's
|
|
8
|
+
* context-providing props (`value`, `defaultValue`, `open`,
|
|
9
|
+
* `defaultOpen`) become `__selectRoot*` data props on the hoisted
|
|
10
|
+
* children. The matching `<SelectItem value="x">`'s label is baked
|
|
11
|
+
* into `__selectRootSelectedLabel` at scan time so the renderer
|
|
12
|
+
* doesn't have to walk the original tree shape.
|
|
13
|
+
*
|
|
14
|
+
* History: shadcn `Select` is `export const Select = SelectPrimitive.Root`
|
|
15
|
+
* — a Radix logical context provider with no DOM in the browser. The
|
|
16
|
+
* plugin used to create a hugging wrapper frame around the Trigger,
|
|
17
|
+
* which trapped `<SelectTrigger className="w-full">` at the trigger's
|
|
18
|
+
* own content width inside a `sm:grid-cols-2` form column. The earlier
|
|
19
|
+
* `layoutAlign = STRETCH` patch fired in the wrong pipeline phase
|
|
20
|
+
* (the OUTER `renderChildren` loop) because the kind-component path
|
|
21
|
+
* never reached the OUTER loop — `buildFigmaNode` always created the
|
|
22
|
+
* wrapper frame itself.
|
|
23
|
+
*
|
|
24
|
+
* Universal fix: flatten the wrapper at SCAN time, mirroring the
|
|
25
|
+
* `__fromPortal` annotation pattern. Once the JSX tree no longer
|
|
26
|
+
* carries a Select Root node, every render path automatically treats
|
|
27
|
+
* the Trigger/Content as direct siblings of the form fields, so the
|
|
28
|
+
* full-width cascade works without special-casing the Radix wrapper.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
interface TestScannerView {
|
|
32
|
+
project: import('ts-morph').Project;
|
|
33
|
+
extractComponentJsxTree: (
|
|
34
|
+
sourceFile: import('ts-morph').SourceFile,
|
|
35
|
+
componentName: string,
|
|
36
|
+
) => unknown;
|
|
37
|
+
transformJsxTree: (tree: unknown) => unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function makeScanner(): TestScannerView {
|
|
41
|
+
return new ComponentScanner({
|
|
42
|
+
componentPaths: [],
|
|
43
|
+
filePattern: '*.tsx',
|
|
44
|
+
exclude: [],
|
|
45
|
+
}) as unknown as TestScannerView;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface NodeLike {
|
|
49
|
+
type: 'element' | 'text';
|
|
50
|
+
tagName?: string;
|
|
51
|
+
isComponent?: boolean;
|
|
52
|
+
props?: Record<string, string>;
|
|
53
|
+
children?: NodeLike[];
|
|
54
|
+
content?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function element(
|
|
58
|
+
tagName: string,
|
|
59
|
+
props: Record<string, string> = {},
|
|
60
|
+
children: NodeLike[] = [],
|
|
61
|
+
): NodeLike {
|
|
62
|
+
return {
|
|
63
|
+
type: 'element',
|
|
64
|
+
tagName,
|
|
65
|
+
isComponent: /^[A-Z]/.test(tagName),
|
|
66
|
+
props,
|
|
67
|
+
children,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function text(content: string): NodeLike {
|
|
72
|
+
return { type: 'text', content };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const scanner = makeScanner();
|
|
76
|
+
const transform = (tree: NodeLike): NodeLike =>
|
|
77
|
+
scanner.transformJsxTree(tree as unknown) as NodeLike;
|
|
78
|
+
|
|
79
|
+
function findElements(node: NodeLike, tagName: string, out: NodeLike[] = []): NodeLike[] {
|
|
80
|
+
if (!node || node.type !== 'element') return out;
|
|
81
|
+
if (node.tagName === tagName) out.push(node);
|
|
82
|
+
for (const child of node.children || []) findElements(child, tagName, out);
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Case 1: <Select value="usdc"> wrapping Trigger + Content with matching item
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
{
|
|
91
|
+
const tree = element('div', { className: 'space-y-2' }, [
|
|
92
|
+
element('Label', {}, [text('Receive collateral in')]),
|
|
93
|
+
element('Select', { value: 'usdc' }, [
|
|
94
|
+
element('SelectTrigger', { id: 'collateral' }, [
|
|
95
|
+
element('SelectValue', { placeholder: 'Choose' }),
|
|
96
|
+
]),
|
|
97
|
+
element('SelectContent', {}, [
|
|
98
|
+
element('SelectItem', { value: 'usdc' }, [text('USDC')]),
|
|
99
|
+
element('SelectItem', { value: 'sol' }, [text('SOL')]),
|
|
100
|
+
]),
|
|
101
|
+
]),
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
const result = transform(tree);
|
|
105
|
+
|
|
106
|
+
// Wrapper is gone — Trigger and Content are direct children of the parent div.
|
|
107
|
+
assert.equal(
|
|
108
|
+
(result.children || []).length,
|
|
109
|
+
3,
|
|
110
|
+
'Label + Trigger + Content become direct children (3 total)',
|
|
111
|
+
);
|
|
112
|
+
const tags = (result.children || []).map(c => c.tagName);
|
|
113
|
+
assert.deepEqual(
|
|
114
|
+
tags,
|
|
115
|
+
['Label', 'SelectTrigger', 'SelectContent'],
|
|
116
|
+
'Hoisted children preserve their original order',
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const triggers = findElements(result, 'SelectTrigger');
|
|
120
|
+
assert.equal(triggers.length, 1, 'exactly one Trigger after hoist');
|
|
121
|
+
assert.equal(
|
|
122
|
+
triggers[0].props?.__selectRootSelectedValue,
|
|
123
|
+
'usdc',
|
|
124
|
+
'Trigger carries the Select value as __selectRootSelectedValue',
|
|
125
|
+
);
|
|
126
|
+
assert.equal(
|
|
127
|
+
triggers[0].props?.__selectRootSelectedLabel,
|
|
128
|
+
'USDC',
|
|
129
|
+
'Trigger carries the matched item label so SelectValue can render it without tree walk',
|
|
130
|
+
);
|
|
131
|
+
assert.equal(
|
|
132
|
+
triggers[0].props?.id,
|
|
133
|
+
'collateral',
|
|
134
|
+
'Original Trigger props (id) survive the merge',
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const contents = findElements(result, 'SelectContent');
|
|
138
|
+
assert.equal(contents.length, 1, 'exactly one Content after hoist');
|
|
139
|
+
assert.equal(
|
|
140
|
+
contents[0].props?.__selectRootSelectedValue,
|
|
141
|
+
'usdc',
|
|
142
|
+
'Content carries the same __selectRootSelectedValue',
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Case 2: defaultValue, defaultOpen, and the highlighted-first fallback
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
{
|
|
151
|
+
const tree = element('div', {}, [
|
|
152
|
+
element('Select', { defaultOpen: 'true' }, [
|
|
153
|
+
element('SelectTrigger'),
|
|
154
|
+
element('SelectContent', {}, [
|
|
155
|
+
element('SelectItem', { value: 'first' }, [text('First option')]),
|
|
156
|
+
element('SelectItem', { value: 'second' }, [text('Second option')]),
|
|
157
|
+
]),
|
|
158
|
+
]),
|
|
159
|
+
]);
|
|
160
|
+
|
|
161
|
+
const result = transform(tree);
|
|
162
|
+
const triggers = findElements(result, 'SelectTrigger');
|
|
163
|
+
assert.equal(triggers[0].props?.__selectRootIsOpen, 'true', 'defaultOpen propagates as isOpen=true');
|
|
164
|
+
assert.equal(
|
|
165
|
+
triggers[0].props?.__selectRootHighlightedValue,
|
|
166
|
+
'first',
|
|
167
|
+
'open with no selected value falls back to highlighting the first item',
|
|
168
|
+
);
|
|
169
|
+
assert.equal(
|
|
170
|
+
triggers[0].props?.__selectRootSelectedValue,
|
|
171
|
+
undefined,
|
|
172
|
+
'no selected value when neither value nor defaultValue is set',
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// Case 3: SelectPrimitive.Root flattens the same way as the Select alias
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
{
|
|
181
|
+
const tree = element('div', {}, [
|
|
182
|
+
element('SelectPrimitive.Root', { value: 'sol' }, [
|
|
183
|
+
element('SelectPrimitive.Trigger'),
|
|
184
|
+
element('SelectPrimitive.Content', {}, [
|
|
185
|
+
element('SelectPrimitive.Item', { value: 'sol' }, [text('SOL')]),
|
|
186
|
+
]),
|
|
187
|
+
]),
|
|
188
|
+
]);
|
|
189
|
+
|
|
190
|
+
const result = transform(tree);
|
|
191
|
+
assert.equal(
|
|
192
|
+
(result.children || []).map(c => c.tagName).indexOf('SelectPrimitive.Root'),
|
|
193
|
+
-1,
|
|
194
|
+
'raw SelectPrimitive.Root wrapper is also flattened',
|
|
195
|
+
);
|
|
196
|
+
const triggers = findElements(result, 'SelectPrimitive.Trigger');
|
|
197
|
+
assert.equal(
|
|
198
|
+
triggers[0].props?.__selectRootSelectedLabel,
|
|
199
|
+
'SOL',
|
|
200
|
+
'label lookup also matches SelectPrimitive.Item (the raw Radix tag)',
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// Case 4: Select with a className is NOT flattened (consumer customised it)
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
{
|
|
209
|
+
const tree = element('div', {}, [
|
|
210
|
+
element('Select', { className: 'custom-select', value: 'x' }, [
|
|
211
|
+
element('SelectTrigger'),
|
|
212
|
+
element('SelectContent', {}, []),
|
|
213
|
+
]),
|
|
214
|
+
]);
|
|
215
|
+
|
|
216
|
+
const result = transform(tree);
|
|
217
|
+
const selectWrappers = findElements(result, 'Select');
|
|
218
|
+
assert.equal(
|
|
219
|
+
selectWrappers.length,
|
|
220
|
+
1,
|
|
221
|
+
'Select with a className is preserved — consumer may rely on the wrapper being a real frame',
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// Case 5: Nested transforms — Select inside another flattenable wrapper
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
{
|
|
230
|
+
const tree = element('div', {}, [
|
|
231
|
+
element('Select', { value: 'a' }, [
|
|
232
|
+
element('SelectTrigger'),
|
|
233
|
+
element('SelectContent', {}, [
|
|
234
|
+
element('SelectItem', { value: 'a' }, [text('A')]),
|
|
235
|
+
]),
|
|
236
|
+
]),
|
|
237
|
+
element('Select', { value: 'b' }, [
|
|
238
|
+
element('SelectTrigger'),
|
|
239
|
+
element('SelectContent', {}, [
|
|
240
|
+
element('SelectItem', { value: 'b' }, [text('B')]),
|
|
241
|
+
]),
|
|
242
|
+
]),
|
|
243
|
+
]);
|
|
244
|
+
|
|
245
|
+
const result = transform(tree);
|
|
246
|
+
// Two Selects → six hoisted children (Trigger + Content × 2).
|
|
247
|
+
assert.equal((result.children || []).length, 4, 'two flattened Selects produce 4 hoisted children');
|
|
248
|
+
const labels = findElements(result, 'SelectTrigger').map(t => t.props?.__selectRootSelectedLabel);
|
|
249
|
+
assert.deepEqual(labels, ['A', 'B'], 'each hoisted set carries its own Select context, not the sibling Select\'s');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// Case 6: shadcn-inlined SelectItem — label lives inside SelectPrimitive.ItemText,
|
|
254
|
+
// NOT as a direct text child. Without the deep-walk the annotation falls back
|
|
255
|
+
// to the raw value (mint address) and the trigger shows "EPjFWdd..." instead
|
|
256
|
+
// of "USDC".
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
{
|
|
260
|
+
const usdcMint = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
|
|
261
|
+
const tree = element('div', {}, [
|
|
262
|
+
element('Select', { value: usdcMint }, [
|
|
263
|
+
element('SelectPrimitive.Trigger', {}, [
|
|
264
|
+
element('SelectValue'),
|
|
265
|
+
]),
|
|
266
|
+
element('SelectPrimitive.Content', {}, [
|
|
267
|
+
// The inlined shadcn shape: Item wraps Indicator + ItemText with the label
|
|
268
|
+
// text living one level deeper than the Item itself.
|
|
269
|
+
element('SelectPrimitive.Item', { value: usdcMint }, [
|
|
270
|
+
element('span', {}, [
|
|
271
|
+
element('SelectPrimitive.ItemIndicator', {}, [
|
|
272
|
+
element('Check'),
|
|
273
|
+
]),
|
|
274
|
+
]),
|
|
275
|
+
element('SelectPrimitive.ItemText', {}, [text('USDC')]),
|
|
276
|
+
]),
|
|
277
|
+
element('SelectPrimitive.Item', { value: 'sol-mint' }, [
|
|
278
|
+
element('SelectPrimitive.ItemText', {}, [text('SOL')]),
|
|
279
|
+
]),
|
|
280
|
+
]),
|
|
281
|
+
]),
|
|
282
|
+
]);
|
|
283
|
+
|
|
284
|
+
const result = transform(tree);
|
|
285
|
+
const triggers = findElements(result, 'SelectPrimitive.Trigger');
|
|
286
|
+
assert.equal(
|
|
287
|
+
triggers[0].props?.__selectRootSelectedLabel,
|
|
288
|
+
'USDC',
|
|
289
|
+
'label is found via deep-walk through SelectPrimitive.ItemText — covers shadcn-inlined items',
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
// Case 7: text children inside Select pass through unchanged
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
{
|
|
298
|
+
const tree = element('div', {}, [
|
|
299
|
+
element('Select', {}, [
|
|
300
|
+
text(' '), // whitespace text node
|
|
301
|
+
element('SelectTrigger'),
|
|
302
|
+
]),
|
|
303
|
+
]);
|
|
304
|
+
const result = transform(tree);
|
|
305
|
+
// Whitespace text node hoisted alongside the Trigger
|
|
306
|
+
assert.equal((result.children || []).length, 2, 'text + Trigger both hoisted');
|
|
307
|
+
assert.equal(result.children?.[0].type, 'text', 'text node preserved');
|
|
308
|
+
assert.equal(result.children?.[1].tagName, 'SelectTrigger', 'Trigger preserved');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Avoid "unused" warnings for path / Project.
|
|
312
|
+
void path;
|
|
313
|
+
|
|
314
|
+
console.log('select-root-flatten-regression: PASS');
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
|
|
3
|
+
(globalThis as unknown as { figma: unknown }).figma = {
|
|
4
|
+
notify: () => undefined,
|
|
5
|
+
showUI: () => undefined,
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Regression: a frame authored with `justify-between` that ends up with
|
|
10
|
+
* fewer than 2 in-flow children must collapse its primary alignment
|
|
11
|
+
* back to MIN. Figma's `primaryAxisAlignItems = 'SPACE_BETWEEN'` with a
|
|
12
|
+
* single child renders as "centred" ("Gap: Auto" in the inspector),
|
|
13
|
+
* which is NOT what CSS does — `justify-content: space-between` with
|
|
14
|
+
* one item anchors to start per the spec.
|
|
15
|
+
*
|
|
16
|
+
* Real-world repro: PerpsHeader at the md+ breakpoint —
|
|
17
|
+
* <header className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
|
18
|
+
* <div>{title + description}</div>
|
|
19
|
+
* {actions ? <div>{actions}</div> : null}
|
|
20
|
+
* </header>
|
|
21
|
+
* When the consumer doesn't pass `actions`, the conditional renders
|
|
22
|
+
* nothing → only one child remains → the header centred "Jupiter
|
|
23
|
+
* Perps Desk" inside an otherwise-empty 900-wide bar.
|
|
24
|
+
*
|
|
25
|
+
* The post-children demotion in `buildFigmaNode`'s generic-element
|
|
26
|
+
* path is the universal fix: any node count < 2 with SPACE_BETWEEN
|
|
27
|
+
* primary gets MIN. Absolute-positioned children are excluded from
|
|
28
|
+
* the count since they don't participate in flex distribution.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
interface StubBase {
|
|
32
|
+
type: string;
|
|
33
|
+
layoutMode: 'NONE' | 'HORIZONTAL' | 'VERTICAL';
|
|
34
|
+
primaryAxisAlignItems: 'MIN' | 'CENTER' | 'MAX' | 'SPACE_BETWEEN';
|
|
35
|
+
layoutPositioning?: 'AUTO' | 'ABSOLUTE';
|
|
36
|
+
width: number;
|
|
37
|
+
height: number;
|
|
38
|
+
children: StubBase[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function mkFrame(overrides?: Partial<StubBase>): StubBase {
|
|
42
|
+
return Object.assign(
|
|
43
|
+
{
|
|
44
|
+
type: 'FRAME',
|
|
45
|
+
layoutMode: 'HORIZONTAL' as const,
|
|
46
|
+
primaryAxisAlignItems: 'SPACE_BETWEEN' as const,
|
|
47
|
+
layoutPositioning: 'AUTO' as const,
|
|
48
|
+
width: 100,
|
|
49
|
+
height: 32,
|
|
50
|
+
children: [],
|
|
51
|
+
},
|
|
52
|
+
overrides || {},
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The demotion is a property mutation. Mirror the production logic
|
|
57
|
+
// here so the test exercises the rule against representative shapes
|
|
58
|
+
// without booting the full ui-builder.
|
|
59
|
+
function demoteSpaceBetweenIfSingleChild(frame: StubBase): void {
|
|
60
|
+
if (frame.primaryAxisAlignItems !== 'SPACE_BETWEEN') return;
|
|
61
|
+
const inFlow = frame.children.filter(
|
|
62
|
+
(c) => c.layoutPositioning !== 'ABSOLUTE',
|
|
63
|
+
);
|
|
64
|
+
if (inFlow.length >= 2) return;
|
|
65
|
+
frame.primaryAxisAlignItems = 'MIN';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Case 1 — single in-flow child: SPACE_BETWEEN → MIN
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
{
|
|
72
|
+
const frame = mkFrame({
|
|
73
|
+
children: [mkFrame({ width: 200, primaryAxisAlignItems: 'MIN' })],
|
|
74
|
+
});
|
|
75
|
+
demoteSpaceBetweenIfSingleChild(frame);
|
|
76
|
+
assert.equal(
|
|
77
|
+
frame.primaryAxisAlignItems,
|
|
78
|
+
'MIN',
|
|
79
|
+
'SPACE_BETWEEN with one in-flow child must demote to MIN',
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Case 2 — zero children: SPACE_BETWEEN → MIN
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
{
|
|
87
|
+
const frame = mkFrame({ children: [] });
|
|
88
|
+
demoteSpaceBetweenIfSingleChild(frame);
|
|
89
|
+
assert.equal(
|
|
90
|
+
frame.primaryAxisAlignItems,
|
|
91
|
+
'MIN',
|
|
92
|
+
'SPACE_BETWEEN with zero children must demote to MIN',
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Case 3 — two in-flow children: keep SPACE_BETWEEN
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
{
|
|
100
|
+
const frame = mkFrame({
|
|
101
|
+
children: [
|
|
102
|
+
mkFrame({ width: 200, primaryAxisAlignItems: 'MIN' }),
|
|
103
|
+
mkFrame({ width: 100, primaryAxisAlignItems: 'MIN' }),
|
|
104
|
+
],
|
|
105
|
+
});
|
|
106
|
+
demoteSpaceBetweenIfSingleChild(frame);
|
|
107
|
+
assert.equal(
|
|
108
|
+
frame.primaryAxisAlignItems,
|
|
109
|
+
'SPACE_BETWEEN',
|
|
110
|
+
'SPACE_BETWEEN with two children must be preserved',
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Case 4 — one in-flow + one absolute-positioned: demote.
|
|
116
|
+
// Absolutes don't participate in flex distribution, so the lone in-flow
|
|
117
|
+
// child would still be centred by SPACE_BETWEEN.
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
{
|
|
120
|
+
const frame = mkFrame({
|
|
121
|
+
children: [
|
|
122
|
+
mkFrame({ width: 200, primaryAxisAlignItems: 'MIN' }),
|
|
123
|
+
mkFrame({
|
|
124
|
+
width: 50,
|
|
125
|
+
primaryAxisAlignItems: 'MIN',
|
|
126
|
+
layoutPositioning: 'ABSOLUTE',
|
|
127
|
+
}),
|
|
128
|
+
],
|
|
129
|
+
});
|
|
130
|
+
demoteSpaceBetweenIfSingleChild(frame);
|
|
131
|
+
assert.equal(
|
|
132
|
+
frame.primaryAxisAlignItems,
|
|
133
|
+
'MIN',
|
|
134
|
+
'SPACE_BETWEEN with one in-flow + one absolute child must demote',
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Case 5 — already MIN: no-op
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
{
|
|
142
|
+
const frame = mkFrame({ primaryAxisAlignItems: 'MIN', children: [] });
|
|
143
|
+
demoteSpaceBetweenIfSingleChild(frame);
|
|
144
|
+
assert.equal(frame.primaryAxisAlignItems, 'MIN', 'MIN stays MIN');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Case 6 — CENTER alignment with one child: not SPACE_BETWEEN so unchanged.
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
{
|
|
151
|
+
const frame = mkFrame({
|
|
152
|
+
primaryAxisAlignItems: 'CENTER',
|
|
153
|
+
children: [mkFrame({ width: 200, primaryAxisAlignItems: 'MIN' })],
|
|
154
|
+
});
|
|
155
|
+
demoteSpaceBetweenIfSingleChild(frame);
|
|
156
|
+
assert.equal(
|
|
157
|
+
frame.primaryAxisAlignItems,
|
|
158
|
+
'CENTER',
|
|
159
|
+
'CENTER with one child is preserved (only SPACE_BETWEEN is demoted)',
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
console.log('space-between-single-child-regression: ok (6 cases)');
|