feat(webui): add tabbed pane workbench (#5322)
This commit is contained in:
@@ -0,0 +1,392 @@
|
||||
import { createPortal } from "react-dom";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PaneWorkbench } from "@/components/workbench/PaneWorkbench";
|
||||
import {
|
||||
EMPTY_WORKBENCH_STATE,
|
||||
addWorkbenchPane,
|
||||
setWorkbenchLayout,
|
||||
setWorkbenchPaneLayoutOrder,
|
||||
workbenchTab,
|
||||
workbenchTabForPane,
|
||||
} from "@/components/workbench/workbench-model";
|
||||
|
||||
function rect(left: number, top: number, width: number, height: number): DOMRect {
|
||||
return {
|
||||
x: left,
|
||||
y: top,
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
right: left + width,
|
||||
bottom: top + height,
|
||||
toJSON: () => ({}),
|
||||
};
|
||||
}
|
||||
|
||||
function WorkbenchHarness({
|
||||
initialLayout = "columns",
|
||||
onPaneOrderChange = () => {},
|
||||
onSplitRatiosChange = () => {},
|
||||
}: {
|
||||
initialLayout?: "columns" | "rows";
|
||||
onPaneOrderChange?: (paneKeys: string[]) => void;
|
||||
onSplitRatiosChange?: (splitRatios: number[]) => void;
|
||||
} = {}) {
|
||||
const [activePaneKey, setActivePaneKey] = useState("beta");
|
||||
const [state, setState] = useState(() => {
|
||||
const initial = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "alpha", "beta");
|
||||
const tabKey = workbenchTabForPane(initial, "alpha").tabKey;
|
||||
return setWorkbenchLayout(initial, tabKey, initialLayout);
|
||||
});
|
||||
const tabKey = workbenchTabForPane(state, "alpha").tabKey;
|
||||
const tab = workbenchTab(state, tabKey);
|
||||
if (!tab) return null;
|
||||
const titles: Record<string, string> = { alpha: "Alpha", beta: "Beta" };
|
||||
|
||||
return (
|
||||
<PaneWorkbench
|
||||
panes={tab.layoutPaneKeys.map((key) => ({ key, title: titles[key] }))}
|
||||
activePaneKey={activePaneKey}
|
||||
layout={tab.layout}
|
||||
splitRatios={tab.splitRatios}
|
||||
showLayoutControl
|
||||
onActivatePane={setActivePaneKey}
|
||||
onAddPane={vi.fn()}
|
||||
onLayoutChange={(layout) => setState((current) => (
|
||||
setWorkbenchLayout(current, tabKey, layout)
|
||||
))}
|
||||
onPaneOrderChange={(paneKeys) => {
|
||||
onPaneOrderChange(paneKeys);
|
||||
setState((current) => (
|
||||
setWorkbenchPaneLayoutOrder(current, tabKey, paneKeys)
|
||||
));
|
||||
}}
|
||||
onSplitRatiosChange={onSplitRatiosChange}
|
||||
renderPane={(pane, context) => (
|
||||
<>
|
||||
<button type="button">Focus {pane.title}</button>
|
||||
{context.headerPortalTarget && context.active ? createPortal(
|
||||
context.headerActions,
|
||||
context.headerPortalTarget,
|
||||
) : null}
|
||||
{context.composerPortalTarget ? createPortal(
|
||||
<div hidden={!context.active}>
|
||||
<textarea aria-label={`Composer ${pane.title}`} />
|
||||
</div>,
|
||||
context.composerPortalTarget,
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BspWorkbenchHarness() {
|
||||
const panes = ["alpha", "beta", "gamma", "delta"].map((key) => ({
|
||||
key,
|
||||
title: key,
|
||||
}));
|
||||
return (
|
||||
<PaneWorkbench
|
||||
panes={panes}
|
||||
activePaneKey="delta"
|
||||
layout="bsp"
|
||||
splitRatios={[]}
|
||||
showLayoutControl
|
||||
onActivatePane={vi.fn()}
|
||||
onAddPane={vi.fn()}
|
||||
onLayoutChange={vi.fn()}
|
||||
onPaneOrderChange={vi.fn()}
|
||||
onSplitRatiosChange={vi.fn()}
|
||||
renderPane={(pane) => <span>{pane.title}</span>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("PaneWorkbench", () => {
|
||||
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
|
||||
const originalAnimate = HTMLElement.prototype.animate;
|
||||
const animate = vi.fn(() => ({
|
||||
addEventListener: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
}) as unknown as Animation);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})));
|
||||
HTMLElement.prototype.animate = animate;
|
||||
HTMLElement.prototype.getBoundingClientRect = function getBoundingClientRect() {
|
||||
if (this.dataset.testid === "pane-grid") return rect(0, 0, 1000, 1000);
|
||||
if (!this.classList.contains("workbench-pane")) {
|
||||
return originalGetBoundingClientRect.call(this);
|
||||
}
|
||||
const layout = this.parentElement?.dataset.layout;
|
||||
const index = Array.from(this.parentElement?.children ?? []).indexOf(this);
|
||||
return layout === "rows"
|
||||
? rect(0, index * 500, 1000, 500)
|
||||
: rect(index * 500, 0, 500, 1000);
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
HTMLElement.prototype.animate = originalAnimate;
|
||||
HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect;
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("focuses without reordering and keeps only the focused composer visible", () => {
|
||||
render(<WorkbenchHarness />);
|
||||
|
||||
const grid = screen.getByTestId("pane-grid");
|
||||
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||
.toEqual(["Alpha", "Beta"]);
|
||||
expect(screen.getByLabelText("Composer Beta")).toBeVisible();
|
||||
expect(screen.getByLabelText("Composer Alpha")).not.toBeVisible();
|
||||
|
||||
fireEvent.pointerDown(
|
||||
within(screen.getByRole("region", { name: "Alpha" }))
|
||||
.getByRole("button", { name: "Focus Alpha" }),
|
||||
);
|
||||
|
||||
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||
.toEqual(["Alpha", "Beta"]);
|
||||
expect(screen.getByLabelText("Composer Alpha")).toBeVisible();
|
||||
expect(screen.getByLabelText("Composer Beta")).not.toBeVisible();
|
||||
});
|
||||
|
||||
it("moves the focused pane between workspace slots from its bottom handle", () => {
|
||||
const onPaneOrderChange = vi.fn();
|
||||
render(<WorkbenchHarness onPaneOrderChange={onPaneOrderChange} />);
|
||||
|
||||
const grid = screen.getByTestId("pane-grid");
|
||||
const handle = screen.getByRole("button", { name: "Move Beta pane" });
|
||||
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||
.toEqual(["Alpha", "Beta"]);
|
||||
|
||||
fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 750, clientY: 990 });
|
||||
fireEvent.pointerMove(window, {
|
||||
buttons: 1,
|
||||
pointerId: 1,
|
||||
clientX: 250,
|
||||
clientY: 500,
|
||||
});
|
||||
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||
.toEqual(["Beta", "Alpha"]);
|
||||
expect(onPaneOrderChange).toHaveBeenCalledOnce();
|
||||
expect(onPaneOrderChange).toHaveBeenCalledWith(["beta", "alpha"]);
|
||||
|
||||
fireEvent.pointerMove(window, {
|
||||
buttons: 1,
|
||||
pointerId: 1,
|
||||
clientX: 750,
|
||||
clientY: 500,
|
||||
});
|
||||
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||
.toEqual(["Alpha", "Beta"]);
|
||||
expect(onPaneOrderChange).toHaveBeenLastCalledWith(["alpha", "beta"]);
|
||||
fireEvent.pointerUp(window, { pointerId: 1, clientX: 750, clientY: 500 });
|
||||
|
||||
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||
.toEqual(["Alpha", "Beta"]);
|
||||
expect(onPaneOrderChange).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("moves panes up and down through stable workspace slots", () => {
|
||||
const onPaneOrderChange = vi.fn();
|
||||
render(
|
||||
<WorkbenchHarness initialLayout="rows" onPaneOrderChange={onPaneOrderChange} />,
|
||||
);
|
||||
|
||||
const grid = screen.getByTestId("pane-grid");
|
||||
const handle = screen.getByRole("button", { name: "Move Beta pane" });
|
||||
fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 500, clientY: 990 });
|
||||
fireEvent.pointerMove(window, {
|
||||
buttons: 1,
|
||||
pointerId: 1,
|
||||
clientX: 500,
|
||||
clientY: 250,
|
||||
});
|
||||
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||
.toEqual(["Beta", "Alpha"]);
|
||||
|
||||
fireEvent.pointerMove(window, {
|
||||
buttons: 1,
|
||||
pointerId: 1,
|
||||
clientX: 500,
|
||||
clientY: 750,
|
||||
});
|
||||
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
||||
.toEqual(["Alpha", "Beta"]);
|
||||
expect(onPaneOrderChange.mock.calls).toEqual([
|
||||
[["beta", "alpha"]],
|
||||
[["alpha", "beta"]],
|
||||
]);
|
||||
fireEvent.pointerUp(window, { pointerId: 1, clientX: 500, clientY: 750 });
|
||||
});
|
||||
|
||||
it("moves the focused pane between workspace slots with arrow keys", () => {
|
||||
render(<WorkbenchHarness />);
|
||||
|
||||
const handle = screen.getByRole("button", { name: "Move Beta pane" });
|
||||
act(() => handle.focus());
|
||||
expect(handle).toHaveFocus();
|
||||
fireEvent.keyDown(handle, { key: "ArrowLeft" });
|
||||
|
||||
expect(Array.from(screen.getByTestId("pane-grid").children)
|
||||
.map((pane) => pane.getAttribute("aria-label")))
|
||||
.toEqual(["Beta", "Alpha"]);
|
||||
});
|
||||
|
||||
it("previews edge resizing locally and commits one ratio when dragging ends", () => {
|
||||
const onSplitRatiosChange = vi.fn();
|
||||
render(<WorkbenchHarness onSplitRatiosChange={onSplitRatiosChange} />);
|
||||
|
||||
const grid = screen.getByTestId("pane-grid");
|
||||
const separator = screen.getByRole("separator", {
|
||||
name: "Resize pane boundary 1",
|
||||
});
|
||||
fireEvent.pointerDown(separator, {
|
||||
button: 0,
|
||||
pointerId: 7,
|
||||
clientX: 500,
|
||||
clientY: 400,
|
||||
});
|
||||
fireEvent.pointerMove(window, {
|
||||
buttons: 1,
|
||||
pointerId: 7,
|
||||
clientX: 700,
|
||||
clientY: 400,
|
||||
});
|
||||
|
||||
expect(grid.style.gridTemplateColumns)
|
||||
.toBe("minmax(0, 700fr) minmax(0, 300fr)");
|
||||
expect(onSplitRatiosChange).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.pointerUp(window, { pointerId: 7, clientX: 700, clientY: 400 });
|
||||
expect(onSplitRatiosChange).toHaveBeenCalledOnce();
|
||||
expect(onSplitRatiosChange).toHaveBeenCalledWith([0.7]);
|
||||
});
|
||||
|
||||
it("resizes a pane boundary with the matching arrow keys", () => {
|
||||
const onSplitRatiosChange = vi.fn();
|
||||
render(<WorkbenchHarness onSplitRatiosChange={onSplitRatiosChange} />);
|
||||
|
||||
const separator = screen.getByRole("separator", {
|
||||
name: "Resize pane boundary 1",
|
||||
});
|
||||
fireEvent.keyDown(separator, { key: "ArrowLeft" });
|
||||
|
||||
expect(onSplitRatiosChange).toHaveBeenCalledWith([0.47]);
|
||||
});
|
||||
|
||||
it("keeps one shared layout control and animates geometry changes", async () => {
|
||||
render(<WorkbenchHarness />);
|
||||
|
||||
const header = screen.getByTestId("workbench-header-host");
|
||||
expect(within(header).getAllByRole("button", { name: "Pane layout" })).toHaveLength(1);
|
||||
fireEvent.pointerDown(within(header).getByRole("button", { name: "Pane layout" }), {
|
||||
button: 0,
|
||||
ctrlKey: false,
|
||||
});
|
||||
fireEvent.click(screen.getByRole("menuitemradio", { name: "Rows" }));
|
||||
expect(screen.getByTestId("pane-grid")).toHaveAttribute("data-layout", "rows");
|
||||
await waitFor(() => expect(animate).toHaveBeenCalledTimes(2));
|
||||
|
||||
fireEvent.pointerDown(within(header).getByRole("button", { name: "Pane layout" }), {
|
||||
button: 0,
|
||||
ctrlKey: false,
|
||||
});
|
||||
fireEvent.click(screen.getByRole("menuitemradio", { name: "BSP" }));
|
||||
expect(screen.getByTestId("pane-grid")).toHaveAttribute("data-layout", "bsp");
|
||||
await waitFor(() => expect(animate).toHaveBeenCalledTimes(4));
|
||||
});
|
||||
|
||||
it("renders only the active pane and hides workbench controls on mobile", () => {
|
||||
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
|
||||
matches: query.includes("max-width: 767px"),
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})));
|
||||
const panes = ["alpha", "beta", "gamma", "delta"].map((key) => ({
|
||||
key,
|
||||
title: key,
|
||||
}));
|
||||
const props = {
|
||||
panes,
|
||||
layout: "bsp" as const,
|
||||
showLayoutControl: true,
|
||||
onActivatePane: vi.fn(),
|
||||
onAddPane: vi.fn(),
|
||||
onLayoutChange: vi.fn(),
|
||||
onPaneOrderChange: vi.fn(),
|
||||
renderPane: (pane: { key: string; title: string }, context: {
|
||||
headerActions: ReactNode;
|
||||
}) => <>{context.headerActions}<span>{pane.title}</span></>,
|
||||
};
|
||||
|
||||
const { rerender } = render(<PaneWorkbench {...props} activePaneKey="gamma" />);
|
||||
|
||||
expect(screen.getByTestId("pane-grid").children).toHaveLength(1);
|
||||
expect(screen.getByTestId("workbench-pane-gamma")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Pane layout" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Add pane" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("separator")).not.toBeInTheDocument();
|
||||
|
||||
rerender(<PaneWorkbench {...props} activePaneKey="delta" />);
|
||||
expect(screen.getByTestId("pane-grid").children).toHaveLength(1);
|
||||
expect(screen.getByTestId("workbench-pane-delta")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("workbench-pane-gamma")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("explains why the desktop add-pane control is disabled", () => {
|
||||
render(
|
||||
<PaneWorkbench
|
||||
panes={[{ key: "alpha", title: "Alpha" }]}
|
||||
activePaneKey="alpha"
|
||||
layout="columns"
|
||||
showLayoutControl={false}
|
||||
addPaneDisabled
|
||||
addPaneDisabledLabel="Maximum 4 panes"
|
||||
onActivatePane={vi.fn()}
|
||||
onAddPane={vi.fn()}
|
||||
onLayoutChange={vi.fn()}
|
||||
onPaneOrderChange={vi.fn()}
|
||||
renderPane={(_pane, context) => context.headerActions}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Add pane" }))
|
||||
.toHaveAttribute("title", "Maximum 4 panes");
|
||||
});
|
||||
|
||||
it("fills the workbench through alternating binary splits", () => {
|
||||
render(<BspWorkbenchHarness />);
|
||||
|
||||
const alpha = screen.getByTestId("workbench-pane-alpha");
|
||||
const beta = screen.getByTestId("workbench-pane-beta");
|
||||
const gamma = screen.getByTestId("workbench-pane-gamma");
|
||||
const delta = screen.getByTestId("workbench-pane-delta");
|
||||
expect([alpha.style.gridColumn, alpha.style.gridRow]).toEqual(["1 / 2", "1 / 3"]);
|
||||
expect([beta.style.gridColumn, beta.style.gridRow]).toEqual(["2 / 4", "1 / 2"]);
|
||||
expect([gamma.style.gridColumn, gamma.style.gridRow]).toEqual(["2 / 3", "2 / 3"]);
|
||||
expect([delta.style.gridColumn, delta.style.gridRow]).toEqual(["3 / 4", "2 / 3"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user