Merge pull request #347 from Coffey-Labs/feat/resizable-sidebar

Let the sidebar be resized by dragging its edge
This commit is contained in:
jcoffey
2026-09-14 07:56:59 -07:00
committed by GitHub
16 changed files with 120 additions and 16 deletions
+5 -3
View File
@@ -71,9 +71,11 @@ per-account shape.
## Layout ## Layout
Three panes: folder tree, message list, reading pane. The splitter between the Three panes: folder tree, message list, reading pane. The splitter between the
list and the reading pane is dragged to resize, and the size is remembered per list and the reading pane is dragged to resize, and so is the edge of the
device — a width chosen on a 27" monitor is wrong on a laptop, so it is one of sidebar, between 240 and 480px; arrow keys move either one, and a double-click
the few settings that does not follow the account. puts it back. The sizes are remembered per device — a width chosen on a 27"
monitor is wrong on a laptop, so they are among the few settings that do not
follow the account.
- **Reading pane** right of the list, below it, or off (messages open full width). - **Reading pane** right of the list, below it, or off (messages open full width).
- **Density** comfortable, cozy or compact, which changes row height as well as padding. - **Density** comfortable, cozy or compact, which changes row height as well as padding.
+1 -1
View File
@@ -25,7 +25,7 @@ describe("which settings follow the account", () => {
const synced = syncedPart(DEFAULT_SETTINGS); const synced = syncedPart(DEFAULT_SETTINGS);
// A pane width picked on a monitor is wrong on a laptop, and the // A pane width picked on a monitor is wrong on a laptop, and the
// notification toggles track a per-browser permission grant. // notification toggles track a per-browser permission grant.
for (const key of ["listPaneWidth", "listPaneHeight", "density", "fontSize", "sidebarCollapsed", "desktopNotifications", "notificationSound"]) { for (const key of ["listPaneWidth", "listPaneHeight", "sidebarWidth", "density", "fontSize", "sidebarCollapsed", "desktopNotifications", "notificationSound"]) {
expect(synced, key).not.toHaveProperty(key); expect(synced, key).not.toHaveProperty(key);
} }
}); });
+2
View File
@@ -1212,6 +1212,8 @@ export const catalog: Catalog = {
"Maximize": "Maximieren", "Maximize": "Maximieren",
"Full screen": "Vollbild", "Full screen": "Vollbild",
"Resize panes": "Bereichsgröße ändern", "Resize panes": "Bereichsgröße ändern",
"Resize message list": "Größe der Nachrichtenliste ändern",
"Resize sidebar": "Größe der Seitenleiste ändern",
"QR code": "QR-Code", "QR code": "QR-Code",
"Draft discarded": "Entwurf verworfen", "Draft discarded": "Entwurf verworfen",
"Draft saved": "Entwurf gespeichert", "Draft saved": "Entwurf gespeichert",
+2
View File
@@ -1185,6 +1185,8 @@ export const catalog: Catalog = {
"Maximize": "Maximizar", "Maximize": "Maximizar",
"Full screen": "Pantalla completa", "Full screen": "Pantalla completa",
"Resize panes": "Cambiar el tamaño de los paneles", "Resize panes": "Cambiar el tamaño de los paneles",
"Resize message list": "Cambiar el tamaño de la lista de mensajes",
"Resize sidebar": "Cambiar el tamaño de la barra lateral",
"QR code": "Código QR", "QR code": "Código QR",
"Draft discarded": "Borrador descartado", "Draft discarded": "Borrador descartado",
"Draft saved": "Borrador guardado", "Draft saved": "Borrador guardado",
+2
View File
@@ -1190,6 +1190,8 @@ export const catalog: Catalog = {
"Maximize": "Agrandir", "Maximize": "Agrandir",
"Full screen": "Plein écran", "Full screen": "Plein écran",
"Resize panes": "Redimensionner les volets", "Resize panes": "Redimensionner les volets",
"Resize message list": "Redimensionner la liste des messages",
"Resize sidebar": "Redimensionner la barre latérale",
"QR code": "QR code", "QR code": "QR code",
"Draft discarded": "Brouillon abandonné", "Draft discarded": "Brouillon abandonné",
"Draft saved": "Brouillon enregistré", "Draft saved": "Brouillon enregistré",
+2
View File
@@ -1193,6 +1193,8 @@ export const catalog: Catalog = {
"Maximize": "最大化", "Maximize": "最大化",
"Full screen": "全画面", "Full screen": "全画面",
"Resize panes": "ペインの大きさを変更", "Resize panes": "ペインの大きさを変更",
"Resize message list": "メール一覧の大きさを変更",
"Resize sidebar": "サイドバーの大きさを変更",
"QR code": "QR コード", "QR code": "QR コード",
"Draft discarded": "下書きを破棄しました", "Draft discarded": "下書きを破棄しました",
"Draft saved": "下書きを保存しました", "Draft saved": "下書きを保存しました",
+2
View File
@@ -1181,6 +1181,8 @@ export const catalog: Catalog = {
"Maximize": "Maximaliseren", "Maximize": "Maximaliseren",
"Full screen": "Volledig scherm", "Full screen": "Volledig scherm",
"Resize panes": "Vensterdelen verslepen", "Resize panes": "Vensterdelen verslepen",
"Resize message list": "Grootte van de berichtenlijst wijzigen",
"Resize sidebar": "Grootte van de zijbalk wijzigen",
"QR code": "QR-code", "QR code": "QR-code",
"Draft discarded": "Concept weggegooid", "Draft discarded": "Concept weggegooid",
"Draft saved": "Concept opgeslagen", "Draft saved": "Concept opgeslagen",
+2
View File
@@ -1188,6 +1188,8 @@ export const catalog: Catalog = {
"Maximize": "Maximizar", "Maximize": "Maximizar",
"Full screen": "Tela cheia", "Full screen": "Tela cheia",
"Resize panes": "Redimensionar os painéis", "Resize panes": "Redimensionar os painéis",
"Resize message list": "Redimensionar a lista de mensagens",
"Resize sidebar": "Redimensionar a barra lateral",
"QR code": "Código QR", "QR code": "Código QR",
"Draft discarded": "Rascunho descartado", "Draft discarded": "Rascunho descartado",
"Draft saved": "Rascunho salvo", "Draft saved": "Rascunho salvo",
+2
View File
@@ -1187,6 +1187,8 @@ export const catalog: Catalog = {
"Maximize": "Развернуть", "Maximize": "Развернуть",
"Full screen": "Во весь экран", "Full screen": "Во весь экран",
"Resize panes": "Изменить размер панелей", "Resize panes": "Изменить размер панелей",
"Resize message list": "Изменить размер списка писем",
"Resize sidebar": "Изменить размер боковой панели",
"QR code": "QR-код", "QR code": "QR-код",
"Draft discarded": "Черновик удалён", "Draft discarded": "Черновик удалён",
"Draft saved": "Черновик сохранён", "Draft saved": "Черновик сохранён",
+2
View File
@@ -1181,6 +1181,8 @@ export const catalog: Catalog = {
"Maximize": "Розгорнути", "Maximize": "Розгорнути",
"Full screen": "На весь екран", "Full screen": "На весь екран",
"Resize panes": "Змінити розмір панелей", "Resize panes": "Змінити розмір панелей",
"Resize message list": "Змінити розмір списку листів",
"Resize sidebar": "Змінити розмір бічної панелі",
"QR code": "QR-код", "QR code": "QR-код",
"Draft discarded": "Чернетку видалено", "Draft discarded": "Чернетку видалено",
"Draft saved": "Чернетку збережено", "Draft saved": "Чернетку збережено",
+2
View File
@@ -1192,6 +1192,8 @@ export const catalog: Catalog = {
"Maximize": "最大化", "Maximize": "最大化",
"Full screen": "全屏", "Full screen": "全屏",
"Resize panes": "调整窗格大小", "Resize panes": "调整窗格大小",
"Resize message list": "调整邮件列表大小",
"Resize sidebar": "调整侧边栏大小",
"QR code": "二维码", "QR code": "二维码",
"Draft discarded": "草稿已放弃", "Draft discarded": "草稿已放弃",
"Draft saved": "草稿已保存", "Draft saved": "草稿已保存",
+9
View File
@@ -256,6 +256,13 @@ export interface Settings {
listPaneWidth: number; listPaneWidth: number;
/** Height (px) of the message list when the reading pane is below. */ /** Height (px) of the message list when the reading pane is below. */
listPaneHeight: number; listPaneHeight: number;
/**
* Width (px) of the sidebar, dragged by its edge (#345). Null until someone
* drags it, and null again after a double-click resets it -- which leaves the
* width to the stylesheet's `--sidebar-w`, so a reader who already widens the
* sidebar with their own CSS keeps what they had until they choose otherwise.
*/
sidebarWidth: number | null;
/** Outlook-style colour categories for calendar events. */ /** Outlook-style colour categories for calendar events. */
eventCategories: Array<{ name: string; color: string }>; eventCategories: Array<{ name: string; color: string }>;
/** Default sending identity per account (JMAP has no such flag). */ /** Default sending identity per account (JMAP has no such flag). */
@@ -370,6 +377,7 @@ export const DEFAULT_SETTINGS: Settings = {
sendAndArchive: false, sendAndArchive: false,
listPaneWidth: 520, listPaneWidth: 520,
listPaneHeight: 340, listPaneHeight: 340,
sidebarWidth: null,
eventCategories: [ eventCategories: [
{ name: "Important", color: "#dc2626" }, { name: "Important", color: "#dc2626" },
{ name: "Work", color: "#2563eb" }, { name: "Work", color: "#2563eb" },
@@ -400,6 +408,7 @@ export const DEVICE_KEYS: ReadonlySet<keyof Settings> = new Set<keyof Settings>(
"notificationSound", "notificationSound",
"listPaneWidth", "listPaneWidth",
"listPaneHeight", "listPaneHeight",
"sidebarWidth",
]); ]);
/** The part of the settings that is written to the account's settings file. */ /** The part of the settings that is written to the account's settings file. */
+10 -1
View File
@@ -1282,8 +1282,17 @@ a.menu-item:hover { color: var(--fg); }
@keyframes push-pulse { 50% { opacity: .45; } } @keyframes push-pulse { 50% { opacity: .45; } }
@media (prefers-reduced-motion: reduce) { .push-dot.connecting { animation: none; } } @media (prefers-reduced-motion: reduce) { .push-dot.connecting { animation: none; } }
.app-body { display: grid; grid-template-columns: var(--sidebar-w) minmax(0, 1fr); min-height: 0; transition: grid-template-columns .2s var(--ease); } .app-body { position: relative; display: grid; grid-template-columns: var(--sidebar-w) minmax(0, 1fr); min-height: 0; transition: grid-template-columns .2s var(--ease); }
.app-body.collapsed { grid-template-columns: var(--sidebar-w-collapsed) minmax(0, 1fr); } .app-body.collapsed { grid-template-columns: var(--sidebar-w-collapsed) minmax(0, 1fr); }
/* A drag follows the pointer; the ease is for the collapse toggle, and applied
to every frame of a drag it leaves the edge trailing behind the hand. */
.app-body.resizing, .app-body.resizing > .sidebar-splitter { transition: none; }
/* The sidebar edge (#345). Laid over the seam rather than given a grid column:
the sidebar and the content meet at the content's own border, and a 6px
bar of border colour between them would draw a line the design never had.
Out of the flow, it shows only on hover and focus, like the list splitter. */
.app-body > .sidebar-splitter { position: absolute; top: 0; bottom: 0; left: calc(var(--sidebar-w) - 3px); background: transparent; transition: left .2s var(--ease); }
.app-body > .sidebar-splitter:hover, .app-body > .sidebar-splitter:focus-visible { background: var(--accent); }
.sidebar { display: flex; flex-direction: column; min-height: 0; padding: 4px 8px 8px 8px; gap: 2px; overflow: hidden; } .sidebar { display: flex; flex-direction: column; min-height: 0; padding: 4px 8px 8px 8px; gap: 2px; overflow: hidden; }
.sidebar-scroll { overflow-y: auto; overflow-x: hidden; flex: 1; min-height: 0; padding-bottom: 8px; } .sidebar-scroll { overflow-y: auto; overflow-x: hidden; flex: 1; min-height: 0; padding-bottom: 8px; }
.compose-btn { display: flex; align-items: center; gap: 12px; height: 52px; padding: 0 22px 0 18px; margin: 6px 4px 12px; border-radius: 16px; background: var(--bg-elev); box-shadow: var(--shadow-1); font-weight: 600; font-size: 1em; color: var(--fg); transition: box-shadow .15s, transform .05s, background .12s; white-space: nowrap; } .compose-btn { display: flex; align-items: center; gap: 12px; height: 52px; padding: 0 22px 0 18px; margin: 6px 4px 12px; border-radius: 16px; background: var(--bg-elev); box-shadow: var(--shadow-1); font-weight: 600; font-size: 1em; color: var(--fg); transition: box-shadow .15s, transform .05s, background .12s; white-space: nowrap; }
+10 -4
View File
@@ -7,15 +7,16 @@ interface Props {
onEnd?: () => void; onEnd?: () => void;
onReset?: () => void; onReset?: () => void;
ariaLabel?: string; ariaLabel?: string;
className?: string;
} }
/** Drag handle between two panes. Calls onResize with the pointer delta since the last event. */ /** Drag handle between two panes. Calls onResize with the pointer delta since the last event. */
export function Splitter({ direction, onResize, onEnd, onReset, ariaLabel }: Props) { export function Splitter({ direction, onResize, onEnd, onReset, ariaLabel, className }: Props) {
const last = useRef(0); const last = useRef(0);
const active = useRef(false); const active = useRef(false);
return ( return (
<div <div
className={`splitter ${direction}`} className={`splitter ${direction}${className ? ` ${className}` : ""}`}
role="separator" role="separator"
aria-orientation={direction === "vertical" ? "vertical" : "horizontal"} aria-orientation={direction === "vertical" ? "vertical" : "horizontal"}
aria-label={ariaLabel ?? t("Resize panes")} aria-label={ariaLabel ?? t("Resize panes")}
@@ -47,8 +48,13 @@ export function Splitter({ direction, onResize, onEnd, onReset, ariaLabel }: Pro
onEnd?.(); onEnd?.();
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "ArrowLeft" || e.key === "ArrowUp") onResize(-24); const step = e.key === "ArrowLeft" || e.key === "ArrowUp" ? -24 : e.key === "ArrowRight" || e.key === "ArrowDown" ? 24 : 0;
if (e.key === "ArrowRight" || e.key === "ArrowDown") onResize(24); if (!step) return;
e.preventDefault();
// A key press is a whole drag in one: without the end, the size moved on
// screen and was never saved.
onResize(step);
onEnd?.();
}} }}
> >
<span className="splitter-grip" /> <span className="splitter-grip" />
+57 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useRef, 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, ShieldCheck, Sun, Upload, Users, X } from "lucide-react"; import { BookOpen, Calendar, ChevronsUpDown, FolderOpen, Globe, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, ShieldCheck, Sun, Upload, Users, X } from "lucide-react";
import { useSession } from "@/store/session"; import { useSession } from "@/store/session";
@@ -10,6 +10,7 @@ import { useMail } from "@/store/mail";
import { draftFromMailto, useCompose } from "@/store/compose"; import { draftFromMailto, useCompose } from "@/store/compose";
import { Avatar, useIsMobile } from "@/ui/misc"; import { Avatar, useIsMobile } from "@/ui/misc";
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover"; import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
import { Splitter } from "@/ui/Splitter";
import { SearchBar } from "./SearchBar"; import { SearchBar } from "./SearchBar";
import { MailboxTree } from "./mail/MailboxTree"; import { MailboxTree } from "./mail/MailboxTree";
import { FilesTree } from "./files/FilesTree"; import { FilesTree } from "./files/FilesTree";
@@ -25,6 +26,16 @@ import { hasAdministration } from "@/lib/adminAccess";
import { usePermissions } from "./admin/usePermissions"; import { usePermissions } from "./admin/usePermissions";
import { AdminNav } from "./admin/AdminNav"; import { AdminNav } from "./admin/AdminNav";
/*
* How far the sidebar edge can be dragged. Below about 228px the module bar
* cuts "Calendar" and "Contacts" short in English; the floor sits a little
* above that. Long folder names are allowed to ellipsise -- narrowing the pane
* is asking for that. The ceiling keeps a list and a reading pane beside it on
* an ordinary laptop screen.
*/
const SIDEBAR_MIN = 240;
const SIDEBAR_MAX = 480;
const PUSH_LABEL = { const PUSH_LABEL = {
connected: "Live updates connected", connected: "Live updates connected",
connecting: "Live updates reconnecting…", connecting: "Live updates reconnecting…",
@@ -35,7 +46,19 @@ export function AppShell({ children }: { children: ReactNode }) {
const [location, navigate] = useLocation(); const [location, navigate] = useLocation();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const collapsed = useSettings((s) => s.settings.sidebarCollapsed); const collapsed = useSettings((s) => s.settings.sidebarCollapsed);
const sidebarWidth = useSettings((s) => s.settings.sidebarWidth);
const update = useSettings((s) => s.update); const update = useSettings((s) => s.update);
/*
* The width while a drag is in progress, kept here and written to settings
* once on release -- the same arrangement as the message-list splitter, so a
* drag is a re-render per frame and not a localStorage write per frame.
*/
const [liveSidebarWidth, setLiveSidebarWidth] = useState<number | null>(null);
// The same value, readable in the same tick it was set: a key press resizes
// and ends in one go, before any render could hand the state back.
const liveSidebarRef = useRef<number | null>(null);
const sidebarRef = useRef<HTMLElement>(null);
const shownSidebarWidth = liveSidebarWidth ?? sidebarWidth;
const [drawer, setDrawer] = useState(false); const [drawer, setDrawer] = useState(false);
const [helpOpen, setHelpOpen] = useState(false); const [helpOpen, setHelpOpen] = useState(false);
const openCompose = useCompose((s) => s.open); const openCompose = useCompose((s) => s.open);
@@ -185,9 +208,12 @@ export function AppShell({ children }: { children: ReactNode }) {
</div> </div>
</header> </header>
<div className={`app-body ${collapsed && !isMobile ? "collapsed" : ""}`}> <div
className={`app-body ${collapsed && !isMobile ? "collapsed" : ""} ${liveSidebarWidth != null ? "resizing" : ""}`}
style={shownSidebarWidth != null && !isMobile ? ({ "--sidebar-w": `${shownSidebarWidth}px` } as React.CSSProperties) : undefined}
>
<div className={`drawer-backdrop ${drawer ? "open" : ""}`} onClick={() => setDrawer(false)} /> <div className={`drawer-backdrop ${drawer ? "open" : ""}`} onClick={() => setDrawer(false)} />
<aside className={`sidebar ${drawer ? "open" : ""}`}> <aside ref={sidebarRef} className={`sidebar ${drawer ? "open" : ""}`}>
{/* {/*
The way back out. The way back out.
@@ -240,6 +266,34 @@ export function AppShell({ children }: { children: ReactNode }) {
<ModuleLink href="/files" icon={<FolderOpen size={20} />} label={t("Files")} active={section === "files"} /> <ModuleLink href="/files" icon={<FolderOpen size={20} />} label={t("Files")} active={section === "files"} />
</nav> </nav>
</aside> </aside>
{/* Not on a phone, where the sidebar is a drawer over the page, and not
while collapsed to icons, where there is no width to choose. */}
{!isMobile && !collapsed && (
<Splitter
direction="vertical"
className="sidebar-splitter"
ariaLabel={t("Resize sidebar")}
onResize={(delta) => {
// From the setting once there is one. Before that it is null and says
// nothing about a width set in the reader's own CSS, so the first
// drag starts from what is on screen. Not always from the screen:
// the width eases, and a second key press lands mid-transition,
// where the measured width is still the old one.
const start = liveSidebarRef.current ?? useSettings.getState().settings.sidebarWidth ?? sidebarRef.current?.getBoundingClientRect().width ?? 256;
const max = Math.max(SIDEBAR_MIN, Math.min(SIDEBAR_MAX, window.innerWidth - 600));
const next = Math.round(Math.min(max, Math.max(SIDEBAR_MIN, start + delta)));
liveSidebarRef.current = next;
setLiveSidebarWidth(next);
}}
onEnd={() => {
const width = liveSidebarRef.current;
liveSidebarRef.current = null;
setLiveSidebarWidth(null);
if (width != null) update({ sidebarWidth: width });
}}
onReset={() => update({ sidebarWidth: null })}
/>
)}
{/* {/*
Scoped to the content, not the shell. If Chrome's translator breaks a Scoped to the content, not the shell. If Chrome's translator breaks a
message list, the top bar, the folder tree and any open composer are message list, the top bar, the folder tree and any open composer are
+10 -4
View File
@@ -385,18 +385,24 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
const layoutRef = useRef<HTMLDivElement>(null); const layoutRef = useRef<HTMLDivElement>(null);
const updateSettings = useSettings((s) => s.update); const updateSettings = useSettings((s) => s.update);
const [liveSize, setLiveSize] = useState<number | null>(null); const [liveSize, setLiveSize] = useState<number | null>(null);
// Mirrors liveSize for the end of a key press, which follows the resize in
// the same tick -- before the state could have come back round.
const liveSizeRef = useRef<number | null>(null);
const paneSize = liveSize ?? (settings.readingPane === "bottom" ? settings.listPaneHeight : settings.listPaneWidth); const paneSize = liveSize ?? (settings.readingPane === "bottom" ? settings.listPaneHeight : settings.listPaneWidth);
const onSplit = (delta: number) => { const onSplit = (delta: number) => {
const el = layoutRef.current; const el = layoutRef.current;
const total = el ? (settings.readingPane === "bottom" ? el.clientHeight : el.clientWidth) : 1200; const total = el ? (settings.readingPane === "bottom" ? el.clientHeight : el.clientWidth) : 1200;
const min = settings.readingPane === "bottom" ? 160 : 320; const min = settings.readingPane === "bottom" ? 160 : 320;
const max = Math.max(min, total - (settings.readingPane === "bottom" ? 200 : 420)); const max = Math.max(min, total - (settings.readingPane === "bottom" ? 200 : 420));
setLiveSize((cur) => Math.min(max, Math.max(min, (cur ?? paneSize) + delta))); const next = Math.min(max, Math.max(min, (liveSizeRef.current ?? paneSize) + delta));
liveSizeRef.current = next;
setLiveSize(next);
}; };
const onSplitEnd = () => { const onSplitEnd = () => {
if (liveSize == null) return; const size = liveSizeRef.current;
updateSettings(settings.readingPane === "bottom" ? { listPaneHeight: liveSize } : { listPaneWidth: liveSize }); liveSizeRef.current = null;
setLiveSize(null); setLiveSize(null);
if (size != null) updateSettings(settings.readingPane === "bottom" ? { listPaneHeight: size } : { listPaneWidth: size });
}; };
return ( return (
@@ -416,7 +422,7 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
/> />
)} )}
{showList && showReading && settings.readingPane !== "off" && !narrow && ( {showList && showReading && settings.readingPane !== "off" && !narrow && (
<Splitter direction={settings.readingPane === "bottom" ? "horizontal" : "vertical"} onResize={onSplit} onEnd={onSplitEnd} onReset={() => updateSettings(settings.readingPane === "bottom" ? { listPaneHeight: 340 } : { listPaneWidth: 520 })} ariaLabel="Resize message list" /> <Splitter direction={settings.readingPane === "bottom" ? "horizontal" : "vertical"} onResize={onSplit} onEnd={onSplitEnd} onReset={() => updateSettings(settings.readingPane === "bottom" ? { listPaneHeight: 340 } : { listPaneWidth: 520 })} ariaLabel={translate("Resize message list")} />
)} )}
{showReading && ( {showReading && (
<div className="mail-reading-pane"> <div className="mail-reading-pane">