Files
siftlode/frontend/src/components/ui/SidePanel.drawer.dom.test.tsx
T
peter ef343dbec0 feat(design): side panels open as a drawer on touch and below 768 (E28 S3f3)
C2 as decided: on a coarse pointer, or a window below 768, the panel's
tab (shown at every width, 44 wide on touch) opens the panel as a
start-edge drawer over the content, with a scrim. It is ui/Modal's new
drawer-start placement, so Esc, Back, the scrim, the focus trap and the
scroll lock are the dialog's own. A tap never peeks; pinning is a button
in the drawer's header, offered only at 1024 and up; a pin saved on a
desktop is left alone below that. A mouse on a wide window keeps
hover-to-peek and click-to-pin.

DESIGN: C2 as built, rows answer to their own width, phone-only layout
deferred to its own epic (principle 2).
2026-09-27 04:38:42 +02:00

145 lines
5.5 KiB
TypeScript

// @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<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (k: string, o?: Record<string, unknown>) => (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(
<SidePanel
title="Filters"
icon={<span />}
collapsed={collapsed}
onToggleCollapse={onToggleCollapse}
>
<p>panel body</p>
</SidePanel>,
);
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");
});
});