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).
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
// @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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user