// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file DropdownMenu.tsx * @input Uses React, StyleX, usePopover, MenuBottomSheet, Button, List, * useListFocus, and the shared viewport-safe menu-width resolver * @output Exports DropdownMenu with caller-selected popover or bottom-sheet * presentation * @position Core implementation; consumed by index.ts * * Supports two modes with a single keyboard/focus path: * - **Data-driven**: pass `items` array (converted to components internally) * - **Compound-component**: pass JSX children directly * * Both modes use useListFocus for DOM-based keyboard navigation. * * Initial focus on open follows the input modality: a keyboard open * (Enter / Space / ArrowDown on the trigger) focuses the first enabled item * (APG menu-button); a pointer open focuses the menu container itself so no * item reads as pre-selected, and the first ArrowDown then moves to item 1. * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/DropdownMenu/DropdownMenu.doc.mjs * - /packages/core/src/DropdownMenu/DropdownMenu.test.tsx * - /packages/core/src/DropdownMenu/index.ts * - /apps/storybook/stories/DropdownMenu.stories.tsx * - /packages/cli/assets/templates/blocks/components/DropdownMenu/ (showcase blocks) */ import React, { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react'; import * as stylex from "../style-runtime.js"; import { usePopover } from "../Popover/usePopover.js"; import { Button, type ButtonProps } from "../Button/index.js"; import { Heading } from "../Heading/index.js"; import { Icon } from "../Icon/index.js"; import { renderDropdownItems } from "./renderDropdownItems.js"; import type { DropdownMenuItemProps } from "./DropdownMenuItem.js"; import { MENU_ITEM_ROLES, MENU_ITEM_SELECTOR, MENU_BOUNDARY_SELECTOR } from "./menuItemRoles.js"; import { DropdownMenuContext, type DropdownMenuContextValue } from "./DropdownMenuContext.js"; import { useIsomorphicLayoutEffect } from "../hooks/useIsomorphicLayoutEffect.js"; import { useListFocus } from "../hooks/useListFocus.js"; import { useTypeahead } from "../hooks/useTypeahead.js"; import { useFocusReturnVisibility } from "../hooks/useFocusReturnVisibility.js"; import { useMenuOverflow } from "./useMenuOverflow.js"; import { resolveMenuWidth } from "./menuWidth.js"; import { useAdaptivePresentation, type AdaptivePresentation } from "../hooks/useAdaptivePresentation.js"; import { MenuBottomSheet } from "./MenuBottomSheet.js"; import { MenuBottomSheetActionList } from "./MenuBottomSheetActionList.js"; import { layerAnimations } from "../Layer/layerAnimations.stylex.js"; import type { LayerAlignment, LayerPlacement } from "../Layer/useLayer.js"; import "../theme/tokens.stylex.js"; import { spacingVars, colorVars, radiusVars, durationVars, easeVars } from "../theme/tokens.stylex.js"; import { mergeProps, rtlStyles } from "../utils/index.js"; import type { BaseProps } from "../BaseProps.js"; import { themeProps } from "../utils/themeProps.js"; import { getInteractionModality, useInteractionModalityTracking } from "../utils/interactionModality.js"; import { useTranslator } from "../i18n/index.js"; import { focusOutlineStyles } from "../utils/focusOutline.stylex.js"; const MENU_VIEWPORT_GUTTER = spacingVars['--spacing-4']; const MENU_MAX_INLINE_SIZE = `calc(100vi - max(${MENU_VIEWPORT_GUTTER}, env(safe-area-inset-left, 0px)) - max(${MENU_VIEWPORT_GUTTER}, env(safe-area-inset-right, 0px)))`; const MENU_MAX_INLINE_SIZE_FALLBACK = `calc(100vw - ${MENU_VIEWPORT_GUTTER} - ${MENU_VIEWPORT_GUTTER})`; const MENU_MAX_BLOCK_SIZE = `min(300px, calc(100dvb - max(${MENU_VIEWPORT_GUTTER}, env(safe-area-inset-top, 0px)) - max(${MENU_VIEWPORT_GUTTER}, env(safe-area-inset-bottom, 0px))))`; const MENU_MAX_BLOCK_SIZE_FALLBACK = `min(300px, calc(100vh - ${MENU_VIEWPORT_GUTTER} - ${MENU_VIEWPORT_GUTTER}))`; const MENU_POSITION_AREA_MAX_INLINE_SIZE = `calc(100% - max(${MENU_VIEWPORT_GUTTER}, env(safe-area-inset-left, 0px), env(safe-area-inset-right, 0px)))`; const MENU_POSITION_AREA_MAX_INLINE_SIZE_FALLBACK = `calc(100% - ${MENU_VIEWPORT_GUTTER})`; const MENU_INLINE_EDGE_GUTTER = `max(${MENU_VIEWPORT_GUTTER}, env(safe-area-inset-left, 0px), env(safe-area-inset-right, 0px))`; const MENU_TRIGGER_OPEN_BACKGROUND = `linear-gradient(${colorVars['--color-overlay-pressed']}, ${colorVars['--color-overlay-pressed']})`; const _temp = { kzqmXN: "x1uyokj7", "$$css": true }; const styles = { triggerOpen: { kKwaWg: "xpl5ynk", $$css: true as const }, dropdown: { kB7OPa: "x9f619", k1xSpc: "x78zum5", kXwgrk: "xdt5ytf", kOIVth: "x1lsbc85", kI3sdo: "x1a2a7pz", ks0D6T: "xs2v1xk", kskxy: "x1hkkhfe", "--_dropdown-menu-radius": "x1fcsqxe", "--_dropdown-menu-padding": "xgory14", kmVPX3: "x9epnlk", kaIpWk: "x1n97fys", kSiTet: "x1hc1fzr", k1ekBW: "x19991ni", kIyJzY: "xuedmi6", kAMwcw: "xlr8y92", $$css: true as const }, scrollable: { kORKVm: "x1odjw0f", kXHlph: "x6ikm8r", kZeWKH: "xish69e", $$css: true as const }, popoverViewport: { kB7OPa: "x9f619", kskxy: "x1hkkhfe", $$css: true as const }, popoverViewportAligned: { ks0D6T: "xbd96yr", $$css: true as const }, popoverViewportStart: { k71WvV: "xxeicf8", $$css: true as const }, popoverViewportEnd: { keTefX: "x47fqr9", $$css: true as const }, popoverViewportBlockStart: { k1K539: "x1kps3mo", $$css: true as const }, popoverViewportBlockEnd: { keoZOQ: "xsoubt3", $$css: true as const }, popoverViewportCentered: { keTefX: "x47fqr9", k71WvV: "xxeicf8", ks0D6T: "xs2v1xk", $$css: true as const }, popoverViewportBlockCentered: { keoZOQ: "xsoubt3", k1K539: "x1kps3mo", ks0D6T: "xs2v1xk", $$css: true as const }, popoverAligned: { k7Eaqz: "x1ks9poc", $$css: true as const }, popoverCentered: { k7Eaqz: "xagy28z", $$css: true as const }, popoverCustomWidth: (width: string) => [{ k7Eaqz: width != null ? "xkj4a21" : width, $$css: true as const }, { "--x-minWidth": (val => typeof val === "number" ? val + "px" : val != null ? val : undefined)(width) }], popoverCustomIntrinsicWidth: (width: string) => [_temp, { "--x-inlineSize": (val => typeof val === "number" ? val + "px" : val != null ? val : undefined)(width) }] }; const bottomSheetStyles = { content: { kzqmXN: "xh8yej3", $$css: true as const }, rootHeading: { keTefX: "x6i7dpz", $$css: true as const }, viewHeading: { kI3sdo: "x1a2a7pz", $$css: true as const } }; // ============================================================================= // Types // ============================================================================= /** * Data-mode shape for one menu row. * * The item fields are sourced from `DropdownMenuItemProps` — data mode renders * through `DropdownMenuItem`, so the two APIs describe the same thing and must * not drift. Only the fields listed here are part of the data API; add a key to * the `Pick` to expose more of the item's props to `items`. */ export interface DropdownMenuItemData extends Pick { /** * Stable identity for the row, used as its React key (as on * `TreeListItemData`). Omit it and the row is keyed by position, which is * correct for a fixed menu; set it when `items` can reorder, filter, or grow, * so a row keeps its DOM node — and therefore keyboard focus — as the array * changes around it. */ id?: string; /** Primary label content. */ label: ReactNode; /** * Nested submenu entries. When present, this row becomes a submenu (a * flyout revealing `items`) instead of a leaf action — no separate item * "type" is needed. Data-mode parity for the compound DropdownMenuSubMenu API. */ items?: DropdownMenuOption[]; } /** * Data-mode shape for a divider row. The compound-mode peer is the * `DropdownMenuDivider` component, which both modes render. */ export interface DropdownMenuDividerData { type: 'divider'; } export interface DropdownMenuSection { type: 'section'; /** Stable identity for the group; see {@link DropdownMenuItemData.id}. */ id?: string; title?: string; items: DropdownMenuItemData[]; } export type DropdownMenuOption = DropdownMenuItemData | DropdownMenuDividerData | DropdownMenuSection; // ============================================================================= // Props // ============================================================================= export type DropdownMenuButtonProps = Omit; export type DropdownMenuPresentation = AdaptivePresentation; interface DropdownMenuBaseProps extends BaseProps { button?: DropdownMenuButtonProps; isMenuOpen?: boolean; onOpenChange?: (isOpen: boolean) => void; /** * Minimum popover width. The menu may grow for its content but is capped to * the available viewport space. Ignored by bottom-sheet presentation. * Defaults to the trigger width. */ menuWidth?: number | string; onClick?: () => void; hasChevron?: boolean; /** * Popover position relative to the trigger. Ignored by bottom-sheet * presentation. * Uses the same placement values as other Astryx layer-based components. * @default 'below' */ placement?: LayerPlacement; /** * Popover alignment along the placement axis. Ignored by bottom-sheet * presentation. * Uses the same alignment values as other Astryx layer-based components. * @default 'start' */ alignment?: LayerAlignment; 'data-testid'?: string; } interface DropdownMenuDataProps extends DropdownMenuBaseProps { /** * Surface used to present the actions. Pass a value selected by product * policy (for example, a compact-touch media query) when the presentation * should adapt. Bottom-sheet presentation is intended for short action sets. * @default 'popover' */ presentation?: DropdownMenuPresentation; items: DropdownMenuOption[]; children?: undefined; } interface DropdownMenuCompoundProps extends DropdownMenuBaseProps { presentation?: 'popover'; items?: undefined; children: ReactNode; } export type DropdownMenuProps = DropdownMenuDataProps | DropdownMenuCompoundProps; // ============================================================================= // DropdownMenu // ============================================================================= /** * A dropdown menu component that displays a list of actionable items. * * Supports two modes: * - **Data-driven**: pass `items` and choose either the default anchored * popover or the modal bottom-sheet presentation. * - **Compound-component**: pass JSX children for dynamic, stateful, or * lazy-loaded anchored popover menus. * * @example * ``` * handleEdit() }, * { label: 'Delete', onClick: () => handleDelete() }, * ]} * /> * ``` */ // When the consumer doesn't pass `button`, the default label is looked up // at render time so it respects the active InternationalizationProvider // locale. const DEFAULT_BUTTON_I18N_KEY = '@astryx.dropdownMenu.label' as const; function DropdownMenuBottomSheet({ button: buttonFromProps, isMenuOpen: controlledIsOpen, onOpenChange, onClick, hasChevron = true, items, presentation: _presentation, menuWidth: _menuWidth, placement: _placement, alignment: _alignment, className, style, xstyle, 'data-testid': testId, ...rest }: DropdownMenuDataProps & { presentation: 'bottom-sheet'; }) { const t = useTranslator(); const button = buttonFromProps ?? { label: t(DEFAULT_BUTTON_I18N_KEY) }; const backLabel = t('@astryx.dropdownMenu.back'); const buttonRef = useRef(null); const actionListRef = useRef(null); const openModalityRef = useRef<'keyboard' | 'pointer'>('pointer'); const { isFocusRingSuppressed, onFocusReturnTargetFocus, prepareFocusReturn, resetFocusReturn } = useFocusReturnVisibility(); const viewHeadingRef = useRef(null); const previousSubmenuDepthRef = useRef(0); const [internalIsOpen, setInternalIsOpen] = useState(false); const [submenuPath, setSubmenuPath] = useState([]); const isControlled = controlledIsOpen !== undefined; const isOpen = isControlled ? controlledIsOpen : internalIsOpen; const [previousIsOpen, setPreviousIsOpen] = useState(isOpen); if (previousIsOpen !== isOpen) { setPreviousIsOpen(isOpen); if (!isOpen && submenuPath.length > 0) { setSubmenuPath([]); } } const currentSubmenu = submenuPath.at(-1); const currentItems = currentSubmenu?.items ?? items; const currentTitle = currentSubmenu?.label ?? button.label; const sheetLabel = typeof currentTitle === 'string' ? currentTitle : button.label; const setOpen = useCallback((nextIsOpen: boolean) => { if (!nextIsOpen) { setSubmenuPath([]); prepareFocusReturn(); } else { resetFocusReturn(); } onOpenChange?.(nextIsOpen); if (!isControlled) { setInternalIsOpen(nextIsOpen); } }, [isControlled, onOpenChange, prepareFocusReturn, resetFocusReturn]); const handleSelect = useCallback((item: DropdownMenuItemData) => { if (item.isDisabled) { return; } item.onClick?.(); if (item.hasCloseOnSelect !== false) { setOpen(false); } }, [setOpen]); const isIconOnly = button.isIconOnly === true; const resolvedEndContent = button.endContent ?? (hasChevron && !isIconOnly ? : undefined); useEffect(() => { if (!isOpen || openModalityRef.current !== 'keyboard') { return; } const frame = requestAnimationFrame(() => { actionListRef.current?.querySelector('button:not(:disabled), a[href]')?.focus({ preventScroll: true }); }); return () => cancelAnimationFrame(frame); }, [currentItems, isOpen]); useEffect(() => { if (!isOpen) { previousSubmenuDepthRef.current = 0; return; } if (submenuPath.length === previousSubmenuDepthRef.current) { return; } previousSubmenuDepthRef.current = submenuPath.length; const frame = requestAnimationFrame(() => { viewHeadingRef.current?.focus({ preventScroll: true }); }); return () => cancelAnimationFrame(frame); }, [isOpen, submenuPath.length]); return <>