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 && ( ); } /** 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 && (
{label}
); }