// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file ContextMenu.tsx * @input Uses React, StyleX, useLayer, BottomSheet, useListFocus, and the * shared menu-presentation resolver * @output Exports ContextMenu with cursor-popover and touch-sheet presentations * @position Core implementation; consumed by index.ts * * Right-click context menu positioned at the cursor. The cursor point is * captured as an offset *inside the trigger* and materialized as a zero-size * anchor element, so the menu is positioned relative to the trigger's context * (via CSS anchor positioning) rather than the viewport. It therefore follows * the content on scroll and auto-flips at viewport edges, while still appearing * under the cursor. * Reuses DropdownMenu item rendering and keyboard navigation. * * Supports two content modes with a single keyboard/focus path: * - **Data-driven**: pass `items` array (converted to components internally) * - **Compound-component**: pass `menuContent` JSX directly * * Both modes use useListFocus for DOM-based keyboard navigation. * Open state is managed internally — right-click opens, click-outside/Escape closes. * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/ContextMenu/ContextMenu.doc.mjs * - /packages/core/src/ContextMenu/ContextMenu.test.tsx * - /packages/core/src/ContextMenu/index.ts * - /apps/storybook/stories/ContextMenu.stories.tsx * - /packages/cli/assets/templates/blocks/components/ContextMenu/ (showcase blocks) */ import React, { useCallback, useEffect, useId, lazy, useMemo, useRef, useState, Suspense } from 'react'; import type { ReactNode } from 'react'; import * as stylex from "../style-runtime.js"; import { Button } from "../Button/index.js"; import { Heading } from "../Heading/index.js"; import { Icon } from "../Icon/index.js"; import { useLayer } from "../Layer/useLayer.js"; import { MenuBottomSheetActionList } from "../DropdownMenu/MenuBottomSheetActionList.js"; import { renderDropdownItems } from "../DropdownMenu/renderDropdownItems.js"; import { DropdownMenuContext, type DropdownMenuContextValue } from "../DropdownMenu/DropdownMenuContext.js"; import { MENU_ITEM_ROLES, MENU_ITEM_SELECTOR, MENU_BOUNDARY_SELECTOR } from "../DropdownMenu/menuItemRoles.js"; import { useListFocus } from "../hooks/useListFocus.js"; import { useTypeahead } from "../hooks/useTypeahead.js"; import { useLongPress } from "../hooks/useLongPress.js"; import { layerAnimations } from "../Layer/layerAnimations.stylex.js"; import "../theme/tokens.stylex.js"; import { colorVars, spacingVars, radiusVars, durationVars, easeVars, shadowVars } from "../theme/tokens.stylex.js"; import { mergeProps, isImeKeyEvent, rtlStyles } from "../utils/index.js"; import type { BaseProps } from "../BaseProps.js"; import type { StyleXStyles } from "../theme/types.js"; import { themeProps } from "../utils/themeProps.js"; import { useTranslator } from "../i18n/index.js"; import type { DropdownMenuOption, DropdownMenuItemData, DropdownMenuDividerData, DropdownMenuSection } from "../DropdownMenu/DropdownMenu.js"; import { useMergedRefs } from "../hooks/useMergedRefs.js"; import { useAdaptivePresentation, type AdaptivePresentation } from "../hooks/useAdaptivePresentation.js"; const LazyMenuBottomSheet = lazy(async () => import("../DropdownMenu/MenuBottomSheet.js").then(module => ({ default: module.MenuBottomSheet }))); const styles = { trigger: { kVAEAm: "x1n2onr6", k7VYTy: "xpdipgo", kO7zv1: "x1winvzj", kfSwDN: "x87ps6o", $$css: true as const }, menu: { kB7OPa: "x9f619", k1xSpc: "x78zum5", kXwgrk: "xdt5ytf", kOIVth: "x1lsbc85", kskxy: "xuyqlj2", kORKVm: "x1odjw0f", "--_dropdown-menu-radius": "x1fcsqxe", "--_dropdown-menu-padding": "xgory14", kmVPX3: "x9epnlk", kaIpWk: "x1n97fys", kWkggS: "x1prclbq", kGVxlE: "x1i5ehqx", kSiTet: "x1hc1fzr", k1ekBW: "x19991ni", kIyJzY: "xuedmi6", kAMwcw: "xlr8y92", kfSwDN: "x87ps6o", $$css: true as const }, popover: { k7Eaqz: "x5w4yej", $$css: true as const }, popoverCustomWidth: (width: string | number) => [{ k7Eaqz: (typeof width === 'number' ? `${width}px` : width) != null ? "xkj4a21" : typeof width === 'number' ? `${width}px` : width, $$css: true as const }, { "--x-minWidth": (val => typeof val === "number" ? val + "px" : val != null ? val : undefined)(typeof width === 'number' ? `${width}px` : width) }], sheetMenu: { kB7OPa: "x9f619", k1xSpc: "x78zum5", kXwgrk: "xdt5ytf", kOIVth: "x1lsbc85", kzqmXN: "xh8yej3", "--_dropdown-menu-radius": "x1fcsqxe", "--_dropdown-menu-padding": "xgory14", kmVPX3: "x9epnlk", kaIpWk: "x1n97fys", kWkggS: "x10xzikg", kI3sdo: "x1a2a7pz", kfSwDN: "x87ps6o", $$css: true as const }, sheetRootHeading: { keTefX: "x6i7dpz", $$css: true as const }, sheetViewHeading: { kI3sdo: "x1a2a7pz", $$css: true as const } }; // ============================================================================= // Types // ============================================================================= export type ContextMenuItemData = DropdownMenuItemData; export type ContextMenuDividerData = DropdownMenuDividerData; export type ContextMenuSection = DropdownMenuSection; export type ContextMenuOption = DropdownMenuOption; // ============================================================================= // Props // ============================================================================= interface ContextMenuBaseProps extends BaseProps { /** Ref forwarded to the trigger wrapper element. */ ref?: React.Ref; /** * Styles applied to the trigger wrapper element (the right-click target). * By default the trigger is a plain block that hugs its content — pass a * fill style (e.g. `width/height: 100%`) when the whole parent area should * be right-clickable (as the Table does for full-cell context menus). */ triggerXstyle?: StyleXStyles | StyleXStyles[]; /** The trigger area — right-click on this to open the menu. */ children: ReactNode; /** Custom menu width. @default '160px' */ menuWidth?: number | string; /** Size of menu items. @default 'md' */ size?: 'sm' | 'md' | 'lg'; /** * Accessible name for the menu surface, announced when it opens. * @default 'Context menu' */ label?: string; /** When true, right-click shows the native browser context menu instead. */ isDisabled?: boolean; /** Called when the menu opens or closes. */ onOpenChange?: (isOpen: boolean) => void; /** * Presentation policy for the menu. * - `popover`: open beside the pointer position. * - `bottom-sheet`: open as an action sheet. * - `adaptive`: use a BottomSheet on compact coarse-pointer viewports and * the cursor-positioned popover elsewhere. * @default 'popover' */ presentation?: AdaptivePresentation; 'data-testid'?: string; } interface ContextMenuDataProps extends ContextMenuBaseProps { /** Array of menu entries (data-driven mode). */ items: ContextMenuOption[]; menuContent?: undefined; } interface ContextMenuCompoundProps extends ContextMenuBaseProps { items?: undefined; /** Custom JSX menu content (compound mode). */ menuContent: ReactNode; } export type ContextMenuProps = ContextMenuDataProps | ContextMenuCompoundProps; // ============================================================================= // ContextMenu // ============================================================================= /** * A context menu component that appears on right-click at cursor position. * * Supports two modes: * - **Data-driven**: pass `items` for static menus * - **Compound-component**: pass `menuContent` JSX for dynamic menus * * Both modes share the same DOM-based keyboard navigation via useListFocus. * * @example * ``` * handleCut() }, * { label: 'Copy', onClick: () => handleCopy() }, * { type: 'divider' }, * { label: 'Paste', onClick: () => handlePaste() }, * ]} * > *
Right-click this area
*
* ``` */ export function ContextMenu({ children, menuWidth, size = 'md', label: labelFromProps, isDisabled = false, onOpenChange, presentation = 'popover', ref, className, style, xstyle, triggerXstyle, 'data-testid': testId, ...rest }: ContextMenuProps) { const t = useTranslator(); const label = labelFromProps ?? t('@astryx.contextMenu.label'); const backLabel = t('@astryx.dropdownMenu.back'); const resolvedPresentation = useAdaptivePresentation(presentation); const usesBottomSheet = resolvedPresentation === 'bottom-sheet'; // Separate content props (union discriminant) from DOM pass-through attrs. // The union means exactly one of items/menuContent exists in rest; destructure // both so triggerProps contains only DOM-safe attributes. const { items: itemsProp, menuContent: menuContentProp, ...triggerProps } = rest as { items?: ContextMenuOption[]; menuContent?: ReactNode; } & Omit; const items = itemsProp ?? []; const menuContent = menuContentProp; const menuId = useId(); // Cursor point in the trigger's local coordinate space (offset from the // trigger's top-left, in-flow). Stored here and written to the zero-size // anchor element so the menu is positioned relative to the trigger context // — it scrolls with the content instead of sitting at a fixed viewport point. const positionRef = useRef({ x: 0, y: 0 }); const cursorAnchorRef = useRef(null); const triggerRef = useRef(null); // Element focused before the menu opened, restored when it closes so focus // does not fall to after Escape or outside-click dismissal. const triggerFocusRef = useRef(null); const sheetHeadingRef = useRef(null); const [isOpen, setIsOpen] = useState(false); const [submenuPath, setSubmenuPath] = useState([]); const currentSubmenu = submenuPath.at(-1); const currentItems = currentSubmenu?.items ?? items; const currentTitle = currentSubmenu?.label ?? label; const sheetLabel = typeof currentTitle === 'string' ? currentTitle : label; const updateOpenState = useCallback((nextIsOpen: boolean) => { if (!nextIsOpen) { setSubmenuPath([]); } setIsOpen(nextIsOpen); onOpenChange?.(nextIsOpen); }, [onOpenChange]); const layer = useLayer({ mode: 'context', onHide: useCallback(() => { setIsOpen(false); onOpenChange?.(false); // Restore focus to the element that was focused before opening. const toRestore = triggerFocusRef.current; triggerFocusRef.current = null; if (toRestore && document.contains(toRestore)) { toRestore.focus(); } }, [onOpenChange]), onShow: useCallback(() => { setIsOpen(true); onOpenChange?.(true); }, [onOpenChange]), lightDismiss: false }); const closeMenu = useCallback(() => { if (usesBottomSheet) { updateOpenState(false); } else { layer.hide(); } }, [layer, updateOpenState, usesBottomSheet]); const handleBottomSheetSelect = useCallback((item: ContextMenuItemData) => { if (item.isDisabled) { return; } item.onClick?.(); if (item.hasCloseOnSelect !== false) { closeMenu(); } }, [closeMenu]); useEffect(() => { if (!isOpen || submenuPath.length === 0) { return; } const frame = requestAnimationFrame(() => { sheetHeadingRef.current?.focus({ preventScroll: true }); }); return () => cancelAnimationFrame(frame); }, [isOpen, submenuPath.length]); const { listRef, handleKeyDown: listNavKeyDown, focusFirst, focusItem, ownsEvent, getItems: getMenuItems } = useListFocus({ itemSelector: MENU_ITEM_SELECTOR, boundarySelector: MENU_BOUNDARY_SELECTOR, wrap: false, onEscape: closeMenu }); // First-character typeahead over the enabled menu items (menus-11). Reuses // the hook's scoped item collection so an inline submenu flyout's items // aren't swept in. const typeahead = useTypeahead({ getItemLabels: () => getMenuItems().map(el => el.textContent), onMatch: focusItem, getCurrentIndex: () => getMenuItems().findIndex(el => el === document.activeElement || el.contains(document.activeElement)) }); // Dismiss on any click outside the menu. We use popover="manual" (not // "auto") because the native light-dismiss treats the mouseup from the // opening right-click as a dismiss event. Handling it ourselves via // mousedown avoids that race. useEffect(() => { if (!isOpen || usesBottomSheet) { return; } const handleClickOutside = (e: MouseEvent) => { const menu = listRef.current; if (menu && !menu.contains(e.target as Node)) { closeMenu(); } }; document.addEventListener('mousedown', handleClickOutside); return () => { document.removeEventListener('mousedown', handleClickOutside); }; }, [isOpen, closeMenu, listRef, usesBottomSheet]); // Dismiss on Escape from anywhere while open. The menu div's own onKeyDown // only fires when focus is inside the menu; a document-level listener is // kept as a reliable fallback Escape path (e.g. if focus has moved out of // the menu). Guards against IME composition-cancel. useEffect(() => { if (!isOpen || usesBottomSheet) { return; } const handleEscape = (e: KeyboardEvent) => { if (e.key !== 'Escape') { return; } if (isImeKeyEvent(e)) { // Ignore Escape that is committing/cancelling an IME composition; // see utils/ime.ts for why. return; } e.preventDefault(); closeMenu(); }; document.addEventListener('keydown', handleEscape); return () => { document.removeEventListener('keydown', handleEscape); }; }, [isOpen, closeMenu, usesBottomSheet]); const listKeyDown = useCallback((e: React.KeyboardEvent) => { // A submenu flyout renders inline inside this menu; its key events bubble // up here. Let that level own them — only handle events from this level. if (!ownsEvent(e)) { return; } if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); const focused = document.activeElement as HTMLElement | null; if (focused && MENU_ITEM_ROLES.has(focused.getAttribute('role') ?? '')) { focused.click(); } return; } // APG menu pattern: Tab closes the menu. Menu items are tabIndex={-1} // so Tab would otherwise leak focus into the page while the menu stayed // open (menus-5). Do NOT preventDefault — closing restores focus to the // previously focused element, and the browser's default Tab then // continues from there to the next element. if (e.key === 'Tab') { closeMenu(); return; } if (typeahead.onKeyDown(e)) { e.preventDefault(); return; } listNavKeyDown(e); }, [listNavKeyDown, closeMenu, typeahead, ownsEvent]); // Place the zero-size cursor anchor at a point in the trigger's local // coordinate space and open the menu. Positioning the anchor inside the // trigger (rather than storing viewport coordinates on the menu itself) is // what makes the menu context-relative: it scrolls with the content and the // browser auto-flips it against the viewport edges via CSS anchor positioning. const openAtLocalPoint = useCallback((localX: number, localY: number, focusEl: HTMLElement | null) => { positionRef.current = { x: localX, y: localY }; const anchorEl = cursorAnchorRef.current; if (anchorEl) { anchorEl.style.left = `${localX}px`; anchorEl.style.top = `${localY}px`; } // Remember the element focused before opening so we can restore it on // close (Escape or outside-click), instead of dropping focus to . triggerFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : focusEl; if (usesBottomSheet) { updateOpenState(true); } else { layer.show(); requestAnimationFrame(() => focusFirst()); } }, [layer, focusFirst, updateOpenState, usesBottomSheet]); const handleContextMenu = useCallback((e: React.MouseEvent) => { if (isDisabled) { return; } e.preventDefault(); const trigger = triggerRef.current; const rect = trigger?.getBoundingClientRect(); // A keyboard-initiated contextmenu (Shift+F10 / the Menu key) fires a // `contextmenu` event whose coordinates are (0, 0) in several browsers. // Detect that and anchor the menu to the trigger's bottom-left instead, // so the menu is reachable without a pointer (menus-8). const isKeyboardInvoked = e.clientX === 0 && e.clientY === 0 && e.detail === 0; // Convert the viewport cursor point into the trigger's local space so // the anchor lives inside the (scrollable) trigger context. const localX = isKeyboardInvoked || !rect ? 0 : e.clientX - rect.left; const localY = isKeyboardInvoked || !rect ? rect?.height ?? 0 : e.clientY - rect.top; openAtLocalPoint(localX, localY, e.currentTarget as HTMLElement); }, [isDisabled, openAtLocalPoint]); // Touch long-press invocation (menus-8). iOS Safari never synthesizes a // `contextmenu` event on long-press, so a context menu is otherwise // unreachable on touch. Open the menu at the touch point once the press is // held long enough (see useLongPress for timer/move-cancel/cleanup logic). const longPressHandlers = useLongPress({ disabled: isDisabled, onLongPress: useCallback((point: { x: number; y: number; }) => { const rect = triggerRef.current?.getBoundingClientRect(); openAtLocalPoint(rect ? point.x - rect.left : point.x, rect ? point.y - rect.top : point.y, triggerRef.current); }, [openAtLocalPoint]) }); const popoverXstyle = menuWidth ? styles.popoverCustomWidth(menuWidth) : styles.popover; const contextValue = useMemo(() => ({ closeMenu, menuSize: size }), [closeMenu, size]); const resolvedMenuContent = itemsProp !== undefined ? renderDropdownItems(items) : menuContent; const renderedMenu = ; const renderedBottomSheetContent = itemsProp !== undefined ?
{submenuPath.length > 0 &&
setSubmenuPath(path => [...path, item])} />
: renderedMenu; return <>
{children}
{usesBottomSheet ? {renderedBottomSheetContent} : layer.render(renderedMenu, { placement: 'below', alignment: 'start', xstyle: [popoverXstyle, layerAnimations.below] })} ; } ContextMenu.displayName = 'ContextMenu';