// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file TopNavMenu.tsx * @input Uses React, StyleX, usePopover, useMenuHover, useListFocus, * useTypeahead, TopNavItem tokens * @output Exports TopNavMenu component and related types * @position Navigation item with hover-triggered overflow menu for TopNav * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/TopNav/TopNav.doc.mjs * - /packages/core/src/TopNav/TopNavMenu.test.tsx * - /packages/core/src/TopNav/index.ts * - /packages/cli/assets/templates/blocks/components/TopNav/ (showcase blocks) */ import React, { useCallback, useId, useRef, useState, type ReactNode } from 'react'; import * as stylex from "../style-runtime.js"; import { usePopover } from "../Popover/usePopover.js"; import { useMenuHover } from "../hooks/useMenuHover.js"; import { useListFocus } from "../hooks/useListFocus.js"; import { useTypeahead } from "../hooks/useTypeahead.js"; import { Icon } from "../Icon/index.js"; import { mergeProps, composeEventHandlers } from "../utils/index.js"; import { useMergedRefs } from "../hooks/useMergedRefs.js"; import type { BaseProps } from "../BaseProps.js"; import { navItemStyles } from "../NavItem/navItemStyles.stylex.js"; import { useTopNavSlot } from "./TopNavContext.js"; import { useTopNavRenderMode } from "./TopNavRenderContext.js"; import { useAppShellMobile } from "../AppShell/AppShellMobileContext.js"; import { useLinkComponent } from "../Link/useLinkComponent.js"; import { themeProps } from "../utils/themeProps.js"; import { focusOutlineProps } from "../utils/focusOutline.stylex.js"; import { interactionOverlayStyles } from "../utils/interactionOverlay.stylex.js"; import "../theme/tokens.stylex.js"; import { colorVars, spacingVars, radiusVars, durationVars, easeVars, fontWeightVars, typeScaleVars } from "../theme/tokens.stylex.js"; // ============================================================================= // Styles // ============================================================================= const styles = { trigger: { k1xSpc: "x3nfvp2", kGNEyG: "x6s0dn4", kOIVth: "x1txdalj", k8WAf4: "x1vofgu7", kg3NbH: "xrrkdod", kaIpWk: "xh6dtrn", kGuDYH: "xcr08ib", kLWn49: "x1kq96og", k63SB2: "x1e4wzip", kMwMTN: "xv1l7n4", kybGjl: "x1hl2dhg", kkrTdU: "x1ypdohk x16khyan", k1ekBW: "xs2xxs2", kIyJzY: "xuedmi6", kAMwcw: "xlr8y92", kWkggS: "xjbqb8w xoevpu5", kMv6JI: "xjb2p0i", $$css: true as const }, triggerOpen: { kMwMTN: "x1tgivj0", kWkggS: "x1lmrjuc", $$css: true as const }, chevron: { k1xSpc: "x3nfvp2", kGNEyG: "x6s0dn4", kzqmXN: "x15vhz18", kZKoxP: "xlzyvqe", kGuDYH: "x1qlqyl8", k1ekBW: "x11xpdln", kIyJzY: "xuedmi6", kAMwcw: "xlr8y92", $$css: true as const }, chevronOpen: { k3aq6I: "x19jd1h0", $$css: true as const }, menuItem: { k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kOIVth: "xjcht0a", k8WAf4: "x8o8v82", kg3NbH: "xrrkdod", kaIpWk: "xh6dtrn", kybGjl: "x1hl2dhg", kkrTdU: "x1ypdohk x16khyan", k1ekBW: "x15406qy", kIyJzY: "xuedmi6", kAMwcw: "xlr8y92", kWkggS: "xjbqb8w xoevpu5", $$css: true as const } }; const drawerStyles = { header: { kjj79g: "x1qughib", $$css: true as const }, chevron: { k1xSpc: "x3nfvp2", kzqmXN: "x15vhz18", kZKoxP: "xlzyvqe", kGuDYH: "x1qlqyl8", k1ekBW: "x11xpdln", kIyJzY: "xuedmi6", kAMwcw: "xlr8y92", $$css: true as const }, chevronExpanded: { k3aq6I: "x19jd1h0", $$css: true as const }, item: { kZCmMZ: "x31w388", kybGjl: "x1hl2dhg", $$css: true as const } }; // ============================================================================= // Types // ============================================================================= /** * An item in the TopNav overflow menu. */ export interface TopNavMenuItemData { /** * Display title for the menu item. */ title: string; /** * Optional description text displayed below the title. */ description?: string; /** * Optional icon element displayed to the left. */ icon?: ReactNode; /** * URL to navigate to when clicked. */ href?: string; /** * Callback when item is clicked. */ onClick?: () => void; } function getMenuItemKey(item: TopNavMenuItemData): string { return item.title; } export interface TopNavMenuProps extends BaseProps { ref?: React.Ref; /** * The visible label for the nav item trigger. */ label: string; /** * Menu items to display in the hover popover. */ items: TopNavMenuItemData[]; /** * Delay before showing the menu on hover (ms). * @default 150 */ delay?: number; /** * Delay before hiding the menu after mouse leaves (ms). * @default 200 */ hideDelay?: number; } // ============================================================================= // TopNavMenu // ============================================================================= /** * A navigation item that displays a hover-triggered overflow menu. * * Renders as a nav item in TopNav's startContent slot. On hover, * shows a popover with rich menu items containing an icon, title, * and optional description. * * @example * ``` * * * , * href: '/products/analytics', * }, * { * title: 'Messaging', * description: 'Real-time communication tools', * icon: , * href: '/products/messaging', * }, * ]} * /> * * } * /> * ``` */ export function TopNavMenu({ ref, label, items, delay = 150, hideDelay = 200, xstyle, className, style, onClick: onClickProp, onMouseEnter: onMouseEnterProp, onMouseLeave: onMouseLeaveProp, ...rest }: TopNavMenuProps) { const renderMode = useTopNavRenderMode(); const { closeMobileNav } = useAppShellMobile(); const LinkComponent = useLinkComponent(); const [drawerExpanded, setDrawerExpanded] = useState(false); const menuId = useId(); const slot = useTopNavSlot(); const triggerButtonRef = useRef(null); const popover = usePopover({ // The popup's own role="menu" is the exposed semantics; a modal dialog // wrapper would announce an unnamed dialog around the menu and make the // trigger claim aria-haspopup="dialog" for menu content (see TabMenu). role: 'none' }); const { triggerProps, contentProps, menuRef, setTriggerEl, close } = useMenuHover({ show: popover.show, hide: popover.hide, isOpen: popover.isOpen, isEnabled: true, showDelay: delay, hideDelay, // Trigger sits outside an auto popover; the invoker relationship exempts // it from light dismiss. popoverId: popover.id }); const setTriggerRef = useMergedRefs(triggerButtonRef, popover.triggerRef, setTriggerEl, ref); // The desktop popup is a composite menu widget per the APG menu pattern: // a single roving tab stop with ArrowUp/ArrowDown traversal (wrapping), // Home/End, and first-character typeahead. The hook owns item tabindex — // items render tabIndex={-1} and exactly one is promoted to 0. The // composition mirrors NavHeadingMenu. const { listRef, handleKeyDown, handleFocus, focusItem } = useListFocus({ itemSelector: '[role="menuitem"]', hasRovingTabIndex: true, // Not popover.hide: Escape must also restore focus to the trigger. onEscape: close }); // First-character typeahead over the menu items (menus-11). const getMenuItems = useCallback((): HTMLElement[] => listRef.current ? Array.from(listRef.current.querySelectorAll('[role="menuitem"]')) : [], [listRef]); const typeahead = useTypeahead({ getItemLabels: () => getMenuItems().map(el => el.textContent), onMatch: focusItem, getCurrentIndex: () => getMenuItems().findIndex(el => el === document.activeElement || el.contains(document.activeElement)) }); // Extend useListFocus with Enter/Space activation. Items rendered without an // `href` are `
` elements, which have no native keyboard // activation — without this, Enter/Space on a focused onClick-only item does // nothing. Anchor items (with `href`) already activate on Enter natively. const menuKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { const focused = document.activeElement as HTMLElement | null; if (focused?.getAttribute('role') === 'menuitem') { e.preventDefault(); focused.click(); return; } } if (typeahead.onKeyDown(e)) { e.preventDefault(); return; } handleKeyDown(e); }, [handleKeyDown, typeahead]); // Menu container carries both the hover hook's ref (for its open/close // focus management) and the list-focus ref (for roving tabindex/typeahead). const setMenuRef = useMergedRefs(menuRef, listRef); // Mobile bar: hide menus entirely if (renderMode === 'mobile-bar') { return null; } // Drawer mode: collapsible section if (renderMode === 'drawer') { return
{items.map(item => { item.onClick?.(); closeMobileNav(); }} {...focusOutlineProps.focusVisible(navItemStyles.item, interactionOverlayStyles.backgroundColor, drawerStyles.item)}> {item.icon && {item.icon} } {item.title} {item.description && {item.description} } )}
; } // Default: desktop popover return <> {popover.render(
{items.map(item => { const Element = item.href ? 'a' : 'div'; return
{item.icon}
{item.title} {item.description && {item.description} }
; })}
, { placement: 'below', alignment: slot, offset: spacingVars['--spacing-1'] })} ; } TopNavMenu.displayName = 'TopNavMenu';