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:
+5
-3
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -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": "下書きを保存しました",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Черновик сохранён",
|
||||
|
||||
@@ -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": "Чернетку збережено",
|
||||
|
||||
@@ -1192,6 +1192,8 @@ export const catalog: Catalog = {
|
||||
"Maximize": "最大化",
|
||||
"Full screen": "全屏",
|
||||
"Resize panes": "调整窗格大小",
|
||||
"Resize message list": "调整邮件列表大小",
|
||||
"Resize sidebar": "调整侧边栏大小",
|
||||
"QR code": "二维码",
|
||||
"Draft discarded": "草稿已放弃",
|
||||
"Draft saved": "草稿已保存",
|
||||
|
||||
@@ -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
@@ -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
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user