305 lines
12 KiB
TypeScript
305 lines
12 KiB
TypeScript
import { DirectionProvider } from "@base-ui/react/direction-provider";
|
|
import { fireEvent, render, screen } from "@testing-library/react";
|
|
import { type CSSProperties, useRef, useState } from "react";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import SidebarResizeHandle from "@/components/AppSidebar/SidebarResizeHandle";
|
|
import { SIDEBAR_DEFAULT_WIDTH, SIDEBAR_MAX_WIDTH, SIDEBAR_MIN_WIDTH, SIDEBAR_WIDTH_VAR } from "@/components/AppSidebar/useSidebarWidth";
|
|
|
|
// The handle's contract has two halves. Keyboard and double-click go straight through
|
|
// `onWidthChange`. A pointer drag deliberately does not: it writes the width custom property
|
|
// on the layout shell every frame and commits to React exactly once, on release, so that
|
|
// dragging does not re-render the sidebar and the memo feed on every pointer move. Both
|
|
// halves are asserted here, since the drag half is invisible to a props-only test.
|
|
|
|
vi.mock("@/utils/i18n", () => ({
|
|
useTranslate: () => (key: string) => key,
|
|
}));
|
|
|
|
// Mirrors how RootLayout wires the handle: the shell owns the custom property and the rail's
|
|
// handle writes to it. `railMounted` stands in for RootLayout's `md &&` gate, which unmounts the
|
|
// rail — and with it the handle — when the window drops below the desktop breakpoint, while the
|
|
// shell itself stays mounted.
|
|
const Harness = ({
|
|
onWidthChange = vi.fn(),
|
|
railMounted = true,
|
|
direction = "ltr",
|
|
}: {
|
|
onWidthChange?: (width: number) => void;
|
|
railMounted?: boolean;
|
|
direction?: "ltr" | "rtl";
|
|
}) => {
|
|
const targetRef = useRef<HTMLDivElement>(null);
|
|
const [width, setWidth] = useState(SIDEBAR_DEFAULT_WIDTH);
|
|
|
|
return (
|
|
<DirectionProvider direction={direction}>
|
|
<div ref={targetRef} data-testid="shell" style={{ [SIDEBAR_WIDTH_VAR]: `${width}px` } as CSSProperties}>
|
|
{railMounted && (
|
|
<SidebarResizeHandle
|
|
width={width}
|
|
minWidth={SIDEBAR_MIN_WIDTH}
|
|
maxWidth={SIDEBAR_MAX_WIDTH}
|
|
onWidthChange={(next) => {
|
|
setWidth(next);
|
|
onWidthChange(next);
|
|
}}
|
|
targetRef={targetRef}
|
|
/>
|
|
)}
|
|
</div>
|
|
</DirectionProvider>
|
|
);
|
|
};
|
|
|
|
const shellWidthVar = () => screen.getByTestId("shell").style.getPropertyValue(SIDEBAR_WIDTH_VAR);
|
|
|
|
// Frames are queued rather than run inline: a synchronous requestAnimationFrame would
|
|
// misrepresent the platform, running the callback before the handle it returns is stored
|
|
// and so defeating the very coalescing these tests exercise.
|
|
let frameQueue: FrameRequestCallback[] = [];
|
|
|
|
const flushFrames = () => {
|
|
const queued = frameQueue;
|
|
frameQueue = [];
|
|
for (const callback of queued) callback(0);
|
|
};
|
|
|
|
const movePointer = (clientX: number) => {
|
|
fireEvent.pointerMove(screen.getByRole("separator"), { pointerId: 1, clientX });
|
|
flushFrames();
|
|
};
|
|
|
|
const drag = (from: number, to: number) => {
|
|
const handle = screen.getByRole("separator");
|
|
fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: from });
|
|
movePointer(to);
|
|
return handle;
|
|
};
|
|
|
|
describe("<SidebarResizeHandle />", () => {
|
|
beforeEach(() => {
|
|
// jsdom ships PointerEvent but not pointer capture. `vi.spyOn` cannot stand in for a method
|
|
// that does not exist at all, so these are plain assignments — undone in afterEach.
|
|
Element.prototype.setPointerCapture = vi.fn();
|
|
Element.prototype.hasPointerCapture = vi.fn(() => true);
|
|
Element.prototype.releasePointerCapture = vi.fn();
|
|
frameQueue = [];
|
|
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => frameQueue.push(callback));
|
|
vi.stubGlobal("cancelAnimationFrame", () => {
|
|
frameQueue = [];
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
// Prototype assignments outlive `restoreMocks`, so drop them rather than leaning on
|
|
// Vitest's per-file isolation to hide the leak.
|
|
for (const method of ["setPointerCapture", "hasPointerCapture", "releasePointerCapture"]) {
|
|
Reflect.deleteProperty(Element.prototype, method);
|
|
}
|
|
});
|
|
|
|
it("exposes splitter semantics with the current width and its bounds", () => {
|
|
render(<Harness />);
|
|
|
|
const handle = screen.getByRole("separator");
|
|
expect(handle).toHaveAttribute("aria-orientation", "vertical");
|
|
expect(handle).toHaveAttribute("aria-valuenow", String(SIDEBAR_DEFAULT_WIDTH));
|
|
expect(handle).toHaveAttribute("aria-valuemin", String(SIDEBAR_MIN_WIDTH));
|
|
expect(handle).toHaveAttribute("aria-valuemax", String(SIDEBAR_MAX_WIDTH));
|
|
expect(handle).toHaveAttribute("tabindex", "0");
|
|
});
|
|
|
|
it("nudges the width with the arrow keys", () => {
|
|
const onWidthChange = vi.fn();
|
|
render(<Harness onWidthChange={onWidthChange} />);
|
|
const handle = screen.getByRole("separator");
|
|
|
|
fireEvent.keyDown(handle, { key: "ArrowRight" });
|
|
expect(onWidthChange).toHaveBeenLastCalledWith(SIDEBAR_DEFAULT_WIDTH + 16);
|
|
|
|
fireEvent.keyDown(handle, { key: "ArrowLeft" });
|
|
expect(onWidthChange).toHaveBeenLastCalledWith(SIDEBAR_DEFAULT_WIDTH);
|
|
});
|
|
|
|
it("mirrors keyboard and pointer resizing for a right-hand RTL sidebar", () => {
|
|
const onWidthChange = vi.fn();
|
|
render(<Harness direction="rtl" onWidthChange={onWidthChange} />);
|
|
const handle = screen.getByRole("separator");
|
|
|
|
expect(handle).toHaveClass("-end-1");
|
|
fireEvent.keyDown(handle, { key: "ArrowLeft" });
|
|
expect(onWidthChange).toHaveBeenLastCalledWith(SIDEBAR_DEFAULT_WIDTH + 16);
|
|
fireEvent.keyDown(handle, { key: "ArrowRight" });
|
|
expect(onWidthChange).toHaveBeenLastCalledWith(SIDEBAR_DEFAULT_WIDTH);
|
|
|
|
drag(SIDEBAR_DEFAULT_WIDTH, SIDEBAR_DEFAULT_WIDTH - 40);
|
|
expect(shellWidthVar()).toBe(`${SIDEBAR_DEFAULT_WIDTH + 40}px`);
|
|
});
|
|
|
|
it("jumps to each bound with Home and End", () => {
|
|
const onWidthChange = vi.fn();
|
|
render(<Harness onWidthChange={onWidthChange} />);
|
|
const handle = screen.getByRole("separator");
|
|
|
|
fireEvent.keyDown(handle, { key: "Home" });
|
|
expect(onWidthChange).toHaveBeenLastCalledWith(SIDEBAR_MIN_WIDTH);
|
|
|
|
fireEvent.keyDown(handle, { key: "End" });
|
|
expect(onWidthChange).toHaveBeenLastCalledWith(SIDEBAR_MAX_WIDTH);
|
|
});
|
|
|
|
it("claims the arrow keys so they do not also scroll the page", () => {
|
|
render(<Harness />);
|
|
|
|
const prevented = !fireEvent.keyDown(screen.getByRole("separator"), { key: "ArrowRight" });
|
|
|
|
expect(prevented).toBe(true);
|
|
});
|
|
|
|
it("ignores keys it does not handle", () => {
|
|
const onWidthChange = vi.fn();
|
|
render(<Harness onWidthChange={onWidthChange} />);
|
|
|
|
const notPrevented = fireEvent.keyDown(screen.getByRole("separator"), { key: "Enter" });
|
|
|
|
expect(onWidthChange).not.toHaveBeenCalled();
|
|
expect(notPrevented).toBe(true);
|
|
});
|
|
|
|
it("resets to the default width on double click", () => {
|
|
const onWidthChange = vi.fn();
|
|
render(<Harness onWidthChange={onWidthChange} />);
|
|
const handle = screen.getByRole("separator");
|
|
|
|
fireEvent.keyDown(handle, { key: "End" });
|
|
fireEvent.doubleClick(handle);
|
|
|
|
expect(onWidthChange).toHaveBeenLastCalledWith(SIDEBAR_DEFAULT_WIDTH);
|
|
});
|
|
|
|
it("previews a drag through the custom property and commits once on release", () => {
|
|
const onWidthChange = vi.fn();
|
|
render(<Harness onWidthChange={onWidthChange} />);
|
|
|
|
const handle = drag(SIDEBAR_DEFAULT_WIDTH, SIDEBAR_DEFAULT_WIDTH + 60);
|
|
|
|
// Mid-drag the layout has already moved, but React has not been told.
|
|
expect(shellWidthVar()).toBe(`${SIDEBAR_DEFAULT_WIDTH + 60}px`);
|
|
expect(onWidthChange).not.toHaveBeenCalled();
|
|
|
|
fireEvent.pointerUp(handle, { pointerId: 1, clientX: SIDEBAR_DEFAULT_WIDTH + 60 });
|
|
|
|
expect(onWidthChange).toHaveBeenCalledTimes(1);
|
|
expect(onWidthChange).toHaveBeenCalledWith(SIDEBAR_DEFAULT_WIDTH + 60);
|
|
});
|
|
|
|
it("tracks the pointer delta rather than its absolute position", () => {
|
|
const onWidthChange = vi.fn();
|
|
render(<Harness onWidthChange={onWidthChange} />);
|
|
|
|
// Grabbing the strip off-centre must not snap the rail's edge onto the cursor.
|
|
const handle = drag(SIDEBAR_DEFAULT_WIDTH + 3, SIDEBAR_DEFAULT_WIDTH + 43);
|
|
fireEvent.pointerUp(handle, { pointerId: 1, clientX: SIDEBAR_DEFAULT_WIDTH + 43 });
|
|
|
|
expect(onWidthChange).toHaveBeenCalledWith(SIDEBAR_DEFAULT_WIDTH + 40);
|
|
});
|
|
|
|
it("clamps a drag to the bounds while previewing", () => {
|
|
render(<Harness />);
|
|
|
|
drag(SIDEBAR_DEFAULT_WIDTH, SIDEBAR_DEFAULT_WIDTH + 500);
|
|
expect(shellWidthVar()).toBe(`${SIDEBAR_MAX_WIDTH}px`);
|
|
|
|
movePointer(SIDEBAR_DEFAULT_WIDTH - 500);
|
|
expect(shellWidthVar()).toBe(`${SIDEBAR_MIN_WIDTH}px`);
|
|
});
|
|
|
|
it("coalesces several moves within one frame into a single write", () => {
|
|
render(<Harness />);
|
|
const handle = screen.getByRole("separator");
|
|
fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: SIDEBAR_DEFAULT_WIDTH });
|
|
|
|
// A real pointer emits many moves per frame; only the newest position should reach the DOM.
|
|
fireEvent.pointerMove(handle, { pointerId: 1, clientX: SIDEBAR_DEFAULT_WIDTH + 10 });
|
|
fireEvent.pointerMove(handle, { pointerId: 1, clientX: SIDEBAR_DEFAULT_WIDTH + 20 });
|
|
fireEvent.pointerMove(handle, { pointerId: 1, clientX: SIDEBAR_DEFAULT_WIDTH + 30 });
|
|
expect(frameQueue).toHaveLength(1);
|
|
|
|
flushFrames();
|
|
expect(shellWidthVar()).toBe(`${SIDEBAR_DEFAULT_WIDTH + 30}px`);
|
|
});
|
|
|
|
it("holds the resize cursor for the whole gesture and restores it after", () => {
|
|
render(<Harness />);
|
|
|
|
const handle = drag(SIDEBAR_DEFAULT_WIDTH, SIDEBAR_DEFAULT_WIDTH + 20);
|
|
expect(document.body.style.cursor).toBe("col-resize");
|
|
expect(document.body.style.userSelect).toBe("none");
|
|
|
|
fireEvent.pointerUp(handle, { pointerId: 1, clientX: SIDEBAR_DEFAULT_WIDTH + 20 });
|
|
expect(document.body.style.cursor).toBe("");
|
|
expect(document.body.style.userSelect).toBe("");
|
|
});
|
|
|
|
it("commits and cleans up when a drag is cancelled", () => {
|
|
const onWidthChange = vi.fn();
|
|
render(<Harness onWidthChange={onWidthChange} />);
|
|
|
|
const handle = drag(SIDEBAR_DEFAULT_WIDTH, SIDEBAR_DEFAULT_WIDTH + 24);
|
|
fireEvent.pointerCancel(handle, { pointerId: 1 });
|
|
|
|
expect(onWidthChange).toHaveBeenCalledWith(SIDEBAR_DEFAULT_WIDTH + 24);
|
|
expect(document.body.style.cursor).toBe("");
|
|
});
|
|
|
|
it("reverts the preview when a drag is interrupted by the rail unmounting", () => {
|
|
const onWidthChange = vi.fn();
|
|
const { rerender } = render(<Harness onWidthChange={onWidthChange} />);
|
|
|
|
drag(SIDEBAR_DEFAULT_WIDTH, SIDEBAR_DEFAULT_WIDTH + 60);
|
|
expect(shellWidthVar()).toBe(`${SIDEBAR_DEFAULT_WIDTH + 60}px`);
|
|
|
|
// The window crosses below the desktop breakpoint mid-drag, so no pointerup ever arrives.
|
|
rerender(<Harness onWidthChange={onWidthChange} railMounted={false} />);
|
|
|
|
// Nothing was committed, so the shell must not keep displaying the previewed width.
|
|
expect(onWidthChange).not.toHaveBeenCalled();
|
|
expect(shellWidthVar()).toBe(`${SIDEBAR_DEFAULT_WIDTH}px`);
|
|
});
|
|
|
|
it("leaves the committed width alone when the rail unmounts outside a drag", () => {
|
|
const onWidthChange = vi.fn();
|
|
const { rerender } = render(<Harness onWidthChange={onWidthChange} />);
|
|
|
|
const handle = drag(SIDEBAR_DEFAULT_WIDTH, SIDEBAR_DEFAULT_WIDTH + 60);
|
|
fireEvent.pointerUp(handle, { pointerId: 1, clientX: SIDEBAR_DEFAULT_WIDTH + 60 });
|
|
rerender(<Harness onWidthChange={onWidthChange} railMounted={false} />);
|
|
|
|
expect(shellWidthVar()).toBe(`${SIDEBAR_DEFAULT_WIDTH + 60}px`);
|
|
});
|
|
|
|
it("ignores pointer movement that did not start on the handle", () => {
|
|
const onWidthChange = vi.fn();
|
|
render(<Harness onWidthChange={onWidthChange} />);
|
|
const handle = screen.getByRole("separator");
|
|
|
|
fireEvent.pointerMove(handle, { pointerId: 1, clientX: 900 });
|
|
fireEvent.pointerUp(handle, { pointerId: 1, clientX: 900 });
|
|
|
|
expect(shellWidthVar()).toBe(`${SIDEBAR_DEFAULT_WIDTH}px`);
|
|
expect(onWidthChange).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("ignores non-primary buttons so a right click cannot start a drag", () => {
|
|
const onWidthChange = vi.fn();
|
|
render(<Harness onWidthChange={onWidthChange} />);
|
|
const handle = screen.getByRole("separator");
|
|
|
|
fireEvent.pointerDown(handle, { button: 2, pointerId: 1, clientX: SIDEBAR_DEFAULT_WIDTH });
|
|
fireEvent.pointerMove(handle, { pointerId: 1, clientX: SIDEBAR_DEFAULT_WIDTH + 60 });
|
|
|
|
expect(shellWidthVar()).toBe(`${SIDEBAR_DEFAULT_WIDTH}px`);
|
|
expect(onWidthChange).not.toHaveBeenCalled();
|
|
});
|
|
});
|