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).
145 lines
5.5 KiB
TypeScript
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");
|
|
});
|
|
});
|