import { Children, isValidElement, useId, type ComponentProps, type ReactNode } from "react"; import { Slot } from "radix-ui"; import { ReasonTooltip } from "./disabled-reason"; import { Skeleton } from "./skeleton"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip"; import { cn } from "../lib/cn"; /** * Navigation items for the main rail and the settings rail. * * NavItem: 32px, radius 10, 16px icon, 14/500 muted. Hover is the `hover` wash; active is the * stronger `selection` fill, full-strength text and a 2x16px brand bar on the left edge. Every * destination appears once, with one name and one icon. Coarse pointers get a * 44px target. * * SettingsNav variants: * - `rail`: the 240px settings rail that replaces the main rail in settings * mode (back link and scope switcher as header). This is what the app uses. * - `column` (default): a 200px column inside the content area, kept for the * kit's history. */ export type NavItemSize = "default" | "comfortable"; export interface NavItemProps extends Omit, "children"> { /** Render your router's link as the only child (``). */ asChild?: boolean | undefined; children?: ReactNode | undefined; /** Same name as the page title and the route noun. */ label: ReactNode; /** A 16px lucide icon. One icon per destination. */ icon?: ReactNode | undefined; active?: boolean | undefined; /** Trailing count or text, 11px: "3". */ badge?: ReactNode | undefined; /** A purple dot for "needs you" (for example pending reviews). */ attention?: boolean | undefined; /** Read after the label when `attention` is set. */ attentionLabel?: string | undefined; /** Trailing icon for links that leave this area ("Organization →"). */ trailingIcon?: ReactNode | undefined; /** Icon-only, with the label in a tooltip (collapsed rail). */ collapsed?: boolean | undefined; /** `default` 32px; `comfortable` 36px (a rail with few items). */ size?: NavItemSize | undefined; /** * Blocks the destination and explains why in a tooltip. Prefer hiding * destinations the viewer can never use. */ disabledReason?: string | undefined; } const ITEM_BASE = "group/nav-item relative flex min-w-0 items-center rounded-[10px] text-sm font-normal text-fg-label transition-colors duration-[120ms] outline-hidden select-none hover:bg-hover hover:text-fg data-[active=true]:bg-selection data-[active=true]:text-fg data-[active=true]:hover:bg-selection aria-disabled:cursor-not-allowed aria-disabled:text-fg-subtle aria-disabled:hover:bg-transparent aria-disabled:hover:text-fg-subtle"; function textLabel(label: ReactNode): string | undefined { return typeof label === "string" ? label : undefined; } export function NavItem({ asChild = false, children, label, icon, active = false, badge, attention = false, attentionLabel = "Needs your review", trailingIcon, collapsed = false, size = "default", disabledReason, className, ...props }: NavItemProps) { const disabled = Boolean(disabledReason); const reasonId = useId(); const Comp = disabled ? "span" : asChild ? Slot.Root : "a"; const item = ( ); if (disabled) { // Touch-safe: a tap opens the reason instead of doing nothing. // The reason sits outside the item so it describes it without joining its name. return ( <> {item} ); } if (!collapsed) return item; return ( {item} {textLabel(label) ?? label} ); } /** A placeholder item while the list of destinations loads. */ export function NavItemSkeleton({ collapsed = false, width = "w-24", }: { collapsed?: boolean | undefined; /** Tailwind width class for the label bar. */ width?: string | undefined; }) { return ( ); } /** * A list of NavItems with an optional 12px sentence-case label. Each child is * wrapped in a list item. */ export function NavGroup({ label, collapsed = false, className, children, }: { label?: ReactNode | undefined; /** Hides the label (collapsed rail); a hairline marks the group instead. */ collapsed?: boolean | undefined; className?: string | undefined; children: ReactNode; }) { const labelId = useId(); const showLabel = Boolean(label) && !collapsed; return (
{showLabel ? (

{label}

) : null} {label && collapsed ? ( ); } export type SettingsNavVariant = "column" | "rail"; export interface SettingsNavProps extends ComponentProps<"nav"> { variant?: SettingsNavVariant | undefined; /** Top slot: the workspace switcher (rail) or a small title (column). */ header?: ReactNode | undefined; /** Bottom slot: the link to organization settings. */ footer?: ReactNode | undefined; } /** The settings navigation. Label it: `aria-label="Workspace settings"`. */ export function SettingsNav({ variant = "column", header, footer, className, children, ...props }: SettingsNavProps) { const rail = variant === "rail"; return ( ); }