Two things the drill-down got wrong, both found on a phone-width window. The folders did not line up. The rule that drops the twisty's 30px gutter was hung on the rows offering a drill, so only folders with children lost it -- they sat 18px left of every folder without any, and the column of icons came apart. Whether a folder has children is not a reason to hang it somewhere else. The class moves to the list, which is what the indent is a property of; icons now share one column and labels another, at every level and on the back row too. There was no obvious way back out of the drawer. It covers the top bar -- it is taller than it -- so the hamburger that opened it is underneath, and pressing the same place again did nothing at all, since that handler only ever set the drawer open. The dimmed strip beside the drawer was the only exit, and nothing says so. There is now a close where the hamburger was, moved by the same rule so it lands on exactly the same pixels, and the hamburger itself toggles rather than only opening. Escape closes it too, for a tablet with a keyboard. Raising the top bar over the drawer instead would have been the smaller change and is not available: the drawer is at 950 and a full-screen composer at 800, so a top bar above the first is also above the second.
154 lines
6.9 KiB
TypeScript
154 lines
6.9 KiB
TypeScript
import { act } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { MailboxTree } from "../MailboxTree";
|
|
import { useMail } from "@/store/mail";
|
|
import { useSettings } from "@/store/settings";
|
|
import type { Mailbox, MailboxRole } from "@/jmap/types";
|
|
|
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
/**
|
|
* jsdom has no matchMedia, and the whole question here is which side of the
|
|
* 768px breakpoint we are on — so it is stubbed rather than skipped, and each
|
|
* test says which width it is standing at.
|
|
*/
|
|
function setWidth(px: number) {
|
|
window.matchMedia = ((q: string) => ({
|
|
matches: /max-width:\s*(\d+)px/.test(q) ? px <= Number(RegExp.$1) : false,
|
|
media: q,
|
|
addEventListener() {},
|
|
removeEventListener() {},
|
|
})) as unknown as typeof window.matchMedia;
|
|
}
|
|
|
|
const rights = { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true };
|
|
const box = (id: string, name: string, parentId: string | null, unread = 0, role: MailboxRole = null): Mailbox => ({
|
|
id, name, parentId, role, sortOrder: 0, totalEmails: unread, unreadEmails: unread, totalThreads: unread, unreadThreads: unread, myRights: rights, isSubscribed: true,
|
|
});
|
|
|
|
/*
|
|
* Inbox, then Work > Clients > Acme. Three levels is the shape the flat tree
|
|
* handled badly in a 300px drawer, and the one the drill has to walk.
|
|
*/
|
|
const MAILBOXES = {
|
|
inbox: box("inbox", "Inbox", null, 2, "inbox"),
|
|
work: box("work", "Work", null, 1),
|
|
clients: box("clients", "Clients", "work", 3),
|
|
acme: box("acme", "Acme Corp", "clients", 4),
|
|
sent: box("sent", "Sent", null, 0, "sent"),
|
|
};
|
|
|
|
describe("folder drill-down", () => {
|
|
let host: HTMLDivElement;
|
|
let root: Root;
|
|
const rows = () => Array.from(document.querySelectorAll(".nav-item.folder-row")).map((r) => r.querySelector(".nav-label")?.textContent);
|
|
const rowFor = (name: string) => Array.from(document.querySelectorAll<HTMLElement>(".nav-item.folder-row")).find((r) => r.querySelector(".nav-label")?.textContent === name);
|
|
const drillInto = (name: string) => act(() => { rowFor(name)!.querySelector<HTMLElement>(".drill-into")!.click(); });
|
|
const back = () => act(() => { document.querySelector<HTMLElement>(".drill-back")!.click(); });
|
|
|
|
const mount = () => {
|
|
host = document.createElement("div");
|
|
document.body.appendChild(host);
|
|
root = createRoot(host);
|
|
act(() => root.render(<MailboxTree />));
|
|
};
|
|
|
|
beforeEach(() => {
|
|
window.history.replaceState({}, "", "/mail/inbox");
|
|
useMail.setState({ mailboxes: MAILBOXES, mailboxesLoaded: true });
|
|
useSettings.setState((s) => ({ settings: { ...s.settings, showHiddenFolders: false, labelsSidebar: false } }));
|
|
});
|
|
afterEach(() => { act(() => root.unmount()); host.remove(); });
|
|
|
|
it("shows the whole tree on a wide screen, and no drill controls", () => {
|
|
setWidth(1280);
|
|
mount();
|
|
expect(rows()).toEqual(["Inbox", "Sent", "Work"]);
|
|
expect(document.querySelector(".drill-into")).toBeNull();
|
|
expect(document.querySelector(".drill-back")).toBeNull();
|
|
// The twisty is what expands a folder in place, and it is still there.
|
|
expect(rowFor("Work")!.querySelector('.nav-twisty[role="button"]')).not.toBeNull();
|
|
});
|
|
|
|
it("shows one level at a time on a phone, deepest folders included", () => {
|
|
setWidth(390);
|
|
mount();
|
|
expect(rows()).toEqual(["Inbox", "Sent", "Work"]);
|
|
// Only a folder with children offers the drill, and it replaces the twisty.
|
|
expect(rowFor("Work")!.querySelector(".drill-into")).not.toBeNull();
|
|
expect(rowFor("Work")!.querySelector('.nav-twisty[role="button"]')).toBeNull();
|
|
expect(rowFor("Inbox")!.querySelector(".drill-into")).toBeNull();
|
|
|
|
drillInto("Work");
|
|
// The folder drilled into is listed with its children, because it is still
|
|
// a folder you can open — going back out to reach it would be absurd.
|
|
expect(rows()).toEqual(["Work", "Clients"]);
|
|
expect(document.querySelector(".drill-back")!.textContent).toContain("Folders");
|
|
|
|
drillInto("Clients");
|
|
expect(rows()).toEqual(["Clients", "Acme Corp"]);
|
|
expect(document.querySelector(".drill-back")!.textContent).toContain("Work");
|
|
});
|
|
|
|
/*
|
|
* jsdom has no layout to measure, so this asserts the mechanism the
|
|
* alignment hangs off instead: the indent is dropped for the whole list, by
|
|
* a class on the nav. The first cut put it on the rows offering a drill,
|
|
* which meant only folders with children lost the twisty's 30px gutter and
|
|
* they hung 18px left of every folder without any.
|
|
*/
|
|
it("hangs every folder off the same edge, children or not", () => {
|
|
setWidth(390);
|
|
mount();
|
|
expect(document.querySelector("nav")!.className).toContain("folder-drill");
|
|
const depths = Array.from(document.querySelectorAll(".nav-item.folder-row")).map((r) => r.className.match(/depth-\d/)?.[0]);
|
|
expect(depths).toEqual(["depth-0", "depth-0", "depth-0"]);
|
|
|
|
drillInto("Work");
|
|
// Work has a child and Clients does not; neither may be indented for it.
|
|
expect(Array.from(document.querySelectorAll(".nav-item.folder-row")).map((r) => r.className.match(/depth-\d/)?.[0])).toEqual(["depth-0", "depth-0"]);
|
|
expect(document.querySelector(".nav-item.folder-row.has-drill")).toBeNull();
|
|
});
|
|
|
|
it("keeps the indent on a wide screen, where the tree still needs it", () => {
|
|
setWidth(1280);
|
|
mount();
|
|
expect(document.querySelector("nav")!.className).not.toContain("folder-drill");
|
|
act(() => { rowFor("Work")!.querySelector<HTMLElement>(".nav-twisty")!.click(); });
|
|
expect(rowFor("Clients")!.className).toContain("depth-1");
|
|
});
|
|
|
|
it("walks back out one level per tap", () => {
|
|
setWidth(390);
|
|
mount();
|
|
drillInto("Work");
|
|
drillInto("Clients");
|
|
back();
|
|
expect(rows()).toEqual(["Work", "Clients"]);
|
|
back();
|
|
expect(rows()).toEqual(["Inbox", "Sent", "Work"]);
|
|
expect(document.querySelector(".drill-back")).toBeNull();
|
|
});
|
|
|
|
it("counts the unread hiding below a folder you have not drilled into", () => {
|
|
setWidth(390);
|
|
mount();
|
|
// Work: 1 of its own, plus Clients' 3 and Acme's 4 out of sight.
|
|
expect(rowFor("Work")!.querySelector(".nav-count")!.textContent).toBe("8");
|
|
drillInto("Work");
|
|
// Drilled in, Work speaks only for itself and Clients carries its own subtree.
|
|
expect(rowFor("Work")!.querySelector(".nav-count")!.textContent).toBe("1");
|
|
expect(rowFor("Clients")!.querySelector(".nav-count")!.textContent).toBe("7");
|
|
});
|
|
|
|
it("opens at the level of the folder being read, not back at the root", () => {
|
|
setWidth(390);
|
|
window.history.replaceState({}, "", "/mail/acme");
|
|
mount();
|
|
// Reading Acme Corp, the drawer comes back inside Clients where it lives.
|
|
expect(rows()).toEqual(["Clients", "Acme Corp"]);
|
|
expect(rowFor("Acme Corp")!.className).toContain("active");
|
|
});
|
|
});
|