Merge pull request #166 from Coffey-Labs/mobile-drawer-alignment-and-close

Hang every folder off one edge, and give the drawer a way out
This commit is contained in:
Coffey Labs
2026-08-31 22:10:33 -07:00
committed by GitHub
13 changed files with 90 additions and 9 deletions
+1
View File
@@ -681,6 +681,7 @@ export const catalog: Catalog = {
"Shortcuts": "Kürzel",
"Go to": "Gehe zu",
"Menu": "Menü",
"Close menu": "Menü schließen",
"Options": "Optionen",
"Send options": "Sendeoptionen",
"Name": "Name",
+1
View File
@@ -692,6 +692,7 @@ export const catalog: Catalog = {
"Shortcuts": "Atajos",
"Go to": "Ir a",
"Menu": "Menú",
"Close menu": "Cerrar el menú",
"Options": "Opciones",
"Send options": "Opciones de envío",
"Name": "Nombre",
+1
View File
@@ -697,6 +697,7 @@ export const catalog: Catalog = {
"Shortcuts": "Raccourcis",
"Go to": "Aller à",
"Menu": "Menu",
"Close menu": "Fermer le menu",
"Options": "Options",
"Send options": "Options d'envoi",
"Name": "Nom",
+1
View File
@@ -694,6 +694,7 @@ export const catalog: Catalog = {
"Shortcuts": "ショートカット",
"Go to": "移動",
"Menu": "メニュー",
"Close menu": "メニューを閉じる",
"Options": "オプション",
"Send options": "送信オプション",
"Name": "名前",
+1
View File
@@ -688,6 +688,7 @@ export const catalog: Catalog = {
"Shortcuts": "Sneltoetsen",
"Go to": "Ga naar",
"Menu": "Menu",
"Close menu": "Menu sluiten",
"Options": "Opties",
"Send options": "Verzendopties",
"Name": "Naam",
+1
View File
@@ -695,6 +695,7 @@ export const catalog: Catalog = {
"Shortcuts": "Atalhos",
"Go to": "Ir para",
"Menu": "Menu",
"Close menu": "Fechar o menu",
"Options": "Opções",
"Send options": "Opções de envio",
"Name": "Nome",
+1
View File
@@ -694,6 +694,7 @@ export const catalog: Catalog = {
"Shortcuts": "Сочетания",
"Go to": "Перейти",
"Menu": "Меню",
"Close menu": "Закрыть меню",
"Options": "Параметры",
"Send options": "Параметры отправки",
"Name": "Имя",
+1
View File
@@ -688,6 +688,7 @@ export const catalog: Catalog = {
"Shortcuts": "Сполучення",
"Go to": "Перейти",
"Menu": "Меню",
"Close menu": "Закрити меню",
"Options": "Параметри",
"Send options": "Параметри надсилання",
"Name": "Ім'я",
+1
View File
@@ -693,6 +693,7 @@ export const catalog: Catalog = {
"Shortcuts": "快捷键",
"Go to": "转到",
"Menu": "菜单",
"Close menu": "关闭菜单",
"Options": "选项",
"Send options": "发送选项",
"Name": "名称",
+21 -5
View File
@@ -449,15 +449,24 @@ a.menu-item:hover { color: var(--fg); }
.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; }
/*
* No indent to pay for, so the name gets the width the tree was spending on
* depth. The twisty's 30px gutter goes with it -- nothing is ever drawn in it
* here, whether or not the folder has children.
*
* Scoped to the list rather than to the rows that offer a drill, which is the
* distinction the first cut got wrong: only folders WITH children carried the
* class, so they sat 18px left of every folder without any and the column of
* icons came apart. Whether a folder has children is not a reason to hang it
* somewhere else.
*/
.folder-drill .nav-item.folder-row { 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 { width: 100%; border: 0; background: none; font: inherit; text-align: left; cursor: pointer; color: var(--fg-muted); padding-left: 12px; }
.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; }
@@ -1058,6 +1067,11 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
.mobile-tabbar a.active { color: var(--accent); }
.main { padding-bottom: calc(60px + env(safe-area-inset-bottom)); }
.sidebar { padding-bottom: env(safe-area-inset-bottom); padding-left: env(safe-area-inset-left); }
/* Lines the close up with the hamburger underneath it: same height as the
top bar, same 12px in from the edge, so the second press lands where the
first one did. The negative margin cancels the drawer's own padding-top,
which would otherwise push it a row lower than the thing it replaces. */
.drawer-head { display: flex; align-items: center; height: var(--topbar-h); margin-top: -12px; padding-left: max(6px, env(safe-area-inset-left)); }
.thread-subject { padding: 14px 16px 8px; }
.message { margin: 0 8px 8px; }
.message-head { padding: 10px 12px; }
@@ -1108,7 +1122,9 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
.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)); }
/* Both, together: the close in the drawer stands exactly on the hamburger it
covers, and it only does that if the two are moved by the same rule. */
.topbar, .drawer-head { padding-left: max(12px, env(safe-area-inset-left)); }
}
@media (hover: none) {
.msg-row .msg-check { opacity: 1; }
+30 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useState, type ReactNode } from "react";
import { Link, useLocation } from "wouter";
import { BookOpen, Calendar, ChevronsUpDown, FolderOpen, Globe, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, Sun, Upload, Users } from "lucide-react";
import { BookOpen, Calendar, ChevronsUpDown, FolderOpen, Globe, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, Sun, Upload, Users, X } from "lucide-react";
import { useSession } from "@/store/session";
import { toggleTarget, useEffectiveTheme, useSettings } from "@/store/settings";
import { useMail } from "@/store/mail";
@@ -40,6 +40,14 @@ export function AppShell({ children }: { children: ReactNode }) {
useGlobalShortcuts({ onHelp: () => setHelpOpen(true) });
useEffect(() => setDrawer(false), [location]);
// Escape closes it too, for the tablet with a keyboard attached.
useEffect(() => {
if (!drawer) return;
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setDrawer(false); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [drawer]);
// Deep link: /mail?compose=new (PWA shortcut) / mailto handler
useEffect(() => {
const params = new URLSearchParams(window.location.search);
@@ -68,7 +76,7 @@ export function AppShell({ children }: { children: ReactNode }) {
return (
<div className="app">
<header className="topbar">
<button className="icon-btn" aria-label={t("Menu")} onClick={() => (isMobile ? setDrawer(true) : update({ sidebarCollapsed: !collapsed }))}>
<button className="icon-btn" aria-label={t("Menu")} onClick={() => (isMobile ? setDrawer((d) => !d) : update({ sidebarCollapsed: !collapsed }))}>
<MenuIcon size={22} />
</button>
<Link href="/mail" className="brand">
@@ -120,6 +128,26 @@ export function AppShell({ children }: { children: ReactNode }) {
<div className={`app-body ${collapsed && !isMobile ? "collapsed" : ""}`}>
<div className={`drawer-backdrop ${drawer ? "open" : ""}`} onClick={() => setDrawer(false)} />
<aside className={`sidebar ${drawer ? "open" : ""}`}>
{/*
The way back out.
The drawer covers the top bar -- it has to, being taller than it --
so the hamburger that opened it is underneath, and pressing the
same place again did nothing. That left the dimmed strip beside the
drawer as the only exit, which is not a thing anyone is told about.
Putting a close where the hamburger was means the second press
lands on the control that undoes the first, which is where the hand
is already going. It cannot be done by raising the top bar over the
drawer instead: it would then also sit over a full-screen composer,
which is stacked lower still.
*/}
{isMobile && (
<div className="drawer-head">
<button className="icon-btn" aria-label={t("Close menu")} onClick={() => setDrawer(false)}>
<X size={22} />
</button>
</div>
)}
{/* Whatever this pane is for. Files offered Compose, which wrote mail
from the file manager and was the one thing nobody wanted there. */}
<button
+2 -2
View File
@@ -150,7 +150,7 @@ export function MailboxTree() {
return (
<>
<nav aria-label={t("Folders")} style={{ marginTop: 6 }}>
<nav aria-label={t("Folders")} className={isMobile ? "folder-drill" : undefined} style={{ marginTop: 6 }}>
<div
className={`nav-section${rootDrop ? " drop-target" : ""}`}
onDragOver={(e) => {
@@ -318,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)} ${onDrillIn ? "has-drill" : ""} ${currentId === m.id ? "active" : ""} ${unread ? "unread" : ""} ${dropping ? "drop-target" : ""} ${dragging ? "dragging" : ""}`}
className={`nav-item folder-row depth-${Math.min(depth, 4)} ${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
@@ -91,6 +91,34 @@ describe("folder drill-down", () => {
expect(document.querySelector(".drill-back")!.textContent).toContain("Work");
});
/*
* jsdom has no layout to measure, so this asserts the mechanism the
* alignment hangs off instead: the indent is dropped for the whole list, by
* a class on the nav. The first cut put it on the rows offering a drill,
* which meant only folders with children lost the twisty's 30px gutter and
* they hung 18px left of every folder without any.
*/
it("hangs every folder off the same edge, children or not", () => {
setWidth(390);
mount();
expect(document.querySelector("nav")!.className).toContain("folder-drill");
const depths = Array.from(document.querySelectorAll(".nav-item.folder-row")).map((r) => r.className.match(/depth-\d/)?.[0]);
expect(depths).toEqual(["depth-0", "depth-0", "depth-0"]);
drillInto("Work");
// Work has a child and Clients does not; neither may be indented for it.
expect(Array.from(document.querySelectorAll(".nav-item.folder-row")).map((r) => r.className.match(/depth-\d/)?.[0])).toEqual(["depth-0", "depth-0"]);
expect(document.querySelector(".nav-item.folder-row.has-drill")).toBeNull();
});
it("keeps the indent on a wide screen, where the tree still needs it", () => {
setWidth(1280);
mount();
expect(document.querySelector("nav")!.className).not.toContain("folder-drill");
act(() => { rowFor("Work")!.querySelector<HTMLElement>(".nav-twisty")!.click(); });
expect(rowFor("Clients")!.className).toContain("depth-1");
});
it("walks back out one level per tap", () => {
setWidth(390);
mount();