With the labels on, the row's name was its text, badge first: 15 unread was heard as "9+ Alerts". The name is now the visible label plus the count, as the icon-only row's is, and the drawn badge is hidden.
163 lines
5.3 KiB
TypeScript
163 lines
5.3 KiB
TypeScript
import clsx from "clsx";
|
|
import type { CSSProperties, ReactNode, Ref } 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>
|
|
);
|
|
}
|
|
|
|
/** The rail's scrolling list of pages. An overflow container clips on BOTH axes, so the list spans the
|
|
* rail's padding (`-mx-1 px-1`): the active row's edge bar, which sits in that padding, stays inside
|
|
* the list's box and is not cut off. */
|
|
export function NavRailList({
|
|
ref,
|
|
style,
|
|
children,
|
|
}: {
|
|
ref?: Ref<HTMLDivElement>;
|
|
/** The scroll fade's mask. */
|
|
style?: CSSProperties;
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
style={style}
|
|
className="-mx-1 flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto px-1 no-scrollbar"
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 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 glyph of every rail row: a page, an action, the language switcher. */
|
|
export const RAIL_GLYPH = "size-4.5";
|
|
|
|
/** 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 touch-target";
|
|
|
|
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 starts with 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 exact count rides in the name either way, so "Messages (3)" is heard and not only
|
|
// seen; the drawn badge is hidden (labelled, it was read first and capped: "9+Alerts").
|
|
aria-label={`${showLabel ? shortLabel : label}${shown ? ` (${badge})` : ""}`}
|
|
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={RAIL_GLYPH} aria-hidden="true" />
|
|
{shown && (
|
|
<span
|
|
aria-hidden="true"
|
|
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>
|
|
);
|
|
}
|
|
|
|
/** A rail row that acts instead of navigating (About, Sign out): the page rows' geometry and glyph, so
|
|
* the column keeps one rhythm (F32: toolbar icon buttons here drew 32 squares with 16px glyphs). */
|
|
export function NavRailAction({
|
|
icon: Icon,
|
|
label,
|
|
onClick,
|
|
}: {
|
|
icon: LucideIcon;
|
|
/** The tooltip and the accessible name. */
|
|
label: string;
|
|
onClick: () => void;
|
|
}) {
|
|
return (
|
|
<Tooltip hint={label} side="right" always openDelay={350}>
|
|
<Pressable
|
|
onClick={onClick}
|
|
aria-label={label}
|
|
className={clsx(RAIL_ROW, "h-9 text-muted state-layer hover:text-fg")}
|
|
>
|
|
<Icon className={RAIL_GLYPH} aria-hidden="true" />
|
|
</Pressable>
|
|
</Tooltip>
|
|
);
|
|
}
|