From abb07acc8064b5dfaee056aa93894ffc08d4ef95 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 14 Sep 2026 07:54:56 -0700 Subject: [PATCH] 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 --- FEATURES.md | 8 +-- web/src/lib/__tests__/settingsSync.test.ts | 2 +- web/src/locales/de.ts | 2 + web/src/locales/es.ts | 2 + web/src/locales/fr.ts | 2 + web/src/locales/ja.ts | 2 + web/src/locales/nl.ts | 2 + web/src/locales/pt-BR.ts | 2 + web/src/locales/ru.ts | 2 + web/src/locales/uk.ts | 2 + web/src/locales/zh-Hans.ts | 2 + web/src/store/settings.ts | 9 ++++ web/src/styles/app.css | 11 +++- web/src/ui/Splitter.tsx | 14 +++-- web/src/views/AppShell.tsx | 60 ++++++++++++++++++++-- web/src/views/mail/MailView.tsx | 14 +++-- 16 files changed, 120 insertions(+), 16 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 4596357..81d042e 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -71,9 +71,11 @@ per-account shape. ## Layout 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 -device — a width chosen on a 27" monitor is wrong on a laptop, so it is one of -the few settings that does not follow the account. +list and the reading pane is dragged to resize, and so is the edge of the +sidebar, between 240 and 480px; arrow keys move either one, and a double-click +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). - **Density** comfortable, cozy or compact, which changes row height as well as padding. diff --git a/web/src/lib/__tests__/settingsSync.test.ts b/web/src/lib/__tests__/settingsSync.test.ts index d9cefd6..64721d5 100644 --- a/web/src/lib/__tests__/settingsSync.test.ts +++ b/web/src/lib/__tests__/settingsSync.test.ts @@ -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); } }); diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index 6795bae..819f76d 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -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", diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index 0c691bf..c5f8e39 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -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", diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index 6a14911..3b87d2d 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -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é", diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index 4c770e8..393bc34 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -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": "下書きを保存しました", diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts index ee3f57d..4475be2 100644 --- a/web/src/locales/nl.ts +++ b/web/src/locales/nl.ts @@ -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", diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts index 6907e52..0e048a0 100644 --- a/web/src/locales/pt-BR.ts +++ b/web/src/locales/pt-BR.ts @@ -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", diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index 8cc6f07..8516127 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -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": "Черновик сохранён", diff --git a/web/src/locales/uk.ts b/web/src/locales/uk.ts index 7aeae8e..63ef1e7 100644 --- a/web/src/locales/uk.ts +++ b/web/src/locales/uk.ts @@ -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": "Чернетку збережено", diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts index 75073d8..5f2de10 100644 --- a/web/src/locales/zh-Hans.ts +++ b/web/src/locales/zh-Hans.ts @@ -1192,6 +1192,8 @@ export const catalog: Catalog = { "Maximize": "最大化", "Full screen": "全屏", "Resize panes": "调整窗格大小", + "Resize message list": "调整邮件列表大小", + "Resize sidebar": "调整侧边栏大小", "QR code": "二维码", "Draft discarded": "草稿已放弃", "Draft saved": "草稿已保存", diff --git a/web/src/store/settings.ts b/web/src/store/settings.ts index 30479c0..4ee9a27 100644 --- a/web/src/store/settings.ts +++ b/web/src/store/settings.ts @@ -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 = new Set( "notificationSound", "listPaneWidth", "listPaneHeight", + "sidebarWidth", ]); /** The part of the settings that is written to the account's settings file. */ diff --git a/web/src/styles/app.css b/web/src/styles/app.css index ab5daea..f8bbc62 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -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; } diff --git a/web/src/ui/Splitter.tsx b/web/src/ui/Splitter.tsx index 5fbebbe..364117c 100644 --- a/web/src/ui/Splitter.tsx +++ b/web/src/ui/Splitter.tsx @@ -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 (
{ - 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?.(); }} > diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx index bee945d..0d0e05f 100644 --- a/web/src/views/AppShell.tsx +++ b/web/src/views/AppShell.tsx @@ -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(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(null); + const sidebarRef = useRef(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 }) {
-
+
setDrawer(false)} /> -