@fragments-sdk/viewer 0.2.1
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/LICENSE +84 -0
- package/index.html +28 -0
- package/package.json +71 -0
- package/src/__tests__/a11y-fixes.test.ts +358 -0
- package/src/__tests__/jsx-parser.test.ts +502 -0
- package/src/__tests__/render-utils.test.ts +232 -0
- package/src/__tests__/style-utils.test.ts +404 -0
- package/src/app/index.ts +1 -0
- package/src/assets/fragments-logo.ts +4 -0
- package/src/assets/fragments_logo.png +0 -0
- package/src/components/AccessibilityPanel.tsx +1457 -0
- package/src/components/ActionCapture.tsx +172 -0
- package/src/components/ActionsPanel.tsx +332 -0
- package/src/components/AllVariantsPreview.tsx +78 -0
- package/src/components/App.tsx +604 -0
- package/src/components/BottomPanel.tsx +288 -0
- package/src/components/CodePanel.naming.test.tsx +59 -0
- package/src/components/CodePanel.tsx +118 -0
- package/src/components/CommandPalette.tsx +392 -0
- package/src/components/ComponentDocView.tsx +164 -0
- package/src/components/ComponentGraph.tsx +380 -0
- package/src/components/ComponentHeader.tsx +88 -0
- package/src/components/ContractPanel.tsx +241 -0
- package/src/components/DeviceMockup.tsx +156 -0
- package/src/components/EmptyVariantMessage.tsx +54 -0
- package/src/components/ErrorBoundary.tsx +97 -0
- package/src/components/FigmaEmbed.tsx +238 -0
- package/src/components/FragmentEditor.tsx +525 -0
- package/src/components/FragmentRenderer.tsx +61 -0
- package/src/components/HeaderSearch.tsx +24 -0
- package/src/components/HealthDashboard.tsx +441 -0
- package/src/components/HmrStatusIndicator.tsx +61 -0
- package/src/components/Icons.tsx +479 -0
- package/src/components/InteractionsPanel.tsx +757 -0
- package/src/components/IsolatedPreviewFrame.tsx +390 -0
- package/src/components/IsolatedRender.tsx +113 -0
- package/src/components/KeyboardShortcutsHelp.tsx +53 -0
- package/src/components/LandingPage.tsx +420 -0
- package/src/components/Layout.tsx +27 -0
- package/src/components/LeftSidebar.tsx +472 -0
- package/src/components/LoadErrorMessage.tsx +102 -0
- package/src/components/MultiViewportPreview.tsx +527 -0
- package/src/components/NoVariantsMessage.tsx +59 -0
- package/src/components/PanelShell.tsx +161 -0
- package/src/components/PerformancePanel.tsx +304 -0
- package/src/components/PreviewArea.tsx +254 -0
- package/src/components/PreviewAside.tsx +168 -0
- package/src/components/PreviewFrameHost.tsx +304 -0
- package/src/components/PreviewToolbar.tsx +80 -0
- package/src/components/PropsEditor.tsx +506 -0
- package/src/components/PropsTable.tsx +111 -0
- package/src/components/RelationsSection.tsx +88 -0
- package/src/components/ResizablePanel.tsx +271 -0
- package/src/components/RightSidebar.tsx +102 -0
- package/src/components/RuntimeToolsRegistrar.tsx +17 -0
- package/src/components/ScreenshotButton.tsx +90 -0
- package/src/components/ShadowPreview.tsx +204 -0
- package/src/components/Sidebar.tsx +169 -0
- package/src/components/SkeletonLoader.tsx +161 -0
- package/src/components/ThemeProvider.tsx +42 -0
- package/src/components/Toast.tsx +3 -0
- package/src/components/TokenStylePanel.tsx +699 -0
- package/src/components/TopToolbar.tsx +159 -0
- package/src/components/Untitled +1 -0
- package/src/components/UsageSection.tsx +95 -0
- package/src/components/VariantMatrix.tsx +391 -0
- package/src/components/VariantRenderer.tsx +131 -0
- package/src/components/VariantTabs.tsx +40 -0
- package/src/components/ViewerHeader.tsx +69 -0
- package/src/components/ViewerStateSync.tsx +52 -0
- package/src/components/ViewportSelector.tsx +172 -0
- package/src/components/WebMCPDevTools.tsx +503 -0
- package/src/components/WebMCPIntegration.tsx +47 -0
- package/src/components/WebMCPStatusIndicator.tsx +60 -0
- package/src/components/_future/CreatePage.tsx +835 -0
- package/src/components/viewer-utils.ts +16 -0
- package/src/composition-renderer.ts +381 -0
- package/src/constants/index.ts +1 -0
- package/src/constants/ui.ts +166 -0
- package/src/entry.tsx +335 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useA11yCache.ts +383 -0
- package/src/hooks/useA11yService.ts +364 -0
- package/src/hooks/useActions.ts +138 -0
- package/src/hooks/useAppState.ts +147 -0
- package/src/hooks/useCompiledFragments.ts +42 -0
- package/src/hooks/useFigmaIntegration.ts +132 -0
- package/src/hooks/useHmrStatus.ts +109 -0
- package/src/hooks/useKeyboardShortcuts.ts +270 -0
- package/src/hooks/usePreviewBridge.ts +347 -0
- package/src/hooks/useScrollSpy.ts +78 -0
- package/src/hooks/useShadowStyles.ts +221 -0
- package/src/hooks/useUrlState.ts +318 -0
- package/src/hooks/useViewSettings.ts +111 -0
- package/src/intelligence/healthReport.ts +505 -0
- package/src/intelligence/styleDrift.ts +340 -0
- package/src/intelligence/usageScanner.ts +309 -0
- package/src/jsx-parser.ts +486 -0
- package/src/preview-frame-entry.tsx +25 -0
- package/src/preview-frame.html +148 -0
- package/src/render-template.html +68 -0
- package/src/render-utils.ts +311 -0
- package/src/shared/ComponentDocContent.module.scss +10 -0
- package/src/shared/ComponentDocContent.module.scss.d.ts +2 -0
- package/src/shared/ComponentDocContent.tsx +274 -0
- package/src/shared/DocsHeaderBar.tsx +129 -0
- package/src/shared/DocsPageAsideHost.tsx +89 -0
- package/src/shared/DocsPageShell.tsx +124 -0
- package/src/shared/DocsSearchCommand.tsx +99 -0
- package/src/shared/DocsSidebarNav.tsx +66 -0
- package/src/shared/PropsTable.module.scss +68 -0
- package/src/shared/PropsTable.module.scss.d.ts +2 -0
- package/src/shared/PropsTable.tsx +76 -0
- package/src/shared/VariantPreviewCard.module.scss +114 -0
- package/src/shared/VariantPreviewCard.module.scss.d.ts +2 -0
- package/src/shared/VariantPreviewCard.tsx +137 -0
- package/src/shared/docs-data/index.ts +32 -0
- package/src/shared/docs-data/mcp-configs.ts +72 -0
- package/src/shared/docs-data/palettes.ts +75 -0
- package/src/shared/docs-data/setup-examples.ts +55 -0
- package/src/shared/docs-layout.scss +28 -0
- package/src/shared/docs-layout.scss.d.ts +2 -0
- package/src/shared/index.ts +34 -0
- package/src/shared/types.ts +53 -0
- package/src/style-utils.ts +414 -0
- package/src/styles/globals.css +278 -0
- package/src/types/a11y.ts +197 -0
- package/src/utils/a11y-fixes.ts +509 -0
- package/src/utils/actionExport.ts +372 -0
- package/src/utils/colorSchemes.ts +201 -0
- package/src/utils/contrast.ts +246 -0
- package/src/utils/detectRelationships.ts +256 -0
- package/src/webmcp/__tests__/analytics.test.ts +108 -0
- package/src/webmcp/analytics.ts +165 -0
- package/src/webmcp/index.ts +3 -0
- package/src/webmcp/posthog-bridge.ts +39 -0
- package/src/webmcp/runtime-tools.ts +152 -0
- package/src/webmcp/scan-utils.ts +135 -0
- package/src/webmcp/use-tool-analytics.ts +69 -0
- package/src/webmcp/viewer-state.ts +45 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { RefObject } from "react";
|
|
2
|
+
import type { FragmentDefinition } from '@fragments-sdk/core';
|
|
3
|
+
import type { useViewSettings } from "../hooks/useViewSettings.js";
|
|
4
|
+
import type { useAppState } from "../hooks/useAppState.js";
|
|
5
|
+
import {
|
|
6
|
+
Header,
|
|
7
|
+
Stack,
|
|
8
|
+
Text,
|
|
9
|
+
Separator,
|
|
10
|
+
Tooltip,
|
|
11
|
+
Button,
|
|
12
|
+
ThemeToggle,
|
|
13
|
+
FragmentsLogo,
|
|
14
|
+
} from "@fragments-sdk/ui";
|
|
15
|
+
import { DeviceMobile, GridFour, SidebarSimple } from "@phosphor-icons/react";
|
|
16
|
+
import { GitHubIcon, FigmaIcon, CompareIcon } from "./Icons.js";
|
|
17
|
+
import { PreviewToolbar } from "./PreviewToolbar.js";
|
|
18
|
+
import { HeaderSearch } from "./HeaderSearch.js";
|
|
19
|
+
import { useTheme } from "./ThemeProvider.js";
|
|
20
|
+
import { WebMCPStatusIndicator } from "./WebMCPStatusIndicator.js";
|
|
21
|
+
|
|
22
|
+
/** Normalize category to Title Case for display */
|
|
23
|
+
function titleCase(str: string): string {
|
|
24
|
+
return str.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface TopToolbarProps {
|
|
28
|
+
fragment: { path: string; fragment: FragmentDefinition };
|
|
29
|
+
viewSettings: ReturnType<typeof useViewSettings>;
|
|
30
|
+
uiState: ReturnType<typeof useAppState>["state"];
|
|
31
|
+
uiActions: ReturnType<typeof useAppState>["actions"];
|
|
32
|
+
figmaUrl?: string;
|
|
33
|
+
searchQuery: string;
|
|
34
|
+
onSearchChange: (value: string) => void;
|
|
35
|
+
searchInputRef: RefObject<HTMLInputElement>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function TopToolbar({
|
|
39
|
+
fragment,
|
|
40
|
+
viewSettings,
|
|
41
|
+
uiState,
|
|
42
|
+
uiActions,
|
|
43
|
+
figmaUrl,
|
|
44
|
+
searchQuery,
|
|
45
|
+
onSearchChange,
|
|
46
|
+
searchInputRef,
|
|
47
|
+
}: TopToolbarProps) {
|
|
48
|
+
const { setTheme, resolvedTheme } = useTheme();
|
|
49
|
+
return (
|
|
50
|
+
<Header aria-label="Component preview toolbar">
|
|
51
|
+
<Header.Trigger />
|
|
52
|
+
<Header.Brand>
|
|
53
|
+
<Stack direction="row" align="center" gap="sm">
|
|
54
|
+
<FragmentsLogo size={20} />
|
|
55
|
+
<Text weight="medium" size="sm">
|
|
56
|
+
{fragment.fragment.meta.name}
|
|
57
|
+
</Text>
|
|
58
|
+
<Text size="xs" color="tertiary">
|
|
59
|
+
{titleCase(fragment.fragment.meta.category || '')}
|
|
60
|
+
</Text>
|
|
61
|
+
</Stack>
|
|
62
|
+
</Header.Brand>
|
|
63
|
+
<HeaderSearch value={searchQuery} onChange={onSearchChange} inputRef={searchInputRef} />
|
|
64
|
+
<Header.Spacer />
|
|
65
|
+
<Header.Actions>
|
|
66
|
+
<PreviewToolbar zoom={viewSettings.zoom} onZoomChange={viewSettings.setZoom} />
|
|
67
|
+
<Separator orientation="vertical" style={{ height: "16px" }} />
|
|
68
|
+
<Tooltip content={uiState.showMatrixView ? "Disable matrix view" : "Enable matrix view"}>
|
|
69
|
+
<Button
|
|
70
|
+
variant={uiState.showMatrixView ? "secondary" : "ghost"}
|
|
71
|
+
size="sm"
|
|
72
|
+
icon
|
|
73
|
+
aria-pressed={uiState.showMatrixView}
|
|
74
|
+
aria-label="Toggle matrix view"
|
|
75
|
+
onClick={() => uiActions.setMatrixView(!uiState.showMatrixView)}
|
|
76
|
+
>
|
|
77
|
+
<GridFour size={16} />
|
|
78
|
+
</Button>
|
|
79
|
+
</Tooltip>
|
|
80
|
+
<Tooltip
|
|
81
|
+
content={uiState.showMultiViewport ? "Disable responsive view" : "Enable responsive view"}
|
|
82
|
+
>
|
|
83
|
+
<Button
|
|
84
|
+
variant={uiState.showMultiViewport ? "secondary" : "ghost"}
|
|
85
|
+
size="sm"
|
|
86
|
+
icon
|
|
87
|
+
aria-pressed={uiState.showMultiViewport}
|
|
88
|
+
aria-label="Toggle responsive view"
|
|
89
|
+
onClick={() => uiActions.setMultiViewport(!uiState.showMultiViewport)}
|
|
90
|
+
>
|
|
91
|
+
<DeviceMobile size={16} />
|
|
92
|
+
</Button>
|
|
93
|
+
</Tooltip>
|
|
94
|
+
<Separator orientation="vertical" style={{ height: "16px" }} />
|
|
95
|
+
{figmaUrl && (
|
|
96
|
+
<>
|
|
97
|
+
<Tooltip
|
|
98
|
+
content={
|
|
99
|
+
uiState.showComparison ? "Hide Figma comparison" : "Compare with Figma design"
|
|
100
|
+
}
|
|
101
|
+
>
|
|
102
|
+
<Button
|
|
103
|
+
variant={uiState.showComparison ? "secondary" : "ghost"}
|
|
104
|
+
size="sm"
|
|
105
|
+
icon
|
|
106
|
+
onClick={uiActions.toggleComparison}
|
|
107
|
+
>
|
|
108
|
+
<CompareIcon style={{ width: "16px", height: "16px" }} />
|
|
109
|
+
</Button>
|
|
110
|
+
</Tooltip>
|
|
111
|
+
<Tooltip content="View in Figma">
|
|
112
|
+
<Button
|
|
113
|
+
onClick={() => window.open(figmaUrl, "_blank", "noopener,noreferrer")}
|
|
114
|
+
variant="ghost"
|
|
115
|
+
size="sm"
|
|
116
|
+
icon
|
|
117
|
+
>
|
|
118
|
+
<FigmaIcon style={{ width: "16px", height: "16px" }} />
|
|
119
|
+
</Button>
|
|
120
|
+
</Tooltip>
|
|
121
|
+
<Separator orientation="vertical" style={{ height: "16px" }} />
|
|
122
|
+
</>
|
|
123
|
+
)}
|
|
124
|
+
<WebMCPStatusIndicator />
|
|
125
|
+
<Tooltip content={uiState.showAside ? "Hide side panel" : "Show side panel"}>
|
|
126
|
+
<Button
|
|
127
|
+
variant={uiState.showAside ? "secondary" : "ghost"}
|
|
128
|
+
size="sm"
|
|
129
|
+
icon
|
|
130
|
+
aria-pressed={uiState.showAside}
|
|
131
|
+
aria-label="Toggle side panel"
|
|
132
|
+
onClick={uiActions.toggleAside}
|
|
133
|
+
>
|
|
134
|
+
<SidebarSimple size={16} style={{ transform: "scaleX(-1)" }} />
|
|
135
|
+
</Button>
|
|
136
|
+
</Tooltip>
|
|
137
|
+
<Separator orientation="vertical" style={{ height: "16px" }} />
|
|
138
|
+
<ThemeToggle
|
|
139
|
+
size="sm"
|
|
140
|
+
value={resolvedTheme}
|
|
141
|
+
onValueChange={(value) => setTheme(value)}
|
|
142
|
+
aria-label={`Theme: ${resolvedTheme}`}
|
|
143
|
+
/>
|
|
144
|
+
<Button
|
|
145
|
+
as="a"
|
|
146
|
+
variant="ghost"
|
|
147
|
+
size="sm"
|
|
148
|
+
icon
|
|
149
|
+
href="https://github.com/ConanMcN/fragments"
|
|
150
|
+
target="_blank"
|
|
151
|
+
rel="noopener noreferrer"
|
|
152
|
+
aria-label="View on GitHub"
|
|
153
|
+
>
|
|
154
|
+
<GitHubIcon />
|
|
155
|
+
</Button>
|
|
156
|
+
</Header.Actions>
|
|
157
|
+
</Header>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
400px
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { FragmentUsage } from '@fragments-sdk/core';
|
|
2
|
+
import { CheckIcon, XIcon, AccessibilityIcon } from './Icons.js';
|
|
3
|
+
|
|
4
|
+
interface UsageSectionProps {
|
|
5
|
+
usage: FragmentUsage;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function UsageSection({ usage }: UsageSectionProps) {
|
|
9
|
+
const hasWhen = usage.when && usage.when.length > 0;
|
|
10
|
+
const hasWhenNot = usage.whenNot && usage.whenNot.length > 0;
|
|
11
|
+
const hasGuidelines = usage.guidelines && usage.guidelines.length > 0;
|
|
12
|
+
const hasAccessibility = usage.accessibility && usage.accessibility.length > 0;
|
|
13
|
+
|
|
14
|
+
if (!hasWhen && !hasWhenNot && !hasGuidelines && !hasAccessibility) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<section id="usage" style={{ scrollMarginTop: '96px' }}>
|
|
20
|
+
<h2 style={{ fontSize: '16px', fontWeight: 600, color: 'var(--text-primary)', marginBottom: '20px' }}>Usage</h2>
|
|
21
|
+
|
|
22
|
+
{/* When to use / When not to use */}
|
|
23
|
+
{(hasWhen || hasWhenNot) && (
|
|
24
|
+
<div style={{ display: 'grid', gridTemplateColumns: hasWhen && hasWhenNot ? '1fr 1fr' : '1fr', gap: '32px', marginBottom: '32px' }}>
|
|
25
|
+
{hasWhen && (
|
|
26
|
+
<div style={{ padding: '16px', borderRadius: '12px', background: 'var(--color-success-bg)', border: '1px solid rgba(16, 163, 127, 0.2)' }}>
|
|
27
|
+
<h3 style={{ fontSize: '13px', fontWeight: 500, color: 'var(--color-success)', marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
28
|
+
<CheckIcon style={{ width: '16px', height: '16px' }} />
|
|
29
|
+
When to use
|
|
30
|
+
</h3>
|
|
31
|
+
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
32
|
+
{usage.when!.map((item, index) => (
|
|
33
|
+
<li key={index} style={{ fontSize: '13px', color: 'var(--text-primary)', lineHeight: 1.6, display: 'flex', alignItems: 'flex-start', gap: '8px' }}>
|
|
34
|
+
<span style={{ color: 'var(--color-success)', marginTop: '6px', fontSize: '12px' }}>•</span>
|
|
35
|
+
<span>{item}</span>
|
|
36
|
+
</li>
|
|
37
|
+
))}
|
|
38
|
+
</ul>
|
|
39
|
+
</div>
|
|
40
|
+
)}
|
|
41
|
+
|
|
42
|
+
{hasWhenNot && (
|
|
43
|
+
<div style={{ padding: '16px', borderRadius: '12px', background: 'var(--color-danger-bg)', border: '1px solid rgba(239, 68, 68, 0.2)' }}>
|
|
44
|
+
<h3 style={{ fontSize: '13px', fontWeight: 500, color: 'var(--color-danger)', marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
45
|
+
<XIcon style={{ width: '16px', height: '16px' }} />
|
|
46
|
+
When not to use
|
|
47
|
+
</h3>
|
|
48
|
+
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
49
|
+
{usage.whenNot!.map((item, index) => (
|
|
50
|
+
<li key={index} style={{ fontSize: '13px', color: 'var(--text-primary)', lineHeight: 1.6, display: 'flex', alignItems: 'flex-start', gap: '8px' }}>
|
|
51
|
+
<span style={{ color: 'var(--color-danger)', marginTop: '6px', fontSize: '12px' }}>•</span>
|
|
52
|
+
<span>{item}</span>
|
|
53
|
+
</li>
|
|
54
|
+
))}
|
|
55
|
+
</ul>
|
|
56
|
+
</div>
|
|
57
|
+
)}
|
|
58
|
+
</div>
|
|
59
|
+
)}
|
|
60
|
+
|
|
61
|
+
{/* Guidelines */}
|
|
62
|
+
{hasGuidelines && (
|
|
63
|
+
<div style={{ marginBottom: '24px' }}>
|
|
64
|
+
<h3 style={{ fontSize: '13px', fontWeight: 500, color: 'var(--text-primary)', marginBottom: '12px' }}>Guidelines</h3>
|
|
65
|
+
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
66
|
+
{usage.guidelines!.map((item, index) => (
|
|
67
|
+
<li key={index} style={{ fontSize: '13px', color: 'var(--text-secondary)', lineHeight: 1.6, display: 'flex', alignItems: 'flex-start', gap: '8px' }}>
|
|
68
|
+
<span style={{ color: 'var(--color-accent)', marginTop: '6px', fontSize: '12px' }}>•</span>
|
|
69
|
+
<span>{item}</span>
|
|
70
|
+
</li>
|
|
71
|
+
))}
|
|
72
|
+
</ul>
|
|
73
|
+
</div>
|
|
74
|
+
)}
|
|
75
|
+
|
|
76
|
+
{/* Accessibility */}
|
|
77
|
+
{hasAccessibility && (
|
|
78
|
+
<div style={{ padding: '16px', borderRadius: '12px', border: '1px solid var(--border)', background: 'var(--bg-secondary)' }}>
|
|
79
|
+
<h3 style={{ fontSize: '13px', fontWeight: 500, color: 'var(--text-primary)', marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
80
|
+
<AccessibilityIcon style={{ width: '16px', height: '16px', color: 'var(--text-secondary)' }} />
|
|
81
|
+
Accessibility
|
|
82
|
+
</h3>
|
|
83
|
+
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
84
|
+
{usage.accessibility!.map((item, index) => (
|
|
85
|
+
<li key={index} style={{ fontSize: '13px', color: 'var(--text-secondary)', lineHeight: 1.6, display: 'flex', alignItems: 'flex-start', gap: '8px' }}>
|
|
86
|
+
<span style={{ color: 'var(--text-tertiary)', marginTop: '6px', fontSize: '12px' }}>•</span>
|
|
87
|
+
<span>{item}</span>
|
|
88
|
+
</li>
|
|
89
|
+
))}
|
|
90
|
+
</ul>
|
|
91
|
+
</div>
|
|
92
|
+
)}
|
|
93
|
+
</section>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/**
|
|
3
|
+
* Variant Matrix View - Display all variants in a grid
|
|
4
|
+
*
|
|
5
|
+
* Shows all variants of a component simultaneously, making it easy to:
|
|
6
|
+
* - Compare states/variants at a glance
|
|
7
|
+
* - Spot visual inconsistencies
|
|
8
|
+
* - Review all component states quickly
|
|
9
|
+
*
|
|
10
|
+
* Uses virtualization to only render visible variants for better performance.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useState, useMemo, useRef, useCallback } from "react";
|
|
14
|
+
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
15
|
+
import { Box, Stack, Text, Button, Badge, EmptyState } from "@fragments-sdk/ui";
|
|
16
|
+
import type { FragmentVariant } from '@fragments-sdk/core';
|
|
17
|
+
import { ErrorBoundary } from "./ErrorBoundary.js";
|
|
18
|
+
import { FragmentRenderer, LoaderIndicator } from "./FragmentRenderer.js";
|
|
19
|
+
import { IsolatedPreviewFrame } from "./IsolatedPreviewFrame.js";
|
|
20
|
+
import { ShadowPreview } from "./ShadowPreview.js";
|
|
21
|
+
import { ChevronDownIcon } from "./Icons.js";
|
|
22
|
+
|
|
23
|
+
interface VariantMatrixProps {
|
|
24
|
+
/** All variants to display */
|
|
25
|
+
variants: FragmentVariant[];
|
|
26
|
+
/** Component name for error display */
|
|
27
|
+
componentName: string;
|
|
28
|
+
/** Fragment path for iframe rendering */
|
|
29
|
+
fragmentPath: string;
|
|
30
|
+
/** Current zoom level */
|
|
31
|
+
zoom: number;
|
|
32
|
+
/** Preview theme */
|
|
33
|
+
previewTheme: "light" | "dark";
|
|
34
|
+
/** Whether to use iframe isolation */
|
|
35
|
+
useIframeIsolation?: boolean;
|
|
36
|
+
/** Callback when a variant is clicked to focus on it */
|
|
37
|
+
onSelectVariant?: (index: number) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type GridSize = "small" | "medium" | "large";
|
|
41
|
+
|
|
42
|
+
interface GridConfig {
|
|
43
|
+
gridTemplateColumns: string;
|
|
44
|
+
minHeight: string;
|
|
45
|
+
heightPx: number; // For virtualization
|
|
46
|
+
scale: number;
|
|
47
|
+
colCount: number; // Default column count for virtualization
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const GRID_SIZES: Record<GridSize, GridConfig> = {
|
|
51
|
+
small: { gridTemplateColumns: "repeat(4, 1fr)", minHeight: "150px", heightPx: 150, scale: 0.5, colCount: 4 },
|
|
52
|
+
medium: { gridTemplateColumns: "repeat(3, 1fr)", minHeight: "200px", heightPx: 200, scale: 0.75, colCount: 3 },
|
|
53
|
+
large: { gridTemplateColumns: "repeat(2, 1fr)", minHeight: "300px", heightPx: 300, scale: 1, colCount: 2 },
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Threshold for enabling virtualization */
|
|
57
|
+
const VIRTUALIZATION_THRESHOLD = 12;
|
|
58
|
+
|
|
59
|
+
export function VariantMatrix({
|
|
60
|
+
variants,
|
|
61
|
+
componentName,
|
|
62
|
+
fragmentPath,
|
|
63
|
+
zoom,
|
|
64
|
+
previewTheme,
|
|
65
|
+
useIframeIsolation = true,
|
|
66
|
+
onSelectVariant,
|
|
67
|
+
}: VariantMatrixProps) {
|
|
68
|
+
const [gridSize, setGridSize] = useState<GridSize>("medium");
|
|
69
|
+
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
|
70
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
71
|
+
|
|
72
|
+
const gridConfig = GRID_SIZES[gridSize];
|
|
73
|
+
const effectiveScale = (zoom / 100) * gridConfig.scale;
|
|
74
|
+
|
|
75
|
+
// Determine if we should use virtualization
|
|
76
|
+
const useVirtualization = variants.length > VIRTUALIZATION_THRESHOLD;
|
|
77
|
+
|
|
78
|
+
// Calculate number of rows for virtualization
|
|
79
|
+
const columns = gridConfig.colCount;
|
|
80
|
+
const rowCount = Math.ceil(variants.length / columns);
|
|
81
|
+
|
|
82
|
+
// Row height includes card height + gap
|
|
83
|
+
const rowHeight = gridConfig.heightPx + 16; // 16px gap
|
|
84
|
+
|
|
85
|
+
const rowVirtualizer = useVirtualizer({
|
|
86
|
+
count: rowCount,
|
|
87
|
+
getScrollElement: () => scrollRef.current,
|
|
88
|
+
estimateSize: () => rowHeight,
|
|
89
|
+
overscan: 2, // Render 2 extra rows above/below for smooth scrolling
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
if (variants.length === 0) {
|
|
93
|
+
return (
|
|
94
|
+
<EmptyState style={{ height: '100%' }}>
|
|
95
|
+
<EmptyState.Title>No variants to display</EmptyState.Title>
|
|
96
|
+
</EmptyState>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<Stack style={{ height: '100%' }}>
|
|
102
|
+
{/* Toolbar */}
|
|
103
|
+
<Box paddingX="md" paddingY="sm" borderBottom background="secondary" style={{ flexShrink: 0 }}>
|
|
104
|
+
<Stack direction="row" align="center" justify="between">
|
|
105
|
+
<Text size="sm" color="secondary">
|
|
106
|
+
{variants.length} variant{variants.length !== 1 ? "s" : ""}
|
|
107
|
+
{useVirtualization && <Text as="span" size="xs" color="tertiary"> (virtualized)</Text>}
|
|
108
|
+
</Text>
|
|
109
|
+
<Stack direction="row" align="center" gap="sm">
|
|
110
|
+
<Text size="xs" color="tertiary">Grid size:</Text>
|
|
111
|
+
<Stack direction="row" style={{ borderRadius: 6, border: '1px solid var(--border)', overflow: 'hidden' }}>
|
|
112
|
+
{(["small", "medium", "large"] as GridSize[]).map((size) => (
|
|
113
|
+
<Button
|
|
114
|
+
key={size}
|
|
115
|
+
variant={gridSize === size ? "secondary" : "ghost"}
|
|
116
|
+
size="sm"
|
|
117
|
+
onClick={() => setGridSize(size)}
|
|
118
|
+
style={{ textTransform: 'capitalize', borderRadius: 0 }}
|
|
119
|
+
>
|
|
120
|
+
{size}
|
|
121
|
+
</Button>
|
|
122
|
+
))}
|
|
123
|
+
</Stack>
|
|
124
|
+
</Stack>
|
|
125
|
+
</Stack>
|
|
126
|
+
</Box>
|
|
127
|
+
|
|
128
|
+
{/* Grid - Virtualized or Regular */}
|
|
129
|
+
{useVirtualization ? (
|
|
130
|
+
<Box ref={scrollRef} overflow="auto" padding="md" style={{ flex: 1 }}>
|
|
131
|
+
<div
|
|
132
|
+
style={{
|
|
133
|
+
height: `${rowVirtualizer.getTotalSize()}px`,
|
|
134
|
+
width: "100%",
|
|
135
|
+
position: "relative",
|
|
136
|
+
}}
|
|
137
|
+
>
|
|
138
|
+
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
|
139
|
+
const startIndex = virtualRow.index * columns;
|
|
140
|
+
const rowVariants = variants.slice(startIndex, startIndex + columns);
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<div
|
|
144
|
+
key={virtualRow.key}
|
|
145
|
+
style={{
|
|
146
|
+
position: "absolute",
|
|
147
|
+
top: 0,
|
|
148
|
+
left: 0,
|
|
149
|
+
width: "100%",
|
|
150
|
+
height: `${virtualRow.size}px`,
|
|
151
|
+
transform: `translateY(${virtualRow.start}px)`,
|
|
152
|
+
}}
|
|
153
|
+
>
|
|
154
|
+
<div style={{
|
|
155
|
+
display: 'grid',
|
|
156
|
+
gap: 16,
|
|
157
|
+
gridTemplateColumns: gridConfig.gridTemplateColumns,
|
|
158
|
+
height: gridConfig.minHeight,
|
|
159
|
+
}}>
|
|
160
|
+
{rowVariants.map((variant, colIndex) => {
|
|
161
|
+
const index = startIndex + colIndex;
|
|
162
|
+
return (
|
|
163
|
+
<VariantCard
|
|
164
|
+
key={variant.name}
|
|
165
|
+
variant={variant}
|
|
166
|
+
index={index}
|
|
167
|
+
componentName={componentName}
|
|
168
|
+
fragmentPath={fragmentPath}
|
|
169
|
+
scale={effectiveScale}
|
|
170
|
+
minHeight={gridConfig.minHeight}
|
|
171
|
+
previewTheme={previewTheme}
|
|
172
|
+
useIframeIsolation={useIframeIsolation}
|
|
173
|
+
isHovered={hoveredIndex === index}
|
|
174
|
+
onHover={() => setHoveredIndex(index)}
|
|
175
|
+
onLeave={() => setHoveredIndex(null)}
|
|
176
|
+
onClick={() => onSelectVariant?.(index)}
|
|
177
|
+
/>
|
|
178
|
+
);
|
|
179
|
+
})}
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
})}
|
|
184
|
+
</div>
|
|
185
|
+
</Box>
|
|
186
|
+
) : (
|
|
187
|
+
<Box overflow="auto" padding="md" style={{ flex: 1 }}>
|
|
188
|
+
<div style={{
|
|
189
|
+
display: 'grid',
|
|
190
|
+
gap: 16,
|
|
191
|
+
gridTemplateColumns: gridConfig.gridTemplateColumns,
|
|
192
|
+
}}>
|
|
193
|
+
{variants.map((variant, index) => (
|
|
194
|
+
<VariantCard
|
|
195
|
+
key={variant.name}
|
|
196
|
+
variant={variant}
|
|
197
|
+
index={index}
|
|
198
|
+
componentName={componentName}
|
|
199
|
+
fragmentPath={fragmentPath}
|
|
200
|
+
scale={effectiveScale}
|
|
201
|
+
minHeight={gridConfig.minHeight}
|
|
202
|
+
previewTheme={previewTheme}
|
|
203
|
+
useIframeIsolation={useIframeIsolation}
|
|
204
|
+
isHovered={hoveredIndex === index}
|
|
205
|
+
onHover={() => setHoveredIndex(index)}
|
|
206
|
+
onLeave={() => setHoveredIndex(null)}
|
|
207
|
+
onClick={() => onSelectVariant?.(index)}
|
|
208
|
+
/>
|
|
209
|
+
))}
|
|
210
|
+
</div>
|
|
211
|
+
</Box>
|
|
212
|
+
)}
|
|
213
|
+
</Stack>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
interface VariantCardProps {
|
|
218
|
+
variant: FragmentVariant;
|
|
219
|
+
index: number;
|
|
220
|
+
componentName: string;
|
|
221
|
+
fragmentPath: string;
|
|
222
|
+
scale: number;
|
|
223
|
+
minHeight: string;
|
|
224
|
+
previewTheme: "light" | "dark";
|
|
225
|
+
useIframeIsolation: boolean;
|
|
226
|
+
isHovered: boolean;
|
|
227
|
+
onHover: () => void;
|
|
228
|
+
onLeave: () => void;
|
|
229
|
+
onClick: () => void;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function VariantCard({
|
|
233
|
+
variant,
|
|
234
|
+
index,
|
|
235
|
+
componentName,
|
|
236
|
+
fragmentPath,
|
|
237
|
+
scale,
|
|
238
|
+
minHeight,
|
|
239
|
+
previewTheme,
|
|
240
|
+
useIframeIsolation,
|
|
241
|
+
isHovered,
|
|
242
|
+
onHover,
|
|
243
|
+
onLeave,
|
|
244
|
+
onClick,
|
|
245
|
+
}: VariantCardProps) {
|
|
246
|
+
return (
|
|
247
|
+
<div
|
|
248
|
+
style={{
|
|
249
|
+
position: 'relative',
|
|
250
|
+
borderRadius: 8,
|
|
251
|
+
border: isHovered
|
|
252
|
+
? '2px solid #3b82f6'
|
|
253
|
+
: '1px solid var(--border)',
|
|
254
|
+
overflow: 'hidden',
|
|
255
|
+
transition: 'border-color 0.2s, box-shadow 0.2s',
|
|
256
|
+
cursor: 'pointer',
|
|
257
|
+
minHeight,
|
|
258
|
+
boxShadow: isHovered
|
|
259
|
+
? '0 10px 15px -3px rgba(0,0,0,0.1), 0 0 0 3px rgba(59,130,246,0.2)'
|
|
260
|
+
: 'none',
|
|
261
|
+
}}
|
|
262
|
+
onMouseEnter={onHover}
|
|
263
|
+
onMouseLeave={onLeave}
|
|
264
|
+
onClick={onClick}
|
|
265
|
+
>
|
|
266
|
+
{/* Header overlay - keep inline styles (CSS art) */}
|
|
267
|
+
<div style={{
|
|
268
|
+
position: 'absolute',
|
|
269
|
+
top: 0,
|
|
270
|
+
left: 0,
|
|
271
|
+
right: 0,
|
|
272
|
+
zIndex: 10,
|
|
273
|
+
padding: '4px 8px',
|
|
274
|
+
background: 'linear-gradient(to bottom, rgba(0,0,0,0.6), transparent)',
|
|
275
|
+
}}>
|
|
276
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
277
|
+
<span style={{
|
|
278
|
+
fontSize: 12,
|
|
279
|
+
fontWeight: 500,
|
|
280
|
+
color: '#ffffff',
|
|
281
|
+
overflow: 'hidden',
|
|
282
|
+
textOverflow: 'ellipsis',
|
|
283
|
+
whiteSpace: 'nowrap',
|
|
284
|
+
}}>
|
|
285
|
+
{variant.name}
|
|
286
|
+
</span>
|
|
287
|
+
<span style={{ fontSize: 10, color: 'rgba(255,255,255,0.7)' }}>
|
|
288
|
+
#{index + 1}
|
|
289
|
+
</span>
|
|
290
|
+
</div>
|
|
291
|
+
</div>
|
|
292
|
+
|
|
293
|
+
{/* Click to view overlay */}
|
|
294
|
+
<div
|
|
295
|
+
style={{
|
|
296
|
+
position: 'absolute',
|
|
297
|
+
inset: 0,
|
|
298
|
+
zIndex: 10,
|
|
299
|
+
display: 'flex',
|
|
300
|
+
alignItems: 'center',
|
|
301
|
+
justifyContent: 'center',
|
|
302
|
+
background: 'rgba(0,0,0,0.4)',
|
|
303
|
+
transition: 'opacity 0.2s',
|
|
304
|
+
opacity: isHovered ? 1 : 0,
|
|
305
|
+
pointerEvents: isHovered ? 'auto' : 'none',
|
|
306
|
+
}}
|
|
307
|
+
>
|
|
308
|
+
<span style={{
|
|
309
|
+
padding: '6px 12px',
|
|
310
|
+
background: '#2563eb',
|
|
311
|
+
color: '#ffffff',
|
|
312
|
+
fontSize: 12,
|
|
313
|
+
fontWeight: 500,
|
|
314
|
+
borderRadius: 9999,
|
|
315
|
+
boxShadow: '0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1)',
|
|
316
|
+
}}>
|
|
317
|
+
Click to focus
|
|
318
|
+
</span>
|
|
319
|
+
</div>
|
|
320
|
+
|
|
321
|
+
{/* Preview content */}
|
|
322
|
+
<div
|
|
323
|
+
data-theme={previewTheme}
|
|
324
|
+
style={{
|
|
325
|
+
height: '100%',
|
|
326
|
+
width: '100%',
|
|
327
|
+
overflow: 'hidden',
|
|
328
|
+
display: 'flex',
|
|
329
|
+
alignItems: 'center',
|
|
330
|
+
justifyContent: 'center',
|
|
331
|
+
}}
|
|
332
|
+
>
|
|
333
|
+
{useIframeIsolation ? (
|
|
334
|
+
<IsolatedPreviewFrame
|
|
335
|
+
fragmentPath={fragmentPath}
|
|
336
|
+
variantName={variant.name}
|
|
337
|
+
theme={previewTheme}
|
|
338
|
+
width="100%"
|
|
339
|
+
height="100%"
|
|
340
|
+
minHeight={minHeight}
|
|
341
|
+
/>
|
|
342
|
+
) : (
|
|
343
|
+
<ShadowPreview theme={previewTheme} width="100%" height="100%" minHeight={minHeight}>
|
|
344
|
+
<div
|
|
345
|
+
style={{
|
|
346
|
+
padding: 16,
|
|
347
|
+
transform: `scale(${scale})`,
|
|
348
|
+
}}
|
|
349
|
+
>
|
|
350
|
+
<ErrorBoundary
|
|
351
|
+
componentName={componentName}
|
|
352
|
+
fallback={
|
|
353
|
+
<Text size="xs" style={{ color: 'var(--color-danger)' }}>
|
|
354
|
+
Error rendering variant
|
|
355
|
+
</Text>
|
|
356
|
+
}
|
|
357
|
+
>
|
|
358
|
+
<FragmentRenderer variant={variant}>
|
|
359
|
+
{(content, isLoading, error) => {
|
|
360
|
+
if (isLoading) {
|
|
361
|
+
return (
|
|
362
|
+
<Stack align="center" justify="center" style={{ padding: 16 }}>
|
|
363
|
+
<LoaderIndicator />
|
|
364
|
+
</Stack>
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
if (error) {
|
|
368
|
+
return (
|
|
369
|
+
<Text size="xs" style={{ color: 'var(--color-danger)', padding: 8 }}>
|
|
370
|
+
{error.message}
|
|
371
|
+
</Text>
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
return content;
|
|
375
|
+
}}
|
|
376
|
+
</FragmentRenderer>
|
|
377
|
+
</ErrorBoundary>
|
|
378
|
+
</div>
|
|
379
|
+
</ShadowPreview>
|
|
380
|
+
)}
|
|
381
|
+
</div>
|
|
382
|
+
|
|
383
|
+
{/* Tags/badges */}
|
|
384
|
+
{variant.hasPlayFunction && (
|
|
385
|
+
<div style={{ position: 'absolute', bottom: 8, right: 8, zIndex: 10 }}>
|
|
386
|
+
<Badge variant="info" size="sm">play</Badge>
|
|
387
|
+
</div>
|
|
388
|
+
)}
|
|
389
|
+
</div>
|
|
390
|
+
);
|
|
391
|
+
}
|