diff --git a/apps/ui/src/components/preview-split-frame/index.test.tsx b/apps/ui/src/components/preview-split-frame/index.test.tsx
new file mode 100644
index 0000000000..e391380341
--- /dev/null
+++ b/apps/ui/src/components/preview-split-frame/index.test.tsx
@@ -0,0 +1,241 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ PREVIEW_CONTENT_WIDTH_STORAGE_KEY,
+ PREVIEW_PANEL_STORAGE_KEY,
+} from '@/lib/resizable-panels';
+import { PreviewSplitFrame } from './index';
+
+function getFrameRoot(): HTMLElement {
+ const root = screen.getByTestId( 'content' ).parentElement?.parentElement;
+ if ( ! root ) {
+ throw new Error( 'PreviewSplitFrame root not found' );
+ }
+ return root;
+}
+
+describe( 'PreviewSplitFrame', () => {
+ let getBoundingClientRectSpy: ReturnType< typeof vi.spyOn >;
+ let frameWidth: number;
+
+ beforeEach( () => {
+ frameWidth = 1000;
+ vi.stubGlobal( 'ResizeObserver', undefined );
+ window.localStorage.setItem( PREVIEW_PANEL_STORAGE_KEY, '400' );
+ getBoundingClientRectSpy = vi
+ .spyOn( HTMLElement.prototype, 'getBoundingClientRect' )
+ .mockImplementation( () => ( {
+ x: 0,
+ y: 0,
+ width: frameWidth,
+ height: 700,
+ top: 0,
+ right: frameWidth,
+ bottom: 700,
+ left: 0,
+ toJSON: () => ( {} ),
+ } ) );
+ } );
+
+ afterEach( () => {
+ getBoundingClientRectSpy.mockRestore();
+ window.localStorage.removeItem( PREVIEW_CONTENT_WIDTH_STORAGE_KEY );
+ window.localStorage.removeItem( PREVIEW_PANEL_STORAGE_KEY );
+ vi.unstubAllGlobals();
+ } );
+
+ it( 'lays out the preview immediately when mounted open', async () => {
+ render(
+ }>
+ Content
+
+ );
+
+ const root = getFrameRoot();
+
+ await waitFor( () => {
+ expect( root ).toHaveStyle( '--preview-frame-content-width: 600px' );
+ } );
+ expect( screen.getByRole( 'separator', { name: 'Resize site preview' } ) ).toHaveAttribute(
+ 'aria-valuenow',
+ '400'
+ );
+ expect( screen.getByLabelText( 'Site preview' ) ).toBeVisible();
+ } );
+
+ it( 'prefers the stored content width over the legacy preview width', async () => {
+ window.localStorage.setItem( PREVIEW_CONTENT_WIDTH_STORAGE_KEY, '500' );
+
+ render(
+ }>
+ Content
+
+ );
+
+ const root = getFrameRoot();
+ await waitFor( () => {
+ expect( root ).toHaveStyle( '--preview-frame-content-width: 500px' );
+ } );
+ expect( screen.getByRole( 'separator', { name: 'Resize site preview' } ) ).toHaveAttribute(
+ 'aria-valuenow',
+ '500'
+ );
+ expect( window.localStorage.getItem( PREVIEW_PANEL_STORAGE_KEY ) ).toBeNull();
+ } );
+
+ it( 'keeps the content width stable as the window grows', async () => {
+ render(
+ }>
+ Content
+
+ );
+
+ const root = getFrameRoot();
+ await waitFor( () => {
+ expect( root ).toHaveStyle( '--preview-frame-content-width: 600px' );
+ } );
+
+ frameWidth = 1120;
+ fireEvent( window, new Event( 'resize' ) );
+
+ expect( root ).toHaveStyle( '--preview-frame-content-width: 600px' );
+
+ frameWidth = 1300;
+ fireEvent( window, new Event( 'resize' ) );
+
+ expect( root ).toHaveStyle( '--preview-frame-content-width: 600px' );
+ } );
+
+ it( 'converts the legacy preview width when the preview opens, not while closed', async () => {
+ const preview = () => ;
+ const { rerender } = render(
+
+ Content
+
+ );
+
+ const root = getFrameRoot();
+ await waitFor( () => {
+ expect( root ).toHaveStyle( '--preview-frame-content-width: calc(100% - 400px)' );
+ } );
+
+ frameWidth = 1120;
+ fireEvent( window, new Event( 'resize' ) );
+
+ expect( root ).toHaveStyle( '--preview-frame-content-width: calc(100% - 400px)' );
+ expect( window.localStorage.getItem( PREVIEW_CONTENT_WIDTH_STORAGE_KEY ) ).toBeNull();
+ expect( window.localStorage.getItem( PREVIEW_PANEL_STORAGE_KEY ) ).toBe( '400' );
+
+ rerender(
+
+ Content
+
+ );
+
+ await waitFor( () => {
+ expect( root ).toHaveStyle( '--preview-frame-content-width: 720px' );
+ } );
+ expect( window.localStorage.getItem( PREVIEW_CONTENT_WIDTH_STORAGE_KEY ) ).toBe( '720' );
+ expect( window.localStorage.getItem( PREVIEW_PANEL_STORAGE_KEY ) ).toBeNull();
+ } );
+
+ it( 'keeps preview space reserved when the first mount measurement is zero', () => {
+ frameWidth = 0;
+
+ render(
+ }>
+ Content
+
+ );
+
+ const root = getFrameRoot();
+ expect( root ).toHaveStyle( '--preview-frame-content-width: calc(100% - 400px)' );
+ expect( screen.getByLabelText( 'Site preview' ) ).toBeVisible();
+ } );
+
+ describe( 'keyboard and pointer resizing', () => {
+ async function renderOpenAndSettle() {
+ render(
+ }>
+ Content
+
+ );
+ await waitFor( () =>
+ expect( getFrameRoot() ).toHaveStyle( '--preview-frame-content-width: 600px' )
+ );
+ return screen.getByRole( 'separator', { name: 'Resize site preview' } );
+ }
+
+ it( 'collapses the preview to its minimum width on Home', async () => {
+ const handle = await renderOpenAndSettle();
+ fireEvent.keyDown( handle, { key: 'Home' } );
+ expect( handle ).toHaveAttribute( 'aria-valuenow', '360' );
+ expect( window.localStorage.getItem( PREVIEW_CONTENT_WIDTH_STORAGE_KEY ) ).toBe( '640' );
+ expect( window.localStorage.getItem( PREVIEW_PANEL_STORAGE_KEY ) ).toBeNull();
+ } );
+
+ it( 'expands the preview to its maximum width on End', async () => {
+ const handle = await renderOpenAndSettle();
+ fireEvent.keyDown( handle, { key: 'End' } );
+ expect( handle ).toHaveAttribute( 'aria-valuenow', '720' );
+ expect( window.localStorage.getItem( PREVIEW_CONTENT_WIDTH_STORAGE_KEY ) ).toBe( '280' );
+ expect( window.localStorage.getItem( PREVIEW_PANEL_STORAGE_KEY ) ).toBeNull();
+ } );
+
+ it( 'steps the preview width with arrow keys, using a larger step with Shift', async () => {
+ const handle = await renderOpenAndSettle();
+ fireEvent.keyDown( handle, { key: 'ArrowLeft' } );
+ expect( handle ).toHaveAttribute( 'aria-valuenow', '416' );
+ fireEvent.keyDown( handle, { key: 'ArrowRight', shiftKey: true } );
+ expect( handle ).toHaveAttribute( 'aria-valuenow', '376' );
+ expect( window.localStorage.getItem( PREVIEW_CONTENT_WIDTH_STORAGE_KEY ) ).toBe( '624' );
+ expect( window.localStorage.getItem( PREVIEW_PANEL_STORAGE_KEY ) ).toBeNull();
+ } );
+
+ it( 'persists the dragged width on mouse resize', async () => {
+ const handle = await renderOpenAndSettle();
+ fireEvent.mouseDown( handle, { button: 0, clientX: 500 } );
+ fireEvent.mouseUp( document, { clientX: 440 } );
+ expect( handle ).toHaveAttribute( 'aria-valuenow', '460' );
+ expect( window.localStorage.getItem( PREVIEW_CONTENT_WIDTH_STORAGE_KEY ) ).toBe( '540' );
+ expect( window.localStorage.getItem( PREVIEW_PANEL_STORAGE_KEY ) ).toBeNull();
+ } );
+
+ it( 'cleans up document drag state if the preview closes mid-resize', async () => {
+ const preview = () => ;
+ const { rerender } = render(
+
+ Content
+
+ );
+ const handle = await screen.findByRole( 'separator', { name: 'Resize site preview' } );
+
+ fireEvent.mouseDown( handle, { button: 0, clientX: 500 } );
+ expect( document.body ).toHaveStyle( { cursor: 'col-resize' } );
+ expect( document.body ).toHaveStyle( { userSelect: 'none' } );
+
+ rerender(
+
+ Content
+
+ );
+
+ await waitFor( () => {
+ expect( document.body ).toHaveStyle( { cursor: '' } );
+ expect( document.body ).toHaveStyle( { userSelect: '' } );
+ } );
+ fireEvent.mouseUp( document, { clientX: 440 } );
+ expect( window.localStorage.getItem( PREVIEW_CONTENT_WIDTH_STORAGE_KEY ) ).toBe( '600' );
+ expect( window.localStorage.getItem( PREVIEW_PANEL_STORAGE_KEY ) ).toBeNull();
+ } );
+
+ it( 'ignores non-primary mouse buttons', async () => {
+ const handle = await renderOpenAndSettle();
+ fireEvent.mouseDown( handle, { button: 2, clientX: 500 } );
+ fireEvent.mouseUp( document, { clientX: 200 } );
+ expect( handle ).toHaveAttribute( 'aria-valuenow', '400' );
+ expect( window.localStorage.getItem( PREVIEW_CONTENT_WIDTH_STORAGE_KEY ) ).toBe( '600' );
+ expect( window.localStorage.getItem( PREVIEW_PANEL_STORAGE_KEY ) ).toBeNull();
+ } );
+ } );
+} );
diff --git a/apps/ui/src/components/preview-split-frame/index.tsx b/apps/ui/src/components/preview-split-frame/index.tsx
index 7000db545f..3e344a5dca 100644
--- a/apps/ui/src/components/preview-split-frame/index.tsx
+++ b/apps/ui/src/components/preview-split-frame/index.tsx
@@ -2,93 +2,98 @@ import { __ } from '@wordpress/i18n';
import { clsx } from 'clsx';
import { useEffect, useState, type CSSProperties, type ReactNode } from 'react';
import { ResizeHandle, ResizeOverlay } from '@/components/resize-handle';
-import { useResizablePanel } from '@/hooks/use-resizable-panel';
-import { PREVIEW_PANEL_CONFIG, PREVIEW_PANEL_STORAGE_KEY } from '@/lib/resizable-panels';
+import { usePreviewSplit } from '@/hooks/use-preview-split';
import styles from './style.module.css';
-// Keep in sync with the flex-basis transition duration in style.module.css.
+// Keep in sync with the content-column transition duration in style.module.css.
const PREVIEW_TOGGLE_DURATION = 150;
+export interface PreviewSplitFramePreviewProps {
+ collapsed: boolean;
+}
+
interface PreviewSplitFrameProps {
- // The preview panel content. Kept mounted (hidden behind the content
- // column) while `previewOpen` is false so the webview stays warm and the
- // panel can slide in and out.
- preview?: ReactNode;
+ // The preview panel content. Kept mounted while closed so the webview stays
+ // warm. The split geometry is owned by usePreviewSplit; this component only
+ // handles the open/close slide animation.
+ preview?: ( props: PreviewSplitFramePreviewProps ) => ReactNode;
previewOpen?: boolean;
children?: ReactNode;
}
-// Splits the frame between the main content column and the site preview. The
-// preview slot is pinned to the right edge at its resizable width; the
-// content column sits on top of it and shrinks to reveal it, so toggling
-// animates by transitioning only the content column's flex-basis while the
-// preview keeps a constant width (no mid-animation webview reflow).
export function PreviewSplitFrame( {
preview,
previewOpen = false,
children,
}: PreviewSplitFrameProps ) {
- const previewMounted = preview != null;
- const showPreview = previewMounted && previewOpen;
- const previewResize = useResizablePanel( {
- config: PREVIEW_PANEL_CONFIG,
- edge: 'left',
- storageKey: PREVIEW_PANEL_STORAGE_KEY,
- } );
- // Animate only open/close toggles of an already-mounted preview — never
- // the initial layout, so a route loading with the preview visible doesn't
- // replay the slide-in. The render-phase update makes the transition class
- // land in the same commit as the flex-basis change; an effect-based
- // update would race it.
- const [ animating, setAnimating ] = useState( false );
+ const showPreview = previewOpen && preview != null;
+ const { rootRef, contentWidthVar, isResizing, handleProps } = usePreviewSplit( { showPreview } );
+
+ // Animate only open/close toggles of an already-mounted preview — never the
+ // initial layout, so a route loading with the preview visible doesn't replay
+ // the slide-in. The render-phase update lands the transition class in the
+ // same commit as the width change; an effect-based update would race it.
+ const [ animatingPreviewToggle, setAnimatingPreviewToggle ] = useState( false );
const [ previousPreview, setPreviousPreview ] = useState( {
- mounted: previewMounted,
+ mounted: preview != null,
open: showPreview,
} );
- if ( previousPreview.mounted !== previewMounted || previousPreview.open !== showPreview ) {
- setPreviousPreview( { mounted: previewMounted, open: showPreview } );
- if ( previousPreview.mounted && previewMounted ) {
- setAnimating( true );
+ if ( previousPreview.mounted !== ( preview != null ) || previousPreview.open !== showPreview ) {
+ setPreviousPreview( { mounted: preview != null, open: showPreview } );
+ if ( previousPreview.mounted && preview != null ) {
+ setAnimatingPreviewToggle( true );
}
}
+
useEffect( () => {
- if ( ! animating ) {
+ if ( ! animatingPreviewToggle ) {
return;
}
- const timeoutId = window.setTimeout( () => setAnimating( false ), PREVIEW_TOGGLE_DURATION );
+ const timeoutId = window.setTimeout(
+ () => setAnimatingPreviewToggle( false ),
+ PREVIEW_TOGGLE_DURATION
+ );
return () => window.clearTimeout( timeoutId );
- }, [ animating, showPreview ] );
+ }, [ animatingPreviewToggle, showPreview ] );
- const rootStyle = { '--site-preview-width': `${ previewResize.width }px` } as CSSProperties;
+ const previewVisible = showPreview || animatingPreviewToggle;
+ const renderedPreview = preview?.( { collapsed: ! previewVisible } );
+ const rootStyle = {
+ '--preview-frame-content-width': contentWidthVar,
+ } as CSSProperties;
return (
{ children }
- { previewMounted ? (
-
- { preview }
+ { preview ? (
+
+ { renderedPreview }
) : null }
- { showPreview && ! animating ? (
+ { showPreview && ! animatingPreviewToggle ? (
) : null }
- { previewResize.isResizing ?
: null }
+ { isResizing ?
: null }
);
}
diff --git a/apps/ui/src/components/preview-split-frame/style.module.css b/apps/ui/src/components/preview-split-frame/style.module.css
index 4b573063e2..b40e63786d 100644
--- a/apps/ui/src/components/preview-split-frame/style.module.css
+++ b/apps/ui/src/components/preview-split-frame/style.module.css
@@ -1,6 +1,4 @@
.root {
- display: flex;
- flex-direction: row;
height: 100%;
min-height: 0;
position: relative;
@@ -14,23 +12,27 @@
.contentColumn {
display: flex;
flex-direction: column;
- flex: 0 0 100%;
+ position: absolute;
+ inset: 0 auto 0 0;
+ width: 100%;
min-width: 0;
min-height: 0;
- position: relative;
z-index: 1;
overflow: auto;
background-color: var(--wpds-color-bg-surface-neutral);
}
.rootPreviewOpen .contentColumn {
- flex-basis: calc(100% - var(--site-preview-width, 520px));
+ width: var(--preview-frame-content-width);
}
-/* Applied only around open/close toggles (see PREVIEW_TOGGLE_DURATION) so
- the initial layout and drag-resizing stay instant. */
.rootPreviewAnimating .contentColumn {
- transition: flex-basis 150ms ease;
+ transition: width 150ms ease;
+}
+
+.rootPreviewResizing .contentColumn {
+ user-select: none;
+ transition: none;
}
.previewSlot {
@@ -38,18 +40,19 @@
top: 0;
right: 0;
bottom: 0;
- width: var(--site-preview-width, 520px);
- /* Once the close animation finishes, drop the slot out of the focus and
- accessibility tree — the webview behind the content column stays
- mounted but must not be reachable. The delay keeps it visible while it
- slides closed; opening flips visibility immediately. */
+ z-index: 0;
+ width: calc(100% - var(--preview-frame-content-width));
+ min-width: 0;
+ pointer-events: none;
visibility: hidden;
- transition: visibility 0s linear 150ms;
}
-.previewSlotOpen {
+.previewSlotVisible {
visibility: visible;
- transition-delay: 0s;
+}
+
+.previewSlotInteractive {
+ pointer-events: auto;
}
/* Centers the 8px hit area on the content/preview boundary. */
@@ -57,6 +60,8 @@
position: absolute;
top: 0;
bottom: 0;
- right: calc(var(--site-preview-width, 520px) - 4px);
+ left: var(--preview-frame-content-width);
+ z-index: 3;
+ transform: translateX(-4px);
justify-content: center;
}
diff --git a/apps/ui/src/hooks/use-pointer-drag.ts b/apps/ui/src/hooks/use-pointer-drag.ts
new file mode 100644
index 0000000000..4093bd4282
--- /dev/null
+++ b/apps/ui/src/hooks/use-pointer-drag.ts
@@ -0,0 +1,114 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import type { MouseEvent, MouseEventHandler } from 'react';
+
+export interface PointerDragHandlers {
+ // Called once on a valid primary-button mousedown, after preventDefault.
+ // Return the starting scalar (e.g. the panel/content width at drag start),
+ // or null to abort the drag (e.g. the container is not measurable yet).
+ onStart: ( event: MouseEvent< HTMLElement > ) => number | null;
+ // rAF-throttled. Receives the start scalar and the raw pixel delta
+ // (clientX - startX); returns the new committed-to-state scalar. Direction
+ // and clamping are the caller's concern.
+ onMove: ( start: number, deltaX: number ) => number;
+ // Called once on mouseup, with the latest scalar from the last onMove.
+ // Persist here.
+ onCommit: ( latest: number ) => void;
+}
+
+export interface PointerDragControls {
+ isDragging: boolean;
+ onMouseDown: MouseEventHandler< HTMLElement >;
+ // Externally end an in-flight drag (e.g. the panel closed mid-drag). With
+ // commit: false (the default) a later mouseup will NOT persist.
+ cancel: ( options?: { commit?: boolean } ) => void;
+}
+
+// Value-agnostic pointer-drag plumbing shared by the resizable panels: primary-
+// button filtering, rAF throttling, cursor/userSelect save+restore, document
+// listener attach/detach, and teardown on unmount or external cancel. It knows
+// nothing about widths, clamping, storage, edges, or measurement.
+export function usePointerDrag( {
+ onStart,
+ onMove,
+ onCommit,
+}: PointerDragHandlers ): PointerDragControls {
+ const [ isDragging, setIsDragging ] = useState( false );
+ // Holds the active drag's teardown so it can be cancelled externally or on
+ // unmount. Passing commit: false marks the drag dead so a trailing mouseup
+ // is a no-op.
+ const cancelRef = useRef< ( ( options?: { commit?: boolean } ) => void ) | null >( null );
+
+ const onMouseDown = useCallback< MouseEventHandler< HTMLElement > >(
+ ( event ) => {
+ if ( event.button !== 0 ) {
+ return;
+ }
+ const start = onStart( event );
+ if ( start === null ) {
+ return;
+ }
+ event.preventDefault();
+ cancelRef.current?.(); // tear down any prior drag
+
+ const startX = event.clientX;
+ const originalCursor = document.body.style.cursor;
+ const originalUserSelect = document.body.style.userSelect;
+ document.body.style.cursor = 'col-resize';
+ document.body.style.userSelect = 'none';
+ setIsDragging( true );
+
+ let frame: number | undefined;
+ let latest = start;
+ let committed = false;
+
+ const run = ( clientX: number ) => {
+ latest = onMove( start, clientX - startX );
+ };
+
+ const handleMouseMove = ( moveEvent: globalThis.MouseEvent ) => {
+ if ( frame !== undefined ) {
+ window.cancelAnimationFrame( frame );
+ }
+ frame = window.requestAnimationFrame( () => run( moveEvent.clientX ) );
+ };
+
+ const cancel = ( { commit = false }: { commit?: boolean } = {} ) => {
+ if ( frame !== undefined ) {
+ window.cancelAnimationFrame( frame );
+ }
+ document.body.style.cursor = originalCursor;
+ document.body.style.userSelect = originalUserSelect;
+ document.removeEventListener( 'mousemove', handleMouseMove );
+ document.removeEventListener( 'mouseup', handleMouseUp );
+ if ( cancelRef.current === cancel ) {
+ cancelRef.current = null;
+ }
+ if ( commit && ! committed ) {
+ committed = true;
+ onCommit( latest );
+ }
+ setIsDragging( false );
+ };
+
+ function handleMouseUp( upEvent: globalThis.MouseEvent ) {
+ run( upEvent.clientX );
+ cancel( { commit: true } );
+ }
+
+ cancelRef.current = cancel;
+ document.addEventListener( 'mousemove', handleMouseMove );
+ document.addEventListener( 'mouseup', handleMouseUp );
+ },
+ [ onStart, onMove, onCommit ]
+ );
+
+ const cancel = useCallback( ( options?: { commit?: boolean } ) => {
+ cancelRef.current?.( options );
+ }, [] );
+
+ useEffect( () => {
+ return () => cancelRef.current?.( { commit: false } );
+ }, [] );
+
+ return { isDragging, onMouseDown, cancel };
+}
diff --git a/apps/ui/src/hooks/use-preview-split.ts b/apps/ui/src/hooks/use-preview-split.ts
new file mode 100644
index 0000000000..4789141b14
--- /dev/null
+++ b/apps/ui/src/hooks/use-preview-split.ts
@@ -0,0 +1,241 @@
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
+import { usePointerDrag } from '@/hooks/use-pointer-drag';
+import {
+ getInitialPreviewLayout,
+ getPreviewSplitLayout,
+ getViewportWidth,
+ PREVIEW_CONTENT_WIDTH_STORAGE_KEY,
+ PREVIEW_PANEL_MIN_WIDTH,
+ PREVIEW_PANEL_STORAGE_KEY,
+ removeStoredResizablePanelWidth,
+ storeResizablePanelWidth,
+} from '@/lib/resizable-panels';
+import type { KeyboardEvent, MouseEventHandler, RefObject } from 'react';
+
+interface UsePreviewSplitOptions {
+ showPreview: boolean;
+}
+
+interface PreviewSplitHandleProps {
+ minWidth: number;
+ maxWidth: number;
+ width: number;
+ isResizing: boolean;
+ onResizeStart: MouseEventHandler< HTMLElement >;
+ onKeyDown: ( event: KeyboardEvent< HTMLElement > ) => void;
+}
+
+interface UsePreviewSplitResult {
+ rootRef: RefObject< HTMLDivElement | null >;
+ // The value for the --preview-frame-content-width CSS var: a px width once
+ // measured, or a `calc(100% -
px)` fallback that reserves preview
+ // space before the first measurement.
+ contentWidthVar: string;
+ isResizing: boolean;
+ handleProps: PreviewSplitHandleProps;
+}
+
+// Owns the preview/content split: a single clamp (getPreviewSplitLayout) feeds
+// both the rendered content width and the resize handle's reported bounds, so
+// CSS never re-clamps. Keeps two state atoms — the user's preferred content
+// width (persisted) and the measured container width — and derives everything
+// else. Also owns the legacy preview-width -> content-width migration.
+export function usePreviewSplit( { showPreview }: UsePreviewSplitOptions ): UsePreviewSplitResult {
+ const rootRef = useRef< HTMLDivElement >( null );
+ const [ initialLayout ] = useState( () => getInitialPreviewLayout( getViewportWidth() ) );
+ const legacyPreviewWidth = initialLayout.legacyPreviewWidth;
+
+ // `preferredContentWidth` is the user's intent (persisted); the displayed
+ // width is always re-derived from it against the current container, never
+ // stored back. Recomputing from a previously-clamped displayed value would
+ // lose the intent on shrink-then-grow.
+ const [ preferredContentWidth, setPreferredContentWidth ] = useState< number | null >(
+ initialLayout.contentWidth
+ );
+ const [ containerWidth, setContainerWidth ] = useState< number | null >( null );
+ // Mirrors preferredContentWidth so event handlers read the latest value
+ // without re-subscribing. setPreferred is the only writer of the state, and
+ // it updates this ref in lockstep, so the two never drift.
+ const preferredRef = useRef( preferredContentWidth );
+ const hasLegacyPreviewWidthRef = useRef( initialLayout.hasLegacyPreviewWidth );
+
+ const measureRootWidth = useCallback( () => {
+ const width = rootRef.current?.getBoundingClientRect().width;
+ if ( ! width ) {
+ return null;
+ }
+ const roundedWidth = Math.round( width );
+ setContainerWidth( roundedWidth );
+ return roundedWidth;
+ }, [] );
+
+ const setPreferred = useCallback( ( next: number ) => {
+ const rounded = Math.round( next );
+ preferredRef.current = rounded;
+ setPreferredContentWidth( rounded );
+ }, [] );
+
+ const clearLegacyPreviewWidth = useCallback( () => {
+ if ( ! hasLegacyPreviewWidthRef.current ) {
+ return;
+ }
+ hasLegacyPreviewWidthRef.current = false;
+ removeStoredResizablePanelWidth( PREVIEW_PANEL_STORAGE_KEY );
+ }, [] );
+
+ const persistContentWidth = useCallback(
+ ( next: number ) => {
+ setPreferred( next );
+ storeResizablePanelWidth( PREVIEW_CONTENT_WIDTH_STORAGE_KEY, Math.round( next ) );
+ clearLegacyPreviewWidth();
+ },
+ [ clearLegacyPreviewWidth, setPreferred ]
+ );
+
+ // First open: convert the legacy preview width into a content width against
+ // the real container, then persist + drop the legacy key. This is also the
+ // only place the migration runs, so the legacy key survives while closed.
+ const ensurePreferred = useCallback(
+ ( container: number ) => {
+ if ( preferredRef.current !== null ) {
+ return preferredRef.current;
+ }
+ const next = getPreviewSplitLayout( container, container - legacyPreviewWidth ).contentWidth;
+ setPreferred( next );
+ if ( hasLegacyPreviewWidthRef.current ) {
+ storeResizablePanelWidth( PREVIEW_CONTENT_WIDTH_STORAGE_KEY, next );
+ clearLegacyPreviewWidth();
+ }
+ return next;
+ },
+ [ clearLegacyPreviewWidth, legacyPreviewWidth, setPreferred ]
+ );
+
+ // Measure synchronously on open so the clamped px var is present before
+ // paint (no flash of the calc() fallback).
+ useLayoutEffect( () => {
+ if ( ! showPreview ) {
+ return;
+ }
+ const width = measureRootWidth();
+ if ( width !== null ) {
+ ensurePreferred( width );
+ }
+ }, [ ensurePreferred, measureRootWidth, showPreview ] );
+
+ // Keep the measured container fresh so the displayed width and the handle's
+ // aria values track window/sidebar resizes. CSS no longer absorbs resizes,
+ // so this recompute is load-bearing. Falls back to a window resize listener
+ // where ResizeObserver is unavailable (mirrors the theme-pattern widget).
+ useEffect( () => {
+ if ( ! showPreview ) {
+ return;
+ }
+ const element = rootRef.current;
+ if ( ! element ) {
+ return;
+ }
+ if ( typeof ResizeObserver === 'undefined' ) {
+ window.addEventListener( 'resize', measureRootWidth );
+ return () => window.removeEventListener( 'resize', measureRootWidth );
+ }
+ const observer = new ResizeObserver( () => measureRootWidth() );
+ observer.observe( element );
+ return () => observer.disconnect();
+ }, [ measureRootWidth, showPreview ] );
+
+ const { isDragging, onMouseDown, cancel } = usePointerDrag( {
+ onStart: () => {
+ const container = measureRootWidth();
+ if ( container === null ) {
+ return null;
+ }
+ return getPreviewSplitLayout( container, ensurePreferred( container ) ).contentWidth;
+ },
+ onMove: ( start, deltaX ) => {
+ const container = Math.round(
+ rootRef.current?.getBoundingClientRect().width ?? containerWidth ?? 0
+ );
+ const layout = getPreviewSplitLayout( container, start + deltaX );
+ setPreferred( layout.contentWidth );
+ return layout.contentWidth;
+ },
+ onCommit: ( latest ) => persistContentWidth( latest ),
+ } );
+
+ // End an in-flight drag if the preview closes mid-resize.
+ useEffect( () => {
+ if ( ! showPreview ) {
+ cancel();
+ }
+ }, [ cancel, showPreview ] );
+
+ // Once a content width exists (already-stored on mount, or just set), the
+ // legacy preview-width key is stale — drop it.
+ useEffect( () => {
+ if ( preferredContentWidth !== null ) {
+ clearLegacyPreviewWidth();
+ }
+ }, [ clearLegacyPreviewWidth, preferredContentWidth ] );
+
+ const handleKeyDown = useCallback(
+ ( event: KeyboardEvent< HTMLElement > ) => {
+ if (
+ event.key !== 'ArrowLeft' &&
+ event.key !== 'ArrowRight' &&
+ event.key !== 'Home' &&
+ event.key !== 'End'
+ ) {
+ return;
+ }
+ const container = measureRootWidth();
+ if ( container === null ) {
+ return;
+ }
+ event.preventDefault();
+ const step = event.shiftKey ? 40 : 16;
+ const current = getPreviewSplitLayout( container, ensurePreferred( container ) );
+ let nextContentWidth = current.contentWidth;
+ if ( event.key === 'Home' ) {
+ nextContentWidth = container - current.previewMinWidth;
+ } else if ( event.key === 'End' ) {
+ nextContentWidth = container - current.previewMaxWidth;
+ } else {
+ nextContentWidth += event.key === 'ArrowRight' ? step : -step;
+ }
+ persistContentWidth( getPreviewSplitLayout( container, nextContentWidth ).contentWidth );
+ },
+ [ ensurePreferred, measureRootWidth, persistContentWidth ]
+ );
+
+ // One clamp, two consumers: the rendered content width and the handle bounds.
+ const layout = useMemo(
+ () =>
+ containerWidth === null
+ ? null
+ : getPreviewSplitLayout(
+ containerWidth,
+ preferredContentWidth ?? containerWidth - legacyPreviewWidth
+ ),
+ [ containerWidth, legacyPreviewWidth, preferredContentWidth ]
+ );
+
+ const contentWidthVar =
+ layout === null || preferredContentWidth === null
+ ? `calc(100% - ${ legacyPreviewWidth }px)`
+ : `${ layout.contentWidth }px`;
+
+ return {
+ rootRef,
+ contentWidthVar,
+ isResizing: isDragging,
+ handleProps: {
+ minWidth: layout?.previewMinWidth ?? PREVIEW_PANEL_MIN_WIDTH,
+ maxWidth: layout?.previewMaxWidth ?? legacyPreviewWidth,
+ width: layout?.previewWidth ?? legacyPreviewWidth,
+ isResizing: isDragging,
+ onResizeStart: onMouseDown,
+ onKeyDown: handleKeyDown,
+ },
+ };
+}
diff --git a/apps/ui/src/hooks/use-resizable-panel.test.tsx b/apps/ui/src/hooks/use-resizable-panel.test.tsx
new file mode 100644
index 0000000000..addf27a8f6
--- /dev/null
+++ b/apps/ui/src/hooks/use-resizable-panel.test.tsx
@@ -0,0 +1,93 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { ResizeHandle } from '@/components/resize-handle';
+import { useResizablePanel } from '@/hooks/use-resizable-panel';
+import { SIDEBAR_PANEL_CONFIG, SIDEBAR_PANEL_STORAGE_KEY } from '@/lib/resizable-panels';
+
+// Wires the hook exactly as SidebarLayout does (edge 'right', sidebar config +
+// key) so this covers the shared usePointerDrag path through useResizablePanel.
+function SidebarHarness() {
+ const resize = useResizablePanel( {
+ config: SIDEBAR_PANEL_CONFIG,
+ edge: 'right',
+ storageKey: SIDEBAR_PANEL_STORAGE_KEY,
+ } );
+ return (
+
+ );
+}
+
+describe( 'useResizablePanel (sidebar wiring)', () => {
+ let originalInnerWidth: number;
+
+ beforeEach( () => {
+ originalInnerWidth = window.innerWidth;
+ // Wide enough for a useful range: max = floor(1600 * 0.25) = 400, min 240,
+ // default 320.
+ Object.defineProperty( window, 'innerWidth', { value: 1600, configurable: true } );
+ } );
+
+ afterEach( () => {
+ Object.defineProperty( window, 'innerWidth', {
+ value: originalInnerWidth,
+ configurable: true,
+ } );
+ window.localStorage.removeItem( SIDEBAR_PANEL_STORAGE_KEY );
+ } );
+
+ function renderHarness() {
+ render( );
+ return screen.getByRole( 'separator', { name: 'Resize sidebar' } );
+ }
+
+ it( 'starts at the default width within the configured bounds', () => {
+ const handle = renderHarness();
+ expect( handle ).toHaveAttribute( 'aria-valuenow', '320' );
+ expect( handle ).toHaveAttribute( 'aria-valuemin', '240' );
+ expect( handle ).toHaveAttribute( 'aria-valuemax', '400' );
+ } );
+
+ it( 'persists a rightward drag (edge: right grows the panel)', () => {
+ const handle = renderHarness();
+ fireEvent.mouseDown( handle, { button: 0, clientX: 500 } );
+ fireEvent.mouseUp( document, { clientX: 560 } );
+ expect( handle ).toHaveAttribute( 'aria-valuenow', '380' );
+ expect( window.localStorage.getItem( SIDEBAR_PANEL_STORAGE_KEY ) ).toBe( '380' );
+ } );
+
+ it( 'clamps a drag to the configured maximum', () => {
+ const handle = renderHarness();
+ fireEvent.mouseDown( handle, { button: 0, clientX: 500 } );
+ fireEvent.mouseUp( document, { clientX: 900 } );
+ expect( handle ).toHaveAttribute( 'aria-valuenow', '400' );
+ expect( window.localStorage.getItem( SIDEBAR_PANEL_STORAGE_KEY ) ).toBe( '400' );
+ } );
+
+ it( 'steps with arrow keys and jumps to the bounds with Home/End', () => {
+ const handle = renderHarness();
+ fireEvent.keyDown( handle, { key: 'ArrowRight' } );
+ expect( handle ).toHaveAttribute( 'aria-valuenow', '336' );
+ fireEvent.keyDown( handle, { key: 'ArrowLeft', shiftKey: true } );
+ expect( handle ).toHaveAttribute( 'aria-valuenow', '296' );
+ fireEvent.keyDown( handle, { key: 'End' } );
+ expect( handle ).toHaveAttribute( 'aria-valuenow', '400' );
+ fireEvent.keyDown( handle, { key: 'Home' } );
+ expect( handle ).toHaveAttribute( 'aria-valuenow', '240' );
+ } );
+
+ it( 'ignores non-primary mouse buttons', () => {
+ const handle = renderHarness();
+ fireEvent.mouseDown( handle, { button: 2, clientX: 500 } );
+ fireEvent.mouseUp( document, { clientX: 900 } );
+ expect( handle ).toHaveAttribute( 'aria-valuenow', '320' );
+ expect( window.localStorage.getItem( SIDEBAR_PANEL_STORAGE_KEY ) ).toBeNull();
+ } );
+} );
diff --git a/apps/ui/src/hooks/use-resizable-panel.ts b/apps/ui/src/hooks/use-resizable-panel.ts
index 591fefd6ef..fc42ec0318 100644
--- a/apps/ui/src/hooks/use-resizable-panel.ts
+++ b/apps/ui/src/hooks/use-resizable-panel.ts
@@ -1,4 +1,5 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { usePointerDrag } from '@/hooks/use-pointer-drag';
import {
clampResizablePanelWidth,
getResizablePanelMaxWidth,
@@ -7,7 +8,7 @@ import {
storeResizablePanelWidth,
type ResizablePanelConfig,
} from '@/lib/resizable-panels';
-import type { KeyboardEvent, MouseEvent } from 'react';
+import type { KeyboardEvent } from 'react';
interface UseResizablePanelOptions {
config: ResizablePanelConfig;
@@ -20,9 +21,6 @@ export function useResizablePanel( { config, edge, storageKey }: UseResizablePan
const [ width, setWidth ] = useState( () =>
getStoredResizablePanelWidth( storageKey, config, getViewportWidth() )
);
- const [ isResizing, setIsResizing ] = useState( false );
- const dragStartX = useRef( 0 );
- const dragStartWidth = useRef( 0 );
const maxWidth = useMemo(
() => getResizablePanelMaxWidth( viewportWidth, config ),
@@ -34,22 +32,24 @@ export function useResizablePanel( { config, edge, storageKey }: UseResizablePan
const clampedWidth = clampResizablePanelWidth( nextWidth, config, getViewportWidth() );
setWidth( clampedWidth );
storeResizablePanelWidth( storageKey, clampedWidth );
+ return clampedWidth;
},
[ config, storageKey ]
);
- const handleResizeStart = useCallback(
- ( event: MouseEvent< HTMLElement > ) => {
- if ( event.button !== 0 ) {
- return;
- }
- event.preventDefault();
- setIsResizing( true );
- dragStartX.current = event.clientX;
- dragStartWidth.current = width;
+ const { isDragging, onMouseDown } = usePointerDrag( {
+ onStart: () => width,
+ onMove: ( start, deltaX ) => {
+ // `edge` decides which way a rightward drag grows the panel.
+ const delta = edge === 'right' ? deltaX : -deltaX;
+ const nextWidth = clampResizablePanelWidth( start + delta, config, getViewportWidth() );
+ setWidth( nextWidth );
+ return nextWidth;
},
- [ width ]
- );
+ onCommit: ( latest ) => {
+ saveWidth( latest );
+ },
+ } );
const handleKeyDown = useCallback(
( event: KeyboardEvent< HTMLElement > ) => {
@@ -87,62 +87,12 @@ export function useResizablePanel( { config, edge, storageKey }: UseResizablePan
return () => window.removeEventListener( 'resize', handleResize );
}, [ config ] );
- useEffect( () => {
- if ( ! isResizing ) {
- return;
- }
-
- let rafId: number | undefined;
- const originalCursor = document.body.style.cursor;
- const originalUserSelect = document.body.style.userSelect;
- document.body.style.cursor = 'col-resize';
- document.body.style.userSelect = 'none';
-
- const handleMouseMove = ( event: globalThis.MouseEvent ) => {
- if ( rafId ) {
- cancelAnimationFrame( rafId );
- }
- rafId = requestAnimationFrame( () => {
- const delta =
- edge === 'right'
- ? event.clientX - dragStartX.current
- : dragStartX.current - event.clientX;
- setWidth(
- clampResizablePanelWidth( dragStartWidth.current + delta, config, getViewportWidth() )
- );
- } );
- };
-
- const handleMouseUp = ( event: globalThis.MouseEvent ) => {
- setIsResizing( false );
- if ( rafId ) {
- cancelAnimationFrame( rafId );
- }
- const delta =
- edge === 'right' ? event.clientX - dragStartX.current : dragStartX.current - event.clientX;
- saveWidth( dragStartWidth.current + delta );
- };
-
- document.addEventListener( 'mousemove', handleMouseMove );
- document.addEventListener( 'mouseup', handleMouseUp );
-
- return () => {
- if ( rafId ) {
- cancelAnimationFrame( rafId );
- }
- document.body.style.cursor = originalCursor;
- document.body.style.userSelect = originalUserSelect;
- document.removeEventListener( 'mousemove', handleMouseMove );
- document.removeEventListener( 'mouseup', handleMouseUp );
- };
- }, [ config, edge, isResizing, saveWidth ] );
-
return {
width,
minWidth: config.minWidth,
maxWidth,
- isResizing,
- handleResizeStart,
+ isResizing: isDragging,
+ handleResizeStart: onMouseDown,
handleKeyDown,
};
}
diff --git a/apps/ui/src/lib/resizable-panels.test.ts b/apps/ui/src/lib/resizable-panels.test.ts
index e4a0a9c0bd..4fdc25dbf0 100644
--- a/apps/ui/src/lib/resizable-panels.test.ts
+++ b/apps/ui/src/lib/resizable-panels.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
clampResizablePanelWidth,
+ getPreviewSplitLayout,
getResizablePanelMaxWidth,
type ResizablePanelConfig,
} from './resizable-panels';
@@ -25,3 +26,34 @@ describe( 'resizable panels', () => {
expect( clampResizablePanelWidth( 320, config, 800 ) ).toBe( 240 );
} );
} );
+
+describe( 'getPreviewSplitLayout', () => {
+ // Content floor 280, preview floor 360.
+ it( 'leaves a comfortable preferred width untouched on a wide frame', () => {
+ const layout = getPreviewSplitLayout( 1000, 600 );
+ expect( layout.contentWidth ).toBe( 600 );
+ expect( layout.previewWidth ).toBe( 400 );
+ expect( layout.previewMinWidth ).toBe( 360 );
+ expect( layout.previewMaxWidth ).toBe( 720 );
+ } );
+
+ it( 'caps content so the preview keeps its minimum width', () => {
+ // Preferred content larger than container - previewMin -> clamped down.
+ expect( getPreviewSplitLayout( 1000, 900 ).contentWidth ).toBe( 640 );
+ expect( getPreviewSplitLayout( 1000, 900 ).previewWidth ).toBe( 360 );
+ } );
+
+ it( 'floors content at its minimum so the preview cannot swallow it', () => {
+ // This is the regime where the old CSS-only clamp diverged: a preferred
+ // content below the 280 floor must be raised to 280, not left as-is.
+ expect( getPreviewSplitLayout( 1000, 100 ).contentWidth ).toBe( 280 );
+ expect( getPreviewSplitLayout( 1000, 100 ).previewWidth ).toBe( 720 );
+ } );
+
+ it( 'degrades gracefully when the frame is narrower than both floors', () => {
+ const layout = getPreviewSplitLayout( 400, 300 );
+ expect( layout.contentWidth ).toBeGreaterThanOrEqual( 0 );
+ expect( layout.contentWidth ).toBeLessThanOrEqual( 400 );
+ expect( layout.previewWidth ).toBe( 400 - layout.contentWidth );
+ } );
+} );
diff --git a/apps/ui/src/lib/resizable-panels.ts b/apps/ui/src/lib/resizable-panels.ts
index 4a1ee2b096..2f20ebcfec 100644
--- a/apps/ui/src/lib/resizable-panels.ts
+++ b/apps/ui/src/lib/resizable-panels.ts
@@ -6,6 +6,10 @@ export interface ResizablePanelConfig {
export const SIDEBAR_PANEL_STORAGE_KEY = 'studio-ui-sidebar-width';
export const PREVIEW_PANEL_STORAGE_KEY = 'studio-ui-preview-width';
+export const PREVIEW_CONTENT_WIDTH_STORAGE_KEY = 'studio-ui-preview-content-width';
+export const PREVIEW_PANEL_DEFAULT_WIDTH = 520;
+export const PREVIEW_PANEL_MIN_WIDTH = 360;
+export const PREVIEW_PANEL_MIN_CONTENT_WIDTH = 280;
export const SIDEBAR_PANEL_CONFIG: ResizablePanelConfig = {
defaultWidth: 320,
@@ -13,12 +17,6 @@ export const SIDEBAR_PANEL_CONFIG: ResizablePanelConfig = {
maxWidthRatio: 0.25,
};
-export const PREVIEW_PANEL_CONFIG: ResizablePanelConfig = {
- defaultWidth: 520,
- minWidth: 360,
- maxWidthRatio: 0.5,
-};
-
export function getResizablePanelMaxWidth(
viewportWidth: number,
{ minWidth, maxWidthRatio }: ResizablePanelConfig
@@ -74,3 +72,108 @@ export function storeResizablePanelWidth( storageKey: string, width: number ): v
// Ignore storage failures; resizing should still work for this session.
}
}
+
+export function removeStoredResizablePanelWidth( storageKey: string ): void {
+ if ( typeof window === 'undefined' ) {
+ return;
+ }
+
+ try {
+ window.localStorage.removeItem( storageKey );
+ } catch {
+ // Ignore storage failures; resizing should still work for this session.
+ }
+}
+
+export interface PreviewSplitLayout {
+ contentWidth: number;
+ previewWidth: number;
+ previewMinWidth: number;
+ previewMaxWidth: number;
+}
+
+// The single source of truth for the preview split clamp. Given the measured
+// frame width and the user's preferred content width, returns the content
+// width clamped so the content column keeps PREVIEW_PANEL_MIN_CONTENT_WIDTH and
+// the preview keeps PREVIEW_PANEL_MIN_WIDTH, plus the derived preview track and
+// the bounds the resize handle reports. CSS just consumes the content width;
+// it does not re-clamp.
+export function getPreviewSplitLayout(
+ containerWidth: number,
+ preferredContentWidth: number
+): PreviewSplitLayout {
+ const width = Math.max( 0, Math.round( containerWidth ) );
+ const minContentWidth = Math.min( PREVIEW_PANEL_MIN_CONTENT_WIDTH, width );
+ const previewMaxWidth = Math.max( 0, width - minContentWidth );
+ const previewMinWidth = Math.min( PREVIEW_PANEL_MIN_WIDTH, previewMaxWidth );
+ const contentWidth = Math.min(
+ width - previewMinWidth,
+ Math.max( width - previewMaxWidth, Math.round( preferredContentWidth ) )
+ );
+
+ return {
+ contentWidth,
+ previewWidth: Math.max( 0, width - contentWidth ),
+ previewMinWidth,
+ previewMaxWidth,
+ };
+}
+
+// Sanitizes a stored legacy preview width against the viewport. Migration-only:
+// used to convert the deprecated `studio-ui-preview-width` key into a content
+// width the first time the preview opens.
+export function clampPreviewWidth( previewWidth: number, viewportWidth: number ): number {
+ const maxPreviewWidth =
+ viewportWidth > 0
+ ? Math.max( PREVIEW_PANEL_MIN_WIDTH, viewportWidth - PREVIEW_PANEL_MIN_CONTENT_WIDTH )
+ : previewWidth;
+ return Math.min(
+ maxPreviewWidth,
+ Math.max( PREVIEW_PANEL_MIN_WIDTH, Math.round( previewWidth ) )
+ );
+}
+
+export interface InitialPreviewLayout {
+ // The persisted content width (new key), or null when we have not stored one
+ // yet and must fall back to the legacy preview width on first open.
+ contentWidth: number | null;
+ // The legacy preview width (sanitized), used only until migration completes.
+ legacyPreviewWidth: number;
+ hasLegacyPreviewWidth: boolean;
+}
+
+// Reads the persisted split state, preferring the new content-width key and
+// falling back to the deprecated preview-width key for migration.
+export function getInitialPreviewLayout( viewportWidth: number ): InitialPreviewLayout {
+ const fallback: InitialPreviewLayout = {
+ contentWidth: null,
+ legacyPreviewWidth: PREVIEW_PANEL_DEFAULT_WIDTH,
+ hasLegacyPreviewWidth: false,
+ };
+
+ if ( typeof window === 'undefined' ) {
+ return fallback;
+ }
+
+ try {
+ const storedContentWidth = Number(
+ window.localStorage.getItem( PREVIEW_CONTENT_WIDTH_STORAGE_KEY )
+ );
+ const storedPreviewWidth = window.localStorage.getItem( PREVIEW_PANEL_STORAGE_KEY );
+ const parsedPreviewWidth = storedPreviewWidth ? Number( storedPreviewWidth ) : NaN;
+ return {
+ contentWidth:
+ Number.isFinite( storedContentWidth ) && storedContentWidth > 0
+ ? Math.round( storedContentWidth )
+ : null,
+ legacyPreviewWidth: clampPreviewWidth(
+ Number.isFinite( parsedPreviewWidth ) ? parsedPreviewWidth : PREVIEW_PANEL_DEFAULT_WIDTH,
+ viewportWidth
+ ),
+ hasLegacyPreviewWidth:
+ storedPreviewWidth !== null && Number.isFinite( Number( storedPreviewWidth ) ),
+ };
+ } catch {
+ return fallback;
+ }
+}
diff --git a/apps/ui/src/ui-classic/router/layout-dashboard/index.tsx b/apps/ui/src/ui-classic/router/layout-dashboard/index.tsx
index a76f582974..a0b1d05f2f 100644
--- a/apps/ui/src/ui-classic/router/layout-dashboard/index.tsx
+++ b/apps/ui/src/ui-classic/router/layout-dashboard/index.tsx
@@ -1,6 +1,9 @@
import { createRoute, Outlet, useRouterState } from '@tanstack/react-router';
-import { useEffect, useState } from 'react';
-import { PreviewSplitFrame } from '@/components/preview-split-frame';
+import { useCallback, useEffect, useState } from 'react';
+import {
+ PreviewSplitFrame,
+ type PreviewSplitFramePreviewProps,
+} from '@/components/preview-split-frame';
import { SidebarLayout } from '@/components/sidebar-layout';
import { SitePreview } from '@/components/site-preview';
import { useSession, useSessionEffectiveEnvironment } from '@/data/queries/use-sessions';
@@ -67,24 +70,24 @@ function DashboardLayoutContent() {
: undefined;
const previewSite = routeSite ?? lastPreviewSite;
const showPreview = preview.open && supportsPreview && !! previewSite;
+ const renderPreview = useCallback(
+ ( { collapsed }: PreviewSplitFramePreviewProps ) =>
+ previewSite ? (
+
+ ) : null,
+ [ onAnnotationsDone, preview.path, preview.reloadNonce, preview.updatePath, previewSite ]
+ );
return (
-
- ) : undefined
- }
- >
+