feat(design): the NavRail primitive with optional short labels, the flat surfaces onto roles (E28 S3d3, B7/B5)

This commit is contained in:
2026-09-25 00:47:46 +02:00
parent 6354454cc9
commit 7fa8f4ebde
20 changed files with 342 additions and 101 deletions
+41 -63
View File
@@ -11,17 +11,24 @@ import * as e2ee from "../lib/e2ee";
import { useLiveQuery } from "../lib/useLiveQuery";
import { getUnreadCount, subscribe } from "../lib/notifications";
import type { Page } from "../lib/urlState";
import { moduleDef, moduleLabelKey, moduleOrder, SYSTEM_PAGES } from "../lib/modules";
import {
moduleDef,
moduleLabelKey,
moduleOrder,
moduleRailKey,
SYSTEM_PAGES,
} from "../lib/modules";
import { useScrollFade } from "../lib/useScrollFade";
import { type LangCode } from "../i18n";
import { IconButton } from "./ui/IconButton";
import AvatarImg from "./ui/Avatar";
import Tooltip from "./ui/Tooltip";
import LanguageSwitcher from "./LanguageSwitcher";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
import { NavRail, NavRailDivider, NavRailItem, RAIL_ROW } from "./ui/NavRail";
import { usePrefs } from "./prefsContext";
// Primary app navigation: a thin icon-only left rail. The modules used to hide under the avatar
// dropdown; they now live here, each revealing its name in a hover/focus flyout label. Account
@@ -157,65 +164,38 @@ export default function NavSidebar({
.filter((p) => sys.has(p))
.map((p) => ({ page: p, ...META(p) }));
const rowBase =
"selectable w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition";
const { railLabels } = usePrefs();
const name = displayName(me);
// Role is surfaced as a plain title tooltip on the account row (no visual badge).
const roleLabel = t(`nav.role.${roleKey(me)}`);
const renderItem = ({ page: p, icon: Icon, label, badge }: NavItem) => {
const active = page === p;
// On the active row the background is the accent colour, so a same-accent badge would be
// red-on-red. Invert it there (light pill, accent-coloured number) for clean contrast.
const badgeColor = active ? "bg-accent-fg text-accent" : "bg-accent text-accent-fg";
const show = !!badge && badge > 0;
return (
// The name rides in a right-side flyout on hover (rest-to-show), so the rail stays icon-only.
<Tooltip key={p} hint={label} side="right" always openDelay={350}>
<Pressable
data-testid={`nav-${p}`}
onClick={() => setPage(p)}
aria-label={label}
aria-current={active ? "page" : undefined}
className={`${rowBase} justify-center px-0 relative is-current`}
>
<span className="relative shrink-0">
<Icon className="w-4.5 h-4.5" />
{/* A numeric badge centred on a circle at the icon corner; the ring matches the row
background so it reads cleanly whether the row is active or not. */}
{show && (
<span
className={`absolute -top-2 -right-2 min-w-4 h-4 px-1 rounded-full text-3xs font-bold leading-none grid place-items-center ring-2 ${badgeColor} ${
active ? "ring-accent" : "ring-bg"
}`}
>
{badge > 9 ? "9+" : badge}
</span>
)}
</span>
</Pressable>
</Tooltip>
);
};
const renderItem = ({ page: p, icon, label, badge }: NavItem) => (
<NavRailItem
key={p}
testId={`nav-${p}`}
icon={icon}
label={label}
shortLabel={t(moduleRailKey[p])}
showLabel={railLabels}
active={page === p}
badge={badge}
onClick={() => setPage(p)}
/>
);
return (
// Slim icon rail, flush against the left viewport edge (only top/bottom margin), never grows.
// The slot is exactly the card's width so the filter tab/bar docks flush against its right edge.
<div className="relative shrink-0 w-14">
<nav
className={`${SURFACE.panel} absolute top-0 bottom-0 left-0 z-rail flex flex-col py-3 my-3 w-14 px-2`}
aria-label={t("nav.primary")}
>
// Slim rail (B7), flush against the left viewport edge (only top/bottom margin), never grows —
// labels on or off. The slot is exactly the rail's width so the filter tab/bar docks flush
// against its right edge.
<div className="relative shrink-0 w-15">
<NavRail label={t("nav.primary")} className="absolute top-0 bottom-0 left-0 z-rail my-3">
<div
ref={listFade.ref}
className="flex-1 min-h-0 overflow-y-auto no-scrollbar flex flex-col gap-1"
className="flex-1 min-h-0 overflow-y-auto no-scrollbar flex flex-col gap-0.5"
style={listFade.style}
>
{userItems.map(renderItem)}
{systemItems.length > 0 && (
// A short, thicker centred rule is the clearest "new section" cue on an icon-only rail.
<div className="my-2 mx-auto w-7 border-t-2 border-border" />
)}
{systemItems.length > 0 && <NavRailDivider />}
{systemItems.map(renderItem)}
</div>
@@ -239,17 +219,15 @@ export default function NavSidebar({
<LogOut className="w-5 h-5" />
</IconButton>
</div>
<Tooltip hint={t("header.account.settings")} side="right" always openDelay={350}>
<Pressable
data-testid="nav-settings"
onClick={() => setPage("settings")}
aria-label={t("header.account.settings")}
aria-current={page === "settings" ? "page" : undefined}
className={`${rowBase} justify-center px-0 is-current`}
>
<Settings className="w-4.5 h-4.5 shrink-0" />
</Pressable>
</Tooltip>
<NavRailItem
testId="nav-settings"
icon={Settings}
label={t("header.account.settings")}
shortLabel={t(moduleRailKey.settings)}
showLabel={railLabels}
active={page === "settings"}
onClick={() => setPage("settings")}
/>
<div>
<Pressable
@@ -257,7 +235,7 @@ export default function NavSidebar({
{...acctPop.getReferenceProps()}
title={`${name} · ${roleLabel}`}
aria-label={`${name} · ${roleLabel}`}
className={`${rowBase} justify-center px-0 text-muted hover:text-fg hover:bg-card`}
className={`${RAIL_ROW} h-9 text-muted state-layer hover:text-fg`}
>
<span className="relative shrink-0">
<AvatarImg
@@ -353,7 +331,7 @@ export default function NavSidebar({
</PopoverSurface>
</div>
</div>
</nav>
</NavRail>
</div>
);
}