// @vitest-environment jsdom // // C2 (DESIGN "Touch"): on touch, and below 768, the side panel is a DRAWER — the tab opens it over the // content (ui/Modal's start-edge drawer: Esc, Back, the scrim), it never peeks on a tap's emulated // hover, and pinning is a button in its header offered only ≥ 1024. A mouse on a wide window keeps // hover-to-peek and click-to-pin. jsdom has no matchMedia: the stub answers the two queries SidePanel // asks from three switches, and can flip them live (a tablet turning). import { describe, it, expect, afterEach, vi } from "vitest"; import { act, render, screen, fireEvent, cleanup } from "@testing-library/react"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ t: (k: string, o?: Record) => (o?.title ? `${k}:${String(o.title)}` : k), }), }; }); import SidePanel from "./SidePanel"; type Env = { coarse: boolean; narrow: boolean; wide: boolean }; let env: Env = { coarse: false, narrow: false, wide: true }; const listeners = new Set<() => void>(); function stubMedia(next: Env) { env = next; window.matchMedia = ((query: string) => ({ get matches() { // A comma list is an OR: each part answers from its own switch. return query .split(",") .some((q) => q.includes("pointer: coarse") ? env.coarse : q.includes("max-width: 767.98px") ? env.narrow : q.includes("min-width: 1024px") ? env.wide : false, ); }, media: query, addEventListener: (_: string, l: () => void) => listeners.add(l), removeEventListener: (_: string, l: () => void) => listeners.delete(l), })) as unknown as typeof window.matchMedia; } function turn(next: Env) { act(() => { env = next; for (const l of [...listeners]) l(); }); } afterEach(() => { cleanup(); listeners.clear(); // @ts-expect-error — jsdom ships none; put it back the way it was delete window.matchMedia; }); function mount(collapsed = true) { const onToggleCollapse = vi.fn(); render( } collapsed={collapsed} onToggleCollapse={onToggleCollapse} >

panel body

, ); return { onToggleCollapse }; } describe("SidePanel · a mouse on a wide window", () => { it("pins from the tab, as before", () => { stubMedia({ coarse: false, narrow: false, wide: true }); const { onToggleCollapse } = mount(); fireEvent.click(screen.getByRole("button", { name: "sidebar.pin" })); expect(onToggleCollapse).toHaveBeenCalledOnce(); expect(screen.queryByRole("dialog")).toBeNull(); }); }); describe("SidePanel · on touch", () => { it("opens a drawer from the tab instead of pinning, and Esc closes it", () => { stubMedia({ coarse: true, narrow: false, wide: false }); const { onToggleCollapse } = mount(); expect(screen.queryByText("panel body")).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "sidebar.openPanel:Filters" })); const dialog = screen.getByRole("dialog"); expect(dialog.textContent).toContain("panel body"); expect(onToggleCollapse).not.toHaveBeenCalled(); // Below 1024 a pinned panel would leave the content no room: no pin in the drawer. expect(screen.queryByRole("button", { name: "sidebar.pin" })).toBeNull(); fireEvent.keyDown(window, { key: "Escape" }); expect(screen.queryByRole("dialog")).toBeNull(); }); it("never peeks on the emulated hover a tap fires", () => { stubMedia({ coarse: true, narrow: false, wide: false }); mount(); fireEvent.mouseEnter(screen.getByRole("button", { name: "sidebar.openPanel:Filters" })); expect(screen.queryByText("panel body")).toBeNull(); }); it("offers the pin in the drawer's header at 1024 and up, and pinning closes the drawer", () => { stubMedia({ coarse: true, narrow: false, wide: true }); const { onToggleCollapse } = mount(); fireEvent.click(screen.getByRole("button", { name: "sidebar.openPanel:Filters" })); fireEvent.click(screen.getByRole("button", { name: "sidebar.pin" })); expect(onToggleCollapse).toHaveBeenCalledOnce(); expect(screen.queryByRole("dialog")).toBeNull(); }); it("leaves a pin saved on a desktop alone below 1024: a tab, not a pinned card", () => { stubMedia({ coarse: true, narrow: false, wide: false }); mount(false); expect(screen.queryByText("panel body")).toBeNull(); expect(screen.getByRole("button", { name: "sidebar.openPanel:Filters" })).toBeTruthy(); }); it("closes for good when the tablet turns to a width where the panel is a card again", () => { stubMedia({ coarse: false, narrow: true, wide: false }); mount(); fireEvent.click(screen.getByRole("button", { name: "sidebar.openPanel:Filters" })); expect(screen.getByRole("dialog")).toBeTruthy(); turn({ coarse: false, narrow: false, wide: true }); expect(screen.queryByRole("dialog")).toBeNull(); turn({ coarse: false, narrow: true, wide: false }); expect(screen.queryByRole("dialog")).toBeNull(); }); }); describe("SidePanel · a narrow window with a mouse", () => { it("is a drawer too (split screen, a small window)", () => { stubMedia({ coarse: false, narrow: true, wide: false }); mount(); fireEvent.click(screen.getByRole("button", { name: "sidebar.openPanel:Filters" })); expect(screen.getByRole("dialog").textContent).toContain("panel body"); }); });