diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index 7b8b5ec..5c4129c 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -229,6 +229,7 @@ export const catalog: Catalog = { "Folder options": "Ordneroptionen", "New folder": "Neuer Ordner", "New subfolder": "Neuer Unterordner", + "Open subfolders of {name}": "Unterordner von {name} öffnen", "Delete folder": "Ordner löschen", "No matching folders": "Keine passenden Ordner", "No subfolders here.": "Hier gibt es keine Unterordner.", diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index 58b05cf..db0b982 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -221,6 +221,7 @@ export const catalog: Catalog = { "Folder options": "Opciones de la carpeta", "New folder": "Carpeta nueva", "New subfolder": "Subcarpeta nueva", + "Open subfolders of {name}": "Abrir las subcarpetas de {name}", "Delete folder": "Eliminar la carpeta", "No matching folders": "Ninguna carpeta coincide", "No subfolders here.": "Aquí no hay subcarpetas.", diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index 9b59de5..7f34517 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -226,6 +226,7 @@ export const catalog: Catalog = { "Folder options": "Options du dossier", "New folder": "Nouveau dossier", "New subfolder": "Nouveau sous-dossier", + "Open subfolders of {name}": "Ouvrir les sous-dossiers de {name}", "Delete folder": "Supprimer le dossier", "No matching folders": "Aucun dossier correspondant", "No subfolders here.": "Aucun sous-dossier ici.", diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index 99c004f..3f09ead 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -220,6 +220,7 @@ export const catalog: Catalog = { "Folder options": "フォルダーのオプション", "New folder": "新しいフォルダー", "New subfolder": "新しいサブフォルダー", + "Open subfolders of {name}": "{name} のサブフォルダーを開く", "Delete folder": "フォルダーを削除", "No matching folders": "該当するフォルダーはありません", "No subfolders here.": "サブフォルダーはありません。", diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts index 40a8eae..6478b80 100644 --- a/web/src/locales/nl.ts +++ b/web/src/locales/nl.ts @@ -217,6 +217,7 @@ export const catalog: Catalog = { "Folder options": "Mapopties", "New folder": "Nieuwe map", "New subfolder": "Nieuwe submap", + "Open subfolders of {name}": "Submappen van {name} openen", "Delete folder": "Map verwijderen", "No matching folders": "Geen overeenkomende mappen", "No subfolders here.": "Hier zijn geen submappen.", diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts index 5b192cb..b281538 100644 --- a/web/src/locales/pt-BR.ts +++ b/web/src/locales/pt-BR.ts @@ -224,6 +224,7 @@ export const catalog: Catalog = { "Folder options": "Opções da pasta", "New folder": "Nova pasta", "New subfolder": "Nova subpasta", + "Open subfolders of {name}": "Abrir as subpastas de {name}", "Delete folder": "Excluir a pasta", "No matching folders": "Nenhuma pasta corresponde", "No subfolders here.": "Não há subpastas aqui.", diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index 5e2cf3f..09daeb3 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -223,6 +223,7 @@ export const catalog: Catalog = { "Folder options": "Параметры папки", "New folder": "Новая папка", "New subfolder": "Новая вложенная папка", + "Open subfolders of {name}": "Открыть вложенные папки «{name}»", "Delete folder": "Удалить папку", "No matching folders": "Подходящих папок нет", "No subfolders here.": "Здесь нет вложенных папок.", diff --git a/web/src/locales/uk.ts b/web/src/locales/uk.ts index a5d77e8..a7d43c8 100644 --- a/web/src/locales/uk.ts +++ b/web/src/locales/uk.ts @@ -217,6 +217,7 @@ export const catalog: Catalog = { "Folder options": "Параметри теки", "New folder": "Нова тека", "New subfolder": "Нова вкладена тека", + "Open subfolders of {name}": "Відкрити вкладені теки «{name}»", "Delete folder": "Видалити теку", "No matching folders": "Відповідних тек немає", "No subfolders here.": "Тут немає вкладених тек.", diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts index f5de101..26c4e2b 100644 --- a/web/src/locales/zh-Hans.ts +++ b/web/src/locales/zh-Hans.ts @@ -219,6 +219,7 @@ export const catalog: Catalog = { "Folder options": "文件夹选项", "New folder": "新建文件夹", "New subfolder": "新建子文件夹", + "Open subfolders of {name}": "打开{name}的子文件夹", "Delete folder": "删除文件夹", "No matching folders": "没有匹配的文件夹", "No subfolders here.": "这里没有子文件夹。", diff --git a/web/src/styles/app.css b/web/src/styles/app.css index b81c8b3..1c08869 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -448,6 +448,18 @@ a.menu-item:hover { color: var(--fg); } .nav-item.folder-row.depth-4 { --folder-indent: 64px; } .nav-item.folder-row .nav-twisty { position: absolute; left: calc(8px + var(--folder-indent)); top: 50%; transform: translateY(-50%); margin: 0; } /* Labels sit in the same column: a 20px slot matching the folder icons. */ +/* ---- Drill-down (phones; see mail/MailboxTree.tsx) ---- */ +/* No indent to pay for, so the name gets the width the tree was spending on + depth. The twisty's 30px gutter goes too -- nothing is drawn in it here. */ +.nav-item.folder-row.has-drill { padding-left: 12px; } +.nav-item.folder-row .drill-into { width: 44px; height: 100%; border-radius: 0; margin-right: -12px; color: var(--fg-faint); } +.nav-item.folder-row .drill-into:hover { background: var(--bg-hover); color: var(--fg); } +/* The row it belongs to is a link and paints its own active state; a second + filled rectangle inside it reads as a separate selected thing. */ +.nav-item.folder-row.active .drill-into { color: inherit; } +.drill-back { width: 100%; border: 0; background: none; font: inherit; text-align: left; cursor: pointer; color: var(--fg-muted); padding-left: 8px; } +.drill-back:hover { background: var(--bg-hover); color: var(--fg); } +.drill-back svg { flex: 0 0 auto; } .nav-item.folder-row .nav-label-color { flex: 0 0 20px; width: 20px; height: 20px; border-radius: 0; display: inline-flex; align-items: center; justify-content: center; } .nav-item.folder-row .nav-label-color::before { content: ""; width: 11px; height: 11px; border-radius: 3px; background: var(--label-color, var(--accent)); } .collapsed .nav-item { justify-content: center; padding: 0; margin: 0 auto; width: 44px; border-radius: 999px; } @@ -927,7 +939,14 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); } * pointermove would arrive after the scroll had already started, and the list * would stutter on the way past. */ -.msg-row { touch-action: pan-y; -webkit-touch-callout: none; } +/* + * `pinch-zoom` is listed alongside `pan-y` rather than left implied: naming any + * value at all drops every gesture not named, so a bare `pan-y` also told the + * browser not to zoom here. Pinch worked on an open message and died on the + * list, which read as the zoom being broken at random. The swipe is unaffected + * -- horizontal is still not in the list, so it still arrives here. + */ +.msg-row { touch-action: pan-y pinch-zoom; -webkit-touch-callout: none; } .msg-row.swiping { z-index: 4; box-shadow: var(--shadow-2); } /* The row being dragged is the one thing on screen that must not lag. */ .msg-row.swiping, .mail-list-pull { will-change: transform; } @@ -1051,6 +1070,46 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); } .files-table .hide-mobile { display: none; } .event-popover { width: calc(100vw - 24px); } } +/* + * Touch targets. + * + * `pointer: coarse` rather than a width, for the reason ui/misc.tsx gives about + * useIsTouch: width decides the layout, the pointer decides what has to be + * aimed at. A tablet in landscape is a wide screen with fat targets. + * + * Everything here was under the 44px a fingertip actually covers -- the top bar + * icons at 36px (the search filter at 30), the folder twisty at 18, the row ⋮ + * at 24. Growing the boxes would reflow a top bar that has no room to give, so + * each control keeps the size it draws at and gains a transparent hit area + * centred on it. Rows grow for real, because a 44px hit area inside a 36px row + * would reach into the rows above and below and steal their taps. + */ +@media (pointer: coarse) { + .icon-btn, .nav-twisty { position: relative; } + /* `.drill-into` already draws at 44px and is excluded: expanding a control + that is big enough only lets it reach into its neighbour. */ + .icon-btn:not(.drill-into)::after, .nav-twisty[role="button"]::after { + content: ""; + position: absolute; + left: 50%; + top: 50%; + width: max(100%, 44px); + height: max(100%, 44px); + transform: translate(-50%, -50%); + } + /* + * The ⋮ is the exception, and narrower on purpose. It sits immediately left + * of the drill chevron, and at a full 44px wide the two hit areas overlapped + * by 4px -- so the right edge of "folder options" silently drilled instead. + * The row is 44px tall here, so it still gets the full height, which is the + * axis a thumb actually misses on. + */ + .nav-item .nav-more::after { width: max(100%, 36px); } + .sidebar .nav-item, .cal-list-item { height: 44px; } + /* The hamburger is the one control in the corner a thumb is worst at, so it + gets real distance from the bezel rather than just a wider hit area. */ + .topbar { padding-left: max(12px, env(safe-area-inset-left)); } +} @media (hover: none) { .msg-row .msg-check { opacity: 1; } .msg-row .msg-actions { display: none !important; } diff --git a/web/src/views/mail/MailboxTree.tsx b/web/src/views/mail/MailboxTree.tsx index 06d4fbb..f576a10 100644 --- a/web/src/views/mail/MailboxTree.tsx +++ b/web/src/views/mail/MailboxTree.tsx @@ -1,13 +1,13 @@ -import { useMemo, useState, type DragEvent, type ReactNode } from "react"; +import { useEffect, useMemo, useState, type DragEvent, type ReactNode } from "react"; import { Link, useLocation } from "wouter"; -import { AlertOctagon, Archive, ChevronDown, Clock, ChevronRight, File, Folder, FolderPlus, Inbox, Mail, MoreVertical, Palette, Send, Star, Tag, Trash2, Plus, Pencil, Eye, EyeOff, CheckCheck, Eraser, Share2, X } from "lucide-react"; +import { AlertOctagon, Archive, ChevronDown, ChevronLeft, Clock, ChevronRight, File, Folder, FolderPlus, Inbox, Mail, MoreVertical, Palette, Send, Star, Tag, Trash2, Plus, Pencil, Eye, EyeOff, CheckCheck, Eraser, Share2, X } from "lucide-react"; import { useMail } from "@/store/mail"; import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/emptyFolder"; import { isScheduledMailbox } from "@/store/scheduled"; import { useSettings } from "@/store/settings"; import type { Id, Mailbox } from "@/jmap/types"; import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover"; -import { CALENDAR_COLORS, useIsTouch } from "@/ui/misc"; +import { CALENDAR_COLORS, useIsMobile, useIsTouch } from "@/ui/misc"; import { confirmDialog, promptDialog } from "@/ui/dialog"; import { toast } from "@/ui/toast"; import { ShareDialog } from "../settings/ShareDialog"; @@ -78,7 +78,7 @@ export function MailboxTree() { setExpanded(next); saveJson("mbx-expanded", next); }; - const rows = useMemo(() => { + const { rows, childrenOf, subtreeUnread } = useMemo(() => { const all = Object.values(mailboxes).filter((m) => showHidden || m.isSubscribed || m.role === "inbox"); const byParent = new Map(); for (const m of all) { @@ -89,21 +89,44 @@ export function MailboxTree() { if ((a.role === "inbox") !== (b.role === "inbox")) return a.role === "inbox" ? -1 : 1; return a.name.localeCompare(b.name, undefined, { sensitivity: "base", numeric: true }); }; + for (const list of byParent.values()) list.sort(cmp); const out: Array<{ m: Mailbox; depth: number; hasChildren: boolean; open: boolean; hiddenUnread: number; childUnread: number }> = []; - const subtreeUnread = (id: Id): number => (byParent.get(id) ?? []).reduce((n, c) => n + c.unreadEmails + subtreeUnread(c.id), 0); + const unreadBelow = (id: Id): number => (byParent.get(id) ?? []).reduce((n, c) => n + c.unreadEmails + unreadBelow(c.id), 0); const walk = (parent: Id | null, depth: number) => { - for (const m of (byParent.get(parent) ?? []).sort(cmp)) { + for (const m of byParent.get(parent) ?? []) { const kids = byParent.get(m.id) ?? []; const open = Boolean(expanded[m.id]); - const childUnread = kids.length ? subtreeUnread(m.id) : 0; + const childUnread = kids.length ? unreadBelow(m.id) : 0; out.push({ m, depth, hasChildren: kids.length > 0, open, hiddenUnread: kids.length && !open ? childUnread : 0, childUnread }); if (kids.length && open) walk(m.id, depth + 1); } }; walk(null, 0); - return out; + return { rows: out, childrenOf: (id: Id | null) => byParent.get(id) ?? [], subtreeUnread: unreadBelow }; }, [mailboxes, showHidden, expanded]); + /* + * On a phone the tree is a drill-down instead: one level at a time, a back + * row above it, no indent. The tree earns its indent on a wide sidebar and + * cannot pay for it in a 300px drawer -- four levels down, the 16px steps and + * the 18px twisty left a folder 85px to print its name in, and the twisty had + * walked far enough right to be hard to hit at all. Width picks the mode, not + * the pointer: this is a layout that does not fit, not a target that is small. + */ + const isMobile = useIsMobile(); + const [drillId, setDrillId] = useState(null); + const drill = drillId && mailboxes[drillId] ? mailboxes[drillId] : null; + /* + * Follow the reader into whichever folder they opened, so the drawer comes + * back at the level they were last looking at rather than at the root they + * would have to walk down from again. + */ + useEffect(() => { + if (!isMobile || !currentId) return; + const m = mailboxes[currentId]; + if (m) setDrillId(m.parentId && mailboxes[m.parentId] ? m.parentId : null); + }, [isMobile, currentId, mailboxes]); + const createFolder = async (parentId: Id | null) => { const name = await promptDialog({ title: parentId ? t("New subfolder") : t("New folder"), placeholder: t("Folder name") }); if (!name?.trim()) return; @@ -144,12 +167,42 @@ export function MailboxTree() { if (id) void moveFolder(id, null); }} > - {draggingId && canDropOn(null) ? t("Drop here for the top level") : t("Folders")} - - {rows.map(({ m, depth, hasChildren, open, hiddenUnread, childUnread }) => ( + {drill && ( + <> + + {/* The folder you drilled into is still a folder you can open. */} + {}} + currentId={currentId} + onMenu={(mb, e) => { setMenuTarget(mb); menu.open(e); }} + dragging={false} + acceptsFolder={false} + onFolderDragStart={() => {}} + onFolderDragEnd={() => {}} + onFolderDrop={() => {}} + /> + + )} + {(isMobile ? childrenOf(drill?.id ?? null).map((m) => ({ m, depth: 0, hasChildren: childrenOf(m.id).length > 0, open: false, hiddenUnread: subtreeUnread(m.id), childUnread: subtreeUnread(m.id) })) : rows).map(({ m, depth, hasChildren, open, hiddenUnread, childUnread }) => ( toggle(m.id)} + onDrillIn={isMobile && hasChildren ? () => setDrillId(m.id) : undefined} currentId={currentId} onMenu={(mb, e) => { setMenuTarget(mb); menu.open(e); }} dragging={draggingId === m.id} @@ -169,7 +223,9 @@ export function MailboxTree() { onFolderDrop={(id) => void moveFolder(id, m.id)} /> ))} - {labelsSidebar && labels.length > 0 && ( + {/* Labels are a flat list that belongs to the mailbox, not to whichever + folder is on screen, so they stay at the top level of the drill. */} + {!drill && labelsSidebar && labels.length > 0 && ( <>
{t("Labels")} @@ -194,8 +250,10 @@ export function MailboxTree() { ); } -function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread, childUnread, onToggle, currentId, onMenu, dragging, acceptsFolder, onFolderDragStart, onFolderDragEnd, onFolderDrop }: { mailbox: Mailbox; label: string; depth: number; hasChildren: boolean; open: boolean; hiddenUnread: number; childUnread: number; onToggle: () => void; currentId?: string; onMenu: (m: Mailbox, e: { currentTarget: Element }) => void; dragging: boolean; acceptsFolder: boolean; onFolderDragStart: () => void; onFolderDragEnd: () => void; onFolderDrop: (id: Id) => void }) { +function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread, childUnread, onToggle, onDrillIn, currentId, onMenu, dragging, acceptsFolder, onFolderDragStart, onFolderDragEnd, onFolderDrop }: { mailbox: Mailbox; label: string; depth: number; hasChildren: boolean; open: boolean; hiddenUnread: number; childUnread: number; onToggle: () => void; onDrillIn?: () => void; currentId?: string; onMenu: (m: Mailbox, e: { currentTarget: Element }) => void; dragging: boolean; acceptsFolder: boolean; onFolderDragStart: () => void; onFolderDragEnd: () => void; onFolderDrop: (id: Id) => void }) { const [dropping, setDropping] = useState(false); + /** Expanding in place and drilling in are the same relationship; only one shows. */ + const twisty = hasChildren && !onDrillIn; // Scheduled counts like Drafts: everything in it is already read, so the // useful number is how many messages are waiting, not how many are unseen. const scheduled = isScheduledMailbox(m); @@ -260,7 +318,7 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread, return ( + {/* Drilling replaces expanding, so the twisty goes with it -- two + controls for one relationship, on opposite ends of the same row, is + worse than either alone. */} { e.preventDefault(); e.stopPropagation(); @@ -292,7 +353,7 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread, : undefined } > - {hasChildren ? open ? : : null} + {twisty ? open ? : : null} {icon} {label} @@ -309,6 +370,26 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread, > + {/* + Drilling in is a separate control from opening the folder, and sits at + the right edge where it is the same size and the same place on every + row -- unlike the twisty, which walks right with the indent and shrinks + the name as it goes. Tapping the row still opens the folder, which is + what a folder is for; this only changes what the list underneath shows. + */} + {onDrillIn && ( + + )} ); } diff --git a/web/src/views/mail/__tests__/mailbox-drilldown.test.tsx b/web/src/views/mail/__tests__/mailbox-drilldown.test.tsx new file mode 100644 index 0000000..1ac3ab7 --- /dev/null +++ b/web/src/views/mail/__tests__/mailbox-drilldown.test.tsx @@ -0,0 +1,125 @@ +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(".nav-item.folder-row")).find((r) => r.querySelector(".nav-label")?.textContent === name); + const drillInto = (name: string) => act(() => { rowFor(name)!.querySelector(".drill-into")!.click(); }); + const back = () => act(() => { document.querySelector(".drill-back")!.click(); }); + + const mount = () => { + host = document.createElement("div"); + document.body.appendChild(host); + root = createRoot(host); + act(() => root.render()); + }; + + 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"); + }); + + 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"); + }); +});