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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user