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
+106
View File
@@ -0,0 +1,106 @@
import clsx from "clsx";
import type { ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
import { Pressable } from "./Pressable";
import { SURFACE } from "./surfaces";
import Tooltip from "./Tooltip";
// B7 (docs/DESIGN.md): the app's primary navigation rail. Slim — 3.75rem wide whether or not the
// labels are on (they add height, never width) — with "you are here" as a soft accent tint plus a 3px
// edge bar, calmer than a solid fill beside the page's own solid accents. Labels under the icons are
// a per-user setting (off by default); each module has a SHORT rail label that fits, and the full name
// is the tooltip.
/** The rail's column: the glass panel, the rail's width and its padding. */
export function NavRail({
label,
className,
children,
}: {
/** The landmark's accessible name ("Primary navigation"). */
label: string;
/** Placement only (position, height); the look and width are the primitive's. */
className?: string;
children: ReactNode;
}) {
return (
<nav
aria-label={label}
className={clsx(SURFACE.panel, "flex w-15 flex-col px-1 py-2.5", className)}
>
{children}
</nav>
);
}
/** A short, thick centred rule: the "new section" cue on an icon rail (the system modules). */
export function NavRailDivider() {
return <div className="mx-auto my-1.5 w-7 shrink-0 border-t-2 border-border" />;
}
/** The shape of one rail row, for a row that is not a page (the account button). */
export const RAIL_ROW =
"relative flex w-full flex-col items-center justify-center gap-0.5 rounded-lg transition";
export function NavRailItem({
icon: Icon,
label,
shortLabel,
showLabel,
active = false,
badge,
onClick,
testId,
}: {
icon: LucideIcon;
/** The full name: the tooltip, and the accessible name while the label is hidden. */
label: string;
/** What fits under the icon when labels are on. */
shortLabel: string;
showLabel: boolean;
active?: boolean;
/** An unread / in-progress count on the icon's corner (0 hides it). */
badge?: number;
onClick: () => void;
testId?: string;
}) {
const shown = !!badge && badge > 0;
return (
<Tooltip hint={label} side="right" always openDelay={350}>
<Pressable
data-testid={testId}
onClick={onClick}
// With the label visible the row's name IS that text (WCAG 2.5.3: the spoken name contains the
// visible one); the full name stays in the tooltip. Icon-only, the full name is the name.
// The count rides in the name too, so "Messages, 3" is heard and not only seen.
aria-label={showLabel ? undefined : shown ? `${label} (${badge})` : label}
aria-current={active ? "page" : undefined}
className={clsx(
RAIL_ROW,
showLabel ? "py-1.5" : "h-9",
active ? "bg-accent/15 text-accent" : "text-muted state-layer hover:text-fg",
)}
>
{active && (
<span
aria-hidden="true"
className="absolute top-1.5 bottom-1.5 -left-1 w-0.75 rounded-r-full bg-accent"
/>
)}
<span className="relative shrink-0">
<Icon className="size-4.5" aria-hidden="true" />
{shown && (
<span className="absolute -top-2 -right-2 grid h-4 min-w-4 place-items-center rounded-full bg-accent px-1 text-3xs leading-none font-bold text-accent-fg ring-2 ring-bg">
{badge > 9 ? "9+" : badge}
</span>
)}
</span>
{showLabel && (
<span className="max-w-full truncate text-3xs font-medium tracking-tight">
{shortLabel}
</span>
)}
</Pressable>
</Tooltip>
);
}