App rail, pill counts, tinted folder icons and raised controls
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 3m4s
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 3m4s
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
On a wide screen the switcher moves from the foot of the folder pane to a
rail down the left edge: Mail (with the inbox's unread badge), Calendar,
Contacts and Files, then Settings and the folder-list toggle at the
bottom. The top bar loses the menu button and the Settings gear there,
both now on the rail. Phones are unchanged: no rail, the tab bar, the
menu button for the drawer and the gear.
- Folder counts are pills: filled with the accent when there is unread
mail, neutral for totals (Drafts, Scheduled).
- Role folders get their own icon tint (Inbox, Drafts, Sent, Archive,
Junk, Trash, Scheduled); a color the reader picked still wins.
- Buttons, Compose and the current selection have a top-lit, raised look
with a pressed state; Compose and the selected rail item are filled
with the accent.
- Collapsing the sidebar keeps Mail's icon strip; Calendar, Contacts,
Files, Settings and Admin have no icon-only form, so their sidebar
hides instead of being crushed.
- The collapsed sidebar no longer draws a cut-off "FOLDERS": the rule
hiding section headings lost to a later one of the same weight (this
is on main today too).
Contrast: the accent behind small text is darkened in light themes and
lightened a touch in dark ones. Measured in a browser across all 12
palettes x 6 accents x both modes: text on pills, the rail badge and
Compose at least 4.83:1, tinted icons at least 3.14:1 on the sidebar.
New strings: 2 ("Show folder list", "Hide folder list"), in all ten
catalogs (1699 -> 1701).
This commit is contained in:
1 parent
3434a5ed39
commit
5fe929c6a4
15 files changed
+248
-12
No files matched your search
+47
-10
@@ -1,6 +1,6 @@
|
||||
import { lazy, Suspense, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { Calendar, Check, ChevronsUpDown, FolderOpen, Globe, HelpCircle, Lock, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, ShieldCheck, Sun, Upload, Users, X } from "lucide-react";
|
||||
import { Calendar, Check, ChevronsUpDown, FolderOpen, Globe, HelpCircle, Lock, LogOut, Mail, Menu as MenuIcon, Moon, PanelLeftClose, PanelLeftOpen, PenSquare, Plus, RefreshCw, Settings, ShieldCheck, Sun, Upload, Users, X } from "lucide-react";
|
||||
import { useSession } from "@/store/session";
|
||||
import { DEFAULT_APP_NAME, brandImage } from "@/lib/brand";
|
||||
import { InbuxaWordmark } from "@/ui/InbuxaWordmark";
|
||||
@@ -52,6 +52,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
const [location, navigate] = useLocation();
|
||||
const isMobile = useIsMobile();
|
||||
const collapsed = useSettings((s) => s.settings.sidebarCollapsed);
|
||||
const inboxUnread = useMail((s) => Object.values(s.mailboxes).find((m) => m.role === "inbox")?.unreadEmails ?? 0);
|
||||
const sidebarWidth = useSettings((s) => s.settings.sidebarWidth);
|
||||
const update = useSettings((s) => s.update);
|
||||
/*
|
||||
@@ -166,9 +167,12 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<div className={`app ${viewing ? "delegated" : ""}`}>
|
||||
<DelegatedBar />
|
||||
<header className="topbar">
|
||||
<button className="icon-btn" aria-label={t("Menu")} onClick={() => (isMobile ? setDrawer((d) => !d) : update({ sidebarCollapsed: !collapsed }))}>
|
||||
<MenuIcon size={22} />
|
||||
</button>
|
||||
{/* On a wide screen the folder list is toggled from the bottom of the rail; phones keep the menu button for the drawer. */}
|
||||
{isMobile && (
|
||||
<button className="icon-btn" aria-label={t("Menu")} onClick={() => setDrawer((d) => !d)}>
|
||||
<MenuIcon size={22} />
|
||||
</button>
|
||||
)}
|
||||
<Link href="/mail" className={`brand ${viewing ? "locked" : ""}`}>
|
||||
<img src={brandImage(appName === DEFAULT_APP_NAME ? "/img/inbuxa-mark.png" : "/img/logo.png")} alt="" />
|
||||
{viewing && <Lock size={18} className="brand-lock" aria-label={t("Locked account")} />}
|
||||
@@ -193,9 +197,12 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<HelpCircle size={21} />
|
||||
</button>
|
||||
<ThemeToggle />
|
||||
<Link href="/settings" className={`icon-btn ${section === "settings" ? "active" : ""}`} aria-label={t("Settings")} title={t("Settings")}>
|
||||
<Settings size={21} />
|
||||
</Link>
|
||||
{/* On a wide screen the rail carries Settings; phones have no rail, so they keep this one. */}
|
||||
{isMobile && (
|
||||
<Link href="/settings" className={`icon-btn ${section === "settings" ? "active" : ""}`} aria-label={t("Settings")} title={t("Settings")}>
|
||||
<Settings size={21} />
|
||||
</Link>
|
||||
)}
|
||||
<button className="icon-btn" style={{ width: "auto", padding: "0 2px", borderRadius: 999 }} onClick={acctMenu.open} aria-label={t("Account")}>
|
||||
<Avatar who={{ name: session?.username, email: session?.username }} size="sm" />
|
||||
</button>
|
||||
@@ -263,10 +270,30 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
</header>
|
||||
|
||||
<div
|
||||
className={`app-body ${collapsed && !isMobile ? "collapsed" : ""} ${liveSidebarWidth != null ? "resizing" : ""}`}
|
||||
className={`app-body ${!isMobile ? "has-rail" : ""} ${collapsed && !isMobile ? "collapsed" : ""} ${collapsed && !isMobile && section !== "mail" && section !== "search" ? "sidebar-hidden" : ""} ${liveSidebarWidth != null ? "resizing" : ""}`}
|
||||
style={shownSidebarWidth != null && !isMobile ? ({ "--sidebar-w": `${shownSidebarWidth}px` } as React.CSSProperties) : undefined}
|
||||
>
|
||||
<div className={`drawer-backdrop ${drawer ? "open" : ""}`} onClick={() => setDrawer(false)} />
|
||||
{!isMobile && (
|
||||
<nav className="app-rail" aria-label={t("Go to")}>
|
||||
<RailLink href="/mail" icon={<Mail size={20} />} label={t("Mail")} active={section === "mail" || section === "search"} badge={inboxUnread} />
|
||||
<RailLink href="/calendar" icon={<Calendar size={20} />} label={t("Calendar")} active={section === "calendar"} />
|
||||
<RailLink href="/contacts" icon={<Users size={20} />} label={t("Contacts")} active={section === "contacts"} />
|
||||
<RailLink href="/files" icon={<FolderOpen size={20} />} label={t("Files")} active={section === "files"} />
|
||||
<span className="app-rail-spacer" />
|
||||
<RailLink href="/settings" icon={<Settings size={20} />} label={t("Settings")} active={section === "settings"} />
|
||||
<button
|
||||
type="button"
|
||||
className="rail-link rail-toggle"
|
||||
aria-label={collapsed ? t("Show folder list") : t("Hide folder list")}
|
||||
title={collapsed ? t("Show folder list") : t("Hide folder list")}
|
||||
aria-expanded={!collapsed}
|
||||
onClick={() => update({ sidebarCollapsed: !collapsed })}
|
||||
>
|
||||
{collapsed ? <PanelLeftOpen size={20} /> : <PanelLeftClose size={20} />}
|
||||
</button>
|
||||
</nav>
|
||||
)}
|
||||
<aside ref={sidebarRef} className={`sidebar ${drawer ? "open" : ""}`}>
|
||||
{/*
|
||||
The way back out.
|
||||
@@ -316,12 +343,12 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
{section === "admin" && <AdminNav />}
|
||||
</div>
|
||||
{(section === "mail" || section === "search") && <QuotaBar />}
|
||||
<nav className="module-bar" aria-label={t("Go to")}>
|
||||
{isMobile && <nav className="module-bar" aria-label={t("Go to")}>
|
||||
<ModuleLink href="/mail" icon={<Mail size={20} />} label={t("Mail")} active={section === "mail" || section === "search"} />
|
||||
<ModuleLink href="/calendar" icon={<Calendar size={20} />} label={t("Calendar")} active={section === "calendar"} />
|
||||
<ModuleLink href="/contacts" icon={<Users size={20} />} label={t("Contacts")} active={section === "contacts"} />
|
||||
<ModuleLink href="/files" icon={<FolderOpen size={20} />} label={t("Files")} active={section === "files"} />
|
||||
</nav>
|
||||
</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. */}
|
||||
@@ -406,6 +433,16 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The app rail down the left edge, which replaces the module bar on a wide screen. */
|
||||
function RailLink({ href, icon, label, active, badge = 0 }: { href: string; icon: ReactNode; label: string; active: boolean; badge?: number }) {
|
||||
return (
|
||||
<Link href={href} className={`rail-link ${active ? "active" : ""}`} title={label} aria-label={label} aria-current={active ? "page" : undefined}>
|
||||
{icon}
|
||||
{badge > 0 && <span className="rail-badge">{badge > 999 ? "999+" : badge}</span>}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/** Outlook-style module switcher at the bottom of the folder pane. */
|
||||
function ModuleLink({ href, icon, label, active }: { href: string; icon: ReactNode; label: string; active: boolean }) {
|
||||
return (
|
||||
|
||||
@@ -438,7 +438,7 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
|
||||
>
|
||||
{twisty ? open ? <ChevronDown size={14} /> : <ChevronRight size={14} /> : null}
|
||||
</span>
|
||||
<span className="folder-icon" style={tint ? ({ "--folder-color": tint } as React.CSSProperties) : undefined}>{icon}</span>
|
||||
<span className="folder-icon" data-role={m.role ?? (scheduled ? "scheduled" : undefined)} style={tint ? ({ "--folder-color": tint } as React.CSSProperties) : undefined}>{icon}</span>
|
||||
<span className="nav-label">{label}</span>
|
||||
{count > 0 && <span className="nav-count" title={hiddenUnread ? t("{here} here, {inSubfolders} in subfolders", { here: own, inSubfolders: hiddenUnread }) : undefined}>{count > 9999 ? "9999+" : count}</span>}
|
||||
{count > 0 && <span className="nav-dot" />}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { MailboxTree } from "../MailboxTree";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import type { Mailbox, MailboxRole } from "@/jmap/types";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
window.matchMedia = ((q: string) => ({ matches: false, media: q, addEventListener() {}, removeEventListener() {} })) as unknown as typeof window.matchMedia;
|
||||
|
||||
/*
|
||||
* Role folders get their own icon tint, read from the icon's data-role; a
|
||||
* plain folder has none, and a color the reader chose still applies.
|
||||
*/
|
||||
|
||||
const rights = { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true };
|
||||
const box = (id: string, name: string, role: MailboxRole, unread = 0): Mailbox => ({
|
||||
id, name, parentId: null, role, sortOrder: 0, totalEmails: unread, unreadEmails: unread, totalThreads: unread, unreadThreads: unread, myRights: rights, isSubscribed: true,
|
||||
});
|
||||
|
||||
describe("folder icons by role", () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
beforeEach(() => {
|
||||
window.history.replaceState({}, "", "/mail/inbox");
|
||||
useMail.setState({
|
||||
mailboxes: { inbox: box("inbox", "Inbox", "inbox", 3), junk: box("junk", "Junk", "junk", 1), work: box("work", "Work", null, 2) },
|
||||
mailboxesLoaded: true,
|
||||
});
|
||||
useSettings.setState((s) => ({ settings: { ...s.settings, showHiddenFolders: false, labelsSidebar: false, folderColors: { junk: "#123456" } } }));
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
act(() => root.render(<MailboxTree />));
|
||||
});
|
||||
afterEach(() => { act(() => root.unmount()); host.remove(); });
|
||||
|
||||
const icons = () => Array.from(document.querySelectorAll<HTMLElement>(".nav-item.folder-row .folder-icon"));
|
||||
const byRole = (role: string) => icons().find((i) => i.dataset.role === role)!;
|
||||
|
||||
it("marks role folders with their role and leaves a plain folder unmarked", () => {
|
||||
expect(icons().map((i) => i.dataset.role ?? null).sort()).toEqual(["inbox", "junk", null].sort());
|
||||
});
|
||||
|
||||
it("keeps the reader's own color on a role folder", () => {
|
||||
expect(byRole("junk").style.getPropertyValue("--folder-color")).toBe("#123456");
|
||||
});
|
||||
|
||||
it("shows unread counts as pills", () => {
|
||||
const pill = Array.from(document.querySelectorAll(".nav-item.folder-row.unread .nav-count")).map((p) => p.textContent);
|
||||
expect(pill).toEqual(expect.arrayContaining(["3", "1", "2"]));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user