Mail (phone): side panel as a drawer sliding in from the right

The reading view's toolbar gains the side-panel toggle on a phone too. It opens
Calendar/People/Attachments as a right-hand drawer over a dimmed backdrop,
closed by its X, the backdrop or Escape, and put away when the conversation
changes. It keeps its own open state so the desktop's remembered panel never
opens on a phone, and it mounts on first use only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 20:35:29 +02:00
co-authored by Claude Opus 5.5
parent 25f7265e55
commit a5b0842e6a
+51 -1
View File
@@ -162,6 +162,16 @@ export function MailLayout() {
useEffect(() => localStorage.setItem("panel.rightTab", rightTab), [rightTab]);
useEffect(() => localStorage.setItem("panel.rightW", String(rightW)), [rightW]);
const openPanel = (tab: PanelTab) => { setRightTab(tab); setRightOpen(true); };
// Phone: the same panel as a drawer sliding in from the right. Its own state,
// so the desktop's remembered "open" never springs it onto a phone screen.
// Mounted on first use only (the Calendar tab loads a whole plugin bundle),
// then kept so it can slide out again.
const [sheetOpen, setSheetOpen] = useState(false);
const [sheetUsed, setSheetUsed] = useState(false);
const openSheet = () => {
setSheetUsed(true);
setSheetOpen(true);
};
useEffect(() => localStorage.setItem("mail.listOrder", listOrder), [listOrder]);
const isDrafts = view === "folder:drafts";
@@ -309,6 +319,14 @@ export function MailLayout() {
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [drawerOpen]);
// The side-panel drawer belongs to the conversation it was opened over.
useEffect(() => setSheetOpen(false), [view, mobile, threadId]);
useEffect(() => {
if (!sheetOpen) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setSheetOpen(false);
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [sheetOpen]);
// Auto-mark an opened thread read after a short dwell (4s), not instantly — and
// cancel it if you leave before then, so a quick glance doesn't mark it read.
@@ -1230,7 +1248,20 @@ export function MailLayout() {
<span className={`ml-auto text-[13px] text-app-faint ${mobile ? "hidden" : ""}`}>
{t("mail.messageCount", { count: detailQuery.data.messages.length })}
</span>
{!mobile && (
{mobile ? (
<button
className="ml-auto rounded p-1.5 text-app-fg hover:bg-app-hover"
title={t("panel.toggle")}
aria-label={t("panel.toggle")}
aria-expanded={sheetOpen}
onClick={openSheet}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<rect x="3" y="4" width="18" height="16" rx="2" />
<path d="M15 4v16" />
</svg>
</button>
) : (
<button
className={`rounded p-1.5 hover:bg-app-hover hover:text-app-fg ${rightOpen ? "text-app-fg" : "text-app-muted"}`}
title={t("panel.toggle")}
@@ -1276,6 +1307,25 @@ export function MailLayout() {
</div>
)}
</main>
{mobile && sheetUsed && (
<>
<div
aria-hidden
onClick={() => setSheetOpen(false)}
className={`fixed inset-0 z-40 bg-black/40 transition-opacity duration-200 ${
sheetOpen ? "opacity-100" : "pointer-events-none opacity-0"
}`}
/>
<div
aria-hidden={!sheetOpen}
className={`fixed inset-y-0 right-0 z-50 w-[88vw] max-w-[420px] border-l border-app-border pb-[env(safe-area-inset-bottom)] pt-[env(safe-area-inset-top)] shadow-2xl transition-transform duration-200 ease-out bg-app-surface ${
sheetOpen ? "translate-x-0" : "pointer-events-none translate-x-full"
}`}
>
<RightPanel tab={rightTab} onTab={setRightTab} onClose={() => setSheetOpen(false)} thread={detailQuery.data} />
</div>
</>
)}
{rightOpen && !mobile && (
<>
<Resizer onDelta={(dx) => setRightW((w) => clampRight(w - dx))} />