handoff-app 2.0.0-7 → 2.0.0-9
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 +130 -14
- package/UPGRADE.md +20 -0
- package/dist/app-builder/build.js +10 -2
- package/dist/app-builder/client-config.js +3 -1
- package/dist/app-builder/watchers.js +17 -7
- package/dist/cache/build-cache.js +5 -1
- package/dist/catalog/normalize.js +3 -0
- package/dist/catalog/previews.js +5 -4
- package/dist/catalog/types.d.ts +8 -0
- package/dist/cli/auth/device.d.ts +6 -1
- package/dist/cli/auth/device.js +5 -2
- package/dist/cli/auth/store.d.ts +15 -6
- package/dist/cli/auth/store.js +72 -19
- package/dist/commands/login/index.d.ts +5 -0
- package/dist/commands/login/index.js +18 -6
- package/dist/commands/logout/index.d.ts +5 -0
- package/dist/commands/logout/index.js +54 -15
- package/dist/commands/types.d.ts +2 -0
- package/dist/commands/utils.d.ts +21 -7
- package/dist/commands/utils.js +11 -10
- package/dist/config/catalog-include.d.ts +2 -2
- package/dist/config/defaults.d.ts +3 -3
- package/dist/config/defaults.js +54 -48
- package/dist/config/env.d.ts +9 -0
- package/dist/config/env.js +43 -0
- package/dist/config/errors.d.ts +4 -0
- package/dist/config/errors.js +11 -0
- package/dist/config/from-env.d.ts +24 -0
- package/dist/config/from-env.js +7 -0
- package/dist/config/helpers.d.ts +2 -0
- package/dist/config/helpers.js +3 -1
- package/dist/config/index.d.ts +2 -1
- package/dist/config/index.js +6 -1
- package/dist/config/loader.d.ts +29 -11
- package/dist/config/loader.js +121 -46
- package/dist/config/resolve-env.d.ts +5 -0
- package/dist/config/resolve-env.js +102 -0
- package/dist/config/runtime.d.ts +3 -3
- package/dist/config/runtime.js +8 -10
- package/dist/config/validator.d.ts +2 -2
- package/dist/config/validator.js +2 -4
- package/dist/handlebars.d.ts +10 -2
- package/dist/index.d.ts +33 -4
- package/dist/index.js +60 -16
- package/dist/pipeline/index.d.ts +1 -1
- package/dist/pipeline/index.js +1 -1
- package/dist/registry/asset-storage/adapters/vercel-blob.js +1 -1
- package/dist/registry/asset-storage/resolve.d.ts +2 -2
- package/dist/registry/asset-storage/resolve.js +4 -1
- package/dist/registry/checkout/index.js +4 -4
- package/dist/registry/connection.d.ts +28 -24
- package/dist/registry/connection.js +53 -67
- package/dist/registry/db/driver.d.ts +4 -11
- package/dist/registry/db/driver.js +4 -16
- package/dist/registry/db/index.d.ts +1 -1
- package/dist/registry/db/index.js +1 -2
- package/dist/registry/publish/index.js +4 -4
- package/dist/store/filesystem.d.ts +2 -2
- package/dist/transformers/plugins/ssr-render.js +40 -15
- package/dist/transformers/preview/component/css.d.ts +1 -0
- package/dist/transformers/preview/component/css.js +85 -47
- package/dist/transformers/preview/pattern/html.js +3 -1
- package/dist/transformers/preview/types.d.ts +13 -0
- package/dist/transformers/utils/build.d.ts +19 -0
- package/dist/transformers/utils/build.js +83 -1
- package/dist/transformers/utils/module.js +2 -1
- package/dist/types/config.d.ts +54 -29
- package/docs/api.md +31 -0
- package/docs/cli.md +2 -2
- package/package.json +1 -1
- package/src/app/components/Component/PageSliceResolver.tsx +1 -0
- package/src/app/components/Component/Preview.tsx +380 -189
- package/src/app/components/Component/slices/ComponentDisplaySlice.tsx +4 -0
- package/src/app/pages/account/tokens.tsx +3 -1
- package/src/app-builder/build.ts +10 -2
- package/src/app-builder/client-config.ts +5 -3
- package/src/app-builder/watchers.ts +18 -8
- package/src/cache/build-cache.ts +5 -1
- package/src/catalog/README.md +3 -0
- package/src/catalog/normalize.ts +2 -0
- package/src/catalog/previews.ts +6 -3
- package/src/catalog/types.ts +8 -0
- package/src/cli/auth/device.ts +8 -3
- package/src/cli/auth/store.ts +75 -18
- package/src/commands/login/index.ts +20 -6
- package/src/commands/logout/index.ts +74 -20
- package/src/commands/types.ts +2 -0
- package/src/commands/utils.ts +14 -5
- package/src/config/README.md +61 -1
- package/src/config/catalog-include.ts +2 -2
- package/src/config/defaults.ts +24 -14
- package/src/config/env.ts +41 -0
- package/src/config/errors.ts +7 -0
- package/src/config/from-env.ts +26 -0
- package/src/config/helpers.ts +4 -2
- package/src/config/index.ts +4 -1
- package/src/config/loader.ts +158 -50
- package/src/config/resolve-env.ts +94 -0
- package/src/config/runtime.ts +12 -7
- package/src/config/validator.ts +4 -6
- package/src/handlebars.ts +8 -2
- package/src/index.d.ts +2 -2
- package/src/index.ts +85 -8
- package/src/pipeline/index.ts +1 -1
- package/src/registry/asset-storage/adapters/vercel-blob.ts +1 -1
- package/src/registry/asset-storage/resolve.ts +6 -3
- package/src/registry/checkout/index.ts +5 -5
- package/src/registry/connection.ts +72 -71
- package/src/registry/db/driver.ts +6 -17
- package/src/registry/db/index.ts +0 -1
- package/src/registry/publish/index.ts +5 -5
- package/src/store/filesystem.ts +2 -2
- package/src/transformers/plugins/ssr-render.ts +53 -16
- package/src/transformers/preview/component/css.ts +93 -50
- package/src/transformers/preview/pattern/html.ts +3 -1
- package/src/transformers/preview/types.ts +14 -0
- package/src/transformers/utils/build.ts +80 -0
- package/src/transformers/utils/module.ts +4 -2
- package/src/types/config.ts +51 -29
- package/templates/default/env.tpl +10 -0
- package/templates/default/gitignore.tpl +7 -0
- package/templates/default/handoff.config.js.tpl +21 -172
- package/templates/default/handoff.config.ts.tpl +21 -156
- package/templates/default/handoff.config.with-examples.js.tpl +20 -171
- package/templates/default/handoff.config.with-examples.ts.tpl +20 -155
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { buildArtifactUrl } from '@handoff/artifacts/url';
|
|
2
2
|
import { SlotMetadata } from '@handoff/transformers/preview/component';
|
|
3
|
-
import { PageSlice } from '@handoff/transformers/preview/types';
|
|
3
|
+
import { PageSlice, PreviewWidth } from '@handoff/transformers/preview/types';
|
|
4
4
|
import { PreviewObject } from '@handoff/types/preview';
|
|
5
5
|
import { Types as CoreTypes } from 'handoff-core';
|
|
6
6
|
import type { TypeNode, TypeNodeProperty } from 'handoff-docgen';
|
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
CircleCheck,
|
|
13
13
|
Component,
|
|
14
14
|
File,
|
|
15
|
+
Maximize2,
|
|
16
|
+
Minimize2,
|
|
15
17
|
Monitor,
|
|
16
18
|
MousePointerClick,
|
|
17
19
|
RefreshCcw,
|
|
@@ -39,6 +41,23 @@ const getRenderablePreviewKeys = (previews: PreviewObject['previews'] | undefine
|
|
|
39
41
|
.filter(([, preview]) => preview.url)
|
|
40
42
|
.map(([key]) => key);
|
|
41
43
|
|
|
44
|
+
/** A frame longer than the card's duration-300 class, so the card rejoins the flow once it lands. */
|
|
45
|
+
const COLLAPSE_MS = 340;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Where the card is between the flow and the floating view.
|
|
49
|
+
*
|
|
50
|
+
* `entering` and `leaving` hold it at the rect it occupies in the flow, and only `full` and
|
|
51
|
+
* `leaving` transition. The geometry must never animate across the change of positioning scheme:
|
|
52
|
+
* `w-full` and the inline width are both percentages, of the viewport while fixed and of the
|
|
53
|
+
* column while static. The browser interpolates between the two, and the card jumps to the wrong
|
|
54
|
+
* width before it grows back.
|
|
55
|
+
*/
|
|
56
|
+
type ExpandPhase = 'collapsed' | 'entering' | 'full' | 'leaving';
|
|
57
|
+
|
|
58
|
+
/** defaultHeight is hand-authored, and the stage scales it with calc(), which rejects bare numbers. */
|
|
59
|
+
const toCssLength = (value: string): string => (/^\d+(\.\d+)?$/.test(value) ? `${value}px` : value);
|
|
60
|
+
|
|
42
61
|
/** The inspect artifact sits beside the preview artifact under a fixed suffix. */
|
|
43
62
|
const toArtifactPath = (url: string, inspect: boolean): string => (inspect ? `${url.split('.html')[0]}-inspect.html` : url);
|
|
44
63
|
|
|
@@ -64,21 +83,101 @@ export const getComponentPreviewTitle = (previewableComponent: ComponentPreview)
|
|
|
64
83
|
export const ComponentDisplay: React.FC<{
|
|
65
84
|
component: PreviewObject | undefined;
|
|
66
85
|
defaultHeight?: string | undefined;
|
|
86
|
+
defaultWidth?: PreviewWidth | undefined;
|
|
67
87
|
title?: string;
|
|
68
88
|
onPreviewChange?: (previewUrl: string) => void;
|
|
69
|
-
}> = ({ component, defaultHeight, title, onPreviewChange }) => {
|
|
89
|
+
}> = ({ component, defaultHeight, defaultWidth, title, onPreviewChange }) => {
|
|
70
90
|
const context = usePreviewContext();
|
|
71
91
|
const ref = React.useRef<HTMLIFrameElement>(null);
|
|
92
|
+
const cardRef = React.useRef<HTMLDivElement>(null);
|
|
93
|
+
const collapseTimer = React.useRef<number | undefined>(undefined);
|
|
94
|
+
const contentObserver = React.useRef<ResizeObserver | null>(null);
|
|
95
|
+
const stageObserver = React.useRef<ResizeObserver | null>(null);
|
|
72
96
|
const [height, setHeight] = React.useState('100px');
|
|
73
97
|
const [previewKey, setPreviewKey] = React.useState<string | null>(null);
|
|
74
|
-
const [
|
|
98
|
+
const [previewWidth, setPreviewWidth] = React.useState<PreviewWidth>(defaultWidth ?? 'fluid');
|
|
99
|
+
const [stageWidth, setStageWidth] = React.useState(0);
|
|
75
100
|
const [inspect, setInspect] = React.useState(false);
|
|
76
|
-
const [
|
|
101
|
+
const [phase, setPhase] = React.useState<ExpandPhase>('collapsed');
|
|
102
|
+
// top/left/width are percentages of the viewport, the same units the expanded card uses. A
|
|
103
|
+
// transition between a pixel value and a vw has nothing reliable to interpolate.
|
|
104
|
+
const [originRect, setOriginRect] = React.useState<{ top: number; left: number; width: number; height: number } | null>(null);
|
|
105
|
+
|
|
106
|
+
const expanded = phase !== 'collapsed';
|
|
107
|
+
const animates = phase === 'full' || phase === 'leaving';
|
|
77
108
|
|
|
78
109
|
const renderablePreviewKeys = getRenderablePreviewKeys(component?.previews);
|
|
79
110
|
const selectedPreview = previewKey ? component?.previews?.[previewKey] : undefined;
|
|
80
111
|
const artifactPath = selectedPreview?.url ? toArtifactPath(selectedPreview.url, inspect) : null;
|
|
81
112
|
|
|
113
|
+
// A fluid preview takes the width the stage has, so it renders at 100% and shows the layout the
|
|
114
|
+
// reader would really get at that width. A fixed width is only scaled when it does not fit, and
|
|
115
|
+
// never scaled up: a component shown larger than it renders misrepresents the design system.
|
|
116
|
+
const renderWidth = previewWidth === 'fluid' ? stageWidth : previewWidth;
|
|
117
|
+
const scale = renderWidth > stageWidth ? stageWidth / renderWidth : 1;
|
|
118
|
+
|
|
119
|
+
// Expanding lifts the card out of the flow rather than moving it into a dialog. Moving it would
|
|
120
|
+
// remount the iframe, and the preview would reload behind the animation instead of growing with
|
|
121
|
+
// it. The card starts at the rect it already occupies, so the first frame looks identical.
|
|
122
|
+
const expand = () => {
|
|
123
|
+
const rect = cardRef.current?.getBoundingClientRect();
|
|
124
|
+
if (!rect) return;
|
|
125
|
+
window.clearTimeout(collapseTimer.current);
|
|
126
|
+
setOriginRect({
|
|
127
|
+
top: (rect.top / window.innerHeight) * 100,
|
|
128
|
+
left: (rect.left / window.innerWidth) * 100,
|
|
129
|
+
width: (rect.width / window.innerWidth) * 100,
|
|
130
|
+
height: rect.height,
|
|
131
|
+
});
|
|
132
|
+
setPhase('entering');
|
|
133
|
+
requestAnimationFrame(() => setPhase('full'));
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// A timer, not transitionend: reduced motion removes the transition, and then the event that
|
|
137
|
+
// would put the card back in the flow never fires.
|
|
138
|
+
const collapse = () => {
|
|
139
|
+
setPhase('leaving');
|
|
140
|
+
window.clearTimeout(collapseTimer.current);
|
|
141
|
+
collapseTimer.current = window.setTimeout(() => setPhase('collapsed'), COLLAPSE_MS);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
React.useEffect(() => {
|
|
145
|
+
if (!expanded) return;
|
|
146
|
+
|
|
147
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
148
|
+
if (event.key === 'Escape') collapse();
|
|
149
|
+
};
|
|
150
|
+
const pageOverflow = document.body.style.overflow;
|
|
151
|
+
|
|
152
|
+
document.addEventListener('keydown', onKeyDown);
|
|
153
|
+
document.body.style.overflow = 'hidden';
|
|
154
|
+
|
|
155
|
+
return () => {
|
|
156
|
+
document.removeEventListener('keydown', onKeyDown);
|
|
157
|
+
document.body.style.overflow = pageOverflow;
|
|
158
|
+
};
|
|
159
|
+
}, [expanded]);
|
|
160
|
+
|
|
161
|
+
// A fluid preview already follows the stage, so animating its width only lags behind a resize.
|
|
162
|
+
// No easing while the card moves: the target changes every frame, so each frame would restart a
|
|
163
|
+
// 0.2s ease and the stage would trail the preview inside it. The ease is for discrete changes -
|
|
164
|
+
// a new device width, a new variant.
|
|
165
|
+
const boxTransition = animates
|
|
166
|
+
? 'none'
|
|
167
|
+
: previewWidth === 'fluid'
|
|
168
|
+
? 'height 0.2s ease-in-out'
|
|
169
|
+
: 'width 0.2s ease-in-out, height 0.2s ease-in-out';
|
|
170
|
+
|
|
171
|
+
// A ref callback, not an effect: the stage only exists once a preview is available. A hidden tab
|
|
172
|
+
// never delivers a ResizeObserver entry, so the first width is read directly.
|
|
173
|
+
const observeStage = useCallback((stage: HTMLDivElement | null) => {
|
|
174
|
+
stageObserver.current?.disconnect();
|
|
175
|
+
if (!stage) return;
|
|
176
|
+
setStageWidth(stage.clientWidth);
|
|
177
|
+
stageObserver.current = new ResizeObserver(([entry]) => setStageWidth(entry.contentRect.width));
|
|
178
|
+
stageObserver.current.observe(stage);
|
|
179
|
+
}, []);
|
|
180
|
+
|
|
82
181
|
// Generate variants from component previews only for Figma atomic components
|
|
83
182
|
const localVariants = React.useMemo(() => {
|
|
84
183
|
if (!component?.figmaComponentId || !component?.previews) return null;
|
|
@@ -107,25 +206,42 @@ export const ComponentDisplay: React.FC<{
|
|
|
107
206
|
return context.variants;
|
|
108
207
|
}, [component?.figmaComponentId, component?.previews, context.preview, context.variants]);
|
|
109
208
|
|
|
209
|
+
// The stage reserves the scaled height, so it needs the live content height. That height changes
|
|
210
|
+
// whenever the preview reflows - a new device width, a late font, an image - not only on load.
|
|
110
211
|
const onLoad = useCallback(() => {
|
|
212
|
+
contentObserver.current?.disconnect();
|
|
213
|
+
|
|
111
214
|
if (defaultHeight) {
|
|
112
|
-
setHeight(defaultHeight);
|
|
113
|
-
|
|
114
|
-
if (ref.current.contentWindow.document.body) {
|
|
115
|
-
setHeight(ref.current.contentWindow.document.body.scrollHeight + 'px');
|
|
116
|
-
}
|
|
215
|
+
setHeight(toCssLength(defaultHeight));
|
|
216
|
+
return;
|
|
117
217
|
}
|
|
118
|
-
|
|
119
|
-
|
|
218
|
+
|
|
219
|
+
const body = ref.current?.contentWindow?.document?.body;
|
|
220
|
+
if (!body) return;
|
|
221
|
+
|
|
222
|
+
const measure = () => setHeight(body.scrollHeight + 'px');
|
|
223
|
+
measure();
|
|
224
|
+
contentObserver.current = new ResizeObserver(measure);
|
|
225
|
+
contentObserver.current.observe(body);
|
|
226
|
+
}, [defaultHeight]);
|
|
120
227
|
|
|
121
228
|
React.useEffect(() => {
|
|
122
229
|
onLoad();
|
|
123
|
-
window.addEventListener('resize', onLoad);
|
|
124
|
-
return () => {
|
|
125
|
-
window.removeEventListener('resize', onLoad);
|
|
126
|
-
};
|
|
127
230
|
}, [onLoad]);
|
|
128
231
|
|
|
232
|
+
React.useEffect(() => {
|
|
233
|
+
setPreviewWidth(defaultWidth ?? 'fluid');
|
|
234
|
+
}, [defaultWidth]);
|
|
235
|
+
|
|
236
|
+
React.useEffect(
|
|
237
|
+
() => () => {
|
|
238
|
+
contentObserver.current?.disconnect();
|
|
239
|
+
stageObserver.current?.disconnect();
|
|
240
|
+
window.clearTimeout(collapseTimer.current);
|
|
241
|
+
},
|
|
242
|
+
[]
|
|
243
|
+
);
|
|
244
|
+
|
|
129
245
|
// One effect owns the selection. Split in two, the filter effect clears the selection, then
|
|
130
246
|
// returns early on the next render and never restores it.
|
|
131
247
|
React.useEffect(() => {
|
|
@@ -165,186 +281,261 @@ export const ComponentDisplay: React.FC<{
|
|
|
165
281
|
);
|
|
166
282
|
};
|
|
167
283
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
<p className="font-monospace text-[11px] text-accent-foreground">{title ?? 'Variant'}</p>
|
|
181
|
-
<Separator orientation="vertical" className="h-3" />
|
|
182
|
-
</div>
|
|
183
|
-
<div className="flex flex-wrap gap-2">
|
|
184
|
-
{Object.keys(localVariants)
|
|
185
|
-
.filter((variantProperty) => localVariants[variantProperty].length > 1)
|
|
186
|
-
.map((variantProperty) => (
|
|
187
|
-
<Select
|
|
188
|
-
key={variantProperty}
|
|
189
|
-
value={context.variantFilter ? context.variantFilter[variantProperty] : undefined}
|
|
190
|
-
onValueChange={(value) => context.updateVariantFilter(variantProperty, value)}
|
|
191
|
-
>
|
|
192
|
-
<SelectTrigger className="h-8 w-[140px] border-none text-xs shadow-none">
|
|
193
|
-
<SelectValue placeholder={variantProperty} />
|
|
194
|
-
</SelectTrigger>
|
|
195
|
-
<SelectContent>
|
|
196
|
-
{localVariants[variantProperty].map((variantPropertyValue) => {
|
|
197
|
-
if (typeof variantPropertyValue !== 'string' || variantPropertyValue === '') return null;
|
|
198
|
-
return (
|
|
199
|
-
<SelectItem
|
|
200
|
-
key={variantPropertyValue}
|
|
201
|
-
value={variantPropertyValue}
|
|
202
|
-
disabled={!isOptionValid(variantProperty, variantPropertyValue)}
|
|
203
|
-
>
|
|
204
|
-
{variantPropertyValue}
|
|
205
|
-
</SelectItem>
|
|
206
|
-
);
|
|
207
|
-
})}
|
|
208
|
-
</SelectContent>
|
|
209
|
-
</Select>
|
|
210
|
-
))}
|
|
211
|
-
</div>
|
|
212
|
-
</>
|
|
213
|
-
)}
|
|
214
|
-
</>
|
|
215
|
-
) : (
|
|
216
|
-
<>
|
|
217
|
-
<Select value={previewKey ?? ''} onValueChange={setPreviewKey}>
|
|
218
|
-
<SelectTrigger className="h-8 w-[180px] border-none border-gray-200 bg-white text-xs shadow-none dark:border-gray-900">
|
|
219
|
-
<SelectValue placeholder="Preview" />
|
|
220
|
-
</SelectTrigger>
|
|
221
|
-
<SelectContent>
|
|
222
|
-
{renderablePreviewKeys.map((key) => (
|
|
223
|
-
<SelectItem key={key} value={key}>
|
|
224
|
-
{component.previews[key].title}
|
|
225
|
-
</SelectItem>
|
|
226
|
-
))}
|
|
227
|
-
</SelectContent>
|
|
228
|
-
</Select>
|
|
229
|
-
</>
|
|
230
|
-
)}
|
|
231
|
-
</div>
|
|
232
|
-
<div className=" hidden items-center gap-0 @2xl:flex">
|
|
233
|
-
<TooltipProvider delayDuration={0}>
|
|
234
|
-
<Tooltip>
|
|
235
|
-
<TooltipTrigger asChild>
|
|
236
|
-
<Button
|
|
237
|
-
className="h-7 px-3 font-mono text-[11px] hover:bg-gray-300"
|
|
238
|
-
onClick={() => setScale(scale === 1 ? 0.8 : 1)}
|
|
239
|
-
variant="ghost"
|
|
240
|
-
>
|
|
241
|
-
{scale === 1 ? '100%' : '80%'}
|
|
242
|
-
</Button>
|
|
243
|
-
</TooltipTrigger>
|
|
244
|
-
<TooltipContent className="rounded-sm px-2 py-1 text-[11px]">Toggle Scale</TooltipContent>
|
|
245
|
-
</Tooltip>
|
|
246
|
-
</TooltipProvider>
|
|
247
|
-
<Separator orientation="vertical" className="mx-3 h-6" />
|
|
248
|
-
<RadioGroup className="flex items-center gap-0" defaultValue="1100" onValueChange={(value) => setWidth(`${value}px`)}>
|
|
249
|
-
<label className="relative flex h-7 cursor-pointer flex-col items-center justify-center rounded-md px-3 text-center text-xl ring-inset transition-colors hover:bg-gray-300 has-data-disabled:cursor-not-allowed has-data-[state=checked]:bg-blue-50 has-data-disabled:opacity-50 has-data-[state=checked]:shadow-[inset_0_1px_1px_0_rgba(0,0,0,0.05)] has-focus-visible:outline-solid has-focus-visible:outline-2 has-focus-visible:outline-ring/70 has-data-[state=checked]:ring-1 has-data-[state=checked]:ring-blue-500/20 [&_svg]:size-3">
|
|
250
|
-
<RadioGroupItem value="1100" className="sr-only after:absolute after:inset-0" />
|
|
251
|
-
<Monitor />
|
|
252
|
-
</label>
|
|
253
|
-
<label className="relative flex h-7 cursor-pointer flex-col items-center justify-center rounded-md px-3 text-center text-xl transition-colors hover:bg-gray-300 has-data-disabled:cursor-not-allowed has-data-[state=checked]:bg-blue-50 has-data-disabled:opacity-50 has-data-[state=checked]:shadow-[inset_0_1px_1px_0_rgba(0,0,0,0.05)] has-focus-visible:outline-solid has-focus-visible:outline-2 has-focus-visible:outline-ring/70 has-data-[state=checked]:ring-1 has-data-[state=checked]:ring-blue-500/20 [&_svg]:size-3">
|
|
254
|
-
<RadioGroupItem value="800" className="sr-only after:absolute after:inset-0" />
|
|
255
|
-
<Tablet />
|
|
256
|
-
</label>
|
|
257
|
-
<label className="relative flex h-7 cursor-pointer flex-col items-center justify-center rounded-md px-3 text-center text-xl transition-colors hover:bg-gray-300 has-data-disabled:cursor-not-allowed has-data-[state=checked]:bg-blue-50 has-data-disabled:opacity-50 has-data-[state=checked]:shadow-[inset_0_1px_1px_0_rgba(0,0,0,0.05)] has-focus-visible:outline-solid has-focus-visible:outline-2 has-focus-visible:outline-ring/70 has-data-[state=checked]:ring-1 has-data-[state=checked]:ring-blue-500/20 [&_svg]:size-2.5">
|
|
258
|
-
<RadioGroupItem value="400" className="sr-only after:absolute after:inset-0" />
|
|
259
|
-
<Smartphone />
|
|
260
|
-
</label>
|
|
261
|
-
</RadioGroup>
|
|
262
|
-
<Separator orientation="vertical" className="mx-3 h-6" />
|
|
263
|
-
<TooltipProvider delayDuration={0}>
|
|
264
|
-
<Tooltip>
|
|
265
|
-
<TooltipTrigger asChild>
|
|
266
|
-
<Button
|
|
267
|
-
className="h-7 px-3 hover:bg-gray-300 [&_svg]:size-3"
|
|
268
|
-
onClick={() => {
|
|
269
|
-
if (ref.current) {
|
|
270
|
-
ref.current.contentWindow.location.reload();
|
|
271
|
-
}
|
|
272
|
-
}}
|
|
273
|
-
variant="ghost"
|
|
274
|
-
>
|
|
275
|
-
<RefreshCcw />
|
|
276
|
-
</Button>
|
|
277
|
-
</TooltipTrigger>
|
|
278
|
-
<TooltipContent className="rounded-sm px-2 py-1 text-[11px]">Refresh Preview</TooltipContent>
|
|
279
|
-
</Tooltip>
|
|
280
|
-
</TooltipProvider>
|
|
281
|
-
<TooltipProvider delayDuration={0}>
|
|
282
|
-
<Tooltip>
|
|
283
|
-
<TooltipTrigger asChild>
|
|
284
|
-
<Button
|
|
285
|
-
className="h-7 px-3 hover:bg-gray-300 [&_svg]:size-4"
|
|
286
|
-
onClick={() => {
|
|
287
|
-
setInspect(!inspect);
|
|
288
|
-
}}
|
|
289
|
-
variant={inspect ? 'default' : 'ghost'}
|
|
290
|
-
>
|
|
291
|
-
<MousePointerClick strokeWidth={1.5} />
|
|
292
|
-
</Button>
|
|
293
|
-
</TooltipTrigger>
|
|
294
|
-
<TooltipContent className="rounded-sm px-2 py-1 text-[11px]">Inspect Component</TooltipContent>
|
|
295
|
-
</Tooltip>
|
|
296
|
-
</TooltipProvider>
|
|
297
|
-
<TooltipProvider delayDuration={0}>
|
|
298
|
-
<Tooltip>
|
|
299
|
-
<TooltipTrigger asChild>
|
|
300
|
-
<Button
|
|
301
|
-
className="h-7 px-3 hover:bg-gray-300 [&_svg]:size-3"
|
|
302
|
-
onClick={() => {
|
|
303
|
-
// open in new tab
|
|
304
|
-
window.open(buildArtifactUrl(`component/${artifactPath}`, process.env.HANDOFF_APP_BASE_PATH ?? ''), '_blank');
|
|
305
|
-
}}
|
|
306
|
-
variant="ghost"
|
|
307
|
-
>
|
|
308
|
-
<SquareArrowOutUpRight />
|
|
309
|
-
</Button>
|
|
310
|
-
</TooltipTrigger>
|
|
311
|
-
<TooltipContent className="rounded-sm px-2 py-1 text-[11px]">Open in New Tab</TooltipContent>
|
|
312
|
-
</Tooltip>
|
|
313
|
-
</TooltipProvider>
|
|
314
|
-
</div>
|
|
315
|
-
</div>
|
|
284
|
+
const collapsedStyle: React.CSSProperties = { position: 'fixed', zIndex: 50 };
|
|
285
|
+
const originStyle: React.CSSProperties = originRect
|
|
286
|
+
? { top: `${originRect.top}%`, left: `${originRect.left}%`, width: `${originRect.width}%`, transform: 'translateY(0)' }
|
|
287
|
+
: {};
|
|
288
|
+
const fullSizeStyle: React.CSSProperties = {
|
|
289
|
+
...collapsedStyle,
|
|
290
|
+
top: '50%',
|
|
291
|
+
left: '2%',
|
|
292
|
+
width: '96%',
|
|
293
|
+
maxHeight: '92vh',
|
|
294
|
+
transform: 'translateY(-50%)',
|
|
295
|
+
};
|
|
316
296
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
297
|
+
const card = (
|
|
298
|
+
<div
|
|
299
|
+
ref={cardRef}
|
|
300
|
+
role={expanded ? 'dialog' : undefined}
|
|
301
|
+
aria-modal={expanded ? true : undefined}
|
|
302
|
+
aria-label={expanded ? `${title ?? 'Component'} preview` : undefined}
|
|
303
|
+
style={expanded ? (phase === 'full' ? fullSizeStyle : { ...collapsedStyle, ...originStyle }) : undefined}
|
|
304
|
+
className={`text-medium flex w-full flex-col items-center rounded-lg border border-gray-200 dark:border-gray-900 ${
|
|
305
|
+
expanded ? 'overflow-hidden bg-background shadow-xl' : ''
|
|
306
|
+
} ${animates ? 'transition-[top,left,width,transform] duration-300 ease-out motion-reduce:transition-none' : ''}`}
|
|
307
|
+
>
|
|
308
|
+
{component?.previews && (
|
|
309
|
+
<>
|
|
310
|
+
<div
|
|
311
|
+
className="flex w-full items-center justify-between rounded-t-lg bg-gray-50 px-6 py-2 pr-3 align-middle @container dark:bg-gray-800"
|
|
312
|
+
>
|
|
313
|
+
<div className="flex flex-1 items-start gap-2">
|
|
314
|
+
{localVariants ? (
|
|
315
|
+
<>
|
|
316
|
+
{Object.keys(localVariants).length > 0 && (
|
|
317
|
+
<>
|
|
318
|
+
<div className="flex h-8 shrink-0 items-center gap-2">
|
|
319
|
+
<p className="font-monospace text-[11px] text-accent-foreground">{title ?? 'Variant'}</p>
|
|
320
|
+
<Separator orientation="vertical" className="h-3" />
|
|
321
|
+
</div>
|
|
322
|
+
<div className="flex flex-wrap gap-2">
|
|
323
|
+
{Object.keys(localVariants)
|
|
324
|
+
.filter((variantProperty) => localVariants[variantProperty].length > 1)
|
|
325
|
+
.map((variantProperty) => (
|
|
326
|
+
<Select
|
|
327
|
+
key={variantProperty}
|
|
328
|
+
value={context.variantFilter ? context.variantFilter[variantProperty] : undefined}
|
|
329
|
+
onValueChange={(value) => context.updateVariantFilter(variantProperty, value)}
|
|
330
|
+
>
|
|
331
|
+
<SelectTrigger className="h-8 w-[140px] border-none text-xs shadow-none">
|
|
332
|
+
<SelectValue placeholder={variantProperty} />
|
|
333
|
+
</SelectTrigger>
|
|
334
|
+
<SelectContent>
|
|
335
|
+
{localVariants[variantProperty].map((variantPropertyValue) => {
|
|
336
|
+
if (typeof variantPropertyValue !== 'string' || variantPropertyValue === '') return null;
|
|
337
|
+
return (
|
|
338
|
+
<SelectItem
|
|
339
|
+
key={variantPropertyValue}
|
|
340
|
+
value={variantPropertyValue}
|
|
341
|
+
disabled={!isOptionValid(variantProperty, variantPropertyValue)}
|
|
342
|
+
>
|
|
343
|
+
{variantPropertyValue}
|
|
344
|
+
</SelectItem>
|
|
345
|
+
);
|
|
346
|
+
})}
|
|
347
|
+
</SelectContent>
|
|
348
|
+
</Select>
|
|
349
|
+
))}
|
|
350
|
+
</div>
|
|
351
|
+
</>
|
|
352
|
+
)}
|
|
353
|
+
</>
|
|
337
354
|
) : (
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
355
|
+
<>
|
|
356
|
+
<Select value={previewKey ?? ''} onValueChange={setPreviewKey}>
|
|
357
|
+
<SelectTrigger className="h-8 w-[180px] border-none border-gray-200 bg-white text-xs shadow-none dark:border-gray-900">
|
|
358
|
+
<SelectValue placeholder="Preview" />
|
|
359
|
+
</SelectTrigger>
|
|
360
|
+
<SelectContent>
|
|
361
|
+
{renderablePreviewKeys.map((key) => (
|
|
362
|
+
<SelectItem key={key} value={key}>
|
|
363
|
+
{component.previews[key].title}
|
|
364
|
+
</SelectItem>
|
|
365
|
+
))}
|
|
366
|
+
</SelectContent>
|
|
367
|
+
</Select>
|
|
368
|
+
</>
|
|
341
369
|
)}
|
|
342
370
|
</div>
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
371
|
+
<div className="hidden items-center gap-0 @xl:flex">
|
|
372
|
+
{scale < 1 && (
|
|
373
|
+
<>
|
|
374
|
+
<TooltipProvider delayDuration={0}>
|
|
375
|
+
<Tooltip>
|
|
376
|
+
<TooltipTrigger asChild>
|
|
377
|
+
<span className="px-3 font-mono text-[11px] text-gray-500">{Math.round(scale * 100)}%</span>
|
|
378
|
+
</TooltipTrigger>
|
|
379
|
+
<TooltipContent className="rounded-sm px-2 py-1 text-[11px]">
|
|
380
|
+
Too wide for this column. Expand to see it at full size.
|
|
381
|
+
</TooltipContent>
|
|
382
|
+
</Tooltip>
|
|
383
|
+
</TooltipProvider>
|
|
384
|
+
<Separator orientation="vertical" className="mx-3 h-6" />
|
|
385
|
+
</>
|
|
386
|
+
)}
|
|
387
|
+
<RadioGroup
|
|
388
|
+
className="flex items-center gap-0"
|
|
389
|
+
value={String(previewWidth)}
|
|
390
|
+
onValueChange={(value) => setPreviewWidth(value === 'fluid' ? 'fluid' : Number(value))}
|
|
391
|
+
>
|
|
392
|
+
<label className="relative flex h-7 cursor-pointer flex-col items-center justify-center rounded-md px-3 text-center text-xl ring-inset transition-colors hover:bg-gray-300 has-data-disabled:cursor-not-allowed has-data-[state=checked]:bg-blue-50 has-data-disabled:opacity-50 has-data-[state=checked]:shadow-[inset_0_1px_1px_0_rgba(0,0,0,0.05)] has-focus-visible:outline-solid has-focus-visible:outline-2 has-focus-visible:outline-ring/70 has-data-[state=checked]:ring-1 has-data-[state=checked]:ring-blue-500/20 [&_svg]:size-3">
|
|
393
|
+
<RadioGroupItem value="fluid" aria-label="Fill the available width" className="sr-only after:absolute after:inset-0" />
|
|
394
|
+
<Monitor />
|
|
395
|
+
</label>
|
|
396
|
+
<label className="relative flex h-7 cursor-pointer flex-col items-center justify-center rounded-md px-3 text-center text-xl transition-colors hover:bg-gray-300 has-data-disabled:cursor-not-allowed has-data-[state=checked]:bg-blue-50 has-data-disabled:opacity-50 has-data-[state=checked]:shadow-[inset_0_1px_1px_0_rgba(0,0,0,0.05)] has-focus-visible:outline-solid has-focus-visible:outline-2 has-focus-visible:outline-ring/70 has-data-[state=checked]:ring-1 has-data-[state=checked]:ring-blue-500/20 [&_svg]:size-3">
|
|
397
|
+
<RadioGroupItem value="800" aria-label="Tablet width, 800px" className="sr-only after:absolute after:inset-0" />
|
|
398
|
+
<Tablet />
|
|
399
|
+
</label>
|
|
400
|
+
<label className="relative flex h-7 cursor-pointer flex-col items-center justify-center rounded-md px-3 text-center text-xl transition-colors hover:bg-gray-300 has-data-disabled:cursor-not-allowed has-data-[state=checked]:bg-blue-50 has-data-disabled:opacity-50 has-data-[state=checked]:shadow-[inset_0_1px_1px_0_rgba(0,0,0,0.05)] has-focus-visible:outline-solid has-focus-visible:outline-2 has-focus-visible:outline-ring/70 has-data-[state=checked]:ring-1 has-data-[state=checked]:ring-blue-500/20 [&_svg]:size-2.5">
|
|
401
|
+
<RadioGroupItem value="400" aria-label="Mobile width, 400px" className="sr-only after:absolute after:inset-0" />
|
|
402
|
+
<Smartphone />
|
|
403
|
+
</label>
|
|
404
|
+
</RadioGroup>
|
|
405
|
+
<Separator orientation="vertical" className="mx-3 h-6" />
|
|
406
|
+
<TooltipProvider delayDuration={0}>
|
|
407
|
+
<Tooltip>
|
|
408
|
+
<TooltipTrigger asChild>
|
|
409
|
+
<Button
|
|
410
|
+
className="h-7 px-3 hover:bg-gray-300 [&_svg]:size-3"
|
|
411
|
+
onClick={() => {
|
|
412
|
+
if (ref.current) {
|
|
413
|
+
ref.current.contentWindow.location.reload();
|
|
414
|
+
}
|
|
415
|
+
}}
|
|
416
|
+
variant="ghost"
|
|
417
|
+
>
|
|
418
|
+
<RefreshCcw />
|
|
419
|
+
</Button>
|
|
420
|
+
</TooltipTrigger>
|
|
421
|
+
<TooltipContent className="rounded-sm px-2 py-1 text-[11px]">Refresh Preview</TooltipContent>
|
|
422
|
+
</Tooltip>
|
|
423
|
+
</TooltipProvider>
|
|
424
|
+
<TooltipProvider delayDuration={0}>
|
|
425
|
+
<Tooltip>
|
|
426
|
+
<TooltipTrigger asChild>
|
|
427
|
+
<Button
|
|
428
|
+
className="h-7 px-3 hover:bg-gray-300 [&_svg]:size-4"
|
|
429
|
+
onClick={() => {
|
|
430
|
+
setInspect(!inspect);
|
|
431
|
+
}}
|
|
432
|
+
variant={inspect ? 'default' : 'ghost'}
|
|
433
|
+
>
|
|
434
|
+
<MousePointerClick strokeWidth={1.5} />
|
|
435
|
+
</Button>
|
|
436
|
+
</TooltipTrigger>
|
|
437
|
+
<TooltipContent className="rounded-sm px-2 py-1 text-[11px]">Inspect Component</TooltipContent>
|
|
438
|
+
</Tooltip>
|
|
439
|
+
</TooltipProvider>
|
|
440
|
+
<TooltipProvider delayDuration={0}>
|
|
441
|
+
<Tooltip>
|
|
442
|
+
<TooltipTrigger asChild>
|
|
443
|
+
<Button
|
|
444
|
+
aria-label={expanded ? 'Collapse preview' : 'Expand preview'}
|
|
445
|
+
className="h-7 px-3 hover:bg-gray-300 [&_svg]:size-3"
|
|
446
|
+
onClick={() => (expanded ? collapse() : expand())}
|
|
447
|
+
variant="ghost"
|
|
448
|
+
>
|
|
449
|
+
{expanded ? <Minimize2 /> : <Maximize2 />}
|
|
450
|
+
</Button>
|
|
451
|
+
</TooltipTrigger>
|
|
452
|
+
<TooltipContent className="rounded-sm px-2 py-1 text-[11px]">
|
|
453
|
+
{expanded ? 'Collapse Preview' : 'Expand Preview'}
|
|
454
|
+
</TooltipContent>
|
|
455
|
+
</Tooltip>
|
|
456
|
+
</TooltipProvider>
|
|
457
|
+
<TooltipProvider delayDuration={0}>
|
|
458
|
+
<Tooltip>
|
|
459
|
+
<TooltipTrigger asChild>
|
|
460
|
+
<Button
|
|
461
|
+
className="h-7 px-3 hover:bg-gray-300 [&_svg]:size-3"
|
|
462
|
+
onClick={() => {
|
|
463
|
+
// open in new tab
|
|
464
|
+
window.open(buildArtifactUrl(`component/${artifactPath}`, process.env.HANDOFF_APP_BASE_PATH ?? ''), '_blank');
|
|
465
|
+
}}
|
|
466
|
+
variant="ghost"
|
|
467
|
+
>
|
|
468
|
+
<SquareArrowOutUpRight />
|
|
469
|
+
</Button>
|
|
470
|
+
</TooltipTrigger>
|
|
471
|
+
<TooltipContent className="rounded-sm px-2 py-1 text-[11px]">Open in New Tab</TooltipContent>
|
|
472
|
+
</Tooltip>
|
|
473
|
+
</TooltipProvider>
|
|
474
|
+
</div>
|
|
475
|
+
</div>
|
|
476
|
+
|
|
477
|
+
<div className={`dotted-bg w-full p-4 ${expanded ? 'min-h-0' : ''}`}>
|
|
478
|
+
{artifactPath ? (
|
|
479
|
+
// The outer div measures the width available to the preview. The inner one carries
|
|
480
|
+
// the scaled size, because a transform does not shrink the layout box: without it
|
|
481
|
+
// the stage keeps the unscaled box and pads the preview with dead space on the
|
|
482
|
+
// right and below.
|
|
483
|
+
<div ref={observeStage}>
|
|
484
|
+
{renderWidth > 0 && (
|
|
485
|
+
<div
|
|
486
|
+
style={{
|
|
487
|
+
width: renderWidth * scale,
|
|
488
|
+
height: `calc(${height} * ${scale})`,
|
|
489
|
+
margin: '0 auto',
|
|
490
|
+
transition: boxTransition,
|
|
491
|
+
}}
|
|
492
|
+
>
|
|
493
|
+
<iframe
|
|
494
|
+
key={`${artifactPath}-${reloadCounter}`}
|
|
495
|
+
onLoad={onLoad}
|
|
496
|
+
ref={ref}
|
|
497
|
+
style={{
|
|
498
|
+
width: renderWidth,
|
|
499
|
+
height: height,
|
|
500
|
+
transform: `scale(${scale})`,
|
|
501
|
+
transformOrigin: 'left top',
|
|
502
|
+
transition: animates ? 'none' : 'transform 0.2s ease-in-out',
|
|
503
|
+
display: 'block',
|
|
504
|
+
}}
|
|
505
|
+
src={buildArtifactUrl(`component/${artifactPath}`, process.env.HANDOFF_APP_BASE_PATH ?? '')}
|
|
506
|
+
/>
|
|
507
|
+
</div>
|
|
508
|
+
)}
|
|
509
|
+
</div>
|
|
510
|
+
) : (
|
|
511
|
+
<div className="flex items-center justify-center p-8 text-sm text-gray-500">
|
|
512
|
+
No preview available for this selection.
|
|
513
|
+
</div>
|
|
514
|
+
)}
|
|
515
|
+
</div>
|
|
516
|
+
</>
|
|
517
|
+
)}
|
|
346
518
|
</div>
|
|
347
519
|
);
|
|
520
|
+
|
|
521
|
+
// The docs column is narrower than the widths most components are designed for. Expanding is
|
|
522
|
+
// how a reader gets the real layout at 100% instead of a scaled copy of it. The placeholder holds
|
|
523
|
+
// the page height while the card floats, so nothing behind the overlay jumps.
|
|
524
|
+
return (
|
|
525
|
+
<>
|
|
526
|
+
{expanded && (
|
|
527
|
+
<div
|
|
528
|
+
className={`backdrop-blur-xs fixed inset-0 z-40 bg-gray-200/50 transition-opacity duration-300 dark:bg-gray-950/60 ${
|
|
529
|
+
phase === 'full' ? 'opacity-100' : 'opacity-0'
|
|
530
|
+
}`}
|
|
531
|
+
onClick={collapse}
|
|
532
|
+
/>
|
|
533
|
+
)}
|
|
534
|
+
<div className="md:flex" id="preview" style={expanded && originRect ? { minHeight: originRect.height } : undefined}>
|
|
535
|
+
{card}
|
|
536
|
+
</div>
|
|
537
|
+
</>
|
|
538
|
+
);
|
|
348
539
|
};
|
|
349
540
|
|
|
350
541
|
export const ComponentPreview: React.FC<{
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { PreviewWidth } from '@handoff/transformers/preview/types';
|
|
1
2
|
import { PreviewObject } from '@handoff/types/preview';
|
|
2
3
|
import { Filter, evaluateFilter } from '@handoff/utils/filter';
|
|
3
4
|
import React from 'react';
|
|
@@ -13,6 +14,7 @@ export interface ComponentDisplaySliceProps {
|
|
|
13
14
|
showPreview?: boolean;
|
|
14
15
|
showCodeHighlight?: boolean;
|
|
15
16
|
defaultHeight?: string;
|
|
17
|
+
defaultWidth?: PreviewWidth;
|
|
16
18
|
filterBy?: Filter;
|
|
17
19
|
codeHighlight?: boolean;
|
|
18
20
|
}
|
|
@@ -38,6 +40,7 @@ const ComponentDisplaySlice: React.FC<ComponentDisplaySliceProps> = ({
|
|
|
38
40
|
showPreview = true,
|
|
39
41
|
showCodeHighlight,
|
|
40
42
|
defaultHeight,
|
|
43
|
+
defaultWidth,
|
|
41
44
|
filterBy,
|
|
42
45
|
codeHighlight = true,
|
|
43
46
|
}) => {
|
|
@@ -68,6 +71,7 @@ const ComponentDisplaySlice: React.FC<ComponentDisplaySliceProps> = ({
|
|
|
68
71
|
title={title}
|
|
69
72
|
component={filteredPreview}
|
|
70
73
|
defaultHeight={finalDefaultHeight}
|
|
74
|
+
defaultWidth={defaultWidth}
|
|
71
75
|
onPreviewChange={onPreviewChange}
|
|
72
76
|
/>
|
|
73
77
|
)}
|