Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
241 changes: 241 additions & 0 deletions apps/ui/src/components/preview-split-frame/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<PreviewSplitFrame previewOpen preview={ () => <aside aria-label="Site preview" /> }>
<span data-testid="content">Content</span>
</PreviewSplitFrame>
);

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(
<PreviewSplitFrame previewOpen preview={ () => <aside aria-label="Site preview" /> }>
<span data-testid="content">Content</span>
</PreviewSplitFrame>
);

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(
<PreviewSplitFrame previewOpen preview={ () => <aside aria-label="Site preview" /> }>
<span data-testid="content">Content</span>
</PreviewSplitFrame>
);

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 = () => <aside aria-label="Site preview" />;
const { rerender } = render(
<PreviewSplitFrame previewOpen={ false } preview={ preview }>
<span data-testid="content">Content</span>
</PreviewSplitFrame>
);

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(
<PreviewSplitFrame previewOpen preview={ preview }>
<span data-testid="content">Content</span>
</PreviewSplitFrame>
);

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(
<PreviewSplitFrame previewOpen preview={ () => <aside aria-label="Site preview" /> }>
<span data-testid="content">Content</span>
</PreviewSplitFrame>
);

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(
<PreviewSplitFrame previewOpen preview={ () => <aside aria-label="Site preview" /> }>
<span data-testid="content">Content</span>
</PreviewSplitFrame>
);
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 = () => <aside aria-label="Site preview" />;
const { rerender } = render(
<PreviewSplitFrame previewOpen preview={ preview }>
<span data-testid="content">Content</span>
</PreviewSplitFrame>
);
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(
<PreviewSplitFrame previewOpen={ false } preview={ preview }>
<span data-testid="content">Content</span>
</PreviewSplitFrame>
);

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();
} );
} );
} );
97 changes: 51 additions & 46 deletions apps/ui/src/components/preview-split-frame/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div
ref={ rootRef }
className={ clsx(
styles.root,
showPreview && styles.rootPreviewOpen,
animating && styles.rootPreviewAnimating
isResizing && styles.rootPreviewResizing,
animatingPreviewToggle && styles.rootPreviewAnimating
) }
style={ rootStyle }
>
<div className={ styles.contentColumn }>{ children }</div>
{ previewMounted ? (
<div className={ clsx( styles.previewSlot, showPreview && styles.previewSlotOpen ) }>
{ preview }
{ preview ? (
<div
className={ clsx(
styles.previewSlot,
previewVisible && styles.previewSlotVisible,
showPreview && styles.previewSlotInteractive
) }
aria-hidden={ ! showPreview }
>
{ renderedPreview }
</div>
) : null }
{ showPreview && ! animating ? (
{ showPreview && ! animatingPreviewToggle ? (
<ResizeHandle
className={ styles.previewResizeHandle }
label={ __( 'Resize site preview' ) }
minWidth={ previewResize.minWidth }
maxWidth={ previewResize.maxWidth }
width={ previewResize.width }
isResizing={ previewResize.isResizing }
onResizeStart={ previewResize.handleResizeStart }
onKeyDown={ previewResize.handleKeyDown }
{ ...handleProps }
/>
) : null }
{ previewResize.isResizing ? <ResizeOverlay /> : null }
{ isResizing ? <ResizeOverlay /> : null }
</div>
);
}
Loading