import clsx from "clsx";
import { useState, type CSSProperties, type ReactNode, type Ref } from "react";
import type { LucideIcon } from "lucide-react";
import { PopoverSurface } from "./AnchoredPopover";
import { useAnchoredPopover } from "./anchoredPopoverHooks";
import { Pressable } from "./Pressable";
import { SURFACE } from "./surfaces";
import Tooltip from "./Tooltip";
import { isPlainLeftClick } from "../../lib/nav";
// 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 (
);
}
/** 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;
/** The scroll fade's mask. */
style?: CSSProperties;
children: ReactNode;
}) {
return (
{children}
);
}
/** A short, thick centred rule: the "new section" cue on an icon rail (the system modules). */
export function NavRailDivider() {
return ;
}
/** 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,
href,
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;
/** The page's own address. A page row is a real link (the owner's call): a middle-click, a
* Ctrl/Cmd-click or the browser's "Open in new tab" opens the page in a tab of its own. */
href: string;
/** The in-app navigation, run only for a plain left click. */
onClick: () => void;
testId?: string;
}) {
const shown = !!badge && badge > 0;
return (
{
if (!isPlainLeftClick(e)) return; // the browser opens its tab or window
e.preventDefault();
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 "Alerts (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 && (
)}
{shown && (
{badge > 9 ? "9+" : badge}
)}
{showLabel && (
{shortLabel}
)}
);
}
/** 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 (
);
}
/** One page of a `NavRailGroup`'s panel. */
export interface NavRailGroupPage {
id: string;
icon: LucideIcon;
/** The page's full name, written out in the panel. */
label: string;
href: string;
active: boolean;
/** The in-app navigation, run only for a plain left click. */
onSelect: () => void;
}
/** One rail row standing for a set of rare pages (the owner's pick: the admin pages), opening a
* panel of them to its right. The rail fits a 133–150% zoom without a scroll nobody sees: five admin
* rows sat below the fold behind a fade alone. The row is "you are here" while any of its pages is.
* The panel's pages are real links, as the rail's own rows are (a middle-click opens a tab), so it is a
* `dialog` popover with its own Tab order, not a roving menu of buttons. */
export function NavRailGroup({
icon: Icon,
label,
shortLabel,
showLabel,
pages,
testId,
}: {
icon: LucideIcon;
/** The group's full name: the tooltip, the row's and the panel's accessible name, the panel heading. */
label: string;
shortLabel: string;
showLabel: boolean;
pages: readonly NavRailGroupPage[];
testId?: string;
}) {
const [open, setOpen] = useState(false);
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "dialog",
role: "dialog",
placement: "right-start",
offset: 8,
});
const { setReference } = pop.refs;
const active = pages.some((p) => p.active);
return (
<>
{active && (
)}
{showLabel && (
{shortLabel}
)}