Skip to content
Merged
168 changes: 168 additions & 0 deletions apps/web/src/components/preview/PreviewPanelShell.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import type { ReactNode } from "react";
import { createPortal } from "react-dom";
import { describe, expect, it } from "vite-plus/test";
import { page, userEvent } from "vite-plus/test/browser";

import { act, render, type Screen } from "../../../test/render";
import { PreviewPanelShell } from "./PreviewPanelShell";

function Workspace(props: { open: boolean; present?: boolean; children?: ReactNode }) {
return (
<div>
<div data-workspace-area="composer">
<input aria-label="Message New thread" />
</div>
<button type="button" aria-label="Toggle right panel" data-right-panel-toggle />
{(props.present ?? true) ? (
<PreviewPanelShell mode="inline" open={props.open}>
{props.children ?? <div role="region" aria-label="Open a surface" tabIndex={-1} />}
</PreviewPanelShell>
) : null}
</div>
);
}

// Mirrors rightPanel.toggle from the composer: the panel opens and the shortcut focuses its content.
async function openPanelFromComposer(screen: Screen) {
await userEvent.click(screen.getByRole("textbox", { name: "Message New thread" }));
await screen.rerender(<Workspace open />);
await act(() => screen.getByRole("region", { name: "Open a surface" }).element().focus());
}

describe("PreviewPanelShell focus", () => {
it("returns focus to the composer when a panel opened from it collapses", async () => {
await using screen = await render(<Workspace open={false} />);
await openPanelFromComposer(screen);

await screen.rerender(<Workspace open={false} />);

await expect.element(screen.getByRole("textbox", { name: "Message New thread" })).toHaveFocus();
});

it("returns focus to the composer when a panel opened from it unmounts", async () => {
await using screen = await render(<Workspace open={false} />);
await openPanelFromComposer(screen);

await screen.rerender(<Workspace open={false} present={false} />);

await expect.element(screen.getByRole("textbox", { name: "Message New thread" })).toHaveFocus();
});

it("returns focus to the composer when the panel's content takes focus as it mounts", async () => {
// A terminal surface focuses itself from its ref callback, before the shell's own ref attaches.
const focusOnMount = (element: HTMLElement | null) => element?.focus();
const panel = (present: boolean) => (
<Workspace open present={present}>
<div role="region" aria-label="Terminal" tabIndex={-1} ref={focusOnMount} />
</Workspace>
);
await using screen = await render(panel(false));
await userEvent.click(screen.getByRole("textbox", { name: "Message New thread" }));
await screen.rerender(panel(true));
await expect.element(screen.getByRole("region", { name: "Terminal" })).toHaveFocus();

await screen.rerender(panel(false));

await expect.element(screen.getByRole("textbox", { name: "Message New thread" })).toHaveFocus();
});

it("keeps the composer as the origin after focus visits a menu portalled from the panel", async () => {
const panel = (open: boolean) => (
<Workspace open={open}>
<div role="region" aria-label="Open a surface" tabIndex={-1} />
{createPortal(<button type="button">Add panel surface</button>, document.body)}
</Workspace>
);
await using screen = await render(panel(false));
await userEvent.click(screen.getByRole("textbox", { name: "Message New thread" }));
await screen.rerender(panel(true));
await act(() => screen.getByRole("region", { name: "Open a surface" }).element().focus());
await act(() => page.getByRole("button", { name: "Add panel surface" }).element().focus());
await act(() => screen.getByRole("region", { name: "Open a surface" }).element().focus());

await screen.rerender(panel(false));

await expect.element(screen.getByRole("textbox", { name: "Message New thread" })).toHaveFocus();
});

it("falls back to the toggle without scrolling when the origin has scrolled out of view", async () => {
const layout = (open: boolean) => (
<div>
<div
data-testid="timeline"
data-workspace-area="conversation"
style={{ height: 100, overflowY: "auto" }}
>
<button type="button">Copy message</button>
<div style={{ height: 2000 }} />
</div>
<button type="button" aria-label="Toggle right panel" data-right-panel-toggle />
<PreviewPanelShell mode="inline" open={open}>
<div role="region" aria-label="Open a surface" tabIndex={-1} />
</PreviewPanelShell>
</div>
);
await using screen = await render(layout(false));
await userEvent.click(screen.getByRole("button", { name: "Copy message" }));
await screen.rerender(layout(true));
await act(() => screen.getByRole("region", { name: "Open a surface" }).element().focus());
const timeline = screen.getByTestId("timeline").element();
timeline.scrollTop = 1500;

await screen.rerender(layout(false));

await expect.element(screen.getByRole("button", { name: "Toggle right panel" })).toHaveFocus();
expect(timeline.scrollTop).toBe(1500);
});

it("falls back to the toggle when the origin's drawer is closing", async () => {
// A closing drawer stays mounted for its exit animation but is no longer a workspace area.
const layout = (open: boolean, drawerOpen: boolean) => (
<div>
<div data-workspace-area={drawerOpen ? "terminal" : undefined}>
<input aria-label="Terminal input" />
</div>
<button type="button" aria-label="Toggle right panel" data-right-panel-toggle />
<PreviewPanelShell mode="inline" open={open}>
<div role="region" aria-label="Open a surface" tabIndex={-1} />
</PreviewPanelShell>
</div>
);
await using screen = await render(layout(false, true));
await userEvent.click(screen.getByRole("textbox", { name: "Terminal input" }));
await screen.rerender(layout(true, true));
await act(() => screen.getByRole("region", { name: "Open a surface" }).element().focus());
await screen.rerender(layout(true, false));

await screen.rerender(layout(false, false));

await expect.element(screen.getByRole("button", { name: "Toggle right panel" })).toHaveFocus();
});

it("does not return focus to the origin of an earlier open", async () => {
await using screen = await render(<Workspace open={false} />);
await openPanelFromComposer(screen);
await screen.rerender(<Workspace open={false} />);
const composer = screen.getByRole("textbox", { name: "Message New thread" });
await expect.element(composer).toHaveFocus();
await act(() => composer.element().blur());
await screen.rerender(<Workspace open />);
await act(() => screen.getByRole("region", { name: "Open a surface" }).element().focus());

await screen.rerender(<Workspace open={false} />);

await expect.element(screen.getByRole("button", { name: "Toggle right panel" })).toHaveFocus();
});

it("returns focus to the toggle when a panel opened from it collapses", async () => {
await using screen = await render(<Workspace open={false} />);
await userEvent.click(screen.getByRole("textbox", { name: "Message New thread" }));
await userEvent.click(screen.getByRole("button", { name: "Toggle right panel" }));
await screen.rerender(<Workspace open />);
await act(() => screen.getByRole("region", { name: "Open a surface" }).element().focus());

await screen.rerender(<Workspace open={false} />);

await expect.element(screen.getByRole("button", { name: "Toggle right panel" })).toHaveFocus();
});
});
53 changes: 43 additions & 10 deletions apps/web/src/components/preview/PreviewPanelShell.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import { type CSSProperties, type ReactNode, useLayoutEffect, useRef, useState } from "react";
import {
type CSSProperties,
type FocusEvent,
type ReactNode,
useLayoutEffect,
useRef,
useState,
} from "react";
import {
type PreviewPanelInlineSize,
usePreviewPanelInlineSize,
} from "~/hooks/usePreviewPanelInlineSize";
import { RESIZABLE_WIDTH_PROPERTY } from "~/hooks/useResizableWidth";
import { workspaceAreaProps } from "../../lib/workspaceFocus";
import { canReturnFocusTo, workspaceAreaProps } from "../../lib/workspaceFocus";
import { cn } from "~/lib/utils";

import { RightPanelResizeHandle } from "./RightPanelResizeHandle";
Expand Down Expand Up @@ -42,6 +49,19 @@ function ResizablePreviewPanelShell(props: PreviewPanelShellProps) {
return <PreviewPanelShellFrame {...props} inlineSize={inlineSize} />;
}

// Prefers the element focus entered the panel from, falling back to the panel toggle.
function returnFocus(host: HTMLElement, origin: HTMLElement | null) {
const { ownerDocument } = host;
// focus() is a no-op on a disabled origin, so activeElement says whether it took.
if (origin?.isConnected && !host.contains(origin) && canReturnFocusTo(origin)) {
origin.focus({ preventScroll: true });
if (ownerDocument.activeElement === origin) return true;
}
const toggle = ownerDocument.querySelector<HTMLButtonElement>("[data-right-panel-toggle]");
toggle?.focus();
return toggle !== null && ownerDocument.activeElement === toggle;
}

function PreviewPanelShellFrame(
props: PreviewPanelShellProps & { inlineSize: PreviewPanelInlineSize },
) {
Expand All @@ -52,6 +72,8 @@ function PreviewPanelShellFrame(
const maximized = props.maximized ?? false;
const hostRef = useRef<HTMLDivElement | null>(null);
const returnedFocus = useRef(false);
const focusOrigin = useRef<HTMLElement | null>(null);
const lastBlurred = useRef<EventTarget | null>(null);
const { width, handlers, observeRow } = props.inlineSize;
// Width animates only for an open or close. Any other width change disables
// the transition in the same commit that changes the width, so the browser
Expand Down Expand Up @@ -84,39 +106,50 @@ function PreviewPanelShellFrame(
stopObservingRow();
};
};
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- on unmount, returns DOM focus to the panel toggle if removal dropped it on the body
// React focus events, unlike a listener added in attachHost, also see content that focuses
// itself while mounting (a terminal does, from a ref that attaches before the host's).
// Focus moving within the panel, portalled menus included, first blurs an element of it.
const rememberFocusOrigin = (event: FocusEvent<HTMLDivElement>) => {
const from = event.relatedTarget;
if (from === null || from === lastBlurred.current) return;
focusOrigin.current = from instanceof HTMLElement ? from : null;
};
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- on unmount, returns DOM focus to where it came from if removal dropped it on the body
useLayoutEffect(() => {
const host = hostRef.current;
if (!host || !collapsible) return;
return () => {
if (!host.contains(host.ownerDocument.activeElement) && !returnedFocus.current) return;
const origin = focusOrigin.current;
// Unmount moves the toggle; restore focus at its committed location if removal sent focus to body.
queueMicrotask(() => {
if (host.ownerDocument.activeElement === host.ownerDocument.body) {
host.ownerDocument.querySelector<HTMLButtonElement>("[data-right-panel-toggle]")?.focus();
returnFocus(host, origin);
}
});
};
}, [collapsible, hostRef]);
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- moves focus to the toggle and sets inert on the host DOM node before a collapse paints
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- moves focus out of the host and sets inert on it before a collapse paints
useLayoutEffect(() => {
const host = hostRef.current;
if (!host) return;
const collapsed = collapsible && !open;
if (!collapsed) returnedFocus.current = false;
// Move focus before making its current owner inert, while the toggle is reachable.
if (collapsed && host.contains(host.ownerDocument.activeElement)) {
const toggle = host.ownerDocument.querySelector<HTMLButtonElement>(
"[data-right-panel-toggle]",
);
toggle?.focus();
returnedFocus.current = toggle !== null && host.ownerDocument.activeElement === toggle;
returnedFocus.current = returnFocus(host, focusOrigin.current);
}
// An origin belongs to one open; the next one records its own.
if (collapsed) focusOrigin.current = null;
host.toggleAttribute("inert", collapsed);
}, [collapsible, open, hostRef]);
return (
<div
ref={attachHost}
onFocus={rememberFocusOrigin}
onBlur={(event) => {
lastBlurred.current = event.target;
}}
{...(props.workspaceArea ? panelArea : {})}
data-workspace-focus-key={props.workspaceFocusKey}
data-workspace-unavailable={collapsible && !open ? "true" : undefined}
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/lib/workspaceFocus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,16 @@ function isInScrollView(target: HTMLElement) {
});
}

/**
* Whether focus can go back to an element without surprising the user: it is still in a
* visible workspace area (a closing drawer drops its area) and in view, so no list scrolls.
*/
export function canReturnFocusTo(element: HTMLElement) {
return (
workspaceArea(element) !== null && isWorkspaceElementVisible(element) && isInScrollView(element)
);
}

/**
* Brings a restored control into view, innermost scrolling list first.
* scrollIntoView would also scroll overflow-hidden layout containers out of place.
Expand Down
Loading