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",
|
"Shortcuts": "Kürzel",
|
||||||
"Go to": "Gehe zu",
|
"Go to": "Gehe zu",
|
||||||
"Menu": "Menü",
|
"Menu": "Menü",
|
||||||
|
"Close menu": "Menü schließen",
|
||||||
"Options": "Optionen",
|
"Options": "Optionen",
|
||||||
"Send options": "Sendeoptionen",
|
"Send options": "Sendeoptionen",
|
||||||
"Name": "Name",
|
"Name": "Name",
|
||||||
|
|||||||
@@ -692,6 +692,7 @@ export const catalog: Catalog = {
|
|||||||
"Shortcuts": "Atajos",
|
"Shortcuts": "Atajos",
|
||||||
"Go to": "Ir a",
|
"Go to": "Ir a",
|
||||||
"Menu": "Menú",
|
"Menu": "Menú",
|
||||||
|
"Close menu": "Cerrar el menú",
|
||||||
"Options": "Opciones",
|
"Options": "Opciones",
|
||||||
"Send options": "Opciones de envío",
|
"Send options": "Opciones de envío",
|
||||||
"Name": "Nombre",
|
"Name": "Nombre",
|
||||||
|
|||||||
@@ -697,6 +697,7 @@ export const catalog: Catalog = {
|
|||||||
"Shortcuts": "Raccourcis",
|
"Shortcuts": "Raccourcis",
|
||||||
"Go to": "Aller à",
|
"Go to": "Aller à",
|
||||||
"Menu": "Menu",
|
"Menu": "Menu",
|
||||||
|
"Close menu": "Fermer le menu",
|
||||||
"Options": "Options",
|
"Options": "Options",
|
||||||
"Send options": "Options d'envoi",
|
"Send options": "Options d'envoi",
|
||||||
"Name": "Nom",
|
"Name": "Nom",
|
||||||
|
|||||||
@@ -694,6 +694,7 @@ export const catalog: Catalog = {
|
|||||||
"Shortcuts": "ショートカット",
|
"Shortcuts": "ショートカット",
|
||||||
"Go to": "移動",
|
"Go to": "移動",
|
||||||
"Menu": "メニュー",
|
"Menu": "メニュー",
|
||||||
|
"Close menu": "メニューを閉じる",
|
||||||
"Options": "オプション",
|
"Options": "オプション",
|
||||||
"Send options": "送信オプション",
|
"Send options": "送信オプション",
|
||||||
"Name": "名前",
|
"Name": "名前",
|
||||||
|
|||||||
@@ -688,6 +688,7 @@ export const catalog: Catalog = {
|
|||||||
"Shortcuts": "Sneltoetsen",
|
"Shortcuts": "Sneltoetsen",
|
||||||
"Go to": "Ga naar",
|
"Go to": "Ga naar",
|
||||||
"Menu": "Menu",
|
"Menu": "Menu",
|
||||||
|
"Close menu": "Menu sluiten",
|
||||||
"Options": "Opties",
|
"Options": "Opties",
|
||||||
"Send options": "Verzendopties",
|
"Send options": "Verzendopties",
|
||||||
"Name": "Naam",
|
"Name": "Naam",
|
||||||
|
|||||||
@@ -695,6 +695,7 @@ export const catalog: Catalog = {
|
|||||||
"Shortcuts": "Atalhos",
|
"Shortcuts": "Atalhos",
|
||||||
"Go to": "Ir para",
|
"Go to": "Ir para",
|
||||||
"Menu": "Menu",
|
"Menu": "Menu",
|
||||||
|
"Close menu": "Fechar o menu",
|
||||||
"Options": "Opções",
|
"Options": "Opções",
|
||||||
"Send options": "Opções de envio",
|
"Send options": "Opções de envio",
|
||||||
"Name": "Nome",
|
"Name": "Nome",
|
||||||
|
|||||||
@@ -694,6 +694,7 @@ export const catalog: Catalog = {
|
|||||||
"Shortcuts": "Сочетания",
|
"Shortcuts": "Сочетания",
|
||||||
"Go to": "Перейти",
|
"Go to": "Перейти",
|
||||||
"Menu": "Меню",
|
"Menu": "Меню",
|
||||||
|
"Close menu": "Закрыть меню",
|
||||||
"Options": "Параметры",
|
"Options": "Параметры",
|
||||||
"Send options": "Параметры отправки",
|
"Send options": "Параметры отправки",
|
||||||
"Name": "Имя",
|
"Name": "Имя",
|
||||||
|
|||||||
@@ -688,6 +688,7 @@ export const catalog: Catalog = {
|
|||||||
"Shortcuts": "Сполучення",
|
"Shortcuts": "Сполучення",
|
||||||
"Go to": "Перейти",
|
"Go to": "Перейти",
|
||||||
"Menu": "Меню",
|
"Menu": "Меню",
|
||||||
|
"Close menu": "Закрити меню",
|
||||||
"Options": "Параметри",
|
"Options": "Параметри",
|
||||||
"Send options": "Параметри надсилання",
|
"Send options": "Параметри надсилання",
|
||||||
"Name": "Ім'я",
|
"Name": "Ім'я",
|
||||||
|
|||||||
@@ -693,6 +693,7 @@ export const catalog: Catalog = {
|
|||||||
"Shortcuts": "快捷键",
|
"Shortcuts": "快捷键",
|
||||||
"Go to": "转到",
|
"Go to": "转到",
|
||||||
"Menu": "菜单",
|
"Menu": "菜单",
|
||||||
|
"Close menu": "关闭菜单",
|
||||||
"Options": "选项",
|
"Options": "选项",
|
||||||
"Send options": "发送选项",
|
"Send options": "发送选项",
|
||||||
"Name": "名称",
|
"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; }
|
.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) ---- */
|
/* ---- 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. */
|
* No indent to pay for, so the name gets the width the tree was spending on
|
||||||
.nav-item.folder-row.has-drill { padding-left: 12px; }
|
* 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 { 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); }
|
.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
|
/* 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. */
|
filled rectangle inside it reads as a separate selected thing. */
|
||||||
.nav-item.folder-row.active .drill-into { color: inherit; }
|
.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:hover { background: var(--bg-hover); color: var(--fg); }
|
||||||
.drill-back svg { flex: 0 0 auto; }
|
.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; }
|
||||||
@@ -1058,6 +1067,11 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
|
|||||||
.mobile-tabbar a.active { color: var(--accent); }
|
.mobile-tabbar a.active { color: var(--accent); }
|
||||||
.main { padding-bottom: calc(60px + env(safe-area-inset-bottom)); }
|
.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); }
|
.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; }
|
.thread-subject { padding: 14px 16px 8px; }
|
||||||
.message { margin: 0 8px 8px; }
|
.message { margin: 0 8px 8px; }
|
||||||
.message-head { padding: 10px 12px; }
|
.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; }
|
.sidebar .nav-item, .cal-list-item { height: 44px; }
|
||||||
/* The hamburger is the one control in the corner a thumb is worst at, so it
|
/* 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. */
|
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) {
|
@media (hover: none) {
|
||||||
.msg-row .msg-check { opacity: 1; }
|
.msg-row .msg-check { opacity: 1; }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState, type ReactNode } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import { Link, useLocation } from "wouter";
|
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 { useSession } from "@/store/session";
|
||||||
import { toggleTarget, useEffectiveTheme, useSettings } from "@/store/settings";
|
import { toggleTarget, useEffectiveTheme, useSettings } from "@/store/settings";
|
||||||
import { useMail } from "@/store/mail";
|
import { useMail } from "@/store/mail";
|
||||||
@@ -40,6 +40,14 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
useGlobalShortcuts({ onHelp: () => setHelpOpen(true) });
|
useGlobalShortcuts({ onHelp: () => setHelpOpen(true) });
|
||||||
useEffect(() => setDrawer(false), [location]);
|
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
|
// Deep link: /mail?compose=new (PWA shortcut) / mailto handler
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
@@ -68,7 +76,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<header className="topbar">
|
<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} />
|
<MenuIcon size={22} />
|
||||||
</button>
|
</button>
|
||||||
<Link href="/mail" className="brand">
|
<Link href="/mail" className="brand">
|
||||||
@@ -120,6 +128,26 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
<div className={`app-body ${collapsed && !isMobile ? "collapsed" : ""}`}>
|
<div className={`app-body ${collapsed && !isMobile ? "collapsed" : ""}`}>
|
||||||
<div className={`drawer-backdrop ${drawer ? "open" : ""}`} onClick={() => setDrawer(false)} />
|
<div className={`drawer-backdrop ${drawer ? "open" : ""}`} onClick={() => setDrawer(false)} />
|
||||||
<aside className={`sidebar ${drawer ? "open" : ""}`}>
|
<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
|
{/* Whatever this pane is for. Files offered Compose, which wrote mail
|
||||||
from the file manager and was the one thing nobody wanted there. */}
|
from the file manager and was the one thing nobody wanted there. */}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export function MailboxTree() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<nav aria-label={t("Folders")} style={{ marginTop: 6 }}>
|
<nav aria-label={t("Folders")} className={isMobile ? "folder-drill" : undefined} style={{ marginTop: 6 }}>
|
||||||
<div
|
<div
|
||||||
className={`nav-section${rootDrop ? " drop-target" : ""}`}
|
className={`nav-section${rootDrop ? " drop-target" : ""}`}
|
||||||
onDragOver={(e) => {
|
onDragOver={(e) => {
|
||||||
@@ -318,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)} ${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}
|
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
|
||||||
|
|||||||
@@ -91,6 +91,34 @@ describe("folder drill-down", () => {
|
|||||||
expect(document.querySelector(".drill-back")!.textContent).toContain("Work");
|
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", () => {
|
it("walks back out one level per tap", () => {
|
||||||
setWidth(390);
|
setWidth(390);
|
||||||
mount();
|
mount();
|
||||||
|
|||||||
Reference in New Issue
Block a user