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:
@@ -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