inkbridge 0.1.0-beta.2 → 0.1.0-beta.21
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 +108 -25
- package/bin/inkbridge.mjs +354 -83
- package/code.js +40 -11802
- package/manifest.json +1 -0
- package/package.json +74 -23
- package/scanner/adapter-utils-regression.ts +159 -0
- package/scanner/aspect-percent-position-regression.ts +237 -0
- package/scanner/aspect-ratio-regression.ts +90 -0
- package/scanner/blob-placement-regression.ts +2 -2
- package/scanner/block-cache-regression.ts +195 -0
- package/scanner/bundle-size-regression.ts +50 -0
- package/scanner/child-sizing-matrix-regression.ts +303 -0
- package/scanner/cli.ts +342 -13
- package/scanner/component-scanner.ts +2108 -174
- package/scanner/component-sections-regression.ts +198 -0
- package/scanner/compound-classes-lookup-regression.ts +163 -0
- package/scanner/css-token-reader-regression.ts +7 -6
- package/scanner/css-token-reader.ts +152 -31
- package/scanner/cva-jsx-child-fallback-regression.ts +98 -0
- package/scanner/cva-master-icon-regression.ts +315 -0
- package/scanner/data-attr-prop-alias-regression.ts +129 -0
- package/scanner/explicit-size-root-regression.ts +102 -0
- package/scanner/font-family-extract-regression.ts +113 -0
- package/scanner/font-style-resolver-regression.ts +1 -1
- package/scanner/framework-adapter-shadcn-regression.ts +480 -0
- package/scanner/full-width-matrix-regression.ts +338 -0
- package/scanner/grid-cols-extraction-regression.ts +110 -0
- package/scanner/image-src-collector-regression.ts +204 -0
- package/scanner/inline-flex-regression.ts +235 -0
- package/scanner/input-range-regression.ts +217 -0
- package/scanner/instance-rendering-regression.ts +224 -0
- package/scanner/jsx-prop-unresolved-regression.ts +178 -0
- package/scanner/jsx-text-regression.ts +178 -0
- package/scanner/layout-alignment-regression.ts +108 -0
- package/scanner/layout-flex-regression.ts +90 -0
- package/scanner/layout-mode-regression.ts +71 -0
- package/scanner/layout-sizing-regression.ts +227 -0
- package/scanner/layout-spacing-regression.ts +135 -0
- package/scanner/local-const-className-regression.ts +331 -0
- package/scanner/percent-position-regression.ts +105 -0
- package/scanner/provider-cascade-regression.ts +224 -0
- package/scanner/provider-flatten-regression.ts +235 -0
- package/scanner/radial-gradient-regression.ts +1 -1
- package/scanner/render-prop-parser-regression.ts +161 -0
- package/scanner/ring-utility-regression.ts +153 -0
- package/scanner/sandbox-spread-regression.ts +125 -0
- package/scanner/selection-pressed-regression.ts +241 -0
- package/scanner/size-full-normalization-regression.ts +127 -0
- package/scanner/state-classification-regression.ts +175 -0
- package/scanner/story-diagnostics-regression.ts +216 -0
- package/scanner/story-dimensioning-regression.ts +298 -0
- package/scanner/story-render-strategy-regression.ts +205 -0
- package/scanner/stretch-to-parent-width-regression.ts +147 -0
- package/scanner/svg-fill-parent-regression.ts +98 -0
- package/scanner/svg-group-inheritance-regression.ts +166 -0
- package/scanner/svg-marker-inline-regression.ts +211 -0
- package/scanner/svg-marker-regression.ts +116 -0
- package/scanner/tailwind-parser.ts +46 -4
- package/scanner/text-resize-matrix-regression.ts +173 -0
- package/scanner/transform-math-regression.ts +1 -1
- package/scanner/types.ts +26 -2
- package/src/cache/frame-cache.ts +150 -0
- package/src/cache/index.ts +2 -0
- package/src/{component-defs.ts → components/component-defs.ts} +25 -10
- package/src/{component-gen.ts → components/component-gen.ts} +43 -116
- package/src/components/component-instance.ts +386 -0
- package/src/components/component-library.ts +44 -0
- package/src/components/component-lookup.ts +161 -0
- package/src/components/index.ts +7 -0
- package/src/components/scanner-types.ts +39 -0
- package/src/components/symbol-instance-policy.ts +312 -0
- package/src/design-system/block-cache.ts +130 -0
- package/src/design-system/component-sections.ts +107 -0
- package/src/design-system/cva-inference.ts +187 -0
- package/src/design-system/cva-master.ts +427 -0
- package/src/design-system/cva-utils.ts +29 -0
- package/src/design-system/design-system.ts +334 -0
- package/src/design-system/frame-stabilizers.ts +191 -0
- package/src/design-system/frame-utils.ts +46 -0
- package/src/design-system/generated-node.ts +84 -0
- package/src/design-system/icon-rendering.ts +229 -0
- package/src/design-system/index.ts +13 -0
- package/src/design-system/instance-rendering.ts +307 -0
- package/src/design-system/master-shared.ts +133 -0
- package/src/design-system/node-helpers.ts +237 -0
- package/src/design-system/node-variants.ts +196 -0
- package/src/design-system/non-cva-master.ts +104 -0
- package/src/design-system/portal-handling.ts +138 -0
- package/src/design-system/preview-builder.ts +738 -0
- package/src/{render-context.ts → design-system/render-context.ts} +32 -6
- package/src/design-system/render-prop-parser.ts +50 -0
- package/src/design-system/responsive-resolver.ts +180 -0
- package/src/design-system/selectable-state.ts +157 -0
- package/src/design-system/state-master.ts +267 -0
- package/src/design-system/state-utils.ts +15 -0
- package/src/design-system/story-builder-context.ts +40 -0
- package/src/design-system/story-builder.ts +1322 -0
- package/src/design-system/story-diagnostics.ts +80 -0
- package/src/design-system/story-dimensioning.ts +272 -0
- package/src/design-system/story-frames.ts +400 -0
- package/src/design-system/story-instance.ts +333 -0
- package/src/{story-layout.ts → design-system/story-layout.ts} +2 -2
- package/src/design-system/story-render-strategy.ts +150 -0
- package/src/design-system/story-tree-search.ts +110 -0
- package/src/design-system/symbol-fallback.ts +89 -0
- package/src/design-system/symbol-source.ts +172 -0
- package/src/design-system/table-helpers.ts +56 -0
- package/src/design-system/tag-predicates.ts +99 -0
- package/src/design-system/theme-context.ts +52 -0
- package/src/design-system/typography.ts +100 -0
- package/src/design-system/ui-builder.ts +2676 -0
- package/src/{clip-path-decorative.ts → effects/clip-path-decorative.ts} +11 -11
- package/src/effects/icon-builder.ts +1074 -0
- package/src/effects/index.ts +5 -0
- package/src/effects/portal-panel.ts +369 -0
- package/src/{radial-gradient.ts → effects/radial-gradient.ts} +1 -1
- package/src/framework-adapters/index.ts +47 -0
- package/src/framework-adapters/shadcn.ts +541 -0
- package/src/{github.ts → github/github.ts} +46 -21
- package/src/github/index.ts +1 -0
- package/src/layout/deferred-layout.ts +1556 -0
- package/src/layout/index.ts +24 -0
- package/src/layout/layout-parser.ts +375 -0
- package/src/{layout-utils.ts → layout/layout-utils.ts} +23 -17
- package/src/layout/parser/alignment.ts +54 -0
- package/src/layout/parser/flex.ts +59 -0
- package/src/layout/parser/index.ts +65 -0
- package/src/layout/parser/ir.ts +80 -0
- package/src/layout/parser/layout-mode.ts +57 -0
- package/src/layout/parser/sizing.ts +241 -0
- package/src/layout/parser/spacing-scale.ts +78 -0
- package/src/layout/parser/spacing.ts +134 -0
- package/src/layout/ring-utils.ts +120 -0
- package/src/layout/size-utils.ts +143 -0
- package/src/layout/text-resize-decision.ts +51 -0
- package/src/{width-solver.ts → layout/width-solver.ts} +168 -37
- package/src/main.ts +444 -162
- package/src/{config.ts → plugin/config.ts} +12 -12
- package/src/{dev-server.ts → plugin/dev-server.ts} +3 -3
- package/src/plugin/image-src-collector.ts +52 -0
- package/src/plugin/index.ts +3 -0
- package/src/plugin/packs/index.ts +2 -0
- package/src/{pack-provider.ts → plugin/packs/pack-provider.ts} +12 -12
- package/src/{packs.ts → plugin/packs/packs.ts} +22 -17
- package/src/render-engine-version.ts +2 -0
- package/src/tailwind/adapter-utils.ts +137 -0
- package/src/{class-utils.ts → tailwind/class-utils.ts} +33 -6
- package/src/tailwind/index.ts +8 -0
- package/src/tailwind/jsx-utils.ts +319 -0
- package/src/{node-ir.ts → tailwind/node-ir.ts} +208 -19
- package/src/{responsive-analyzer.ts → tailwind/responsive-analyzer.ts} +32 -2
- package/src/{state-analyzer.ts → tailwind/state-analyzer.ts} +71 -5
- package/src/{tailwind.ts → tailwind/tailwind.ts} +423 -674
- package/src/{utility-resolver.ts → tailwind/utility-resolver.ts} +27 -6
- package/src/{font-style-resolver.ts → text/font-style-resolver.ts} +0 -2
- package/src/text/index.ts +4 -0
- package/src/{inline-text.ts → text/inline-text.ts} +13 -13
- package/src/{text-builder.ts → text/text-builder.ts} +24 -7
- package/src/{text-line.ts → text/text-line.ts} +2 -2
- package/src/{change-detection.ts → tokens/change-detection.ts} +12 -12
- package/src/{color-resolver.ts → tokens/color-resolver.ts} +1 -6
- package/src/{colors.ts → tokens/colors.ts} +13 -6
- package/src/tokens/index.ts +6 -0
- package/src/{token-source.ts → tokens/token-source.ts} +4 -1
- package/src/{tokens.ts → tokens/tokens.ts} +116 -20
- package/src/{variables.ts → tokens/variables.ts} +447 -102
- package/templates/patch-tokens-route.ts +25 -6
- package/templates/scan-components-route.ts +26 -5
- package/ui.html +485 -37
- package/src/component-lookup.ts +0 -82
- package/src/design-system.ts +0 -59
- package/src/icon-builder.ts +0 -607
- package/src/layout-parser.ts +0 -667
- package/src/story-builder.ts +0 -1706
- package/src/ui-builder.ts +0 -1996
- /package/src/{image-cache.ts → cache/image-cache.ts} +0 -0
- /package/src/{blob-placement.ts → effects/blob-placement.ts} +0 -0
- /package/src/{transform-math.ts → tailwind/transform-math.ts} +0 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { getBaseClass, mergeClasses, parseUtilityClass } from '../tailwind';
|
|
2
|
+
import { parseSquareSizeToken } from '../layout';
|
|
3
|
+
import { getComponentDefByName } from '../components';
|
|
4
|
+
import {
|
|
5
|
+
isAccordionRootTag,
|
|
6
|
+
isAccordionItemTag,
|
|
7
|
+
isSelectItemIndicatorTag,
|
|
8
|
+
isRadioGroupIndicatorTag,
|
|
9
|
+
} from './tag-predicates';
|
|
10
|
+
import type { NodeIR } from '../tailwind';
|
|
11
|
+
import type { RenderContext } from './render-context';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* NodeIR tree helpers shared by ui-builder and the rendering pipeline.
|
|
15
|
+
* Mostly about resolving the *effective* classes for a node (taking
|
|
16
|
+
* scanned component defs into account), per-child render-context
|
|
17
|
+
* derivation, and small structural utilities.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Unwraps transparent single-child component wrappers so the caller's loop
|
|
22
|
+
* sees the visual child directly. Matches the same short-circuit applied
|
|
23
|
+
* during scanner classification.
|
|
24
|
+
*
|
|
25
|
+
* Without the `compDef` guard a real CVA component like `<Badge variant="soft">`
|
|
26
|
+
* (which has empty `classes` because the className is computed inside the
|
|
27
|
+
* component) would be unwrapped to its single text child, dropping the
|
|
28
|
+
* symbol entirely.
|
|
29
|
+
*/
|
|
30
|
+
export function unwrapTransparentWrapper(node: NodeIR): NodeIR {
|
|
31
|
+
let current = node;
|
|
32
|
+
for (let i = 0; i < 8; i++) {
|
|
33
|
+
if (current.kind !== 'component') return current;
|
|
34
|
+
if ((current.classes || []).length !== 0) return current;
|
|
35
|
+
if (!current.children || current.children.length !== 1) return current;
|
|
36
|
+
if (current.tagName.toLowerCase().endsWith('trigger')) return current;
|
|
37
|
+
if (getComponentDefByName(current.tagName)) return current;
|
|
38
|
+
// Context-sensitive indicator wrappers (Select/RadioGroup item indicators)
|
|
39
|
+
// gate their child's visibility on the parent Item being selected/checked.
|
|
40
|
+
// The gating check lives in `buildFigmaNode` and only runs when this
|
|
41
|
+
// function preserves the wrapper — without this guard, the wrapper was
|
|
42
|
+
// unwrapped to its check/dot icon BEFORE the suppression could fire, and
|
|
43
|
+
// every non-selected item rendered a stale indicator (the recurring
|
|
44
|
+
// "checkmarks on every Select item" bug).
|
|
45
|
+
if (isSelectItemIndicatorTag(current.tagName)) return current;
|
|
46
|
+
if (isRadioGroupIndicatorTag(current.tagName)) return current;
|
|
47
|
+
current = current.children[0];
|
|
48
|
+
}
|
|
49
|
+
return current;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createPerChildRenderContext(
|
|
53
|
+
baseContext: RenderContext,
|
|
54
|
+
parentNode: NodeIR,
|
|
55
|
+
child: NodeIR,
|
|
56
|
+
accordionItemOrdinal: { value: number }
|
|
57
|
+
): RenderContext {
|
|
58
|
+
const childContext: RenderContext = { ...baseContext };
|
|
59
|
+
if (
|
|
60
|
+
(parentNode.kind === 'element' || parentNode.kind === 'component') &&
|
|
61
|
+
isAccordionRootTag(parentNode.tagName) &&
|
|
62
|
+
(child.kind === 'element' || child.kind === 'component') &&
|
|
63
|
+
isAccordionItemTag(child.tagName)
|
|
64
|
+
) {
|
|
65
|
+
childContext.accordionItemIndex = accordionItemOrdinal.value++;
|
|
66
|
+
}
|
|
67
|
+
return childContext;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function getRenderableChildren(node: NodeIR, classes: string[]): NodeIR[] {
|
|
71
|
+
if (node.kind !== 'element' && node.kind !== 'component') return [];
|
|
72
|
+
const children = node.children.slice();
|
|
73
|
+
if (node.kind !== 'element' || node.tagLower !== 'table') return children;
|
|
74
|
+
if (!classes.includes('caption-bottom')) return children;
|
|
75
|
+
|
|
76
|
+
const captions: NodeIR[] = [];
|
|
77
|
+
const others: NodeIR[] = [];
|
|
78
|
+
for (const child of children) {
|
|
79
|
+
if ((child.kind === 'element' || child.kind === 'component') && child.tagLower === 'caption') captions.push(child);
|
|
80
|
+
else others.push(child);
|
|
81
|
+
}
|
|
82
|
+
return captions.length > 0 ? others.concat(captions) : children;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function normalizeComponentLookupKey(value: string): string {
|
|
86
|
+
return String(value || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
90
|
+
export function shouldMergeDefBaseClassesForTag(def: any, tagName: string): boolean {
|
|
91
|
+
if (!def || !def.name || !tagName) return false;
|
|
92
|
+
// Do not merge scanned component base classes into intrinsic HTML tags.
|
|
93
|
+
// Example: a native `button` inside Dialog.Close should not inherit the
|
|
94
|
+
// global Button component's class bag.
|
|
95
|
+
if (tagName === tagName.toLowerCase()) return false;
|
|
96
|
+
return normalizeComponentLookupKey(def.name) === normalizeComponentLookupKey(tagName);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Unwraps the scanner's nested `{ analysis: ... }` envelope when present,
|
|
101
|
+
* preserving the metadata fields (stories, layout, responsive, colorScheme,
|
|
102
|
+
* etc.) that live alongside `analysis`.
|
|
103
|
+
*/
|
|
104
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
105
|
+
export function normalizeComponentDef(raw: any): any {
|
|
106
|
+
if (!raw || !raw.analysis) return raw;
|
|
107
|
+
|
|
108
|
+
// Scanner output stores component metadata (stories, layout, responsive, colorScheme)
|
|
109
|
+
// alongside the nested `analysis`. Many render paths want the analysis shape but still
|
|
110
|
+
// need the attached metadata, so preserve it when normalizing.
|
|
111
|
+
return {
|
|
112
|
+
...raw.analysis,
|
|
113
|
+
filePath: raw.analysis.filePath || raw.filePath,
|
|
114
|
+
stories: Array.isArray(raw.analysis.stories)
|
|
115
|
+
? raw.analysis.stories
|
|
116
|
+
: (Array.isArray(raw.stories) ? raw.stories : []),
|
|
117
|
+
hasStory: typeof raw.analysis.hasStory === 'boolean'
|
|
118
|
+
? raw.analysis.hasStory
|
|
119
|
+
: !!raw.hasStory,
|
|
120
|
+
layout: raw.layout,
|
|
121
|
+
responsive: raw.responsive,
|
|
122
|
+
colorScheme: raw.colorScheme,
|
|
123
|
+
kind: raw.kind,
|
|
124
|
+
usesCount: raw.usesCount,
|
|
125
|
+
usedByCount: raw.usedByCount,
|
|
126
|
+
isLeaf: raw.isLeaf,
|
|
127
|
+
symbolCandidate: raw.symbolCandidate,
|
|
128
|
+
safeTextOverrideProps: Array.isArray(raw.safeTextOverrideProps) ? raw.safeTextOverrideProps : undefined,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function getNodeEffectiveClasses(node: NodeIR): string[] {
|
|
133
|
+
if (node.kind !== 'element' && node.kind !== 'component') return [];
|
|
134
|
+
if (node.kind === 'element') return node.classes || [];
|
|
135
|
+
|
|
136
|
+
const ownClasses = node.classes || [];
|
|
137
|
+
const def = getComponentDefByName(node.tagName);
|
|
138
|
+
if (!def) return ownClasses;
|
|
139
|
+
const normalized = normalizeComponentDef(def);
|
|
140
|
+
const baseClasses = Array.isArray(normalized?.baseClasses)
|
|
141
|
+
? normalized.baseClasses
|
|
142
|
+
: (Array.isArray(normalized?.classes) ? normalized.classes : []);
|
|
143
|
+
if (!baseClasses || baseClasses.length === 0) return ownClasses;
|
|
144
|
+
if (!shouldMergeDefBaseClassesForTag(normalized, node.tagName)) return ownClasses;
|
|
145
|
+
if (normalized?.type === 'simple') {
|
|
146
|
+
// Scanner "simple" class bags are file-wide and can include child-only display/layout classes.
|
|
147
|
+
// Prefer the instance classes; only fall back to sanitized base classes when none exist.
|
|
148
|
+
if (ownClasses.length > 0) return ownClasses;
|
|
149
|
+
return pickPreservedWrapperBaseClasses(baseClasses);
|
|
150
|
+
}
|
|
151
|
+
return mergeClasses(baseClasses, ownClasses);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function getNodeMarginTopPx(node: NodeIR): number {
|
|
155
|
+
const classes = getNodeEffectiveClasses(node);
|
|
156
|
+
let maxMarginTop = 0;
|
|
157
|
+
for (const cls of classes) {
|
|
158
|
+
const atom = parseUtilityClass(cls);
|
|
159
|
+
if (!atom || !atom.utility) continue;
|
|
160
|
+
if (Array.isArray(atom.variants) && atom.variants.length > 0) continue;
|
|
161
|
+
const match = atom.utility.match(/^mt-(.+)$/);
|
|
162
|
+
if (!match) continue;
|
|
163
|
+
const resolved = parseSquareSizeToken(match[1]);
|
|
164
|
+
if (resolved != null && Number.isFinite(resolved) && resolved > maxMarginTop) {
|
|
165
|
+
maxMarginTop = resolved;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return maxMarginTop;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* From a scanned component's bag of base classes, keep only the structural
|
|
173
|
+
* intent (positioning, container spacing, the first display class, gap /
|
|
174
|
+
* grid-template tokens) while dropping child-only classes that would
|
|
175
|
+
* over-style a wrapper frame.
|
|
176
|
+
*/
|
|
177
|
+
export function pickPreservedWrapperBaseClasses(defClasses: string[]): string[] {
|
|
178
|
+
if (!Array.isArray(defClasses) || defClasses.length === 0) return [];
|
|
179
|
+
|
|
180
|
+
const out: string[] = [];
|
|
181
|
+
let pickedDisplay = false;
|
|
182
|
+
|
|
183
|
+
for (const cls of defClasses) {
|
|
184
|
+
const base = getBaseClass(cls);
|
|
185
|
+
if (!base) continue;
|
|
186
|
+
|
|
187
|
+
// Positioning + container spacing
|
|
188
|
+
if (base === 'relative' || base === 'isolate') {
|
|
189
|
+
out.push(cls);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (base.startsWith('p-') || base.startsWith('px-') || base.startsWith('py-')) {
|
|
193
|
+
out.push(cls);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (base.startsWith('rounded')) {
|
|
197
|
+
out.push(cls);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Preserve only the first display class from base definitions.
|
|
202
|
+
// Scanner class bags can include child classes; taking the first display signal
|
|
203
|
+
// keeps root intent (e.g. grid) and avoids later child overrides (e.g. inline-flex).
|
|
204
|
+
const isDisplayClass =
|
|
205
|
+
base === 'flex'
|
|
206
|
+
|| base === 'inline-flex'
|
|
207
|
+
|| base === 'grid'
|
|
208
|
+
|| base === 'inline-grid'
|
|
209
|
+
|| base === 'block'
|
|
210
|
+
|| base === 'inline-block'
|
|
211
|
+
|| base === 'contents'
|
|
212
|
+
|| base === 'flow-root';
|
|
213
|
+
if (isDisplayClass) {
|
|
214
|
+
if (!pickedDisplay) {
|
|
215
|
+
out.push(cls);
|
|
216
|
+
pickedDisplay = true;
|
|
217
|
+
}
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (
|
|
222
|
+
base.startsWith('gap-')
|
|
223
|
+
|| base.startsWith('gap-x-')
|
|
224
|
+
|| base.startsWith('gap-y-')
|
|
225
|
+
|| base.startsWith('space-x-')
|
|
226
|
+
|| base.startsWith('space-y-')
|
|
227
|
+
|| base.startsWith('grid-cols-')
|
|
228
|
+
|| base.startsWith('grid-rows-')
|
|
229
|
+
|| base.startsWith('auto-cols-')
|
|
230
|
+
|| base.startsWith('auto-rows-')
|
|
231
|
+
) {
|
|
232
|
+
out.push(cls);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { parseUtilityClass } from '../tailwind';
|
|
2
|
+
import type { NodeIR } from '../tailwind';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Evaluates Tailwind v4 conditional variants (`data-[…]`, `has-[…]`,
|
|
6
|
+
* `has-data-[…]`, compound `has-[[…].…]`) against a resolved NodeIR. Used
|
|
7
|
+
* by ui-builder to "activate" conditionally-applied utilities — i.e. when
|
|
8
|
+
* the variant predicate matches the node's actual props/structure, the
|
|
9
|
+
* underlying utility is appended to the live class list.
|
|
10
|
+
*
|
|
11
|
+
* Pure: NodeIR-only walk, no Figma calls.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Aliases that map a `data-*` lookup to the React/JSX prop conventions
|
|
16
|
+
* various component libraries use to express the same state. The variant
|
|
17
|
+
* engine itself only knows `data-*` selectors, but consumers write
|
|
18
|
+
* `defaultPressed`, `defaultChecked`, `disabled`, etc. as plain props.
|
|
19
|
+
*
|
|
20
|
+
* Without this table, `data-[pressed]:bg-accent` on a `<Toggle defaultPressed>`
|
|
21
|
+
* never resolves — there's no literal `data-pressed` attribute on the node,
|
|
22
|
+
* only the React prop. With it, `defaultPressed` flowing through after
|
|
23
|
+
* scanner expansion is treated as if the element had `data-pressed="true"`
|
|
24
|
+
* for variant-matching purposes.
|
|
25
|
+
*
|
|
26
|
+
* Only consulted by `getNodePropValue` when a lookup for a literal `data-*`
|
|
27
|
+
* key misses — so it never overrides an explicit attribute the consumer set.
|
|
28
|
+
*/
|
|
29
|
+
const DATA_ATTR_PROP_ALIASES: Record<string, ReadonlyArray<string>> = {
|
|
30
|
+
'data-pressed': ['defaultPressed', 'pressed'],
|
|
31
|
+
'data-disabled': ['disabled', 'aria-disabled'],
|
|
32
|
+
'data-checked': ['checked', 'defaultChecked', 'aria-checked'],
|
|
33
|
+
'data-selected': ['selected', 'aria-selected'],
|
|
34
|
+
'data-open': ['open', 'defaultOpen'],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function isTruthyPropValue(value: unknown): boolean {
|
|
38
|
+
if (value === true) return true;
|
|
39
|
+
if (typeof value === 'string') {
|
|
40
|
+
const lower = value.trim().toLowerCase();
|
|
41
|
+
return lower === 'true' || lower === 'on';
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getNodePropValue(node: NodeIR, key: string): string | null {
|
|
47
|
+
if ((node.kind !== 'element' && node.kind !== 'component') || !node.props) return null;
|
|
48
|
+
const props = node.props as Record<string, unknown>;
|
|
49
|
+
const value = props[key];
|
|
50
|
+
if (value != null) return String(value);
|
|
51
|
+
const aliases = DATA_ATTR_PROP_ALIASES[key];
|
|
52
|
+
if (aliases) {
|
|
53
|
+
for (let i = 0; i < aliases.length; i++) {
|
|
54
|
+
if (isTruthyPropValue(props[aliases[i]])) return 'true';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function nodeMatchesDataVariant(node: NodeIR, variant: string): boolean {
|
|
61
|
+
const equalMatch = variant.match(/^data-\[([a-zA-Z0-9_-]+)=([^\]]+)\]$/);
|
|
62
|
+
if (equalMatch) {
|
|
63
|
+
const attr = 'data-' + equalMatch[1];
|
|
64
|
+
const expected = equalMatch[2];
|
|
65
|
+
const actual = getNodePropValue(node, attr);
|
|
66
|
+
return actual != null && actual.trim() === expected;
|
|
67
|
+
}
|
|
68
|
+
const presentMatch = variant.match(/^data-\[([a-zA-Z0-9_-]+)\]$/);
|
|
69
|
+
if (presentMatch) {
|
|
70
|
+
const attr = 'data-' + presentMatch[1];
|
|
71
|
+
return getNodePropValue(node, attr) != null;
|
|
72
|
+
}
|
|
73
|
+
// Tailwind v4 / shadcn bare syntax: `data-highlighted` (without brackets).
|
|
74
|
+
const bareMatch = variant.match(/^data-([a-zA-Z][a-zA-Z0-9_-]*)$/);
|
|
75
|
+
if (bareMatch) {
|
|
76
|
+
const attr = 'data-' + bareMatch[1];
|
|
77
|
+
return getNodePropValue(node, attr) != null;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function getNodeChildren(node: NodeIR): NodeIR[] {
|
|
83
|
+
if (node.kind === 'element' || node.kind === 'component' || node.kind === 'fragment') {
|
|
84
|
+
return node.children;
|
|
85
|
+
}
|
|
86
|
+
if (node.kind === 'ring') return [node.child];
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function nodeHasDescendantDataVariant(node: NodeIR, variant: string): boolean {
|
|
91
|
+
const equalMatch = variant.match(/^has-data-\[([a-zA-Z0-9_-]+)=([^\]]+)\]$/);
|
|
92
|
+
const presentMatch = variant.match(/^has-data-\[([a-zA-Z0-9_-]+)\]$/);
|
|
93
|
+
if (!equalMatch && !presentMatch) return false;
|
|
94
|
+
|
|
95
|
+
const attr = 'data-' + (equalMatch ? equalMatch[1] : presentMatch![1]);
|
|
96
|
+
const expected = equalMatch ? equalMatch[2] : null;
|
|
97
|
+
|
|
98
|
+
const stack: NodeIR[] = getNodeChildren(node).slice();
|
|
99
|
+
while (stack.length > 0) {
|
|
100
|
+
const current = stack.pop() as NodeIR;
|
|
101
|
+
const value = getNodePropValue(current, attr);
|
|
102
|
+
if (value != null) {
|
|
103
|
+
if (expected == null || value.trim() === expected) return true;
|
|
104
|
+
}
|
|
105
|
+
const children = getNodeChildren(current);
|
|
106
|
+
if (children.length > 0) {
|
|
107
|
+
for (let i = 0; i < children.length; i++) {
|
|
108
|
+
stack.push(children[i]);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function nodeHasClass(node: NodeIR, className: string): boolean {
|
|
116
|
+
if (node.kind !== 'element' && node.kind !== 'component') return false;
|
|
117
|
+
return Array.isArray(node.classes) && node.classes.indexOf(className) !== -1;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function nodeHasDescendantCompoundSelector(node: NodeIR, variant: string): boolean {
|
|
121
|
+
// Matches patterns like `has-[[data-slot=card-header].border-b]` — any
|
|
122
|
+
// descendant that carries BOTH the data attribute and the class.
|
|
123
|
+
const compoundMatch = variant.match(/^has-\[\[([a-zA-Z0-9_-]+)(?:=([^\]]+))?\]\.([a-zA-Z0-9_:\[\]\/-]+)\]$/);
|
|
124
|
+
if (!compoundMatch) return false;
|
|
125
|
+
const attr = compoundMatch[1];
|
|
126
|
+
const expected = compoundMatch[2] != null ? compoundMatch[2] : null;
|
|
127
|
+
const className = compoundMatch[3];
|
|
128
|
+
|
|
129
|
+
const stack: NodeIR[] = getNodeChildren(node).slice();
|
|
130
|
+
while (stack.length > 0) {
|
|
131
|
+
const current = stack.pop() as NodeIR;
|
|
132
|
+
const attrValue = getNodePropValue(current, attr);
|
|
133
|
+
const attrMatches = attrValue != null && (expected == null || attrValue.trim() === expected);
|
|
134
|
+
if (attrMatches && nodeHasClass(current, className)) return true;
|
|
135
|
+
const children = getNodeChildren(current);
|
|
136
|
+
for (let i = 0; i < children.length; i++) stack.push(children[i]);
|
|
137
|
+
}
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function nodeMatchesHasVariant(node: NodeIR, variant: string): boolean {
|
|
142
|
+
if (variant === 'has-[>img:first-child]') {
|
|
143
|
+
const children = getNodeChildren(node);
|
|
144
|
+
if (children.length === 0) return false;
|
|
145
|
+
for (let i = 0; i < children.length; i++) {
|
|
146
|
+
const child = children[i];
|
|
147
|
+
if (child.kind !== 'element' && child.kind !== 'component') continue;
|
|
148
|
+
return child.tagLower === 'img';
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
if (nodeHasDescendantDataVariant(node, variant)) return true;
|
|
153
|
+
return nodeHasDescendantCompoundSelector(node, variant);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isActivatedConditionalVariant(node: NodeIR, variant: string): boolean {
|
|
157
|
+
if (!variant) return false;
|
|
158
|
+
if (nodeMatchesDataVariant(node, variant)) return true;
|
|
159
|
+
if (nodeMatchesHasVariant(node, variant)) return true;
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* For each class with conditional variants (`data-*`, `has-*`), if every
|
|
165
|
+
* variant predicate matches the node, append the underlying utility to
|
|
166
|
+
* the active class list. This is the "if the data-state is open, also
|
|
167
|
+
* apply `bg-accent`" expansion the renderer needs to do at draw time.
|
|
168
|
+
*/
|
|
169
|
+
export function expandActiveConditionalVariants(classes: string[], node: NodeIR): string[] {
|
|
170
|
+
if (!classes || classes.length === 0) return classes;
|
|
171
|
+
const out = classes.slice();
|
|
172
|
+
const seen: Record<string, true> = {};
|
|
173
|
+
for (let i = 0; i < out.length; i++) {
|
|
174
|
+
seen[out[i]] = true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
for (let i = 0; i < classes.length; i++) {
|
|
178
|
+
const atom = parseUtilityClass(classes[i]);
|
|
179
|
+
if (!atom || !atom.utility) continue;
|
|
180
|
+
if (!Array.isArray(atom.variants) || atom.variants.length === 0) continue;
|
|
181
|
+
|
|
182
|
+
let allMatched = true;
|
|
183
|
+
for (let j = 0; j < atom.variants.length; j++) {
|
|
184
|
+
if (!isActivatedConditionalVariant(node, atom.variants[j])) {
|
|
185
|
+
allMatched = false;
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (!allMatched) continue;
|
|
190
|
+
if (seen[atom.utility]) continue;
|
|
191
|
+
seen[atom.utility] = true;
|
|
192
|
+
out.push(atom.utility);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { findChildByName, getFrameHash, setFrameHash, hashString, hashDef } from '../cache';
|
|
2
|
+
import { RENDER_ENGINE_VERSION } from '../render-engine-version';
|
|
3
|
+
import { tagGeneratedNode } from './generated-node';
|
|
4
|
+
import { getThemeContext } from './theme-context';
|
|
5
|
+
import { ENABLE_SYMBOL_MASTERS, ensureThemeComponentLibrary } from './master-shared';
|
|
6
|
+
import { buildNonCvaSymbolSourceNode } from './story-frames';
|
|
7
|
+
import { ensureCvaComponentSet } from './cva-master';
|
|
8
|
+
import { ensureStateComponentSet } from './state-master';
|
|
9
|
+
import type { StoryBuilderContext } from './story-builder-context';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Master creation for non-CVA component types (simple / compound) and the
|
|
13
|
+
* preflight pass that warms all three master kinds (CVA, state, non-CVA)
|
|
14
|
+
* for a given theme.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
18
|
+
export function buildBasicComponentMasterHash(def: any, theme: string): string {
|
|
19
|
+
const themeContext = getThemeContext(theme);
|
|
20
|
+
return hashString(
|
|
21
|
+
hashDef(def)
|
|
22
|
+
+ ':'
|
|
23
|
+
+ theme
|
|
24
|
+
+ ':basic-symbol-v3:'
|
|
25
|
+
+ RENDER_ENGINE_VERSION
|
|
26
|
+
+ ':'
|
|
27
|
+
+ JSON.stringify({
|
|
28
|
+
colorGroup: themeContext.colorGroup,
|
|
29
|
+
radiusGroup: themeContext.radiusGroup,
|
|
30
|
+
})
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function ensureNonCvaComponentMaster(
|
|
35
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
36
|
+
def: any,
|
|
37
|
+
theme: string,
|
|
38
|
+
ctx: StoryBuilderContext
|
|
39
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
40
|
+
): any | null {
|
|
41
|
+
if (!ENABLE_SYMBOL_MASTERS) return null;
|
|
42
|
+
if (!def || def.type === 'cva') return null;
|
|
43
|
+
if (def.symbolCandidate === false) return null;
|
|
44
|
+
|
|
45
|
+
const themeLibrary = ensureThemeComponentLibrary(theme);
|
|
46
|
+
if (!themeLibrary) return null;
|
|
47
|
+
|
|
48
|
+
const name = def.name + ' [' + theme + ']';
|
|
49
|
+
const masterHash = buildBasicComponentMasterHash(def, theme);
|
|
50
|
+
const existing = findChildByName(themeLibrary, name);
|
|
51
|
+
if (existing && existing.type === 'COMPONENT' && getFrameHash(existing) === masterHash) {
|
|
52
|
+
return existing;
|
|
53
|
+
}
|
|
54
|
+
if (existing) {
|
|
55
|
+
existing.remove();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const sourceNode = buildNonCvaSymbolSourceNode(def, theme, ctx);
|
|
59
|
+
if (!sourceNode) return null;
|
|
60
|
+
themeLibrary.appendChild(sourceNode);
|
|
61
|
+
|
|
62
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
63
|
+
let component: any = null;
|
|
64
|
+
try {
|
|
65
|
+
component = figma.createComponentFromNode(sourceNode);
|
|
66
|
+
} catch (_error) {
|
|
67
|
+
try {
|
|
68
|
+
sourceNode.remove();
|
|
69
|
+
} catch (_cleanupError) {
|
|
70
|
+
// ignore cleanup errors
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
if (!component) return null;
|
|
75
|
+
|
|
76
|
+
component.name = name;
|
|
77
|
+
component.visible = false;
|
|
78
|
+
if (component.parent !== themeLibrary) {
|
|
79
|
+
themeLibrary.appendChild(component);
|
|
80
|
+
}
|
|
81
|
+
setFrameHash(component, masterHash);
|
|
82
|
+
tagGeneratedNode(component, 'component-master:' + def.name + ':' + theme);
|
|
83
|
+
return component;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
87
|
+
export function warmSymbolMasters(defs: any[], theme: string, ctx: StoryBuilderContext): void {
|
|
88
|
+
if (!ENABLE_SYMBOL_MASTERS || !Array.isArray(defs) || defs.length === 0) return;
|
|
89
|
+
for (let i = 0; i < defs.length; i++) {
|
|
90
|
+
const def = defs[i];
|
|
91
|
+
if (!def || def.symbolCandidate === false) continue;
|
|
92
|
+
try {
|
|
93
|
+
if (def.type === 'cva') {
|
|
94
|
+
ensureCvaComponentSet(def, theme, ctx);
|
|
95
|
+
} else if (def.type === 'state') {
|
|
96
|
+
ensureStateComponentSet(def, theme, ctx);
|
|
97
|
+
} else if (def.type === 'simple' || def.type === 'compound') {
|
|
98
|
+
ensureNonCvaComponentMaster(def, theme, ctx);
|
|
99
|
+
}
|
|
100
|
+
} catch (_error) {
|
|
101
|
+
// Keep runtime resilient: missing master creation should not block raw preview rendering.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portal-aware story-tree resolution.
|
|
3
|
+
*
|
|
4
|
+
* Radix UI components like Dialog / Popover / Tooltip render content via
|
|
5
|
+
* React portals. When the scanner walks a story's JSX tree, portal-rooted
|
|
6
|
+
* subtrees are tagged `__fromPortal: true`. This module decides what to do
|
|
7
|
+
* with those tagged nodes for each story:
|
|
8
|
+
*
|
|
9
|
+
* - **Closed-state stories** (e.g. Dialog.Default — no `defaultOpen`):
|
|
10
|
+
* strip portal subtrees so only the trigger button shows in Figma.
|
|
11
|
+
* Mirrors browser closed state — the portal panel isn't visible.
|
|
12
|
+
*
|
|
13
|
+
* - **Open-state stories** (e.g. Popover.OpenPanel with `defaultOpen`):
|
|
14
|
+
* keep the full tree so both trigger and panel render. Storybook shows
|
|
15
|
+
* both in this case, and earlier behaviour (stripping the trigger) made
|
|
16
|
+
* open stories look like orphan panels.
|
|
17
|
+
*
|
|
18
|
+
* The decision happens up-front in `resolvePortalAwareStoryTree`; the
|
|
19
|
+
* other exports are building blocks for callers that need finer control.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Removes nodes marked __fromPortal from the tree.
|
|
24
|
+
* Used for closed-state stories (e.g. Select.Default, Dialog.Default) where Radix UI
|
|
25
|
+
* renders portal content in SSR even when closed — only the trigger should show in Figma.
|
|
26
|
+
*/
|
|
27
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
28
|
+
export function filterPortalNodesFromTree(node: any): any {
|
|
29
|
+
if (!node || typeof node !== 'object') return node;
|
|
30
|
+
if (node.__fromPortal || (node.props && node.props.__fromPortal)) return null;
|
|
31
|
+
const children = Array.isArray(node.children) ? node.children : [];
|
|
32
|
+
if (children.length === 0) return node;
|
|
33
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
34
|
+
const filtered: any[] = [];
|
|
35
|
+
let changed = false;
|
|
36
|
+
for (let i = 0; i < children.length; i++) {
|
|
37
|
+
const result = filterPortalNodesFromTree(children[i]);
|
|
38
|
+
if (result === null) {
|
|
39
|
+
changed = true;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (result !== children[i]) changed = true;
|
|
43
|
+
filtered.push(result);
|
|
44
|
+
}
|
|
45
|
+
if (!changed) return node;
|
|
46
|
+
return Object.assign({}, node, { children: filtered });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Extracts the first node marked __fromPortal from the tree.
|
|
51
|
+
* Used for open-state stories (e.g. Popover.OpenPanel, Tooltip.OpenPanel) where
|
|
52
|
+
* the popup content should render in Figma, not the trigger button.
|
|
53
|
+
*/
|
|
54
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
55
|
+
export function extractPortalContentFromTree(node: any): any {
|
|
56
|
+
if (!node || typeof node !== 'object') return null;
|
|
57
|
+
if (node.props && node.props.__fromPortal) return node;
|
|
58
|
+
if (Array.isArray(node.children)) {
|
|
59
|
+
for (const child of node.children) {
|
|
60
|
+
const found = extractPortalContentFromTree(child);
|
|
61
|
+
if (found) return found;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Returns true if the jsxTree contains any element whose tagName ends with "trigger".
|
|
69
|
+
*/
|
|
70
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
71
|
+
function treeHasTriggerElement(node: any): boolean {
|
|
72
|
+
if (!node || typeof node !== 'object') return false;
|
|
73
|
+
if (node.tagName && typeof node.tagName === 'string' &&
|
|
74
|
+
node.tagName.toLowerCase().endsWith('trigger')) return true;
|
|
75
|
+
if (Array.isArray(node.children)) {
|
|
76
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
77
|
+
return node.children.some((c: any) => treeHasTriggerElement(c));
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Returns true if the jsxTree contains any node marked with __fromPortal by the scanner.
|
|
84
|
+
*/
|
|
85
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
86
|
+
function treeHasPortalContent(node: any): boolean {
|
|
87
|
+
if (!node || typeof node !== 'object') return false;
|
|
88
|
+
if (node.props && node.props.__fromPortal) return true;
|
|
89
|
+
if (Array.isArray(node.children)) {
|
|
90
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
91
|
+
return node.children.some((c: any) => treeHasPortalContent(c));
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Returns true if any node within the first few levels has defaultOpen or open=true.
|
|
98
|
+
* Used to distinguish intentionally-open stories from closed-state Radix SSR renders.
|
|
99
|
+
*/
|
|
100
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
101
|
+
export function treeHasOpenState(node: any, depth: number = 0): boolean {
|
|
102
|
+
if (!node || typeof node !== 'object' || depth > 4) return false;
|
|
103
|
+
const p = node.props;
|
|
104
|
+
if (p && (p.defaultOpen === 'true' || p.defaultOpen === true || p.open === 'true' || p.open === true)) return true;
|
|
105
|
+
if (Array.isArray(node.children)) {
|
|
106
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
107
|
+
return node.children.some((c: any) => treeHasOpenState(c, depth + 1));
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Returns true when the story jsxTree has both a trigger element and portal-unwrapped content
|
|
114
|
+
* AND the component is intentionally open (defaultOpen). Used for open-state stories like
|
|
115
|
+
* Popover.OpenPanel, Tooltip.OpenPanel.
|
|
116
|
+
*/
|
|
117
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
118
|
+
export function jsxTreeIsPortalTriggerOnly(tree: any): boolean {
|
|
119
|
+
return treeHasTriggerElement(tree) && treeHasPortalContent(tree);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Resolve story jsxTree for portal components:
|
|
124
|
+
* - closed stories: remove portal subtree so only trigger remains (matches
|
|
125
|
+
* browser closed-state — portal isn't visible).
|
|
126
|
+
* - open stories: keep the full tree so both trigger and panel render. This
|
|
127
|
+
* mirrors how Storybook renders the open state (e.g. Popover.OpenPanel,
|
|
128
|
+
* Select.OpenPanel) — users see the button that opened the panel.
|
|
129
|
+
* Previously we stripped the trigger for most portals, which made Popover
|
|
130
|
+
* / Tooltip / DropdownMenu open stories look like orphan panels.
|
|
131
|
+
*/
|
|
132
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
133
|
+
export function resolvePortalAwareStoryTree(tree: any): any {
|
|
134
|
+
if (!tree) return null;
|
|
135
|
+
if (!jsxTreeIsPortalTriggerOnly(tree)) return tree;
|
|
136
|
+
if (treeHasOpenState(tree)) return tree;
|
|
137
|
+
return filterPortalNodesFromTree(tree);
|
|
138
|
+
}
|