feat(design): the NavRail primitive with optional short labels, the flat surfaces onto roles (E28 S3d3, B7/B5)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user