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
+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