Let the sidebar be resized by dragging its edge

The sidebar's right edge is now a splitter, like the one between the
message list and the reading pane: drag it between 240 and 480px, move it
with the arrow keys, double-click to put it back. The width is a device
setting, and stays null until someone drags, so a width set in the
reader's own CSS through --sidebar-w is kept until they choose otherwise.
Hidden on a phone, where the sidebar is a drawer, and while collapsed.

Arrow keys on either splitter moved the pane and never saved it: the
keyboard path called onResize without onEnd. It ends each key press now,
and both views keep the in-progress size in a ref as well as state, so
the end reads the value set in the same tick.

The message-list splitter's accessible name was an untranslated literal.
It goes through translate() now, and it and the sidebar's new name are in
all nine catalogues.

Closes #345
This commit is contained in:
2026-09-14 07:54:56 -07:00
parent 1dc0caeae9
commit abb07acc80
16 changed files with 120 additions and 16 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ describe("which settings follow the account", () => {
const synced = syncedPart(DEFAULT_SETTINGS);
// A pane width picked on a monitor is wrong on a laptop, and the
// 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);
}
});
+2
View File
@@ -1212,6 +1212,8 @@ export const catalog: Catalog = {
"Maximize": "Maximieren",
"Full screen": "Vollbild",
"Resize panes": "Bereichsgröße ändern",
"Resize message list": "Größe der Nachrichtenliste ändern",
"Resize sidebar": "Größe der Seitenleiste ändern",
"QR code": "QR-Code",
"Draft discarded": "Entwurf verworfen",
"Draft saved": "Entwurf gespeichert",
+2
View File
@@ -1185,6 +1185,8 @@ export const catalog: Catalog = {
"Maximize": "Maximizar",
"Full screen": "Pantalla completa",
"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",
"Draft discarded": "Borrador descartado",
"Draft saved": "Borrador guardado",
+2
View File
@@ -1190,6 +1190,8 @@ export const catalog: Catalog = {
"Maximize": "Agrandir",
"Full screen": "Plein écran",
"Resize panes": "Redimensionner les volets",
"Resize message list": "Redimensionner la liste des messages",
"Resize sidebar": "Redimensionner la barre latérale",
"QR code": "QR code",
"Draft discarded": "Brouillon abandonné",
"Draft saved": "Brouillon enregistré",
+2
View File
@@ -1193,6 +1193,8 @@ export const catalog: Catalog = {
"Maximize": "最大化",
"Full screen": "全画面",
"Resize panes": "ペインの大きさを変更",
"Resize message list": "メール一覧の大きさを変更",
"Resize sidebar": "サイドバーの大きさを変更",
"QR code": "QR コード",
"Draft discarded": "下書きを破棄しました",
"Draft saved": "下書きを保存しました",
+2
View File
@@ -1181,6 +1181,8 @@ export const catalog: Catalog = {
"Maximize": "Maximaliseren",
"Full screen": "Volledig scherm",
"Resize panes": "Vensterdelen verslepen",
"Resize message list": "Grootte van de berichtenlijst wijzigen",
"Resize sidebar": "Grootte van de zijbalk wijzigen",
"QR code": "QR-code",
"Draft discarded": "Concept weggegooid",
"Draft saved": "Concept opgeslagen",
+2
View File
@@ -1188,6 +1188,8 @@ export const catalog: Catalog = {
"Maximize": "Maximizar",
"Full screen": "Tela cheia",
"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",
"Draft discarded": "Rascunho descartado",
"Draft saved": "Rascunho salvo",
+2
View File
@@ -1187,6 +1187,8 @@ export const catalog: Catalog = {
"Maximize": "Развернуть",
"Full screen": "Во весь экран",
"Resize panes": "Изменить размер панелей",
"Resize message list": "Изменить размер списка писем",
"Resize sidebar": "Изменить размер боковой панели",
"QR code": "QR-код",
"Draft discarded": "Черновик удалён",
"Draft saved": "Черновик сохранён",
+2
View File
@@ -1181,6 +1181,8 @@ export const catalog: Catalog = {
"Maximize": "Розгорнути",
"Full screen": "На весь екран",
"Resize panes": "Змінити розмір панелей",
"Resize message list": "Змінити розмір списку листів",
"Resize sidebar": "Змінити розмір бічної панелі",
"QR code": "QR-код",
"Draft discarded": "Чернетку видалено",
"Draft saved": "Чернетку збережено",
+2
View File
@@ -1192,6 +1192,8 @@ export const catalog: Catalog = {
"Maximize": "最大化",
"Full screen": "全屏",
"Resize panes": "调整窗格大小",
"Resize message list": "调整邮件列表大小",
"Resize sidebar": "调整侧边栏大小",
"QR code": "二维码",
"Draft discarded": "草稿已放弃",
"Draft saved": "草稿已保存",
+9
View File
@@ -256,6 +256,13 @@ export interface Settings {
listPaneWidth: number;
/** Height (px) of the message list when the reading pane is below. */
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. */
eventCategories: Array<{ name: string; color: string }>;
/** Default sending identity per account (JMAP has no such flag). */
@@ -370,6 +377,7 @@ export const DEFAULT_SETTINGS: Settings = {
sendAndArchive: false,
listPaneWidth: 520,
listPaneHeight: 340,
sidebarWidth: null,
eventCategories: [
{ name: "Important", color: "#dc2626" },
{ name: "Work", color: "#2563eb" },
@@ -400,6 +408,7 @@ export const DEVICE_KEYS: ReadonlySet<keyof Settings> = new Set<keyof Settings>(
"notificationSound",
"listPaneWidth",
"listPaneHeight",
"sidebarWidth",
]);
/** 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; } }
@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); }
/* 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-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; }
+10 -4
View File
@@ -7,15 +7,16 @@ interface Props {
onEnd?: () => void;
onReset?: () => void;
ariaLabel?: string;
className?: string;
}
/** 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 active = useRef(false);
return (
<div
className={`splitter ${direction}`}
className={`splitter ${direction}${className ? ` ${className}` : ""}`}
role="separator"
aria-orientation={direction === "vertical" ? "vertical" : "horizontal"}
aria-label={ariaLabel ?? t("Resize panes")}
@@ -47,8 +48,13 @@ export function Splitter({ direction, onResize, onEnd, onReset, ariaLabel }: Pro
onEnd?.();
}}
onKeyDown={(e) => {
if (e.key === "ArrowLeft" || e.key === "ArrowUp") onResize(-24);
if (e.key === "ArrowRight" || e.key === "ArrowDown") onResize(24);
const step = e.key === "ArrowLeft" || e.key === "ArrowUp" ? -24 : e.key === "ArrowRight" || e.key === "ArrowDown" ? 24 : 0;
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" />
+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 { 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";
@@ -10,6 +10,7 @@ import { useMail } from "@/store/mail";
import { draftFromMailto, useCompose } from "@/store/compose";
import { Avatar, useIsMobile } from "@/ui/misc";
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
import { Splitter } from "@/ui/Splitter";
import { SearchBar } from "./SearchBar";
import { MailboxTree } from "./mail/MailboxTree";
import { FilesTree } from "./files/FilesTree";
@@ -25,6 +26,16 @@ import { hasAdministration } from "@/lib/adminAccess";
import { usePermissions } from "./admin/usePermissions";
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 = {
connected: "Live updates connected",
connecting: "Live updates reconnecting…",
@@ -35,7 +46,19 @@ export function AppShell({ children }: { children: ReactNode }) {
const [location, navigate] = useLocation();
const isMobile = useIsMobile();
const collapsed = useSettings((s) => s.settings.sidebarCollapsed);
const sidebarWidth = useSettings((s) => s.settings.sidebarWidth);
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 [helpOpen, setHelpOpen] = useState(false);
const openCompose = useCompose((s) => s.open);
@@ -185,9 +208,12 @@ export function AppShell({ children }: { children: ReactNode }) {
</div>
</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)} />
<aside className={`sidebar ${drawer ? "open" : ""}`}>
<aside ref={sidebarRef} className={`sidebar ${drawer ? "open" : ""}`}>
{/*
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"} />
</nav>
</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
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 updateSettings = useSettings((s) => s.update);
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 onSplit = (delta: number) => {
const el = layoutRef.current;
const total = el ? (settings.readingPane === "bottom" ? el.clientHeight : el.clientWidth) : 1200;
const min = settings.readingPane === "bottom" ? 160 : 320;
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 = () => {
if (liveSize == null) return;
updateSettings(settings.readingPane === "bottom" ? { listPaneHeight: liveSize } : { listPaneWidth: liveSize });
const size = liveSizeRef.current;
liveSizeRef.current = null;
setLiveSize(null);
if (size != null) updateSettings(settings.readingPane === "bottom" ? { listPaneHeight: size } : { listPaneWidth: size });
};
return (
@@ -416,7 +422,7 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
/>
)}
{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 && (
<div className="mail-reading-pane">