Files
PaperClipAI/ui/src/components/SidebarShell.test.tsx
T
DottaandClaude Opus 4.8 47bebe8bb8 feat(ui): sidebar rail + peek overlay shell (PAP-10644)
Task 2 of the collapsible sidebar plan (PAP-10638, plan §4/§5/§6).

Replace ResizableSidebarPane with a SidebarShell that renders two layers:
- an in-flow spacer reserving `reservedWidth` (rail when collapsed, expanded
  width otherwise) so content to the right reflows only on pin, never on peek;
- an absolutely-positioned panel (`inset-y-0 left-0`) of `panelWidth` (rail at
  rest, expanded while peeking) that overlays content with raised z-index +
  shadow-lg + bg-background + 1px border when wider than the spacer.

Unified width function (one code path for every pinned state):
  reservedWidth = collapsed ? SIDEBAR_RAIL_WIDTH : expandedWidth
  panelWidth    = (collapsed && !peeking) ? SIDEBAR_RAIL_WIDTH : expandedWidth
  overlay        when panelWidth > reservedWidth

SIDEBAR_RAIL_WIDTH=56 (ends just past the icon). The drag resize handle is
disabled while collapsed; only the expanded width is persisted. The existing
`transition-[width] duration-100 ease-out` is reused; `motion-reduce` drops it.

Wire Layout: the shell wraps the whole sidebar column (nav + account row) so
the entire sidebar peeks as one overlay. Peek triggers live in Layout —
onMouseEnter (50ms open), onMouseLeave (120ms debounce close), focus-within
(immediate open), close on route change and Escape; pointer is never trapped.
Context already gates effective `peeking` to desktop + collapsed + hover-capable
pointers. Mobile drawer path untouched.

Icon-alignment guarantee: item padding/gap/icon markup are unchanged; collapse
is a pure pane-width animation + overflow-hidden label clipping. Verified in a
headless browser that the first nav icon's offset is pixel-identical (24,16)
across expanded/collapsed/peek, that peek does not reflow content (spacer stays
56px) while the panel overlaps content with a shadow.

Tests cover width split, rail collapse, overlay-on-peek styling, handle hidden
when collapsed, and the reduced-motion path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 16:46:02 +00:00

183 lines
5.6 KiB
TypeScript

// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SidebarShell, SIDEBAR_RAIL_WIDTH } from "./SidebarShell";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function pointerEvent(type: string, clientX: number) {
const event = new MouseEvent(type, { bubbles: true, clientX });
Object.defineProperty(event, "pointerId", { value: 1 });
return event;
}
describe("SidebarShell", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
window.localStorage.clear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
window.localStorage.clear();
});
// The in-flow spacer that reserves layout width.
function spacer() {
return container.firstElementChild as HTMLDivElement;
}
// The absolutely-positioned overlay panel that holds the sidebar content.
function panel() {
return spacer().firstElementChild as HTMLDivElement;
}
function handle() {
return container.querySelector('[role="separator"]') as HTMLDivElement | null;
}
it("uses a persisted width when expanded", () => {
window.localStorage.setItem("test.sidebar.width", "320");
act(() => {
root.render(
<SidebarShell open resizable storageKey="test.sidebar.width">
<div>Sidebar</div>
</SidebarShell>,
);
});
// Both the reserved spacer and the panel match the expanded width; no overlay.
expect(spacer().style.width).toBe("320px");
expect(panel().style.width).toBe("320px");
expect(panel().getAttribute("data-sidebar-overlay")).toBeNull();
expect(handle()?.getAttribute("aria-valuenow")).toBe("320");
});
it("resizes by dragging and persists the new width", () => {
act(() => {
root.render(
<SidebarShell open resizable storageKey="test.sidebar.width">
<div>Sidebar</div>
</SidebarShell>,
);
});
const separator = handle();
expect(separator).not.toBeNull();
separator!.setPointerCapture = vi.fn();
act(() => {
separator!.dispatchEvent(pointerEvent("pointerdown", 240));
separator!.dispatchEvent(pointerEvent("pointermove", 320));
separator!.dispatchEvent(pointerEvent("pointerup", 320));
});
expect(panel().style.width).toBe("320px");
expect(window.localStorage.getItem("test.sidebar.width")).toBe("320");
});
it("supports keyboard resizing and clamps to the configured bounds", () => {
act(() => {
root.render(
<SidebarShell open resizable storageKey="test.sidebar.width">
<div>Sidebar</div>
</SidebarShell>,
);
});
const separator = handle();
act(() => {
separator?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
});
expect(panel().style.width).toBe("256px");
expect(window.localStorage.getItem("test.sidebar.width")).toBe("256");
act(() => {
separator?.dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true }));
});
expect(panel().style.width).toBe("208px");
act(() => {
separator?.dispatchEvent(new KeyboardEvent("keydown", { key: "End", bubbles: true }));
});
expect(panel().style.width).toBe("420px");
});
it("can render without a resize handle", () => {
act(() => {
root.render(
<SidebarShell open resizable={false}>
<div>Sidebar</div>
</SidebarShell>,
);
});
expect(handle()).toBeNull();
expect(panel().style.width).toBe("240px");
});
it("reserves only the rail width when collapsed and hides the resize handle", () => {
window.localStorage.setItem("test.sidebar.width", "320");
act(() => {
root.render(
<SidebarShell open collapsed resizable storageKey="test.sidebar.width">
<div>Sidebar</div>
</SidebarShell>,
);
});
// Reserved spacer and panel both collapse to the rail; content never reflows
// beyond the rail, and the rail is not user-resizable.
expect(spacer().style.width).toBe(`${SIDEBAR_RAIL_WIDTH}px`);
expect(panel().style.width).toBe(`${SIDEBAR_RAIL_WIDTH}px`);
expect(panel().getAttribute("data-sidebar-overlay")).toBeNull();
expect(handle()).toBeNull();
});
it("overlays content while peeking without expanding the reserved spacer", () => {
window.localStorage.setItem("test.sidebar.width", "300");
act(() => {
root.render(
<SidebarShell open collapsed peeking storageKey="test.sidebar.width">
<div>Sidebar</div>
</SidebarShell>,
);
});
// The reserved spacer stays at the rail (no page reflow) while the panel
// grows to the expanded width and gets overlay styling.
expect(spacer().style.width).toBe(`${SIDEBAR_RAIL_WIDTH}px`);
expect(panel().style.width).toBe("300px");
expect(panel().getAttribute("data-sidebar-overlay")).toBe("");
expect(panel().className).toContain("shadow-lg");
expect(panel().className).toContain("z-30");
});
it("drops the width transition for reduced-motion users", () => {
act(() => {
root.render(
<SidebarShell open>
<div>Sidebar</div>
</SidebarShell>,
);
});
expect(panel().className).toContain("transition-[width]");
expect(panel().className).toContain("motion-reduce:transition-none");
});
});