// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; import { ChevronDown } from "../../icons.js"; /** * @file SideNavHeading.tsx * @input Uses React, useRef, useCallback, ReactNode, StyleX, usePopover * @output Exports SideNavHeading component and SideNavHeadingProps * @position Core implementation; used inside SideNav header slot * * Product/suite/account heading with smart interaction boundary logic. * Composes usePopover internally when menu prop is provided. * * 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 { useMemo, useRef, type ReactNode } from 'react'; import * as stylex from "../style-runtime.js"; import "../theme/tokens.stylex.js"; import { colorVars, spacingVars, fontWeightVars, radiusVars, typeScaleVars } from "../theme/tokens.stylex.js"; // TODO(#264): Lazy-load usePopover so the popover/layer bundle is not // eagerly imported when `menu` is not provided. See Text for an example // of lazy loading layer resources. import { usePopover } from "../Popover/usePopover.js"; import { Link } from "../Link/index.js"; import { Icon } from "../Icon/index.js"; import { Tooltip } from "../Tooltip/index.js"; import { navItemStyles } from "../NavItem/navItemStyles.stylex.js"; import { focusOutlineProps } from "../utils/focusOutline.stylex.js"; import { interactionOverlayStyles } from "../utils/interactionOverlay.stylex.js"; import { useSideNavCollapse } from "./SideNavCollapseContext.js"; import { useLinkComponent } from "../Link/useLinkComponent.js"; import type { LinkComponentType } from "../Link/types.js"; import { mergeProps } from "../utils/index.js"; import { useMergedRefs } from "../hooks/useMergedRefs.js"; import type { BaseProps } from "../BaseProps.js"; import { useMenuHover } from "../hooks/useMenuHover.js"; import { NavHeadingCloseContext } from "../NavMenu/NavMenuContext.js"; import { themeProps } from "../utils/themeProps.js"; import { useTranslator } from "../i18n/index.js"; // ============================================================================= // Styles // ============================================================================= const styles = { root: { k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kOIVth: "x1txdalj", kAzted: "xkoleio", kZCmMZ: "x12gdq22 xmnmr5y", kwRFfy: "x1djylfy", k8WAf4: "xt970qd", kB7OPa: "x9f619", kybGjl: "x1hl2dhg", kMwMTN: "x1heor9g", kkrTdU: "xt0e3qv", $$css: true as const }, rootCollapsed: { kjj79g: "xl56j7k", kg3NbH: "xnjsko4", $$css: true as const }, interactive: { kkrTdU: "x1ypdohk x16khyan", kaIpWk: "xh6dtrn", kMzoRj: "xc342km", ksu8eU: "xng3xce", kWkggS: "xjbqb8w", kMv6JI: "xjb2p0i", kGuDYH: "x1qlqyl8", k63SB2: "x1sodnla", k9WMMc: "x1yc453h", kRL5z6: "xoevpu5", $$css: true as const }, menuTrigger: { kkrTdU: "x1ypdohk x16khyan", kaIpWk: "xh6dtrn", kMzoRj: "xc342km", ksu8eU: "xng3xce", kWkggS: "xjbqb8w", kMv6JI: "xjb2p0i", kGuDYH: "x1qlqyl8", k63SB2: "x1sodnla", k9WMMc: "x1yc453h", $$css: true as const }, icon: { kmuXW: "x2lah0s", k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kjj79g: "xl56j7k", $$css: true as const }, heading: { kGuDYH: "x18juvz8", k63SB2: "x2mo6ok", kLWn49: "xf74fhv", kMwMTN: "x1tgivj0", kybGjl: "x1hl2dhg", kVQacm: "xb3r6kr", kg5iWk: "xlyipyv", khDVqt: "xuxw1ft", $$css: true as const }, headingLink: { kybGjl: "x1hl2dhg", kMwMTN: "x1heor9g", kVQacm: "xb3r6kr", kg5iWk: "xlyipyv", khDVqt: "xuxw1ft", $$css: true as const }, chevron: { kmuXW: "x2lah0s", k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kjj79g: "xl56j7k", k7Eaqz: "xy8csz5", kAzted: "ximsjs8", kMwMTN: "xv9yike", kGuDYH: "x1qlqyl8", $$css: true as const }, popoverHeading: { k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kOIVth: "x1txdalj", kzqmXN: "xh8yej3", kWkggS: "xjbqb8w", kMv6JI: "xjb2p0i", kGuDYH: "x1qlqyl8", kMwMTN: "x1heor9g", k9WMMc: "x1yc453h", kAzted: "xkoleio", kZCmMZ: "x12gdq22 xmnmr5y", kwRFfy: "x1djylfy", k8WAf4: "xt970qd", keoZOQ: "xcsaf9d", k1K539: "x1p37lm5", kUOVxO: "xhrcg97", kkrTdU: "x1ypdohk x16khyan", $$css: true as const }, popoverChevron: { kmuXW: "x2lah0s", k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kjj79g: "xl56j7k", k7Eaqz: "xy8csz5", kAzted: "ximsjs8", kMwMTN: "xv9yike", kGuDYH: "x1qlqyl8", k3aq6I: "x19jd1h0", $$css: true as const }, chevronGlyph: { kGuDYH: "x1qlqyl8", $$css: true as const }, popover: { k7Eaqz: "xrzjruh", keoZOQ: "xcsaf9d", $$css: true as const }, popoverOverlap: { k7Eaqz: "x8wyhu6", keoZOQ: "xvyqdj1", keTefX: "x1qfufaz", $$css: true as const } }; // ============================================================================= // Types // ============================================================================= export interface SideNavHeadingProps extends BaseProps { /** Ref forwarded to the root element */ ref?: React.Ref; /** * Product/app icon. */ icon?: ReactNode; /** * Custom component to render instead of ``. * Overrides the provider-level default set by LinkProvider. * Must accept href, className, style, and children props. */ as?: LinkComponentType; /** * Product/app name. */ heading: string; /** * Link for the heading (e.g., product home). */ headingHref?: string; /** * Text above the heading (e.g., suite name). */ superheading?: string; /** * Link for the superheading (e.g., suite home). */ superheadingHref?: string; /** * Text below the heading (e.g., account context). */ subheading?: string; /** * Link for the subheading. */ subheadingHref?: string; /** * Content rendered at the trailing edge of the heading row. * Hidden in collapsed mode. */ headerEndContent?: ReactNode; /** * Menu content shown in a popover. When provided, the header composes * usePopover internally and shows a dropdown chevron. The trigger * boundary is determined automatically: * - No hrefs → whole header is the trigger * - With hrefs → links are independent, chevron/remaining area is the trigger */ menu?: ReactNode; } // ============================================================================= // ============================================================================= // Component // ============================================================================= /** * Product/suite/account heading for SideNav. * * Supports smart interaction boundary logic: * - No hrefs + menu → whole heading is the popover trigger * - headingHref only, no menu → whole heading is one link * - headingHref + superheadingHref, no menu → each is an independent link * - menu + hrefs → links are independent, chevron/remaining area is the trigger * * The chevron indicator is automatically shown when `menu` is provided. * * @example * ``` * } heading="My App" headingHref="/" /> * } * superheading="Suite Name" * superheadingHref="/suite" * heading="Product Name" * headingHref="/product" * menu={} * /> * } * heading="Product Name" * subheading="Business Account" * menu={} * /> * ``` */ export function SideNavHeading({ as, icon, heading, headingHref, superheading, superheadingHref, subheading, subheadingHref, headerEndContent, menu, xstyle, className, style, 'data-testid': testId, ref, ...props }: SideNavHeadingProps) { const t = useTranslator(); const LinkComponent = useLinkComponent(as); const { isCollapsed } = useSideNavCollapse(); const rootRef = useRef(null); const collapsedItemRef = useRef(null); const popover = usePopover({ dialogLabel: t('@astryx.sideNav.heading.dialogLabel'), // The popup exposes its own role="menu" semantics; a role="dialog" // aria-modal wrapper would announce "dialog, Navigation menu" around a // menu (the anti-pattern removed in a478a3dcf). role: 'none', hasCloseButton: false }); const { triggerProps, contentProps, menuRef, setTriggerEl, close: closeMenu } = useMenuHover({ show: popover.show, hide: popover.hide, isOpen: popover.isOpen, isEnabled: !!menu, showDelay: 0 }); const closeMenuCtx = useMemo(() => ({ closeMenu }), [closeMenu]); // setTriggerEl belongs on the chevron button, not this root: it is the // focus-restore target and a
cannot take focus. triggerRef stays here // because the panel anchors to the whole heading. const setRef = useMergedRefs(rootRef, ref, menu ? popover.triggerRef : undefined); const collapsedSetRef = useMergedRefs(collapsedItemRef, ref, // Collapsed, this button is the trigger, so it takes both roles. menu ? popover.triggerRef : undefined, menu ? setTriggerEl : undefined); // In collapsed mode: hide if no icon, show icon-only if has icon if (isCollapsed && !icon) { return null; } if (isCollapsed && icon) { const collapsedIcon = {icon}; let collapsedElement: ReactNode; if (headingHref) { collapsedElement = } href={headingHref} aria-label={heading} data-testid={testId} {...mergeProps(themeProps('side-nav-heading'), focusOutlineProps.focusVisible(navItemStyles.item, interactionOverlayStyles.backgroundColor, styles.rootCollapsed, xstyle), className, style)}> {collapsedIcon} ; } else if (menu) { collapsedElement = <> {popover.render(
{/* The menu role is scoped to the actual menu items so the heading button above stays a valid sibling, not an invalid child of a role="menu" element. */}
{menu}
, { placement: 'below', alignment: 'start', xstyle: styles.popover })} ; } else { collapsedElement =
{collapsedIcon}
; } return <> {collapsedElement} ; } const showChevron = !!menu; const hasAnyHref = !!(headingHref || superheadingHref || subheadingHref); const hasCompactHeading = !!(superheading || subheading); // Determine interaction mode const isWholeHeadingTrigger = !!menu && !hasAnyHref; const isWholeHeadingLink = !!headingHref && !menu && !superheadingHref && !subheadingHref; // Render text content with optional inline chevron const renderTextContent = (inlineChevron?: ReactNode) => {superheading && (hasAnyHref && superheadingHref && menu ? {superheading} : {superheading})} {hasAnyHref && headingHref && menu ? {heading} : {heading}} {inlineChevron} {subheading && (hasAnyHref && subheadingHref && menu ? {subheading} : {subheading})} ; const chevronElement = showChevron &&