// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file TopNavHeading.tsx * @input Uses React, useRef, useCallback, ReactNode, StyleX, usePopover * @output Exports TopNavHeading component and TopNavHeadingProps * @position Companion component for TopNav heading slot * * Product/suite/account heading with smart interaction boundary logic. * Mirrors SideNavHeading features: superheading, subheading, menu popover, * chevron indicator, and independent link boundaries. * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/TopNav/TopNav.doc.mjs * - /packages/core/src/TopNav/TopNav.test.tsx * - /packages/core/src/TopNav/index.ts * - /apps/storybook/stories/TopNav.stories.tsx * - /packages/cli/assets/templates/blocks/components/TopNav/ (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"; import { usePopover } from "../Popover/usePopover.js"; import { Link } from "../Link/index.js"; import { Icon } from "../Icon/index.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: "x1tgivj0", kkrTdU: "xt0e3qv", $$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 }, chevron: { kmuXW: "x2lah0s", k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kjj79g: "xl56j7k", k7Eaqz: "xy8csz5", kAzted: "ximsjs8", kMwMTN: "xv9yike", $$css: true as const }, chevronGlyph: { kzqmXN: "x15vhz18", kZKoxP: "xlzyvqe", kGuDYH: "x1qlqyl8", $$css: true as const }, popoverChevron: { k3aq6I: "x19jd1h0", $$css: true as const }, popoverOverlap: { k7Eaqz: "x8wyhu6", keoZOQ: "xvyqdj1", keTefX: "x1qfufaz", $$css: true as const } }; // ============================================================================= // Types // ============================================================================= export interface TopNavHeadingProps extends BaseProps { /** Ref forwarded to the root element */ ref?: React.Ref; /** * Logo element to display before the heading. * Can be an image, NavIcon, or any ReactNode. */ logo?: ReactNode; /** * Accessible name for the logo when it links somewhere (`headingHref`) and * has no adjacent text to name it (for example a logo-only heading). Defaults * to `heading` when available. Ignored when the logo is not a link. */ logoLabel?: string; /** * 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. */ 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; /** * Custom component to render instead of ``. * Overrides the provider-level default set by LinkProvider. * Must accept href, className, style, and children props. */ as?: LinkComponentType; } // ============================================================================= // Component // ============================================================================= /** * Product/suite/account heading for TopNav. * * 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={} * /> * ``` */ export function TopNavHeading({ as, logo, logoLabel, heading, headingHref, superheading, superheadingHref, subheading, subheadingHref, headerEndContent, menu, xstyle, className, style, 'data-testid': testId, ref, ...props }: TopNavHeadingProps) { const t = useTranslator(); const LinkComponent = useLinkComponent(as); // When the logo is wrapped in a link it needs its own accessible name (the // logo image itself is decorative). Prefer an explicit logoLabel, fall back // to the heading text. axe: link-name. const logoLinkLabel = logoLabel ?? heading; const rootRef = useRef(null); const popover = usePopover({ dialogLabel: t('@astryx.topNav.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 showChevron = !!menu; const hasAnyHref = !!(headingHref || superheadingHref || subheadingHref); // 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 ? e.stopPropagation()} color="secondary" size="xsm"> {superheading} : {superheading})} {hasAnyHref && headingHref && menu ? e.stopPropagation()} className="x18juvz8 x2mo6ok xf74fhv x1hl2dhg x1heor9g xb3r6kr xlyipyv xuxw1ft"> {heading} : {heading}} {inlineChevron} {subheading && (hasAnyHref && subheadingHref && menu ? e.stopPropagation()} color="secondary" size="xsm"> {subheading} : {subheading})} ; const chevronElement = showChevron && ; const headerEndContentElement = headerEndContent && {headerEndContent}; // Shared popover heading content — uses renderTextContent for consistent // sizing, with flipped chevron inline after the title. Always static (no links). const popoverHeadingContent = ; // Simple: no heading text, just logo (backward compat for logo-only usage) if (!heading && !menu) { const Element = headingHref ? LinkComponent : 'div'; return } href={headingHref} aria-label={headingHref ? logoLabel : undefined} data-testid={testId} {...mergeProps(themeProps('top-nav-heading'), stylex.props(styles.root, !!headingHref && styles.menuTrigger, xstyle), className, style)} {...props}> {logo && {logo}} ; } // Whole heading is a link (no menu, single headingHref) if (isWholeHeadingLink && headingHref) { return } href={headingHref} data-testid={testId} {...mergeProps(themeProps('top-nav-heading'), stylex.props(styles.root, styles.menuTrigger, xstyle), className, style)} {...props as React.AnchorHTMLAttributes}> {logo && {logo}} {renderTextContent()} {headerEndContentElement} {chevronElement} ; } // Whole header is the popover trigger (menu, no hrefs) if (isWholeHeadingTrigger) { return <>
{logo && {logo}} {renderTextContent( // Stays a )} {headerEndContentElement}
{popover.render(
{popoverHeadingContent} {/* 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.popoverOverlap })} ; } // Mixed mode: independent links + chevron trigger for menu if (menu && hasAnyHref) { return <>
{logo && (headingHref ? e.stopPropagation()} className="x2lah0s x78zum5 x6s0dn4 xl56j7k"> {logo} : {logo})} {renderTextContent(showChevron ? // Stays a : undefined)} {headerEndContentElement}
{popover.render(
{popoverHeadingContent} {/* 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.popoverOverlap })} ; } // Static heading with independent links (no menu) if (hasAnyHref && !isWholeHeadingLink) { return
} data-testid={testId} {...mergeProps(themeProps('top-nav-heading'), stylex.props(styles.root, xstyle), className, style)} {...props}> {logo && (headingHref ? {logo} : {logo})} {superheading && (superheadingHref ? {superheading} : {superheading})} {headingHref ? {heading} : {heading}} {subheading && (subheadingHref ? {subheading} : {subheading})} {headerEndContentElement} {chevronElement}
; } // Default: static heading, no links, no menu return
} data-testid={testId} {...mergeProps(themeProps('top-nav-heading'), stylex.props(styles.root, xstyle), className, style)} {...props}> {logo && {logo}} {renderTextContent()} {headerEndContentElement} {chevronElement}
; } TopNavHeading.displayName = 'TopNavHeading';