Folders one level at a time on a phone, and targets a thumb can hit
Three things the mobile interface got wrong, all of them measurable. The folder tree spent its width on depth. Four levels down, the 16px indent steps and the 18px twisty left a folder 85px of a 300px drawer to print its name in, and the twisty had walked far enough right that hitting it was luck -- a miss landed on the row, which is a link, so the wrong tap also cost a navigation. Under 768px the tree is now a drill-down: one level at a time, no indent, a back row above it, and a chevron at the right edge that is the same size in the same place on every row. Tapping the row still opens the folder; only the chevron changes what the list shows. The tree is untouched above 768px, where a wide sidebar can afford the indent and where dragging a folder onto another folder -- still the only way to reparent one -- needs both of them on screen at once. Every control in the top bar was under the 44px a fingertip covers: the icons at 36, the search filter at 30, the row menu at 24, and the hamburger 6px from the bezel in the corner a thumb is worst at. They keep the size they draw at and gain a transparent hit area, since growing the boxes would reflow a bar with no room to give; rows grow for real, because a 44px target inside a 36px row reaches into its neighbours. The one exception is the row menu, held to 36px wide: at a full 44 it overlapped the drill chevron by 4px, so its right edge silently drilled instead. Pinch was dead on the message list. `.msg-row` sets `touch-action: pan-y` to feed the swipe gesture the horizontal movement the browser is not using -- but naming any value drops every gesture not named, zoom included. It worked on an open message and died on the list, which reads as the zoom being broken at random rather than as a rule about rows. `pan-y pinch-zoom` keeps the swipe and gives the zoom back.
This commit is contained in:
@@ -229,6 +229,7 @@ export const catalog: Catalog = {
|
|||||||
"Folder options": "Ordneroptionen",
|
"Folder options": "Ordneroptionen",
|
||||||
"New folder": "Neuer Ordner",
|
"New folder": "Neuer Ordner",
|
||||||
"New subfolder": "Neuer Unterordner",
|
"New subfolder": "Neuer Unterordner",
|
||||||
|
"Open subfolders of {name}": "Unterordner von {name} öffnen",
|
||||||
"Delete folder": "Ordner löschen",
|
"Delete folder": "Ordner löschen",
|
||||||
"No matching folders": "Keine passenden Ordner",
|
"No matching folders": "Keine passenden Ordner",
|
||||||
"No subfolders here.": "Hier gibt es keine Unterordner.",
|
"No subfolders here.": "Hier gibt es keine Unterordner.",
|
||||||
|
|||||||
@@ -221,6 +221,7 @@ export const catalog: Catalog = {
|
|||||||
"Folder options": "Opciones de la carpeta",
|
"Folder options": "Opciones de la carpeta",
|
||||||
"New folder": "Carpeta nueva",
|
"New folder": "Carpeta nueva",
|
||||||
"New subfolder": "Subcarpeta nueva",
|
"New subfolder": "Subcarpeta nueva",
|
||||||
|
"Open subfolders of {name}": "Abrir las subcarpetas de {name}",
|
||||||
"Delete folder": "Eliminar la carpeta",
|
"Delete folder": "Eliminar la carpeta",
|
||||||
"No matching folders": "Ninguna carpeta coincide",
|
"No matching folders": "Ninguna carpeta coincide",
|
||||||
"No subfolders here.": "Aquí no hay subcarpetas.",
|
"No subfolders here.": "Aquí no hay subcarpetas.",
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ export const catalog: Catalog = {
|
|||||||
"Folder options": "Options du dossier",
|
"Folder options": "Options du dossier",
|
||||||
"New folder": "Nouveau dossier",
|
"New folder": "Nouveau dossier",
|
||||||
"New subfolder": "Nouveau sous-dossier",
|
"New subfolder": "Nouveau sous-dossier",
|
||||||
|
"Open subfolders of {name}": "Ouvrir les sous-dossiers de {name}",
|
||||||
"Delete folder": "Supprimer le dossier",
|
"Delete folder": "Supprimer le dossier",
|
||||||
"No matching folders": "Aucun dossier correspondant",
|
"No matching folders": "Aucun dossier correspondant",
|
||||||
"No subfolders here.": "Aucun sous-dossier ici.",
|
"No subfolders here.": "Aucun sous-dossier ici.",
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ export const catalog: Catalog = {
|
|||||||
"Folder options": "フォルダーのオプション",
|
"Folder options": "フォルダーのオプション",
|
||||||
"New folder": "新しいフォルダー",
|
"New folder": "新しいフォルダー",
|
||||||
"New subfolder": "新しいサブフォルダー",
|
"New subfolder": "新しいサブフォルダー",
|
||||||
|
"Open subfolders of {name}": "{name} のサブフォルダーを開く",
|
||||||
"Delete folder": "フォルダーを削除",
|
"Delete folder": "フォルダーを削除",
|
||||||
"No matching folders": "該当するフォルダーはありません",
|
"No matching folders": "該当するフォルダーはありません",
|
||||||
"No subfolders here.": "サブフォルダーはありません。",
|
"No subfolders here.": "サブフォルダーはありません。",
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ export const catalog: Catalog = {
|
|||||||
"Folder options": "Mapopties",
|
"Folder options": "Mapopties",
|
||||||
"New folder": "Nieuwe map",
|
"New folder": "Nieuwe map",
|
||||||
"New subfolder": "Nieuwe submap",
|
"New subfolder": "Nieuwe submap",
|
||||||
|
"Open subfolders of {name}": "Submappen van {name} openen",
|
||||||
"Delete folder": "Map verwijderen",
|
"Delete folder": "Map verwijderen",
|
||||||
"No matching folders": "Geen overeenkomende mappen",
|
"No matching folders": "Geen overeenkomende mappen",
|
||||||
"No subfolders here.": "Hier zijn geen submappen.",
|
"No subfolders here.": "Hier zijn geen submappen.",
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ export const catalog: Catalog = {
|
|||||||
"Folder options": "Opções da pasta",
|
"Folder options": "Opções da pasta",
|
||||||
"New folder": "Nova pasta",
|
"New folder": "Nova pasta",
|
||||||
"New subfolder": "Nova subpasta",
|
"New subfolder": "Nova subpasta",
|
||||||
|
"Open subfolders of {name}": "Abrir as subpastas de {name}",
|
||||||
"Delete folder": "Excluir a pasta",
|
"Delete folder": "Excluir a pasta",
|
||||||
"No matching folders": "Nenhuma pasta corresponde",
|
"No matching folders": "Nenhuma pasta corresponde",
|
||||||
"No subfolders here.": "Não há subpastas aqui.",
|
"No subfolders here.": "Não há subpastas aqui.",
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ export const catalog: Catalog = {
|
|||||||
"Folder options": "Параметры папки",
|
"Folder options": "Параметры папки",
|
||||||
"New folder": "Новая папка",
|
"New folder": "Новая папка",
|
||||||
"New subfolder": "Новая вложенная папка",
|
"New subfolder": "Новая вложенная папка",
|
||||||
|
"Open subfolders of {name}": "Открыть вложенные папки «{name}»",
|
||||||
"Delete folder": "Удалить папку",
|
"Delete folder": "Удалить папку",
|
||||||
"No matching folders": "Подходящих папок нет",
|
"No matching folders": "Подходящих папок нет",
|
||||||
"No subfolders here.": "Здесь нет вложенных папок.",
|
"No subfolders here.": "Здесь нет вложенных папок.",
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ export const catalog: Catalog = {
|
|||||||
"Folder options": "Параметри теки",
|
"Folder options": "Параметри теки",
|
||||||
"New folder": "Нова тека",
|
"New folder": "Нова тека",
|
||||||
"New subfolder": "Нова вкладена тека",
|
"New subfolder": "Нова вкладена тека",
|
||||||
|
"Open subfolders of {name}": "Відкрити вкладені теки «{name}»",
|
||||||
"Delete folder": "Видалити теку",
|
"Delete folder": "Видалити теку",
|
||||||
"No matching folders": "Відповідних тек немає",
|
"No matching folders": "Відповідних тек немає",
|
||||||
"No subfolders here.": "Тут немає вкладених тек.",
|
"No subfolders here.": "Тут немає вкладених тек.",
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ export const catalog: Catalog = {
|
|||||||
"Folder options": "文件夹选项",
|
"Folder options": "文件夹选项",
|
||||||
"New folder": "新建文件夹",
|
"New folder": "新建文件夹",
|
||||||
"New subfolder": "新建子文件夹",
|
"New subfolder": "新建子文件夹",
|
||||||
|
"Open subfolders of {name}": "打开{name}的子文件夹",
|
||||||
"Delete folder": "删除文件夹",
|
"Delete folder": "删除文件夹",
|
||||||
"No matching folders": "没有匹配的文件夹",
|
"No matching folders": "没有匹配的文件夹",
|
||||||
"No subfolders here.": "这里没有子文件夹。",
|
"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.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; }
|
.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. */
|
/* 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 { 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)); }
|
.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; }
|
.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
|
* pointermove would arrive after the scroll had already started, and the list
|
||||||
* would stutter on the way past.
|
* 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); }
|
.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. */
|
/* The row being dragged is the one thing on screen that must not lag. */
|
||||||
.msg-row.swiping, .mail-list-pull { will-change: transform; }
|
.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; }
|
.files-table .hide-mobile { display: none; }
|
||||||
.event-popover { width: calc(100vw - 24px); }
|
.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) {
|
@media (hover: none) {
|
||||||
.msg-row .msg-check { opacity: 1; }
|
.msg-row .msg-check { opacity: 1; }
|
||||||
.msg-row .msg-actions { display: none !important; }
|
.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 { 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 { useMail } from "@/store/mail";
|
||||||
import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/emptyFolder";
|
import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/emptyFolder";
|
||||||
import { isScheduledMailbox } from "@/store/scheduled";
|
import { isScheduledMailbox } from "@/store/scheduled";
|
||||||
import { useSettings } from "@/store/settings";
|
import { useSettings } from "@/store/settings";
|
||||||
import type { Id, Mailbox } from "@/jmap/types";
|
import type { Id, Mailbox } from "@/jmap/types";
|
||||||
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
|
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 { confirmDialog, promptDialog } from "@/ui/dialog";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
import { ShareDialog } from "../settings/ShareDialog";
|
import { ShareDialog } from "../settings/ShareDialog";
|
||||||
@@ -78,7 +78,7 @@ export function MailboxTree() {
|
|||||||
setExpanded(next);
|
setExpanded(next);
|
||||||
saveJson("mbx-expanded", 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 all = Object.values(mailboxes).filter((m) => showHidden || m.isSubscribed || m.role === "inbox");
|
||||||
const byParent = new Map<Id | null, Mailbox[]>();
|
const byParent = new Map<Id | null, Mailbox[]>();
|
||||||
for (const m of all) {
|
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;
|
if ((a.role === "inbox") !== (b.role === "inbox")) return a.role === "inbox" ? -1 : 1;
|
||||||
return a.name.localeCompare(b.name, undefined, { sensitivity: "base", numeric: true });
|
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 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) => {
|
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 kids = byParent.get(m.id) ?? [];
|
||||||
const open = Boolean(expanded[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 });
|
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);
|
if (kids.length && open) walk(m.id, depth + 1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
walk(null, 0);
|
walk(null, 0);
|
||||||
return out;
|
return { rows: out, childrenOf: (id: Id | null) => byParent.get(id) ?? [], subtreeUnread: unreadBelow };
|
||||||
}, [mailboxes, showHidden, expanded]);
|
}, [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 createFolder = async (parentId: Id | null) => {
|
||||||
const name = await promptDialog({ title: parentId ? t("New subfolder") : t("New folder"), placeholder: t("Folder name") });
|
const name = await promptDialog({ title: parentId ? t("New subfolder") : t("New folder"), placeholder: t("Folder name") });
|
||||||
if (!name?.trim()) return;
|
if (!name?.trim()) return;
|
||||||
@@ -144,12 +167,42 @@ export function MailboxTree() {
|
|||||||
if (id) void moveFolder(id, null);
|
if (id) void moveFolder(id, null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{draggingId && canDropOn(null) ? t("Drop here for the top level") : t("Folders")}</span>
|
<span>{draggingId && canDropOn(null) ? t("Drop here for the top level") : drill ? mailboxDisplayName(drill) : t("Folders")}</span>
|
||||||
<button className="icon-btn" title={t("New folder")} aria-label={t("New folder")} onClick={() => void createFolder(null)}>
|
{/* 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} />
|
<Plus size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
<FolderRow
|
||||||
key={m.id}
|
key={m.id}
|
||||||
mailbox={m}
|
mailbox={m}
|
||||||
@@ -160,6 +213,7 @@ export function MailboxTree() {
|
|||||||
hiddenUnread={hiddenUnread}
|
hiddenUnread={hiddenUnread}
|
||||||
childUnread={childUnread}
|
childUnread={childUnread}
|
||||||
onToggle={() => toggle(m.id)}
|
onToggle={() => toggle(m.id)}
|
||||||
|
onDrillIn={isMobile && hasChildren ? () => setDrillId(m.id) : undefined}
|
||||||
currentId={currentId}
|
currentId={currentId}
|
||||||
onMenu={(mb, e) => { setMenuTarget(mb); menu.open(e); }}
|
onMenu={(mb, e) => { setMenuTarget(mb); menu.open(e); }}
|
||||||
dragging={draggingId === m.id}
|
dragging={draggingId === m.id}
|
||||||
@@ -169,7 +223,9 @@ export function MailboxTree() {
|
|||||||
onFolderDrop={(id) => void moveFolder(id, m.id)}
|
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">
|
<div className="nav-section">
|
||||||
<span>{t("Labels")}</span>
|
<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);
|
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
|
// 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.
|
// useful number is how many messages are waiting, not how many are unseen.
|
||||||
const scheduled = isScheduledMailbox(m);
|
const scheduled = isScheduledMailbox(m);
|
||||||
@@ -260,7 +318,7 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/mail/${m.id}`}
|
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}
|
title={label}
|
||||||
{...press}
|
{...press}
|
||||||
// Dragging a folder is a mouse gesture; on a touchscreen the browser
|
// 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 });
|
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
|
<span
|
||||||
className="nav-twisty"
|
className="nav-twisty"
|
||||||
role={hasChildren ? "button" : undefined}
|
role={twisty ? "button" : undefined}
|
||||||
aria-label={hasChildren ? (open ? "Collapse" : "Expand") : undefined}
|
aria-label={twisty ? (open ? "Collapse" : "Expand") : undefined}
|
||||||
aria-expanded={hasChildren ? open : undefined}
|
aria-expanded={twisty ? open : undefined}
|
||||||
aria-hidden={hasChildren ? undefined : true}
|
aria-hidden={twisty ? undefined : true}
|
||||||
onClick={
|
onClick={
|
||||||
hasChildren
|
twisty
|
||||||
? (e) => {
|
? (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -292,7 +353,7 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{hasChildren ? open ? <ChevronDown size={14} /> : <ChevronRight size={14} /> : null}
|
{twisty ? open ? <ChevronDown size={14} /> : <ChevronRight size={14} /> : null}
|
||||||
</span>
|
</span>
|
||||||
<span className="folder-icon" style={tint ? ({ "--folder-color": tint } as React.CSSProperties) : undefined}>{icon}</span>
|
<span className="folder-icon" style={tint ? ({ "--folder-color": tint } as React.CSSProperties) : undefined}>{icon}</span>
|
||||||
<span className="nav-label">{label}</span>
|
<span className="nav-label">{label}</span>
|
||||||
@@ -309,6 +370,26 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
|
|||||||
>
|
>
|
||||||
<MoreVertical size={16} />
|
<MoreVertical size={16} />
|
||||||
</button>
|
</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>
|
</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