+
+ );
+}
+
export const scenarios: Record ReactNode> = {
smoke: () => ,
"approval-card": () => ,
@@ -647,4 +721,5 @@ export const scenarios: Record ReactNode> = {
"prompt-input-dialog": () => ,
"prompt-input-chat": () => ,
"prompt-input-chat-no-stop": () => ,
+ panel: () => ,
};
diff --git a/e2e/panel.spec.ts b/e2e/panel.spec.ts
new file mode 100644
index 00000000..d9ea9f52
--- /dev/null
+++ b/e2e/panel.spec.ts
@@ -0,0 +1,236 @@
+import { expect, test, type Page } from "@playwright/test";
+
+/** React commits so far, counted by the scenario's Profiler. */
+function commits(page: Page): Promise {
+ return page.evaluate(() => Number(document.body.dataset["commits"] ?? 0));
+}
+
+function width(page: Page, target: "panel" | "main" | "row") {
+ const locator =
+ target === "panel"
+ ? page.getByRole("complementary", { name: "Side panel" })
+ : page.getByTestId(target);
+ return locator.evaluate((element) => element.getBoundingClientRect().width);
+}
+
+function nextFrames(page: Page): Promise {
+ return page.evaluate(
+ () =>
+ new Promise((resolve) =>
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
+ ),
+ );
+}
+
+test("dragging the handle resizes the panel without a React render", async ({
+ page,
+}) => {
+ await page.goto("/panel");
+ const handle = page.getByRole("separator", { name: "Resize panel" });
+ const start = Math.round(await width(page, "panel"));
+ const box = await handle.boundingBox();
+ if (box === null) throw new Error("the handle has no box");
+ const x = box.x + box.width / 2;
+ const y = box.y + box.height / 2;
+
+ await page.mouse.move(x, y);
+ const before = await commits(page);
+ expect(before).toBeGreaterThan(0);
+ await page.mouse.down();
+ for (let step = 1; step <= 10; step += 1) {
+ await page.mouse.move(x - step * 10, y);
+ }
+ await nextFrames(page);
+
+ expect(await commits(page)).toBe(before);
+ expect(Math.abs((await width(page, "panel")) - (start + 100))).toBeLessThan(
+ 1,
+ );
+ await expect(handle).toHaveAttribute("aria-valuenow", String(start + 100));
+
+ await page.mouse.up();
+ await expect.poll(() => commits(page)).toBeGreaterThan(before);
+ await expect(page.getByRole("status", { name: "Width" })).toHaveText(
+ String(start + 100),
+ );
+ await expect(handle).toHaveAttribute("aria-valuenow", String(start + 100));
+});
+
+test("double-click and Enter reset the width", async ({ page }) => {
+ await page.goto("/panel");
+ const handle = page.getByRole("separator", { name: "Resize panel" });
+ const output = page.getByRole("status", { name: "Width" });
+ const start = Math.round(await width(page, "panel"));
+
+ await handle.focus();
+ await page.keyboard.press("ArrowLeft");
+ await expect(output).toHaveText(String(start + 16));
+ await page.keyboard.press("Shift+ArrowLeft");
+ await expect(output).toHaveText(String(start + 80));
+ await page.keyboard.press("ArrowRight");
+ await expect(output).toHaveText(String(start + 64));
+
+ await handle.dblclick();
+ await expect(output).toHaveText("default");
+ expect(Math.round(await width(page, "panel"))).toBe(start);
+
+ await page.keyboard.press("Home");
+ await expect(output).toHaveText("320");
+ await page.keyboard.press("Enter");
+ await expect(output).toHaveText("default");
+ expect(Math.round(await width(page, "panel"))).toBe(start);
+});
+
+test("a click on the handle focuses it for the arrow keys", async ({
+ page,
+}) => {
+ await page.goto("/panel");
+ const handle = page.getByRole("separator", { name: "Resize panel" });
+ const output = page.getByRole("status", { name: "Width" });
+ const start = Math.round(await width(page, "panel"));
+ await page.getByRole("button", { name: "Show notes" }).focus();
+
+ await handle.click();
+ await expect(handle).toBeFocused();
+ await expect(output).toHaveText("default");
+ await page.keyboard.press("ArrowLeft");
+ await expect(output).toHaveText(String(start + 16));
+});
+
+test("the main area keeps its minimum width", async ({ page }) => {
+ await page.goto("/panel");
+ const handle = page.getByRole("separator", { name: "Resize panel" });
+ const row = await width(page, "row");
+
+ await handle.focus();
+ await page.keyboard.press("End");
+ await expect(handle).toHaveAttribute(
+ "aria-valuenow",
+ String(Math.round(row - 440)),
+ );
+ expect(Math.abs((await width(page, "main")) - 440)).toBeLessThan(1);
+
+ const box = await handle.boundingBox();
+ if (box === null) throw new Error("the handle has no box");
+ await page.mouse.move(box.x + box.width / 2, box.y + 20);
+ await page.mouse.down();
+ await page.mouse.move(box.x - 300, box.y + 20, { steps: 5 });
+ await page.mouse.up();
+ expect(Math.abs((await width(page, "main")) - 440)).toBeLessThan(1);
+});
+
+test("a hidden pane keeps its typed text and scroll position", async ({
+ page,
+}) => {
+ await page.goto("/panel");
+ const notes = page.getByTestId("notes-pane");
+ const draft = page.getByTestId("draft-pane");
+ await page
+ .getByRole("textbox", { name: "Notes field" })
+ .fill("half a thought");
+ await notes.evaluate((element) => {
+ element.scrollTop = 400;
+ });
+
+ await page.getByRole("button", { name: "Show draft" }).click();
+ await expect(notes).toHaveAttribute("data-state", "inactive");
+ await expect(draft).toHaveAttribute("data-state", "active");
+ await page.getByRole("textbox", { name: "Draft field" }).fill("a draft");
+ await draft.evaluate((element) => {
+ element.scrollTop = 250;
+ });
+
+ await page.getByRole("button", { name: "Show notes" }).click();
+ await expect(notes).toHaveAttribute("data-state", "active");
+ await expect(page.getByRole("textbox", { name: "Notes field" })).toHaveValue(
+ "half a thought",
+ );
+ expect(
+ Math.abs((await notes.evaluate((element) => element.scrollTop)) - 400),
+ ).toBeLessThanOrEqual(1);
+
+ await page.getByRole("button", { name: "Show draft" }).click();
+ await expect(page.getByRole("textbox", { name: "Draft field" })).toHaveValue(
+ "a draft",
+ );
+ expect(
+ Math.abs((await draft.evaluate((element) => element.scrollTop)) - 250),
+ ).toBeLessThanOrEqual(1);
+});
+
+test("a narrow row gets an overlay that returns focus on close", async ({
+ page,
+}) => {
+ await page.setViewportSize({ width: 600, height: 720 });
+ await page.goto("/panel");
+ const panel = page.getByRole("complementary", { name: "Side panel" });
+ const row = await page.getByTestId("row").boundingBox();
+ const covered = await panel.boundingBox();
+ if (row === null || covered === null) throw new Error("missing boxes");
+ expect(Math.abs(covered.x - row.x)).toBeLessThan(1);
+ expect(Math.abs(covered.width - row.width)).toBeLessThan(1);
+ await expect(page.getByRole("separator")).toBeHidden();
+
+ await page.getByRole("button", { name: "Close panel" }).click();
+ const opener = page.getByRole("button", { name: "Open panel" });
+ await opener.click();
+ await expect(panel).toBeFocused();
+ await page.getByRole("button", { name: "Close panel" }).click();
+ await expect(panel).toBeHidden();
+ await expect(opener).toBeFocused();
+});
+
+/** Whether focus is inside the panel, or on nothing at all. */
+function focusStaysInPanel(page: Page): Promise {
+ return page.evaluate(() => {
+ const active = document.activeElement;
+ const panel = document.querySelector("[data-slot='panel']");
+ return (
+ active === null ||
+ active === document.body ||
+ (panel !== null && panel.contains(active))
+ );
+ });
+}
+
+test("Shift+Tab never leaves a narrow overlay for the area it covers", async ({
+ page,
+}) => {
+ await page.setViewportSize({ width: 600, height: 720 });
+ await page.goto("/panel");
+ const main = page.getByTestId("main");
+ await page.getByRole("button", { name: "Close panel" }).click();
+ await expect(main).not.toHaveAttribute("inert");
+ await page.getByRole("button", { name: "Open panel" }).click();
+ await expect(main).toHaveAttribute("inert", "");
+
+ await page.getByRole("button", { name: "Close panel" }).focus();
+ for (let press = 0; press < 4; press += 1) {
+ await page.keyboard.press("Shift+Tab");
+ expect(await focusStaysInPanel(page)).toBe(true);
+ }
+
+ await page.getByRole("button", { name: "Close panel" }).click();
+ await expect(main).not.toHaveAttribute("inert");
+ await expect(page.getByRole("button", { name: "Open panel" })).toBeFocused();
+});
+
+test("narrowing the row while the panel is open turns it into an overlay", async ({
+ page,
+}) => {
+ await page.goto("/panel");
+ const main = page.getByTestId("main");
+ const panel = page.getByRole("complementary", { name: "Side panel" });
+ await page.getByRole("button", { name: "Show notes" }).focus();
+ await expect(main).not.toHaveAttribute("inert");
+
+ await page.setViewportSize({ width: 600, height: 720 });
+ await expect(main).toHaveAttribute("inert", "");
+ await expect(panel).toBeFocused();
+
+ await page.setViewportSize({ width: 1280, height: 720 });
+ await expect(main).not.toHaveAttribute("inert");
+ await expect(page.getByRole("separator")).toBeVisible();
+ // Side by side again, focus goes back to what the overlay covered.
+ await expect(page.getByRole("button", { name: "Show notes" })).toBeFocused();
+});
diff --git a/src/hooks/use-resizable.test.ts b/src/hooks/use-resizable.test.ts
new file mode 100644
index 00000000..9a7acd20
--- /dev/null
+++ b/src/hooks/use-resizable.test.ts
@@ -0,0 +1,379 @@
+import { act, createElement, startTransition, Suspense, use } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+
+import {
+ RESIZABLE_WIDTH_VAR,
+ useResizable,
+ type UseResizableOptions,
+} from "./use-resizable.js";
+
+const MIN = 320;
+const MAIN_MIN = 440;
+
+// happy-dom does no layout, so the resized element's geometry is emulated
+// here the way the panel stylesheet lays it out: the chosen width, or 46% of
+// the row between 480 and 760, held between `MIN` and `row - MAIN_MIN`.
+let rowWidth = 1400;
+const originalRect = HTMLElement.prototype.getBoundingClientRect;
+const originalClientWidth = Object.getOwnPropertyDescriptor(
+ HTMLElement.prototype,
+ "clientWidth",
+);
+
+function cssWidth(element: HTMLElement): number {
+ const chosen = element.style.getPropertyValue(RESIZABLE_WIDTH_VAR);
+ const fallback = Math.min(Math.max(rowWidth * 0.46, 480), 760);
+ const wanted = chosen === "" ? fallback : Number.parseFloat(chosen);
+ return Math.min(Math.max(wanted, MIN), Math.max(MIN, rowWidth - MAIN_MIN));
+}
+
+beforeEach(() => {
+ rowWidth = 1400;
+ HTMLElement.prototype.getBoundingClientRect = function (
+ this: HTMLElement,
+ ): DOMRect {
+ return this.tagName === "ASIDE"
+ ? new DOMRect(0, 0, cssWidth(this), 600)
+ : originalRect.call(this);
+ };
+ Object.defineProperty(HTMLElement.prototype, "clientWidth", {
+ configurable: true,
+ get(this: HTMLElement) {
+ return this.getAttribute("data-row") === null ? 0 : rowWidth;
+ },
+ });
+});
+
+afterEach(() => {
+ HTMLElement.prototype.getBoundingClientRect = originalRect;
+ if (originalClientWidth !== undefined) {
+ Object.defineProperty(
+ HTMLElement.prototype,
+ "clientWidth",
+ originalClientWidth,
+ );
+ }
+});
+
+type Mounted = {
+ readonly panel: HTMLElement;
+ readonly handle: HTMLElement;
+ readonly changes: (number | null)[];
+ press(key: string, init?: KeyboardEventInit): void;
+ pointer(type: string, clientX: number): void;
+ rerender(options?: Partial): void;
+ unmount(): void;
+};
+
+function find(container: HTMLElement, selector: string): HTMLElement {
+ const element = container.querySelector(selector);
+ if (!(element instanceof HTMLElement)) throw new Error(`no ${selector}`);
+ return element;
+}
+
+function mount(
+ options: Partial = {},
+ dir?: "rtl",
+): Mounted {
+ const changes: (number | null)[] = [];
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ let root!: Root;
+
+ function Harness(props: Partial) {
+ const resizable = useResizable({
+ minWidth: MIN,
+ mainMinWidth: MAIN_MIN,
+ onWidthChange: (width) => changes.push(width),
+ ...props,
+ });
+ return createElement(
+ "div",
+ { "data-row": "" },
+ createElement(
+ "aside",
+ {
+ ref: resizable.panelRef,
+ // happy-dom does not derive `direction` from a `dir` attribute.
+ style: dir === undefined ? undefined : { direction: dir },
+ },
+ createElement("div", {
+ "data-handle": "",
+ "aria-label": "Resize",
+ ...resizable.handleProps,
+ }),
+ ),
+ );
+ }
+
+ const render = (props: Partial) =>
+ act(() => root.render(createElement(Harness, props)));
+ act(() => {
+ root = createRoot(container);
+ });
+ render(options);
+ const handle = find(container, "[data-handle]");
+ // happy-dom has no pointer capture; the hook only needs it not to throw.
+ handle.setPointerCapture = () => {};
+ handle.releasePointerCapture = () => {};
+ handle.hasPointerCapture = () => false;
+ return {
+ panel: find(container, "aside"),
+ handle,
+ changes,
+ press(key, init = {}) {
+ act(() => {
+ handle.dispatchEvent(
+ new KeyboardEvent("keydown", { key, bubbles: true, ...init }),
+ );
+ });
+ },
+ pointer(type, clientX) {
+ act(() => {
+ handle.dispatchEvent(
+ new PointerEvent(type, {
+ bubbles: true,
+ button: 0,
+ isPrimary: true,
+ pointerId: 1,
+ clientX,
+ }),
+ );
+ });
+ },
+ rerender(next = options) {
+ render(next);
+ },
+ unmount() {
+ act(() => root.unmount());
+ container.remove();
+ },
+ };
+}
+
+function shownWidth(mounted: Mounted): number {
+ return mounted.panel.getBoundingClientRect().width;
+}
+
+describe("useResizable", () => {
+ test("starts at the stylesheet default and reports it on the handle", () => {
+ const mounted = mount();
+ expect(mounted.panel.style.getPropertyValue(RESIZABLE_WIDTH_VAR)).toBe("");
+ expect(mounted.handle.getAttribute("aria-valuenow")).toBe("644");
+ expect(mounted.handle.getAttribute("aria-valuemin")).toBe(String(MIN));
+ expect(mounted.handle.getAttribute("aria-valuemax")).toBe("960");
+ mounted.unmount();
+ });
+
+ test("arrow keys step from the rendered width, Shift for bigger steps", () => {
+ const mounted = mount();
+ mounted.press("ArrowLeft");
+ expect(mounted.changes).toEqual([660]);
+ expect(shownWidth(mounted)).toBe(660);
+ expect(mounted.handle.getAttribute("aria-valuenow")).toBe("660");
+ mounted.press("ArrowLeft", { shiftKey: true });
+ expect(mounted.changes).toEqual([660, 724]);
+ mounted.press("ArrowRight");
+ expect(mounted.changes).toEqual([660, 724, 708]);
+ mounted.unmount();
+ });
+
+ test("clamps to the minimum and the maximum", () => {
+ const mounted = mount();
+ mounted.press("Home");
+ expect(mounted.changes).toEqual([MIN]);
+ mounted.press("ArrowRight");
+ expect(mounted.changes).toEqual([MIN]);
+ mounted.press("End");
+ expect(mounted.changes).toEqual([MIN, 960]);
+ mounted.press("ArrowLeft", { shiftKey: true });
+ expect(mounted.changes).toEqual([MIN, 960]);
+ expect(shownWidth(mounted)).toBe(960);
+ mounted.unmount();
+ });
+
+ test("keeps the main area's minimum width", () => {
+ rowWidth = 1100;
+ const mounted = mount();
+ expect(mounted.handle.getAttribute("aria-valuemax")).toBe(
+ String(1100 - MAIN_MIN),
+ );
+ mounted.press("End");
+ mounted.press("ArrowLeft");
+ expect(mounted.changes).toEqual([1100 - MAIN_MIN]);
+ expect(shownWidth(mounted)).toBe(1100 - MAIN_MIN);
+ mounted.unmount();
+ });
+
+ test("gives way to the minimum when the row cannot hold both", () => {
+ rowWidth = 600;
+ const mounted = mount({ defaultWidth: 500 });
+ expect(shownWidth(mounted)).toBe(MIN);
+ expect(mounted.handle.getAttribute("aria-valuemax")).toBe(String(MIN));
+ mounted.unmount();
+ });
+
+ test("steps from the clamped width, not a stale stored one", () => {
+ rowWidth = 1000;
+ const mounted = mount({ defaultWidth: 900 });
+ expect(shownWidth(mounted)).toBe(560);
+ mounted.press("ArrowRight");
+ expect(mounted.changes).toEqual([544]);
+ mounted.unmount();
+ });
+
+ test("Enter and double-click reset to the default", () => {
+ const mounted = mount();
+ mounted.press("ArrowLeft");
+ mounted.press("Enter");
+ expect(mounted.changes).toEqual([660, null]);
+ expect(mounted.panel.style.getPropertyValue(RESIZABLE_WIDTH_VAR)).toBe("");
+ expect(shownWidth(mounted)).toBe(644);
+
+ mounted.press("End");
+ act(() => {
+ mounted.handle.dispatchEvent(
+ new MouseEvent("dblclick", { bubbles: true }),
+ );
+ });
+ expect(mounted.changes).toEqual([660, null, 960, null]);
+ expect(shownWidth(mounted)).toBe(644);
+ mounted.unmount();
+ });
+
+ test("landing back on the default stores no width", () => {
+ const mounted = mount();
+ mounted.press("ArrowLeft");
+ mounted.press("ArrowRight");
+ expect(mounted.changes).toEqual([660, null]);
+ mounted.unmount();
+ });
+
+ test("a controlled parent that ignores a change keeps its width", () => {
+ const mounted = mount({ width: 700 });
+ expect(shownWidth(mounted)).toBe(700);
+ mounted.press("ArrowLeft");
+ expect(mounted.changes).toEqual([716]);
+ expect(shownWidth(mounted)).toBe(700);
+ mounted.rerender({ width: 716 });
+ expect(shownWidth(mounted)).toBe(716);
+ mounted.unmount();
+ });
+
+ test("a render React throws away leaves the committed width in charge", async () => {
+ // A transition that suspends renders the hook with the new width, then
+ // drops that render and keeps the committed one on screen.
+ const never = new Promise(() => {});
+ const changes: (number | null)[] = [];
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ function Suspending({
+ width,
+ pending,
+ }: {
+ readonly width: number;
+ readonly pending: boolean;
+ }) {
+ const resizable = useResizable({
+ width,
+ minWidth: MIN,
+ mainMinWidth: MAIN_MIN,
+ onWidthChange: (next) => changes.push(next),
+ });
+ if (pending) use(never);
+ return createElement(
+ "div",
+ { "data-row": "" },
+ createElement(
+ "aside",
+ { ref: resizable.panelRef },
+ createElement("div", {
+ "data-handle": "",
+ "aria-label": "Resize",
+ ...resizable.handleProps,
+ }),
+ ),
+ );
+ }
+ const tree = (width: number, pending: boolean) =>
+ createElement(
+ Suspense,
+ { fallback: null },
+ createElement(Suspending, { width, pending }),
+ );
+ let root!: Root;
+ act(() => {
+ root = createRoot(container);
+ root.render(tree(700, false));
+ });
+ await act(async () => {
+ startTransition(() => root.render(tree(900, true)));
+ });
+ const panel = find(container, "aside");
+ expect(panel.getBoundingClientRect().width).toBe(700);
+
+ // The parent keeps 700, so the element has to stay at 700.
+ act(() => {
+ find(container, "[data-handle]").dispatchEvent(
+ new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true }),
+ );
+ });
+ expect(changes).toEqual([716]);
+ expect(panel.style.getPropertyValue(RESIZABLE_WIDTH_VAR)).toBe("700px");
+ act(() => root.unmount());
+ container.remove();
+ });
+
+ test("ignores keys it does not handle", () => {
+ const mounted = mount();
+ mounted.press("ArrowUp");
+ mounted.press("a");
+ expect(mounted.changes).toEqual([]);
+ mounted.unmount();
+ });
+
+ test("a drag commits once on release, from where the pointer stopped", () => {
+ const mounted = mount();
+ mounted.pointer("pointerdown", 500);
+ mounted.pointer("pointermove", 450);
+ mounted.pointer("pointermove", 400);
+ expect(mounted.changes).toEqual([]);
+ mounted.pointer("pointerup", 400);
+ expect(mounted.changes).toEqual([744]);
+ mounted.unmount();
+ });
+
+ test("a press that never moves past the drag threshold commits nothing", () => {
+ // The stored 900 renders clamped to 560: a commit would overwrite it.
+ rowWidth = 1000;
+ const clamped = mount({ defaultWidth: 900 });
+ expect(shownWidth(clamped)).toBe(560);
+ clamped.pointer("pointerdown", 500);
+ clamped.pointer("pointermove", 498);
+ clamped.pointer("pointerup", 498);
+ expect(clamped.changes).toEqual([]);
+ clamped.unmount();
+
+ // A stored width equal to the default: a commit would reset it.
+ rowWidth = 1400;
+ const atDefault = mount({ defaultWidth: 644 });
+ atDefault.pointer("pointerdown", 500);
+ atDefault.pointer("pointerup", 500);
+ expect(atDefault.changes).toEqual([]);
+ atDefault.unmount();
+ });
+
+ test("mirrors keys and drags in right-to-left text", () => {
+ const mounted = mount({}, "rtl");
+ mounted.press("ArrowRight");
+ expect(mounted.changes).toEqual([660]);
+ mounted.press("ArrowLeft");
+ expect(mounted.changes).toEqual([660, null]);
+ mounted.pointer("pointerdown", 500);
+ mounted.pointer("pointermove", 600);
+ mounted.pointer("pointerup", 600);
+ expect(mounted.changes).toEqual([660, null, 744]);
+ mounted.unmount();
+ });
+});
diff --git a/src/hooks/use-resizable.ts b/src/hooks/use-resizable.ts
new file mode 100644
index 00000000..e4be8ac5
--- /dev/null
+++ b/src/hooks/use-resizable.ts
@@ -0,0 +1,352 @@
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from "react";
+import type * as React from "react";
+
+import { useControllableState } from "./use-controllable-state.js";
+
+/** The CSS custom property a resizable element reads its chosen width from. */
+export const RESIZABLE_WIDTH_VAR = "--resizable-width";
+
+const KEY_STEP_PX = 16;
+/** Shift multiplies the arrow-key step by this. */
+const LARGE_STEP_FACTOR = 4;
+/** A drag ending this close to the default width lands on the default. */
+const SNAP_PX = 12;
+/** How far the pointer travels before a press on the handle becomes a drag. */
+const DRAG_THRESHOLD_PX = 4;
+
+export type UseResizableOptions = {
+ /**
+ * Controlled width in px. `null` means the element's own CSS default, which
+ * the hook never duplicates: it is measured from the element when needed.
+ */
+ readonly width?: number | null | undefined;
+ /** Seeds the width when `width` is not supplied. */
+ readonly defaultWidth?: number | null | undefined;
+ /** Called once per finished change: on release, a key press, or a reset. */
+ readonly onWidthChange?: ((width: number | null) => void) | undefined;
+ /** The narrowest the element may be set, in px. */
+ readonly minWidth: number;
+ /** How much of the parent's width the content beside the element keeps, in px. */
+ readonly mainMinWidth: number;
+ /** Arrow-key step in px; Shift moves four times as far. */
+ readonly step?: number | undefined;
+};
+
+/** The element's rendered width and how wide it may go, as last measured. */
+type Metrics = { readonly width: number; readonly max: number };
+
+export type ResizableHandleProps = {
+ readonly role: "separator";
+ readonly tabIndex: 0;
+ readonly "aria-orientation": "vertical";
+ readonly "aria-valuemin": number;
+ readonly "aria-valuemax": number;
+ readonly "aria-valuenow": number;
+ readonly onPointerDown: (event: React.PointerEvent) => void;
+ readonly onDoubleClick: () => void;
+ readonly onKeyDown: (event: React.KeyboardEvent) => void;
+};
+
+export type UseResizableResult = {
+ /** The committed width; `null` while the element shows its default. */
+ readonly width: number | null;
+ /** Attach to the element being resized; its parent is the row it shares. */
+ readonly panelRef: (element: HTMLElement | null) => void;
+ /** Spread onto the drag handle. */
+ readonly handleProps: ResizableHandleProps;
+ /** Back to the element's default width. */
+ readonly reset: () => void;
+};
+
+function clamp(value: number, min: number, max: number): number {
+ return Math.min(Math.max(value, min), max);
+}
+
+function writeWidth(element: HTMLElement, width: number | null): void {
+ if (width === null) element.style.removeProperty(RESIZABLE_WIDTH_VAR);
+ else element.style.setProperty(RESIZABLE_WIDTH_VAR, `${width}px`);
+}
+
+/** The parent's content box: what the element's percentage widths resolve against. */
+function rowWidth(element: HTMLElement): number {
+ const parent = element.parentElement;
+ if (parent === null) return window.innerWidth;
+ const style = getComputedStyle(parent);
+ // An unset padding reads as "" and parses to NaN; that is zero padding.
+ const padding =
+ (Number.parseFloat(style.paddingLeft) || 0) +
+ (Number.parseFloat(style.paddingRight) || 0);
+ return parent.clientWidth - padding;
+}
+
+function renderedWidth(element: HTMLElement): number {
+ return Math.round(element.getBoundingClientRect().width);
+}
+
+/** The width the element's stylesheet gives it with no chosen width set. */
+function measureDefault(element: HTMLElement): number {
+ const chosen = element.style.getPropertyValue(RESIZABLE_WIDTH_VAR);
+ element.style.removeProperty(RESIZABLE_WIDTH_VAR);
+ const width = renderedWidth(element);
+ if (chosen !== "") element.style.setProperty(RESIZABLE_WIDTH_VAR, chosen);
+ return width;
+}
+
+/** Pointer movement right is +1 when the element sits at the row's end (LTR). */
+function growDirection(element: HTMLElement): 1 | -1 {
+ return getComputedStyle(element).direction === "rtl" ? -1 : 1;
+}
+
+/**
+ * Width state for an element resized from its start edge, such as a side
+ * panel at the end of a row.
+ *
+ * The stylesheet owns layout: the hook only writes `--resizable-width` on the
+ * element (or removes it for the default) and reads back what was rendered,
+ * so the CSS default, min and max are never restated in JS. The element is
+ * expected to cap itself at `max(minWidth, 100% - mainMinWidth)` of its
+ * parent; `Panel` does.
+ *
+ * A pointer drag writes the variable straight to the element each frame and
+ * updates the handle's ARIA value by hand, so nothing re-renders until
+ * release, when the width is committed once (snapping to the default when
+ * close). A press that never moves is a click and commits nothing. Like a
+ * controlled input, a parent that ignores `onWidthChange` keeps its width:
+ * the drag's override is dropped before the commit.
+ *
+ * Keys on the handle: Left/Right arrows step (mirrored in right-to-left
+ * text), Shift for bigger steps, Home and End jump to the limits, Enter
+ * resets. A double-click resets too. Steps start from the rendered width,
+ * so a stored width the container has since clamped never swallows a press.
+ */
+export function useResizable(options: UseResizableOptions): UseResizableResult {
+ const {
+ width: controlledWidth,
+ defaultWidth = null,
+ onWidthChange,
+ minWidth,
+ mainMinWidth,
+ step = KEY_STEP_PX,
+ } = options;
+ const [width, setWidth] = useControllableState({
+ value: controlledWidth,
+ defaultValue: defaultWidth,
+ onChange: onWidthChange,
+ name: "useResizable",
+ });
+
+ const elementRef = useRef(null);
+ const observerRef = useRef(null);
+ // The committed width and bounds, for handlers and the ref callback. Set
+ // in the layout effect below, so a render React discards never lands here.
+ const widthRef = useRef(width);
+ const boundsRef = useRef({ minWidth, mainMinWidth });
+ const draggingRef = useRef(false);
+ const stopDragRef = useRef<(() => void) | null>(null);
+ const [metrics, setMetrics] = useState(null);
+
+ const maxFor = useCallback((element: HTMLElement) => {
+ const { minWidth: min, mainMinWidth: mainMin } = boundsRef.current;
+ return Math.max(min, Math.round(rowWidth(element) - mainMin));
+ }, []);
+
+ const measure = useCallback(() => {
+ const element = elementRef.current;
+ if (element === null || draggingRef.current) return;
+ const next = { width: renderedWidth(element), max: maxFor(element) };
+ setMetrics((previous) =>
+ previous !== null &&
+ previous.width === next.width &&
+ previous.max === next.max
+ ? previous
+ : next,
+ );
+ }, [maxFor]);
+
+ const panelRef = useCallback(
+ (element: HTMLElement | null) => {
+ observerRef.current?.disconnect();
+ observerRef.current = null;
+ elementRef.current = element;
+ if (element === null) return;
+ writeWidth(element, widthRef.current);
+ measure();
+ if (typeof ResizeObserver === "undefined") return;
+ const observer = new ResizeObserver(measure);
+ observer.observe(element);
+ if (element.parentElement !== null)
+ observer.observe(element.parentElement);
+ observerRef.current = observer;
+ },
+ [measure],
+ );
+
+ useLayoutEffect(() => {
+ widthRef.current = width;
+ boundsRef.current = { minWidth, mainMinWidth };
+ const element = elementRef.current;
+ if (element === null) return;
+ writeWidth(element, width);
+ measure();
+ }, [width, measure, minWidth, mainMinWidth]);
+
+ // The observer is the ref callback's to tear down: StrictMode replays this
+ // effect's cleanup on a live element.
+ useEffect(() => () => stopDragRef.current?.(), []);
+
+ const commit = useCallback(
+ (next: number | null) => {
+ const element = elementRef.current;
+ if (element === null) return;
+ let resolved: number | null = null;
+ if (next !== null) {
+ const clamped = clamp(
+ Math.round(next),
+ boundsRef.current.minWidth,
+ maxFor(element),
+ );
+ resolved = clamped === measureDefault(element) ? null : clamped;
+ }
+ // Drop any drag-time override first: if the parent keeps its width,
+ // nothing else would put the element back.
+ writeWidth(element, widthRef.current);
+ if (resolved !== widthRef.current) setWidth(resolved);
+ measure();
+ },
+ [maxFor, measure, setWidth],
+ );
+
+ const reset = useCallback(() => commit(null), [commit]);
+
+ const onPointerDown = (event: React.PointerEvent) => {
+ const element = elementRef.current;
+ const handle = event.currentTarget;
+ if (element === null || event.button !== 0 || !event.isPrimary) return;
+ stopDragRef.current?.();
+ handle.setPointerCapture(event.pointerId);
+
+ const direction = growDirection(element);
+ const startX = event.clientX;
+ const startWidth = renderedWidth(element);
+ const fallback = measureDefault(element);
+ const min = boundsRef.current.minWidth;
+ const max = maxFor(element);
+ const startValue = handle.getAttribute("aria-valuenow");
+ const root = document.documentElement;
+ const previousCursor = root.style.cursor;
+ const previousUserSelect = root.style.userSelect;
+ let latest = startWidth;
+ let frame = 0;
+ // A press that never travels is a click (focus, or half a double-click),
+ // not a resize: it commits nothing.
+ let moved = false;
+
+ draggingRef.current = true;
+ handle.dataset["dragging"] = "true";
+ root.style.cursor = "col-resize";
+ // This, not preventing the press's default, keeps the drag from
+ // selecting text: the default is what focuses the handle.
+ root.style.userSelect = "none";
+
+ const move = (moveEvent: PointerEvent) => {
+ if (!moved && Math.abs(moveEvent.clientX - startX) < DRAG_THRESHOLD_PX)
+ return;
+ moved = true;
+ let next = clamp(
+ startWidth + direction * (startX - moveEvent.clientX),
+ min,
+ max,
+ );
+ if (Math.abs(next - fallback) < SNAP_PX) next = fallback;
+ latest = Math.round(next);
+ cancelAnimationFrame(frame);
+ frame = requestAnimationFrame(() => {
+ element.style.setProperty(RESIZABLE_WIDTH_VAR, `${latest}px`);
+ handle.setAttribute("aria-valuenow", String(latest));
+ });
+ };
+
+ const stop = () => {
+ cancelAnimationFrame(frame);
+ handle.removeEventListener("pointermove", move);
+ handle.removeEventListener("pointerup", end);
+ handle.removeEventListener("pointercancel", end);
+ handle.removeEventListener("lostpointercapture", end);
+ if (handle.hasPointerCapture(event.pointerId))
+ handle.releasePointerCapture(event.pointerId);
+ delete handle.dataset["dragging"];
+ root.style.cursor = previousCursor;
+ root.style.userSelect = previousUserSelect;
+ // React only rewrites the attribute when its value changes, so put
+ // back what it last rendered.
+ if (startValue !== null) handle.setAttribute("aria-valuenow", startValue);
+ draggingRef.current = false;
+ stopDragRef.current = null;
+ };
+
+ function end() {
+ stop();
+ if (moved) commit(latest);
+ }
+
+ handle.addEventListener("pointermove", move);
+ handle.addEventListener("pointerup", end);
+ handle.addEventListener("pointercancel", end);
+ handle.addEventListener("lostpointercapture", end);
+ stopDragRef.current = stop;
+ };
+
+ const onKeyDown = (event: React.KeyboardEvent) => {
+ const element = elementRef.current;
+ if (element === null) return;
+ const current = renderedWidth(element);
+ const delta =
+ growDirection(element) *
+ (event.shiftKey ? step * LARGE_STEP_FACTOR : step);
+ let next: number | null;
+ switch (event.key) {
+ case "ArrowLeft":
+ next = current + delta;
+ break;
+ case "ArrowRight":
+ next = current - delta;
+ break;
+ case "Home":
+ next = boundsRef.current.minWidth;
+ break;
+ case "End":
+ next = maxFor(element);
+ break;
+ case "Enter":
+ next = null;
+ break;
+ default:
+ return;
+ }
+ event.preventDefault();
+ commit(next);
+ };
+
+ return {
+ width,
+ panelRef,
+ reset,
+ handleProps: {
+ role: "separator",
+ tabIndex: 0,
+ "aria-orientation": "vertical",
+ "aria-valuemin": minWidth,
+ "aria-valuemax": metrics?.max ?? minWidth,
+ "aria-valuenow": metrics?.width ?? minWidth,
+ onPointerDown,
+ onDoubleClick: reset,
+ onKeyDown,
+ },
+ };
+}
diff --git a/src/index.ts b/src/index.ts
index 43bb94be..dc769c66 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -21,6 +21,7 @@ export * from "./hooks/use-list-selection.js";
export * from "./hooks/use-prefers-reduced-motion.js";
export * from "./hooks/use-scroll-current-into-view.js";
export * from "./hooks/use-prompt-input.js";
+export * from "./hooks/use-resizable.js";
export * from "./lib/artifact.js";
export * from "./lib/chart-palette.js";
export * from "./lib/chat-message.js";
@@ -131,3 +132,4 @@ export * from "./ui/voice-waveform.js";
export * from "./ui/conversation.js";
export * from "./ui/conversation-divider.js";
export * from "./ui/prompt-input.js";
+export * from "./ui/panel.js";
diff --git a/src/lib/inert-siblings.test.ts b/src/lib/inert-siblings.test.ts
new file mode 100644
index 00000000..c3ac2706
--- /dev/null
+++ b/src/lib/inert-siblings.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, test } from "bun:test";
+
+import { inertSiblings } from "./inert-siblings.js";
+
+function row(...names: string[]): HTMLElement {
+ const parent = document.createElement("div");
+ for (const name of names) {
+ const child = document.createElement("div");
+ child.dataset["name"] = name;
+ parent.appendChild(child);
+ }
+ return parent;
+}
+
+function child(parent: HTMLElement, name: string): HTMLElement {
+ const found = parent.querySelector(`[data-name='${name}']`);
+ if (found === null) throw new Error(`no child named ${name}`);
+ return found;
+}
+
+describe("inertSiblings", () => {
+ test("makes every sibling inert but not the element itself", () => {
+ const parent = row("before", "panel", "after");
+ inertSiblings(child(parent, "panel"));
+ expect(child(parent, "before").hasAttribute("inert")).toBe(true);
+ expect(child(parent, "after").hasAttribute("inert")).toBe(true);
+ expect(child(parent, "panel").hasAttribute("inert")).toBe(false);
+ });
+
+ test("restoring lifts the inertness it added", () => {
+ const parent = row("main", "panel");
+ const restore = inertSiblings(child(parent, "panel"));
+ restore();
+ expect(child(parent, "main").hasAttribute("inert")).toBe(false);
+ });
+
+ test("a sibling that was already inert stays inert after restoring", () => {
+ const parent = row("main", "rail", "panel");
+ child(parent, "rail").setAttribute("inert", "");
+ const restore = inertSiblings(child(parent, "panel"));
+ restore();
+ expect(child(parent, "main").hasAttribute("inert")).toBe(false);
+ expect(child(parent, "rail").hasAttribute("inert")).toBe(true);
+ });
+
+ test("an element with no parent changes nothing", () => {
+ const restore = inertSiblings(document.createElement("div"));
+ expect(restore).not.toThrow();
+ });
+});
diff --git a/src/lib/inert-siblings.ts b/src/lib/inert-siblings.ts
new file mode 100644
index 00000000..08c1dfab
--- /dev/null
+++ b/src/lib/inert-siblings.ts
@@ -0,0 +1,17 @@
+/**
+ * Makes every sibling of `element` inert, so nothing beside it can take
+ * focus or be reached by assistive technology. A sibling that was already
+ * inert is left alone. The returned function lifts only the inertness this
+ * call added.
+ */
+export function inertSiblings(element: Element): () => void {
+ const added: Element[] = [];
+ for (const sibling of element.parentElement?.children ?? []) {
+ if (sibling === element || sibling.hasAttribute("inert")) continue;
+ sibling.setAttribute("inert", "");
+ added.push(sibling);
+ }
+ return () => {
+ for (const sibling of added) sibling.removeAttribute("inert");
+ };
+}
diff --git a/src/ui/panel.test.tsx b/src/ui/panel.test.tsx
new file mode 100644
index 00000000..c6c00e1c
--- /dev/null
+++ b/src/ui/panel.test.tsx
@@ -0,0 +1,350 @@
+import { act, createRef, StrictMode, useState } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { describe, expect, test } from "bun:test";
+
+import {
+ Panel,
+ PanelActions,
+ PanelClose,
+ PanelContent,
+ PanelHandle,
+ PanelHeader,
+ PanelPane,
+} from "./panel.js";
+
+type Mounted = {
+ container: HTMLElement;
+ render: (node: React.ReactElement) => void;
+ unmount: () => void;
+};
+
+function mount(node: React.ReactElement): Mounted {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ let root!: Root;
+ act(() => {
+ root = createRoot(container);
+ root.render(node);
+ });
+ return {
+ container,
+ render: (next) => act(() => root.render(next)),
+ unmount: () => {
+ act(() => root.unmount());
+ container.remove();
+ },
+ };
+}
+
+function panes(value: string, keepMounted: boolean) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function pane(container: HTMLElement, value: string): HTMLElement | null {
+ const input = container.querySelector(
+ `input[aria-label='${value === "a" ? "First" : "Second"}']`,
+ );
+ return input?.closest("[data-slot='panel-pane']") ?? null;
+}
+
+describe("Panel", () => {
+ test("without keepMounted only the shown pane is mounted", () => {
+ const mounted = mount(panes("a", false));
+ expect(pane(mounted.container, "a")).not.toBeNull();
+ expect(pane(mounted.container, "b")).toBeNull();
+ mounted.render(panes("b", false));
+ expect(pane(mounted.container, "a")).toBeNull();
+ expect(pane(mounted.container, "b")).not.toBeNull();
+ mounted.unmount();
+ });
+
+ test("keepMounted hides the other panes and keeps what was typed", () => {
+ const mounted = mount(panes("a", true));
+ const first = mounted.container.querySelector(
+ "input[aria-label='First']",
+ );
+ if (first === null) throw new Error("expected the first input");
+ first.value = "half a reply";
+
+ mounted.render(panes("b", true));
+ const hidden = pane(mounted.container, "a");
+ expect(hidden?.dataset["state"]).toBe("inactive");
+ expect(hidden?.hasAttribute("inert")).toBe(true);
+ expect(hidden?.className).toContain("invisible");
+ expect(pane(mounted.container, "b")?.hasAttribute("inert")).toBe(false);
+
+ mounted.render(panes("a", true));
+ expect(first.isConnected).toBe(true);
+ expect(first.value).toBe("half a reply");
+ expect(pane(mounted.container, "a")?.dataset["state"]).toBe("active");
+ mounted.unmount();
+ });
+
+ test("a ref reaches the panel element", () => {
+ const ref = createRef();
+ const mounted = mount();
+ expect(ref.current).toBe(mounted.container.querySelector("aside"));
+ mounted.unmount();
+ expect(ref.current).toBeNull();
+ });
+
+ test("the handle is a named separator that controls the panel", () => {
+ const mounted = mount(
+
+
+
+
Thread
+
+
+
+
+ ,
+ );
+ const panel = mounted.container.querySelector("aside");
+ const handle = mounted.container.querySelector("[role='separator']");
+ expect(handle?.getAttribute("aria-label")).toBe("Resize panel");
+ expect(handle?.getAttribute("aria-controls")).toBe(panel?.id ?? "");
+ expect(handle?.getAttribute("tabindex")).toBe("0");
+ expect(handle?.getAttribute("aria-orientation")).toBe("vertical");
+ for (const name of ["aria-valuenow", "aria-valuemin", "aria-valuemax"]) {
+ expect(handle?.hasAttribute(name)).toBe(true);
+ }
+ expect(
+ mounted.container
+ .querySelector("[data-slot='panel-close']")
+ ?.getAttribute("aria-label"),
+ ).toBe("Close panel");
+ mounted.unmount();
+ });
+
+ test("closing returns focus from inside the panel to its opener", () => {
+ function Host() {
+ const [open, setOpen] = useState(false);
+ return (
+ <>
+
+ {open ? (
+
+ setOpen(false)} />
+
+ ) : null}
+ >
+ );
+ }
+ const mounted = mount();
+ const opener = mounted.container.querySelector("button");
+ if (opener === null) throw new Error("expected the opener");
+ opener.focus();
+ act(() => opener.click());
+ const close = mounted.container.querySelector(
+ "[data-slot='panel-close']",
+ );
+ if (close === null) throw new Error("expected the close button");
+ close.focus();
+ act(() => close.click());
+ expect(mounted.container.querySelector("aside")).toBeNull();
+ expect(document.activeElement).toBe(opener);
+ mounted.unmount();
+ });
+
+ test("closing returns focus to the opener when a pane autofocused a field", () => {
+ function Host() {
+ const [open, setOpen] = useState(false);
+ return (
+ <>
+
+
+ {open ? (
+
+
+
+
+
+
+
+ ) : null}
+ >
+ );
+ }
+ const mounted = mount();
+ const [opener, hide] = mounted.container.querySelectorAll("button");
+ if (opener === undefined || hide === undefined)
+ throw new Error("expected the host's buttons");
+ opener.focus();
+ act(() => opener.click());
+ expect(document.activeElement?.getAttribute("aria-label")).toBe("Reply");
+ // Closed by the host while focus is still in the panel, as a shortcut
+ // would: a click on the button would move focus to it first.
+ act(() => hide.dispatchEvent(new MouseEvent("click", { bubbles: true })));
+ expect(mounted.container.querySelector("aside")).toBeNull();
+ expect(document.activeElement).toBe(opener);
+ mounted.unmount();
+ });
+
+ test("under StrictMode an autofocused field keeps focus, and close returns it", () => {
+ function Host() {
+ const [open, setOpen] = useState(false);
+ return (
+ <>
+
+
+ {open ? (
+
+
+
+
+
+
+
+ ) : null}
+ >
+ );
+ }
+ const mounted = mount(
+
+
+ ,
+ );
+ const [opener, hide] = mounted.container.querySelectorAll("button");
+ if (opener === undefined || hide === undefined)
+ throw new Error("expected the host's buttons");
+ opener.focus();
+ act(() => opener.click());
+ expect(document.activeElement?.getAttribute("aria-label")).toBe("Reply");
+ act(() => hide.dispatchEvent(new MouseEvent("click", { bubbles: true })));
+ expect(mounted.container.querySelector("aside")).toBeNull();
+ expect(document.activeElement).toBe(opener);
+ mounted.unmount();
+ });
+
+ test("as an overlay the panel makes the covered main area inert until it closes", () => {
+ function Host() {
+ const [open, setOpen] = useState(false);
+ return (
+ <>
+
+
+
+ {open ? (
+ // The `@container` query is not evaluated here; an inline
+ // position stands in for the overlay it would apply.
+
+ setOpen(false)} />
+
+ ) : null}
+ >
+ );
+ }
+ const mounted = mount();
+ const main = mounted.container.querySelector("main");
+ const opener = mounted.container.querySelector("button");
+ if (main === null || opener === null) throw new Error("expected main");
+ opener.focus();
+ act(() => opener.click());
+ expect(main.hasAttribute("inert")).toBe(true);
+ expect(document.activeElement).toBe(
+ mounted.container.querySelector("aside"),
+ );
+ const close = mounted.container.querySelector(
+ "[data-slot='panel-close']",
+ );
+ if (close === null) throw new Error("expected the close button");
+ close.focus();
+ act(() => close.click());
+ expect(main.hasAttribute("inert")).toBe(false);
+ expect(document.activeElement).toBe(opener);
+ mounted.unmount();
+ });
+
+ test("a panel beside the main area leaves it reachable", () => {
+ const mounted = mount(
+ <>
+
+
+ >,
+ );
+ expect(mounted.container.querySelector("main")?.hasAttribute("inert")).toBe(
+ false,
+ );
+ mounted.unmount();
+ });
+
+ test("a part outside its parent says where it belongs", () => {
+ const original = console.error;
+ console.error = () => {};
+ try {
+ expect(() => mount()).toThrow(
+ "PanelHandle must be inside a Panel.",
+ );
+ } finally {
+ console.error = original;
+ }
+ });
+
+ test("in development, pane values that cannot show one pane log an error", () => {
+ const errors: unknown[][] = [];
+ const original = console.error;
+ console.error = (...args: unknown[]) => errors.push(args);
+ try {
+ const duplicate = mount(
+
+ first
+ second
+ ,
+ );
+ duplicate.unmount();
+ expect(errors).toEqual([
+ ["PanelContent: two PanelPanes share the value `a`."],
+ ]);
+
+ errors.length = 0;
+ const missing = mount(
+
+ first
+ ,
+ );
+ missing.unmount();
+ expect(errors).toEqual([
+ [
+ "PanelContent: `value` names none of its panes, so the panel body is empty.",
+ ],
+ ]);
+
+ errors.length = 0;
+ const fine = mount(
+
+ first
+ second
+ ,
+ );
+ fine.render();
+ fine.unmount();
+ expect(errors).toEqual([]);
+ } finally {
+ console.error = original;
+ }
+ });
+});
diff --git a/src/ui/panel.tsx b/src/ui/panel.tsx
new file mode 100644
index 00000000..fe6c460f
--- /dev/null
+++ b/src/ui/panel.tsx
@@ -0,0 +1,432 @@
+"use client";
+
+import { X } from "lucide-react";
+import {
+ createContext,
+ forwardRef,
+ useCallback,
+ useContext,
+ useEffect,
+ useId,
+ useLayoutEffect,
+ useRef,
+} from "react";
+import type * as React from "react";
+
+import {
+ useResizable,
+ type ResizableHandleProps,
+} from "../hooks/use-resizable.js";
+import { inertSiblings } from "../lib/inert-siblings.js";
+import { cn } from "../lib/utils.js";
+import { Button } from "./button.js";
+
+const isProduction =
+ typeof process !== "undefined" && process.env?.NODE_ENV === "production";
+
+const DEFAULT_MIN_WIDTH_PX = 320;
+const DEFAULT_MAIN_MIN_WIDTH_PX = 440;
+
+// React 18 does not know `inert` and drops a boolean for it, while React 19
+// reads it as a boolean. A non-empty string other than "true" or "false"
+// reaches the DOM on both without a warning; the record type is wider than
+// React 19's boolean-only `inert` to allow it.
+const HIDDEN_PANE_PROPS: Record = { inert: "inert" };
+
+type PanelContextValue = {
+ readonly panelId: string;
+ readonly handleProps: ResizableHandleProps;
+};
+
+const PanelContext = createContext(null);
+
+function usePanelContext(part: string): PanelContextValue {
+ const context = useContext(PanelContext);
+ if (context === null) throw new Error(`${part} must be inside a Panel.`);
+ return context;
+}
+
+type PanelPanesContextValue = {
+ readonly value: string;
+ readonly keepMounted: boolean;
+ /** Development only: a pane reports its value; returns its removal. */
+ readonly register: (value: string) => () => void;
+};
+
+const PanelPanesContext = createContext(null);
+
+export type PanelProps = React.ComponentProps<"aside"> & {
+ /**
+ * Controlled width in px. `null` is the panel's own default: 46% of the
+ * row, between 480px and 760px at the default spacing scale.
+ */
+ readonly width?: number | null | undefined;
+ /** Seeds the width when `width` is not supplied. */
+ readonly defaultWidth?: number | null | undefined;
+ /** Called once per finished resize, with `null` for a reset. */
+ readonly onWidthChange?: ((width: number | null) => void) | undefined;
+ /** The narrowest the panel may be set, in px. */
+ readonly minWidth?: number;
+ /** The width the content beside the panel always keeps, in px. */
+ readonly mainMinWidth?: number;
+};
+
+/**
+ * A side panel at the end of a row, resized from its start edge by a
+ * `PanelHandle`.
+ *
+ * Put it in a row that holds only the main area and the panel, with no gap:
+ * `
` around a `min-w-0 flex-1`
+ * main area and the `Panel`. The panel never takes so much of that row that
+ * the main area drops below `mainMinWidth`, giving way down to `minWidth`.
+ * When the row is narrower than the two side by side (the `@container`
+ * query), the panel covers the row instead: it takes focus, hides its
+ * handle, and makes the rest of the row inert so focus and assistive
+ * technology cannot reach what it covers. This follows the row's size while
+ * the panel is open; when the row widens again, focus still on the panel
+ * itself goes back to what the overlay took it from.
+ *
+ * However it closes, focus that was inside the panel goes back to whatever
+ * had it before the panel opened. `ref` reaches the `aside` on React 18 and
+ * 19.
+ */
+export const Panel = forwardRef(function Panel(
+ {
+ width,
+ defaultWidth,
+ onWidthChange,
+ minWidth = DEFAULT_MIN_WIDTH_PX,
+ mainMinWidth = DEFAULT_MAIN_MIN_WIDTH_PX,
+ id,
+ style,
+ className,
+ children,
+ onFocus,
+ ...props
+ },
+ ref,
+) {
+ const generatedId = useId();
+ const panelId = id ?? generatedId;
+ const { panelRef, handleProps } = useResizable({
+ width,
+ defaultWidth,
+ onWidthChange,
+ minWidth,
+ mainMinWidth,
+ });
+ const elementRef = useRef(null);
+ // Where focus came from when it last moved into the panel. An `autoFocus`
+ // field in a pane takes focus before the mount effect below runs, and
+ // this is how that effect still finds the opener.
+ const focusedFromRef = useRef(null);
+ // What had focus inside the panel when the mount effect last cleaned up.
+ // Only StrictMode's simulated remount runs the effect again, and it gives
+ // that element its focus back.
+ const focusedOnCleanupRef = useRef(null);
+
+ const setElement = useCallback(
+ (element: HTMLElement | null) => {
+ elementRef.current = element;
+ panelRef(element);
+ if (typeof ref === "function") ref(element);
+ else if (ref !== null && ref !== undefined) ref.current = element;
+ },
+ [panelRef, ref],
+ );
+
+ useLayoutEffect(() => {
+ const panel = elementRef.current;
+ if (panel === null) return;
+ const opener = panel.contains(document.activeElement)
+ ? focusedFromRef.current
+ : document.activeElement;
+ let restoreSiblings: (() => void) | null = null;
+ // What had focus when the panel became an overlay and took it.
+ let covered: HTMLElement | null = null;
+ // The `@container` query decides whether the panel covers the row, so
+ // its computed position is the signal. As an overlay, the panel takes
+ // focus and makes the covered main area inert; side by side again, the
+ // main area comes back, and so does focus the overlay took if it is
+ // still on the panel itself. Re-checked whenever the row resizes, as
+ // plain DOM work with no React render.
+ const syncOverlay = () => {
+ const overlay = getComputedStyle(panel).position === "absolute";
+ if (overlay && restoreSiblings === null) {
+ const active = document.activeElement;
+ if (!panel.contains(active)) {
+ covered =
+ active instanceof HTMLElement && active !== document.body
+ ? active
+ : null;
+ panel.focus();
+ }
+ restoreSiblings = inertSiblings(panel);
+ } else if (!overlay && restoreSiblings !== null) {
+ restoreSiblings();
+ restoreSiblings = null;
+ if (document.activeElement === panel && covered?.isConnected)
+ covered.focus();
+ covered = null;
+ }
+ };
+ syncOverlay();
+ const refocus = focusedOnCleanupRef.current;
+ focusedOnCleanupRef.current = null;
+ if (refocus?.isConnected && panel.contains(refocus)) refocus.focus();
+ const row = panel.parentElement;
+ const observer =
+ row === null || typeof ResizeObserver === "undefined"
+ ? null
+ : new ResizeObserver(syncOverlay);
+ if (row !== null) observer?.observe(row);
+ // A layout cleanup runs while the panel is still attached, so `contains`
+ // still sees focus inside it.
+ return () => {
+ observer?.disconnect();
+ const active = document.activeElement;
+ const focusLeftWithPanel =
+ active === null || active === document.body || panel.contains(active);
+ focusedOnCleanupRef.current =
+ active instanceof HTMLElement && panel.contains(active) ? active : null;
+ // The opener is usually in the main area, so lift its inertness first.
+ restoreSiblings?.();
+ if (
+ focusLeftWithPanel &&
+ opener instanceof HTMLElement &&
+ opener !== document.body &&
+ opener.isConnected
+ ) {
+ opener.focus();
+ }
+ };
+ }, []);
+
+ const sizing: Record<`--${string}`, string> = {
+ "--panel-min-width": `${minWidth}px`,
+ "--panel-main-min-width": `${mainMinWidth}px`,
+ };
+
+ return (
+
+
+
+ );
+});
+
+export type PanelHandleProps = Omit<
+ React.ComponentProps<"div">,
+ keyof ResizableHandleProps | "children"
+> & {
+ /** The handle's accessible name. */
+ readonly label?: string;
+};
+
+/**
+ * The panel's start edge: a focusable separator over its border. Drag it,
+ * use the arrow keys (Shift for bigger steps, Home and End for the limits),
+ * or double-click or press Enter to go back to the default width.
+ */
+export function PanelHandle({
+ label = "Resize panel",
+ className,
+ ...props
+}: PanelHandleProps) {
+ const { panelId, handleProps } = usePanelContext("PanelHandle");
+ return (
+
+
+
+ );
+}
+
+/** The panel's top row: a title, then `PanelActions`. */
+export function PanelHeader({
+ className,
+ ...props
+}: React.ComponentProps<"header">) {
+ return (
+
+ );
+}
+
+/** The header's controls, kept at its end edge. */
+export function PanelActions({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export type PanelCloseProps = Omit<
+ React.ComponentProps<"button">,
+ "children"
+> & {
+ /** The button's accessible name. */
+ readonly label?: string;
+};
+
+/** Closes the panel: the caller unmounts it from `onClick`. */
+export function PanelClose({
+ label = "Close panel",
+ className,
+ ...props
+}: PanelCloseProps) {
+ return (
+
+ );
+}
+
+export type PanelContentProps = React.ComponentProps<"div"> & {
+ /** The `value` of the `PanelPane` to show. */
+ readonly value: string;
+ /**
+ * Keep every pane mounted, hiding all but the shown one, so a pane keeps
+ * its scroll position and anything typed in it while another is in front.
+ * Without it only the shown pane is mounted.
+ */
+ readonly keepMounted?: boolean;
+};
+
+/** The panel's body: holds its `PanelPane`s and shows one at a time. */
+export function PanelContent({
+ value,
+ keepMounted = false,
+ className,
+ ...props
+}: PanelContentProps) {
+ // Every mounted pane's value, kept in development to catch host mistakes:
+ // panes report from effects, which run before this component's own.
+ const registeredRef = useRef([]);
+ const register = useCallback((paneValue: string) => {
+ const registered = registeredRef.current;
+ registered.push(paneValue);
+ return () => {
+ registered.splice(registered.indexOf(paneValue), 1);
+ };
+ }, []);
+
+ useEffect(() => {
+ if (isProduction) return;
+ const registered = registeredRef.current;
+ if (registered.length === 0) return;
+ const duplicate = registered.find(
+ (paneValue, index) => registered.indexOf(paneValue) !== index,
+ );
+ if (duplicate !== undefined)
+ console.error(
+ `PanelContent: two PanelPanes share the value \`${duplicate}\`.`,
+ );
+ if (!registered.includes(value))
+ console.error(
+ "PanelContent: `value` names none of its panes, so the panel body is empty.",
+ );
+ });
+
+ return (
+
+
+
+ );
+}
+
+export type PanelPaneProps = React.ComponentProps<"div"> & {
+ /** Matched against `PanelContent`'s `value`. */
+ readonly value: string;
+};
+
+/**
+ * One pane of the panel, and its scroll container. A hidden pane kept
+ * mounted is invisible and inert rather than removed from layout, which is
+ * what keeps its scroll offset.
+ */
+export function PanelPane({ value, className, ...props }: PanelPaneProps) {
+ const context = useContext(PanelPanesContext);
+ if (context === null)
+ throw new Error("PanelPane must be inside PanelContent.");
+ const { register } = context;
+ useEffect(
+ () => (isProduction ? undefined : register(value)),
+ [register, value],
+ );
+ const shown = context.value === value;
+ if (!shown && !context.keepMounted) return null;
+ return (
+
+ );
+}
diff --git a/stories/chat/panel.stories.tsx b/stories/chat/panel.stories.tsx
new file mode 100644
index 00000000..75f92ffe
--- /dev/null
+++ b/stories/chat/panel.stories.tsx
@@ -0,0 +1,235 @@
+import { useId, useState } from "react";
+import type { ReactNode } from "react";
+
+import { Button } from "../../src/ui/button.js";
+import {
+ Panel,
+ PanelActions,
+ PanelClose,
+ PanelContent,
+ PanelHandle,
+ PanelHeader,
+ PanelPane,
+} from "../../src/ui/panel.js";
+import { Textarea } from "../../src/ui/textarea.js";
+
+export default { title: "Chat / Panel" };
+
+const MESSAGES = Array.from({ length: 24 }, (_, index) => ({
+ id: `m-${index}`,
+ text:
+ index % 2 === 0
+ ? `Step ${index + 1}: the agent read the next batch of rows and noted two that need a second look.`
+ : `Reply ${index + 1}: keep going, and flag anything over the usual range.`,
+}));
+
+/**
+ * The main area beside the panel, with the button that opens and closes it.
+ * The button stays mounted while the panel is open, so the panel records it
+ * as the opener and gives it focus back on close.
+ */
+function Main({
+ open,
+ onToggle,
+ panelId,
+ label,
+ children,
+}: {
+ readonly open: boolean;
+ readonly onToggle: () => void;
+ readonly panelId: string;
+ readonly label: string;
+ readonly children?: ReactNode;
+}) {
+ return (
+
+
Conversation
+
+ The conversation keeps at least 440px however wide the panel is dragged.
+