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:
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.": "サブフォルダーはありません。",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.": "Здесь нет вложенных папок.",
|
||||
|
||||
@@ -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.": "Тут немає вкладених тек.",
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user