Merge pull request #165 from Coffey-Labs/mobile-touch-targets-drilldown

Folders one level at a time on a phone, and targets a thumb can hit
This commit is contained in:
Coffey Labs
2026-08-31 21:51:15 -07:00
committed by GitHub
12 changed files with 295 additions and 21 deletions
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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.": "サブフォルダーはありません。",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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.": "Здесь нет вложенных папок.",
+1
View File
@@ -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.": "Тут немає вкладених тек.",
+1
View File
@@ -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.": "这里没有子文件夹。",
+60 -1
View File
@@ -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; }
+101 -20
View File
@@ -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<Id | null, Mailbox[]>();
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<Id | null>(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);
}}
>
<span>{draggingId && canDropOn(null) ? t("Drop here for the top level") : t("Folders")}</span>
<button className="icon-btn" title={t("New folder")} aria-label={t("New folder")} onClick={() => void createFolder(null)}>
<span>{draggingId && canDropOn(null) ? t("Drop here for the top level") : drill ? mailboxDisplayName(drill) : t("Folders")}</span>
{/* Drilled in, the + makes a subfolder of the folder on screen --
which is the one place in the app where "new folder here" has an
unambiguous here. */}
<button className="icon-btn" title={drill ? t("New subfolder") : t("New folder")} aria-label={drill ? t("New subfolder") : t("New folder")} onClick={() => void createFolder(drill?.id ?? null)}>
<Plus size={16} />
</button>
</div>
{rows.map(({ m, depth, hasChildren, open, hiddenUnread, childUnread }) => (
{drill && (
<>
<button className="nav-item drill-back" onClick={() => setDrillId(drill.parentId && mailboxes[drill.parentId] ? drill.parentId : null)}>
<ChevronLeft size={20} />
<span className="nav-label">{drill.parentId && mailboxes[drill.parentId] ? mailboxDisplayName(mailboxes[drill.parentId]) : t("Folders")}</span>
</button>
{/* The folder you drilled into is still a folder you can open. */}
<FolderRow
key={drill.id}
mailbox={drill}
label={mailboxDisplayName(drill)}
depth={0}
hasChildren={false}
open={false}
hiddenUnread={0}
childUnread={subtreeUnread(drill.id)}
onToggle={() => {}}
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 }) => (
<FolderRow
key={m.id}
mailbox={m}
@@ -160,6 +213,7 @@ export function MailboxTree() {
hiddenUnread={hiddenUnread}
childUnread={childUnread}
onToggle={() => 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 && (
<>
<div className="nav-section">
<span>{t("Labels")}</span>
@@ -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 (
<Link
href={`/mail/${m.id}`}
className={`nav-item folder-row depth-${Math.min(depth, 4)} ${currentId === m.id ? "active" : ""} ${unread ? "unread" : ""} ${dropping ? "drop-target" : ""} ${dragging ? "dragging" : ""}`}
className={`nav-item folder-row depth-${Math.min(depth, 4)} ${onDrillIn ? "has-drill" : ""} ${currentId === m.id ? "active" : ""} ${unread ? "unread" : ""} ${dropping ? "drop-target" : ""} ${dragging ? "dragging" : ""}`}
title={label}
{...press}
// Dragging a folder is a mouse gesture; on a touchscreen the browser
@@ -276,14 +334,17 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
onMenu(m, { currentTarget: e.currentTarget });
}}
>
{/* 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. */}
<span
className="nav-twisty"
role={hasChildren ? "button" : undefined}
aria-label={hasChildren ? (open ? "Collapse" : "Expand") : undefined}
aria-expanded={hasChildren ? open : undefined}
aria-hidden={hasChildren ? undefined : true}
role={twisty ? "button" : undefined}
aria-label={twisty ? (open ? "Collapse" : "Expand") : undefined}
aria-expanded={twisty ? open : undefined}
aria-hidden={twisty ? undefined : true}
onClick={
hasChildren
twisty
? (e) => {
e.preventDefault();
e.stopPropagation();
@@ -292,7 +353,7 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
: undefined
}
>
{hasChildren ? open ? <ChevronDown size={14} /> : <ChevronRight size={14} /> : null}
{twisty ? open ? <ChevronDown size={14} /> : <ChevronRight size={14} /> : null}
</span>
<span className="folder-icon" style={tint ? ({ "--folder-color": tint } as React.CSSProperties) : undefined}>{icon}</span>
<span className="nav-label">{label}</span>
@@ -309,6 +370,26 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
>
<MoreVertical size={16} />
</button>
{/*
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 && (
<button
className="icon-btn drill-into"
aria-label={t("Open subfolders of {name}", { name: label })}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDrillIn();
}}
>
<ChevronRight size={20} />
</button>
)}
</Link>
);
}
@@ -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<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");
});
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");
});
});