1import {
2 type CSSProperties,
3 type ReactNode,
4 type RefObject,
5 useLayoutEffect,
6 useRef,
7 useState,
8} from "react";
9
10import {
11 getPreviewPanelMaxWidth,
12 type PreviewPanelInlineSize,
13 usePreviewPanelInlineSize,
14} from "~/hooks/usePreviewPanelInlineSize";
15import { RESIZABLE_WIDTH_PROPERTY } from "~/hooks/useResizableWidth";
16
17export { getPreviewPanelMaxWidth };
18import { cn } from "~/lib/utils";
19
20import { RightPanelResizeHandle } from "./RightPanelResizeHandle";
21
22export type PreviewPanelMode = "inline" | "sheet" | "sidebar" | "embedded";
23
24/**
25 * Shell for the preview panel. In inline mode the panel is user-resizable
26 * via a drag handle on the left edge; width persists per browser. In
27 * sheet/sidebar modes the parent owns the size.
28 */
29interface PreviewPanelShellProps {
30 mode: PreviewPanelMode;
31 maximized?: boolean;
32 inlineSize?: PreviewPanelInlineSize;
33 open?: boolean;
34 /**
35 * Overrides the localStorage key used to persist the panel width. Callers
36 * embedding this shell for a different surface (e.g. the pull requests
37 * page) should pass their own key so resizing one panel doesn't clobber
38 * the other's remembered width.
39 */
40 widthStorageKey?: string;
41 /** Overrides the initial width (px) before the user has resized the panel. */
42 defaultWidth?: number;
43 children: ReactNode;
44}
45
46export function PreviewPanelShell(props: PreviewPanelShellProps) {
47 if (props.inlineSize) {
48 return <PreviewPanelShellFrame {...props} inlineSize={props.inlineSize} />;
49 }
50
51 return <ResizablePreviewPanelShell {...props} />;
52}
53
54function ResizablePreviewPanelShell(props: PreviewPanelShellProps) {
55 const hostRef = useRef<HTMLDivElement | null>(null);
56 const inlineSize = usePreviewPanelInlineSize(hostRef, {
57 enabled: props.mode === "inline" && !props.maximized,
58 widthStorageKey: props.widthStorageKey,
59 defaultWidth: props.defaultWidth,
60 });
61 return <PreviewPanelShellFrame {...props} inlineSize={inlineSize} hostRef={hostRef} />;
62}
63
64function PreviewPanelShellFrame(
65 props: PreviewPanelShellProps & {
66 inlineSize: PreviewPanelInlineSize;
67 hostRef?: RefObject<HTMLDivElement | null>;
68 },
69) {
70 const isInline = props.mode === "inline";
71 const collapsible = isInline && props.open !== undefined;
72 const open = props.open ?? true;
73 const maximized = props.maximized ?? false;
74 const localHostRef = useRef<HTMLDivElement | null>(null);
75 const hostRef = props.hostRef ?? localHostRef;
76 const { width, handlers } = props.inlineSize;
77 // Derive suppression before the layout commits so the browser never creates
78 // a width transition for resize or maximize changes.
79 const [layoutTransition, setLayoutTransition] = useState(() => ({
80 open,
81 width,
82 maximized,
83 suppressed: false,
84 }));
85 if (
86 layoutTransition.open !== open ||
87 layoutTransition.width !== width ||
88 layoutTransition.maximized !== maximized
89 ) {
90 setLayoutTransition({
91 open,
92 width,
93 maximized,
94 suppressed:
95 collapsible &&
96 layoutTransition.open === open &&
97 (layoutTransition.width !== width || layoutTransition.maximized !== maximized),
98 });
99 }
100 const suppressWidthTransition = layoutTransition.suppressed;
101 useLayoutEffect(() => {
102 if (!suppressWidthTransition) return;
103 let restoreFrame = 0;
104 const paintFrame = window.requestAnimationFrame(() => {
105 restoreFrame = window.requestAnimationFrame(() => {
106 setLayoutTransition((current) => ({ ...current, suppressed: false }));
107 });
108 });
109 return () => {
110 window.cancelAnimationFrame(paintFrame);
111 window.cancelAnimationFrame(restoreFrame);
112 };
113 }, [suppressWidthTransition]);
114 return (
115 <div
116 ref={hostRef}
117 className={cn(
118 "relative flex h-full min-h-0 min-w-0 max-w-full flex-col self-stretch bg-background",
119 isInline
120 ? maximized
121 ? "flex-1 border-l border-border"
122 : "shrink-0 border-l border-border"
123 : "w-full",
124 collapsible &&
125 "[[data-panel-animations=true]_&]:transition-[width] [[data-panel-animations=true]_&]:duration-(--panel-animation-duration) [[data-panel-animations=true]_&]:ease-out",
126 collapsible && open && "[[data-panel-animations=true]_&]:starting:w-0!",
127 collapsible && !open && "pointer-events-none",
128 )}
129 style={
130 isInline
131 ? ({
132 [RESIZABLE_WIDTH_PROPERTY]: `${width}px`,
133 width: maximized
134 ? "100%"
135 : collapsible && !open
136 ? "0px"
137 : `var(${RESIZABLE_WIDTH_PROPERTY})`,
138 transitionDuration: suppressWidthTransition ? "0ms" : undefined,
139 } as CSSProperties)
140 : undefined
141 }
142 data-preview-panel-mode={props.mode}
143 data-preview-panel-maximized={maximized ? "true" : "false"}
144 >
145 {isInline && !maximized ? <RightPanelResizeHandle handlers={handlers} /> : null}
146 <div
147 className={cn(
148 "h-full min-h-0 w-full",
149 isInline && !maximized && "pl-3",
150 collapsible && "overflow-clip",
151 )}
152 >
153 <div
154 className="flex h-full min-h-0 min-w-0 flex-col"
155 style={
156 collapsible && !maximized
157 ? { width: `calc(var(${RESIZABLE_WIDTH_PROPERTY}) - 13px)` }
158 : undefined
159 }
160 >
161 {props.children}
162 </div>
163 </div>
164 </div>
165 );
166}