import { useId, useState, type ComponentPropsWithoutRef, type ReactNode } from 'react' import { Collapsible } from '@base-ui/react/collapsible' import { IconAlertSquareRounded, IconChevronRight, IconDotsVertical } from '@tabler/icons-react' import { useInAppClick } from './AppProvider' import { cn } from './cn' import { COLLAPSIBLE_PANEL_CLASSES, COUNT_TEXT_CLASSES, SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_TEXT_CLASSES } from './looks' import { IconButton } from './IconButton' import { Menu } from './Menu' import type { SectionAction } from './SectionHeader' import { UnreadDot } from './UnreadDot' import { WithTooltip } from './Tooltip' /** * One row of a sidebar: a label, an optional count, an active state * (Ship's NavItem, 2026-09-02, verbatim). * * A count is a number of ONE thing, and its tooltip says which ("18 * open", "5 active") — the caller passes the number and the words for it, * and a zero is not drawn at all. That is this row's rule; a *tab's* * count draws its zero (see Tabs) — both rulings stand, deliberately. * * Active is the same fill as a selected tab — `bg-active`, neutral. * * `menu` gives the row a "More options" menu (UIG-44, Katerina, 30 * September): a ⋮ that shows while the row is pointed at, focused or its * menu is open, in the count's place — the count steps aside rather than * sitting under it. The ⋮ is beside the link, not inside it (a button inside * an anchor is not a button), and follows it in the Tab order. * * `children` are rows under this one (Katerina, 2 October: Leaf's folder * tree). The row stays a link; while it is pointed at or focused its icon * gives way to an arrow that folds the rows, in the icon's own place — so a * row with rows under it is as wide as one without. The arrow is beside the * link, like the ⋮. The rows slide as `CollapsibleSection`'s do, indented * past the icon so their icons start under this row's label. * * `action` is one button of its own beside the ⋮ — any icon, with its tooltip * (Katerina, 14 October: Peek's Open work keeps its ✕ "Remove from list" in * sight, not inside a menu). The same shape as a `SectionHeader`'s actions, * and drawn the same way: an `IconButton` that shows with the ⋮, in the * count's place, before the ⋮ when there is one. One, never a row of them: a * second thing to do goes in the menu. * * `hint` is shown only while the row is pointed at or focused, left of the * count — a neutral `Chip`, typically. It takes its own room while it shows, * so the label ends with "…" before it instead of running under it. * * `unread` brightens the row's words and icon, sets the label in medium, and * draws the `UnreadDot` at the right; `urgent` with it draws the warning badge * in the dot's place (Katerina, 2 October: Peek's row, moved in). The ⋮ lands * on whatever is in that place, and with nothing there it takes no room until * the row is pointed at, so a label at rest keeps the row's whole width. * * Active is `bg-nav-active`, the current row in a sidebar, which each theme * sets: the selected tab's neutral fill in most, Signal's blue in Signal. */ export interface NavItemProps extends Omit, 'href' | 'children'> { label: string href: string /** `0` or absent renders no counter. */ count?: number /** What the count counts, for the tooltip — "18 open", "5 active". */ countLabel?: string active?: boolean /** 16px, stroke 1.5. */ icon?: ReactNode /** The row's "More options" menu: `MenuItem`s. Absent, the row has no menu and is exactly the plain row. */ menu?: ReactNode /** The ⋮'s accessible name and tooltip. Default "More options for {label}". */ menuLabel?: string /** One button beside the ⋮, shown with it: any 16px icon, its tooltip and its handler — a `SectionHeader` action's shape. With a menu too, it sits before the ⋮. */ action?: SectionAction /** Shown only while the row is pointed at or focused, left of the count: a neutral `Chip`, typically. */ hint?: ReactNode /** A sentence about the item, in the package's tooltip while the label is pointed at; the row stays one line. Absent or blank, no tooltip. */ tooltip?: string /** Something new here: brighter words, the label in medium, and the `UnreadDot` at the right. Say so in the row's own words for assistive tech too. */ unread?: boolean /** With `unread`: the warning badge in the dot's place. */ urgent?: boolean /** Rows under this one, folding under it: `NavItem`s. While the row is pointed at or focused, its icon becomes the arrow. */ children?: ReactNode /** With `children`: open unless told otherwise. */ defaultOpen?: boolean /** With `children`: the caller owns the state. */ open?: boolean onOpenChange?: (open: boolean) => void className?: string } export function NavItem({ label, href, count, countLabel, active = false, icon, menu, menuLabel, action, hint, tooltip, unread = false, urgent = false, children, defaultOpen = true, open: openProp, onOpenChange, className, onClick, ...props }: NavItemProps) { const inApp = useInAppClick(href, onClick) const [openState, setOpenState] = useState(defaultOpen) const panelId = useId() const hasRows = children != null && children !== false const open = openProp ?? openState const setOpen = (next: boolean) => { setOpenState(next) onOpenChange?.(next) } // The row draws beside it — the ⋮, the arrow — so the pointer can leave the // link for them while the row still reads as the one it is on. const buttons = (menu != null ? 1 : 0) + (action ? 1 : 0) const besides = buttons > 0 || hasRows // What sits at the right at rest, after the count: the dot, or the badge. const mark = unread ? (urgent ? : ) : null // Under the ⋮ while it shows: the count and the mark step aside for it. // "While it shows" is the pointer on the row or the keyboard in it // (`:focus-visible`), never any focus: a clicked row keeps its focus, and // with `focus-within` its icon and dot went while the arrow and the ⋮ stayed // hidden (Katerina, 5 October). // A screen with no hover shows the ⋮ always (below), so what it lands on // steps aside always: the row reads as it does under a pointer. const stepsAside = buttons > 0 && 'transition-opacity group-hover/nav:opacity-0 group-has-[:focus-visible]/nav:opacity-0 group-has-[[data-nav-menu]_[aria-expanded=true]]/nav:opacity-0 [@media(hover:none)]:opacity-0' const row = ( 0 && !active && 'group-has-[[data-nav-menu]_[aria-expanded=true]]/nav:bg-bg-hover group-has-[[data-nav-menu]_[aria-expanded=true]]/nav:text-text-primary', className, )} {...props} onClick={inApp} > {(icon || hasRows) && ( {icon} )} {tooltip?.trim() ? ( // On the label alone, not the row: the ⋮ and the action keep their // own tooltips, and the two never stand up at once. {label} ) : ( {label} )} {hint != null && ( // Takes room only while it shows, so the label gives way to it. {hint} )} {buttons > 1 && ( // Two buttons: the second one's room (24px and the 4px between them, the // row's 8px gap making up the rest), kept only while they show. ) if (!besides && hint == null) return row const name = menuLabel ?? `More options for ${label}` const line = (
{row} {hasRows && ( // `left-1`: the 24px button's 16px arrow then sits where the icon // sits (the row's px-2), so the icon turns into the arrow in place.
setOpen(!open)} >
)} {buttons > 0 && ( // `right-1`: the 24px button's 16px icon ends 8px from the row's // edge, where a count ends (px-2) — and where a SectionHeader's // actions and ⋮ end, so a folder's ⋮ and its rows' ⋮ are one column // (Katerina, 5 October: the rows move to the heading, not the // heading to the rows). Over a dot or the empty slot it sits 4px // right of the slot's centre. // On a screen with no hover it always shows: nothing there can point // at the row, and the menu has no other way in.
{action && ( {action.icon} )} {menu != null && ( } > {menu} )}
)}
) if (!hasRows) return line return ( {line} {/* CollapsibleSection's slide, the same way: Base UI measures the panel and the height transitions, none under prefers-reduced-motion. */} {/* `pl-6`: past the 16px icon and its 8px gap, so the rows' icons start under this row's label. */}
{children}
) } /** Urgent and unread: a 12px warning mark on its tint, in the dot's 24px slot. */ function UrgentMark() { return (
) }