// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file SideNavItem.tsx * @input Uses React, ReactNode, StyleX, Icon, IconType, useMenuHover * @output Exports SideNavItem component and SideNavItemProps * @position Core implementation; used inside SideNav children * * Navigation item with icon, selected state, row-level actions, and nesting. * Collapsed icon-only controls use a meaningful consumer `aria-label` when * provided and fall back to `label` when it is missing or blank. * * Collapsed items with children open their submenu flyout through * `useMenuHover`, the shared hover-intent hook (same one `SideNavHeading` and * `TopNavMenu` use). Hover is a progressive enhancement over the popover's * click behavior and is inert on coarse pointers. * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/SideNav/SideNav.doc.mjs * - /packages/core/src/SideNav/SideNav.test.tsx * - /packages/core/src/SideNav/index.ts * - /apps/storybook/stories/SideNav.stories.tsx * - /packages/cli/assets/templates/blocks/components/SideNav/ (showcase blocks) */ import { useCallback, useId, useMemo, useRef, useState, type ReactNode } from 'react'; import * as stylex from "../style-runtime.js"; import "../theme/tokens.stylex.js"; import { colorVars, spacingVars, sizeVars, fontWeightVars, typeScaleVars, durationVars, easeVars, radiusVars } from "../theme/tokens.stylex.js"; import { Icon, renderIconSlot, type IconType } from "../Icon/index.js"; import { useLinkComponent } from "../Link/useLinkComponent.js"; import type { LinkComponentType } from "../Link/types.js"; import { usePopover } from "../Popover/usePopover.js"; import { useMenuHover } from "../hooks/useMenuHover.js"; import { mergeProps } from "../utils/index.js"; import type { BaseProps } from "../BaseProps.js"; import { Tooltip } from "../Tooltip/index.js"; import { navItemStyles, type NavItemSize } from "../NavItem/navItemStyles.stylex.js"; import { SizeProvider } from "../SizeContext/SizeContext.js"; import { focusOutlineProps } from "../utils/focusOutline.stylex.js"; import { interactionOverlayStyles } from "../utils/interactionOverlay.stylex.js"; import { useSideNavCollapse, SideNavCollapseContext } from "./SideNavCollapseContext.js"; import { useSideNavRenderMode } from "./SideNavRenderContext.js"; import { useAppShellMobile } from "../AppShell/AppShellMobileContext.js"; import { themeProps } from "../utils/themeProps.js"; import { useTranslator } from "../i18n/index.js"; import { useMergedRefs } from "../hooks/useMergedRefs.js"; // ============================================================================= // Styles // ============================================================================= const styles = { root: { k1xSpc: "x78zum5", kXwgrk: "xdt5ytf", kzqmXN: "xh8yej3", $$css: true as const }, itemCollapsed: { kjj79g: "xl56j7k", kzqmXN: "x805d0l", kg3NbH: "xnjsko4", $$css: true as const }, itemCollapsedSm: { kzqmXN: "x1fvqzum", $$css: true as const }, itemCollapsedLg: { kzqmXN: "x1m747yf", $$css: true as const }, expandChevron: { k1xSpc: "x3nfvp2", kGNEyG: "x6s0dn4", kjj79g: "xl56j7k", kzqmXN: "x1mtvzj0", kZKoxP: "xvpftlf", kGuDYH: "x1qlqyl8", k1ekBW: "x11xpdln", kIyJzY: "xuedmi6 x12w9bfk", kAMwcw: "xlr8y92", kmuXW: "x2lah0s", $$css: true as const }, expandChevronExpanded: { k3aq6I: "x19jd1h0", $$css: true as const }, expandToggle: { kzqmXN: "x1fvqzum", kZKoxP: "x6k0iem", k1xSpc: "x3nfvp2", kGNEyG: "x6s0dn4", kjj79g: "xl56j7k", kmuXW: "x2lah0s", kmVPX3: "x1717udv", kogj98: "x1ghz6dp", kMzoRj: "xc342km", ksu8eU: "xng3xce", kWkggS: "xjbqb8w", kMwMTN: "x1heor9g", kkrTdU: "x1ypdohk x16khyan", kaIpWk: "xh6dtrn", $$css: true as const }, splitAction: { k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kSGwAc: "xkh2ocl", kOIVth: "x1txdalj", kUk6DE: "x98rzlu", k7Eaqz: "xeuugli", kMwMTN: "x1heor9g", kybGjl: "x1hl2dhg", kmVPX3: "x1717udv", kogj98: "x1ghz6dp", kMzoRj: "xc342km", ksu8eU: "xng3xce", kWkggS: "xjbqb8w", kMv6JI: "xjb2p0i", kGuDYH: "x1qlqyl8", k63SB2: "x1pd3egz", kLWn49: "x15bjb6t", k9WMMc: "x1yc453h", kkrTdU: "x1ypdohk x16khyan", $$css: true as const }, popoverGap: { keTefX: "x11g1kdw", k71WvV: "xnur1sd", $$css: true as const } }; /** * Cascaded to the `actions` slot through `SizeContext` so a consumer's row * controls come out the same height as the built-in expand/collapse toggle, * the way `SideNav` already cascades one size to its footer icons. An * explicit `size` on a supplied control still wins. * * SYNC: `styles.expandToggle` carries the matching box. */ const ROW_CONTROL_SIZE = 'sm'; // Non-collapsed state for popover children — ensures nested items render expanded const EXPANDED_COLLAPSE_STATE = { isCollapsed: false, toggle: () => {}, isCollapsible: false }; // ============================================================================= // NavItemElement — resolves link vs button based on props // ============================================================================= interface NavItemElementProps { href?: string; as?: LinkComponentType; isDisabled?: boolean; onClick?: (e: React.MouseEvent) => void; ref?: React.Ref; children: ReactNode; [key: string]: unknown; } /** * Renders `` (via LinkComponent) when `href` is set, otherwise `; } // ============================================================================= // Types // ============================================================================= export interface SideNavItemProps extends BaseProps { /** Ref forwarded to the root element */ ref?: React.Ref; /** * Custom component to render instead of `` for link items. * Overrides the provider-level default set by LinkProvider. * Only applies when `href` is provided. Must accept href, className, style, and children props. */ as?: LinkComponentType; /** * Item label. */ label: string; /** * Icon (outline variant). */ icon?: ReactNode | IconType; /** * Icon when selected (filled variant). */ selectedIcon?: ReactNode | IconType; /** * Current page indicator. * @default false */ isSelected?: boolean; /** * Whether the item is disabled. * @default false */ isDisabled?: boolean; /** * Navigation URL. */ href?: string; /** * Click handler. */ onClick?: (e: React.MouseEvent) => void; /** * Passive right-side content only (badges, counts). Interactive * controls (icon buttons, menus) go in `actions`. `endContent` * renders inside the primary link or button. */ endContent?: ReactNode; /** * Row-level secondary controls (icon buttons, menus) rendered as siblings * of the primary element at the trailing edge of the row — after the * expand/collapse toggle, and before any nested children in DOM and focus * order. Content is passthrough: each control owns its accessible name, * keyboard behavior, and disabled state. Hidden while the SideNav rail is * collapsed. * * Controls inherit the row's control size through `SizeContext`, so an * unsized icon button comes out the same box as the built-in * expand/collapse toggle. An explicit `size` still wins. */ actions?: ReactNode; /** * Sub-items for nesting. */ children?: ReactNode; /** * Enables collapse behavior for items with children. * When true, clicking the item toggles visibility of sub-items. * * - `true` — collapsible with defaults (starts expanded) * - Object — controlled/configured: * - `defaultIsCollapsed` — start collapsed (default: false) * - `isCollapsed` + `onCollapsedChange` — controlled mode * * @default false */ collapsible?: boolean | { defaultIsCollapsed?: boolean; isCollapsed?: boolean; onCollapsedChange?: (isCollapsed: boolean) => void; }; /** * Size variant for the nav item. * @default 'md' */ size?: NavItemSize; } // ============================================================================= // Component // ============================================================================= /** * Navigation item for SideNav. * * Supports icons, selected state, nesting, and end content like badges or * counts. Interactive row-level controls (menus, icon buttons) go in * `actions`, which renders them as siblings of the primary element — never * nested inside it — before any nested children in DOM and focus order. * * @example * ``` * * * * * * }> * * * ``` */ export function SideNavItem({ as, label, icon, selectedIcon, isSelected = false, isDisabled = false, href, onClick, endContent, actions, children, collapsible: itemCollapsible, size = 'md', 'data-testid': testId, ref, xstyle, ...rest }: SideNavItemProps) { const t = useTranslator(); const { isCollapsed } = useSideNavCollapse(); const renderMode = useSideNavRenderMode(); const { closeMobileNav } = useAppShellMobile(); const isInDrawer = renderMode === 'drawer' || renderMode === 'drawer-content'; const id = useId(); const hasChildren = !!children; const itemRef = useRef(null); // Popover for collapsed items with children const popover = usePopover({ hasLightDismiss: true, hasAutoFocus: true, hasCloseButton: false, dialogLabel: t('@astryx.sideNavItem.submenuLabel', { label }) }); const mergedTriggerRef = useMergedRefs(ref, popover.triggerRef); // Collapse state for items with children const itemCollapsibleConfig = useMemo(() => typeof itemCollapsible === 'object' ? itemCollapsible : {}, [itemCollapsible]); const isItemCollapsible = hasChildren && itemCollapsible !== false; const itemControlledCollapsed = itemCollapsibleConfig.isCollapsed; const isItemControlled = itemControlledCollapsed !== undefined; const [uncontrolledCollapsed, setUncontrolledCollapsed] = useState(itemCollapsibleConfig.defaultIsCollapsed ?? false); const isItemCollapsed = isItemControlled ? itemControlledCollapsed : uncontrolledCollapsed; const toggleItemCollapse = useCallback(() => { const next = !isItemCollapsed; if (!isItemControlled) { setUncontrolledCollapsed(next); } itemCollapsibleConfig.onCollapsedChange?.(next); }, [isItemCollapsed, isItemControlled, itemCollapsibleConfig]); const displayIcon = isSelected && selectedIcon ? selectedIcon : icon; // When collapsible + a primary action (href or onClick), the action and // toggle are independent: clicking the label navigates/fires onClick, // clicking the chevron expands/collapses children. const hasPrimaryAction = !!href || !!onClick; const hasIndependentToggle = isItemCollapsible && hasPrimaryAction && !isCollapsed; const hasActions = !!actions; const handleClick = (e: React.MouseEvent) => { if (isDisabled) { e.preventDefault(); return; } if (isItemCollapsible && !hasIndependentToggle && !isCollapsed) { e.preventDefault(); toggleItemCollapse(); return; } onClick?.(e); // Close the mobile nav when a nav item is activated inside the drawer if (isInDrawer) { closeMobileNav(); } }; const handleToggleClick = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); toggleItemCollapse(); }; // Pointer half only. The hook's `onKeyDown`/`menuRef` drive a `useListFocus` // over `[role="menuitem"]`, and this flyout is a focus-trapped dialog of // links — wiring them would swallow arrow keys rather than navigate with // them. Keyboard stays with `usePopover`'s trap, as in DropdownMenuSubMenu. const { triggerProps: hoverTriggerProps, contentProps: hoverContentProps } = useMenuHover({ show: popover.show, hide: popover.hide, isOpen: popover.isOpen, isEnabled: isCollapsed && hasChildren, // Standard popover toggling: the flyout opens beside the rail, not over // the icon, so the click after a hover-open is a deliberate dismissal // rather than the #3121 confirmation the nav menus need. clickGuardMs: 0, ownsFocus: false }); // In collapsed mode: hide items without icons if (isCollapsed && !icon) { return null; } // ========================================================================= // Collapsed mode — icon-only items, popover for items with children // ========================================================================= if (isCollapsed) { const collapsedIcon = displayIcon && renderIconSlot(displayIcon, { size: 'sm', // `inherit` so a selected row's icon follows the row to HighlightText // under forced colors. Identical to `primary` otherwise: both token // families are emitted from one expression. color: isSelected ? 'inherit' : isDisabled ? 'disabled' : 'secondary' }); // Shared collapsed item styles — used by trigger, link, and button. // All three render a focusable element, so each draws the shared ring. const collapsedItemStyles = mergeProps(themeProps('side-nav-item', { size, selected: isSelected ? 'selected' : null, disabled: isDisabled ? 'disabled' : null }), focusOutlineProps.focusVisible(navItemStyles.item, interactionOverlayStyles.backgroundColor, navItemStyles[size], styles.itemCollapsed, size === 'sm' && styles.itemCollapsedSm, size === 'lg' && styles.itemCollapsedLg, isSelected && navItemStyles.selected, isDisabled && navItemStyles.disabled)); const collapsedAccessibleLabel = rest['aria-label'] != null && rest['aria-label'].trim() !== '' ? rest['aria-label'] : label; // Items with children: popover trigger + popover if (hasChildren) { return
{popover.render(
popover.hide()}>
{label}
{children}
, { placement: 'end', alignment: 'start', xstyle: styles.popoverGap })}
; } // Items without children: icon-only link/button with tooltip const collapsedAriaProps = { 'aria-current': isSelected ? 'page' as const : undefined, 'aria-disabled': isDisabled || undefined, 'aria-label': collapsedAccessibleLabel, 'data-testid': testId }; const collapsedElement = {collapsedIcon} ; return
{collapsedElement}
; } const itemContent = <> {displayIcon && renderIconSlot(displayIcon, { size: 'sm', // `inherit` — see the collapsed path above. color: isSelected ? 'inherit' : isDisabled ? 'disabled' : 'secondary' })} {!isCollapsed && {label}} {!isCollapsed && endContent && {endContent}} {!isCollapsed && isItemCollapsible && !hasIndependentToggle && } ; const itemThemeProps = themeProps('side-nav-item', { size, selected: isSelected ? 'selected' : null, disabled: isDisabled ? 'disabled' : null }); const itemStyleArgs = [navItemStyles.item, interactionOverlayStyles.backgroundColor, navItemStyles[size], isSelected && navItemStyles.selected, isDisabled && navItemStyles.disabled] as const; // Three shapes of the same row appearance: // - `rowProps` — split-action path (toggle, no actions): presentational //
; the ring belongs on each child tab stop. // - `focusableRowProps` — ordinary row: the row element is the control. // - `actionsRowProps` — same pill, but the ring is drawn for the primary // only. The wrapper is not a tab stop, so `:focus-visible` on it would // never match, and matching any descendant instead would light the whole // row around the chevron's or an action's own ring. const rowProps = mergeProps(itemThemeProps, stylex.props(...itemStyleArgs)); const focusableRowProps = mergeProps(itemThemeProps, focusOutlineProps.focusVisible(...itemStyleArgs)); const actionsRowProps = mergeProps(itemThemeProps, focusOutlineProps.focusWithinFirstChild(...itemStyleArgs)); // Row-wrapper path: primary element + row controls as siblings. // // Used when the row carries more than one control: an independent // expand/collapse toggle (collapsible + href/onClick), consumer-supplied // actions, or both. A
is the styled flex row; the primary link or // button, the chevron toggle, and the actions slot render as siblings so // no interactive element nests inside another, and every row-level // control precedes the nested children group in DOM and focus order. const hasRowWrapper = hasIndependentToggle || hasActions; let itemElement; if (hasRowWrapper) { // aria-expanded/-controls stay on the primary element only when the // whole row is the collapse toggle (no independent chevron button). const rowPrimaryAriaProps = hasIndependentToggle ? { 'aria-current': isSelected ? 'page' as const : undefined } : { 'aria-current': isSelected ? 'page' as const : undefined, 'aria-disabled': isDisabled || undefined, 'aria-expanded': isItemCollapsible ? !isItemCollapsed : undefined, 'aria-controls': isItemCollapsible ? `${id}-children` : undefined }; itemElement =
{itemContent} {hasIndependentToggle && } {hasActions && {actions} }
; } else { const ariaProps = { 'aria-current': isSelected ? 'page' as const : undefined, 'aria-disabled': isDisabled || undefined, 'aria-expanded': isItemCollapsible ? !isItemCollapsed : undefined, 'aria-controls': isItemCollapsible ? `${id}-children` : undefined, 'data-testid': testId }; itemElement = {itemContent} ; } const item =
{itemElement} {hasChildren && !isCollapsed &&
{children}
}
; return item; } SideNavItem.displayName = 'SideNavItem';