Hang every folder off one edge, and give the drawer a way out
Two things the drill-down got wrong, both found on a phone-width window. The folders did not line up. The rule that drops the twisty's 30px gutter was hung on the rows offering a drill, so only folders with children lost it -- 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. The class moves to the list, which is what the indent is a property of; icons now share one column and labels another, at every level and on the back row too. There was no obvious way back out of the drawer. It covers the top bar -- it is taller than it -- so the hamburger that opened it is underneath, and pressing the same place again did nothing at all, since that handler only ever set the drawer open. The dimmed strip beside the drawer was the only exit, and nothing says so. There is now a close where the hamburger was, moved by the same rule so it lands on exactly the same pixels, and the hamburger itself toggles rather than only opening. Escape closes it too, for a tablet with a keyboard. Raising the top bar over the drawer instead would have been the smaller change and is not available: the drawer is at 950 and a full-screen composer at 800, so a top bar above the first is also above the second.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -694,6 +694,7 @@ export const catalog: Catalog = {
|
||||
"Shortcuts": "ショートカット",
|
||||
"Go to": "移動",
|
||||
"Menu": "メニュー",
|
||||
"Close menu": "メニューを閉じる",
|
||||
"Options": "オプション",
|
||||
"Send options": "送信オプション",
|
||||
"Name": "名前",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -694,6 +694,7 @@ export const catalog: Catalog = {
|
||||
"Shortcuts": "Сочетания",
|
||||
"Go to": "Перейти",
|
||||
"Menu": "Меню",
|
||||
"Close menu": "Закрыть меню",
|
||||
"Options": "Параметры",
|
||||
"Send options": "Параметры отправки",
|
||||
"Name": "Имя",
|
||||
|
||||
@@ -688,6 +688,7 @@ export const catalog: Catalog = {
|
||||
"Shortcuts": "Сполучення",
|
||||
"Go to": "Перейти",
|
||||
"Menu": "Меню",
|
||||
"Close menu": "Закрити меню",
|
||||
"Options": "Параметри",
|
||||
"Send options": "Параметри надсилання",
|
||||
"Name": "Ім'я",
|
||||
|
||||
@@ -693,6 +693,7 @@ export const catalog: Catalog = {
|
||||
"Shortcuts": "快捷键",
|
||||
"Go to": "转到",
|
||||
"Menu": "菜单",
|
||||
"Close menu": "关闭菜单",
|
||||
"Options": "选项",
|
||||
"Send options": "发送选项",
|
||||
"Name": "名称",
|
||||
|
||||
+21
-5
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user