/** * @file Layout.test.tsx * @description Responsive shell: with no stored preference the sidebar starts * collapsed below `lg`, and below `md` it becomes an off-canvas drawer opened * from a menu button — a modal dialog that Escape and the backdrop close, * handing focus back to the button. The expanded sidebar's edge resizes it * within its bounds and remembers the width. * @author Nguyễn Ngọc Trí Vĩ */ import { describe, it, expect, vi, afterEach } from "vitest"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; vi.mock("../Sagi/Sagi", () => ({ Sagi: () => null })); vi.mock("../UpdateNotifier", () => ({ UpdateNotifier: () => null })); vi.mock("../StatusBar", () => ({ StatusBar: () => null })); vi.mock("../WorkspaceTree", () => ({ WorkspaceTree: () => null })); import { Layout, DRAWER_QUERY, SIDEBAR_MIN, SIDEBAR_MAX } from "../Layout"; import { loadCollapsed, SIDEBAR_STORAGE_KEY } from "../Sidebar"; function viewport(width: number) { vi.stubGlobal("matchMedia", (q: string) => { const max = Number(/max-width:\s*([\d.]+)px/.exec(q)?.[1] ?? Infinity); return { matches: width <= max, media: q, addEventListener: () => {}, removeEventListener: () => {}, }; }); } afterEach(() => { vi.unstubAllGlobals(); localStorage.removeItem(SIDEBAR_STORAGE_KEY); localStorage.removeItem("agenthub.sidebar.width"); }); describe("loadCollapsed", () => { it("starts collapsed below lg when nothing is stored", () => { viewport(800); expect(loadCollapsed()).toBe(true); viewport(1440); expect(loadCollapsed()).toBe(false); }); it("a stored choice wins over the viewport", () => { viewport(800); localStorage.setItem(SIDEBAR_STORAGE_KEY, "false"); expect(loadCollapsed()).toBe(false); }); }); describe("Layout drawer", () => { it("below md: the sidebar is off-canvas until the menu button opens it", async () => { viewport(600); expect(window.matchMedia(DRAWER_QUERY).matches).toBe(true); render( ); const aside = screen.getByTestId("sidebar"); expect(aside.className).toContain("invisible"); expect(aside).not.toHaveAttribute("role", "dialog"); expect(screen.getByRole("main").style.marginLeft).toBe("0rem"); const open = screen.getByRole("button", { name: "Open menu" }); open.focus(); fireEvent.click(open); expect(aside).toHaveAttribute("role", "dialog"); expect(aside).toHaveAttribute("aria-modal", "true"); expect(aside.className).not.toContain("invisible"); await waitFor(() => expect(aside.contains(document.activeElement)).toBe(true)); fireEvent.keyDown(document, { key: "Escape" }); expect(aside.className).toContain("invisible"); expect(open).toHaveFocus(); fireEvent.click(open); fireEvent.click(screen.getByTestId("sidebar-backdrop")); expect(aside.className).toContain("invisible"); }); it("at md and up: no menu button, the sidebar is a landmark beside the content", () => { viewport(800); render( ); expect(screen.queryByRole("button", { name: "Open menu" })).toBeNull(); expect(screen.getByRole("complementary", { name: "Sidebar" })).toBeInTheDocument(); // Collapsed rail by default below lg, so the content is not covered. expect(screen.getByRole("main").style.marginLeft).toBe("4.25rem"); }); }); describe("Layout sidebar width", () => { it("the edge drags within bounds, is remembered, and a double-click resets it", () => { viewport(1440); vi.stubGlobal("innerWidth", 2560); render( ); const edge = screen.getByTestId("sidebar-resize"); const main = screen.getByRole("main"); expect(main.style.marginLeft).toBe(`${SIDEBAR_MIN}px`); fireEvent.pointerDown(edge, { pointerId: 1 }); fireEvent.pointerMove(edge, { clientX: 360 }); fireEvent.pointerUp(edge, { clientX: 360 }); expect(main.style.marginLeft).toBe("360px"); expect(screen.getByTestId("sidebar").style.width).toBe("360px"); expect(localStorage.getItem("agenthub.sidebar.width")).toBe("360"); fireEvent.pointerDown(edge, { pointerId: 1 }); fireEvent.pointerUp(edge, { clientX: 2000 }); expect(main.style.marginLeft).toBe(`${SIDEBAR_MAX}px`); fireEvent.keyDown(edge, { key: "ArrowLeft" }); expect(main.style.marginLeft).toBe(`${SIDEBAR_MAX - 16}px`); fireEvent.doubleClick(edge); expect(main.style.marginLeft).toBe(`${SIDEBAR_MIN}px`); }); it("never wider than 40% of the window", () => { viewport(1100); vi.stubGlobal("innerWidth", 1100); localStorage.setItem("agenthub.sidebar.width", "480"); render( ); expect(screen.getByRole("main").style.marginLeft).toBe("440px"); }); });