// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file TabMenu.tsx * @input Uses React, StyleX, usePopover, TabListContext * @output Exports TabMenu component, TabMenuProps type, TabMenuOption type * @position Menu trigger button; opens dropdown of overflow menu items and * mirrors selected tab indicator positioning * * SYNC: When modified, update: * - /packages/core/src/TabList/TabList.doc.mjs * - /packages/core/src/TabList/index.ts * - /packages/core/src/TabList/TabList.test.tsx * - /packages/cli/assets/templates/blocks/components/TabList/ (showcase blocks) */ import React, { useCallback, useId, useRef, type ReactNode } from 'react'; import * as stylex from "../style-runtime.js"; import { Icon } from "../Icon/index.js"; import { renderIconSlot, type IconType } from "../Icon/index.js"; import "../theme/tokens.stylex.js"; import { colorVars, spacingVars, sizeVars, radiusVars, durationVars, easeVars, fontWeightVars, typeScaleVars } from "../theme/tokens.stylex.js"; import { focusOutlineProps } from "../utils/focusOutline.stylex.js"; import { usePopover } from "../Popover/usePopover.js"; import { MENU_ITEM_SELECTOR } from "../DropdownMenu/menuItemRoles.js"; import { useListFocus } from "../hooks/useListFocus.js"; import { useTabListContext } from "./TabListContext.js"; import type { TabListSize } from "./TabListContext.js"; import "./tab.markers.stylex.js"; import { tabScope } from "./tab.markers.stylex.js"; import { mergeProps } from "../utils/index.js"; import { useMergedRefs } from "../hooks/useMergedRefs.js"; import type { BaseProps } from "../BaseProps.js"; import { themeProps } from "../utils/themeProps.js"; export interface TabMenuOption { value: string; label: string; /** * Icon to display before the label. */ icon?: ReactNode | IconType; } export interface TabMenuProps extends Pick, 'xstyle' | 'className' | 'style'> { ref?: React.Ref; /** * Label for the trigger button and dropdown heading. * Displayed as trigger text when no option is selected. */ label: string; /** * Menu options rendered in the dropdown. */ options: TabMenuOption[]; } // ============================================================================= // Styles // ============================================================================= const styles = { trigger: { kVAEAm: "x1n2onr6", k1xSpc: "x3nfvp2", kGNEyG: "x6s0dn4", kjj79g: "xl56j7k", kOIVth: "xzye2dw", kg3NbH: "xrrkdod", kWkggS: "xjbqb8w", kMzoRj: "xc342km", ksu8eU: "xng3xce", kaIpWk: "xh6dtrn", kMv6JI: "xjb2p0i", kGuDYH: "xcr08ib", kLWn49: "x1kq96og", k63SB2: "x1sodnla", kMwMTN: "xv1l7n4", kkrTdU: "x1ypdohk x16khyan", kybGjl: "x1hl2dhg", k1ekBW: "xt3l3uh", kIyJzY: "xuedmi6", kAMwcw: "xlr8y92", $$css: true as const }, triggerSelected: { kMwMTN: "x1tgivj0", k63SB2: "x2mo6ok", $$css: true as const }, hoverBg: { kVAEAm: "x10l6tqk", kpwlN0: "x10a8y8t", kogj98: "x1bpp3o7", kzqmXN: "xh8yej3", kaIpWk: "xh6dtrn", kfzvcC: "x47corl", kWkggS: "xjbqb8w x1a1zr74", k1ekBW: "x15406qy", kIyJzY: "xuedmi6", kAMwcw: "xlr8y92", $$css: true as const }, chevronIcon: { k1ekBW: "x11xpdln", kIyJzY: "xuedmi6", kAMwcw: "xlr8y92", $$css: true as const }, chevronOpen: { k3aq6I: "x19jd1h0", $$css: true as const }, menuItem: { k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kjj79g: "x1qughib", kOIVth: "x1txdalj", k8WAf4: "xce4md1", kg3NbH: "xrrkdod", kaIpWk: "xh6dtrn", kMv6JI: "xjb2p0i", kGuDYH: "xcr08ib", kLWn49: "x1kq96og", k63SB2: "x1sodnla", kMwMTN: "x1tgivj0", kkrTdU: "x1ypdohk x16khyan", k1ekBW: "x15406qy", kIyJzY: "xuedmi6", kAMwcw: "xlr8y92", kWkggS: "xjbqb8w xoevpu5", $$css: true as const }, menuItemSelected: { k63SB2: "x1e4wzip", $$css: true as const } }; const sizeStyles = { sm: { kZKoxP: "x6k0iem", $$css: true as const }, md: { kZKoxP: "x1ueg155", $$css: true as const }, lg: { kZKoxP: "xssyfek", $$css: true as const } }; // Hover bg uses the standard element size (one step smaller than tab) const hoverSizeStyles = { sm: { kZKoxP: "x6k0iem", $$css: true as const }, md: { kZKoxP: "x1ueg155", $$css: true as const }, lg: { kZKoxP: "xssyfek", $$css: true as const } }; /** * Tab menu trigger that opens a dropdown of additional tab options. * Shows the selected option's label as trigger text when an option is active. * Dropdown includes a heading showing the menu's label prop. * * @example * ``` * * * * * ``` */ export function TabMenu({ ref, label, options, xstyle, className, style }: TabMenuProps) { const tabListCtx = useTabListContext(); const menuId = useId(); const buttonRef = useRef(null); const popover = usePopover({ hasLightDismiss: true, hasCloseButton: false, hasAutoFocus: false, // The popup's own role="menu" is the exposed semantics; a modal dialog // wrapper would announce an unnamed dialog around the menu. role: 'none', // Return focus to the trigger when the menu closes (Escape, Tab, select, // or light dismiss) so keyboard focus is never dropped to . onHide: useCallback(() => { buttonRef.current?.focus(); }, []) }); // The overflow menu is a composite widget: a single roving tab stop with // arrow-key navigation between items, per the APG menu pattern. The hook owns // item tabindex — items render tabIndex={-1} and exactly one is promoted to 0. const { listRef, handleKeyDown: handleListKeyDown, handleFocus, focusFirst } = useListFocus({ hasRovingTabIndex: true, // Options render as menuitemradio (single-select menu), which the // default '[role="menuitem"]' selector would not match. itemSelector: MENU_ITEM_SELECTOR, onEscape: () => popover.hide() }); const handleToggle = useCallback(() => { if (popover.isOpen) { popover.hide(); } else { popover.show(); // Move focus into the menu on open so arrow navigation works and the // roving tab stop lands on a real item (APG menu-button focus-on-open). requestAnimationFrame(() => focusFirst()); } }, [popover, focusFirst]); const handleMenuKeyDown = useCallback((e: React.KeyboardEvent) => { // APG menu-button: Tab leaves the menu rather than walking its items. // Close and let onHide return focus to the trigger, from which the // browser's default Tab continues to the next element. if (e.key === 'Tab') { popover.hide(); return; } handleListKeyDown(e); }, [handleListKeyDown, popover]); const selectedOption = options.find(o => o.value === tabListCtx.value); const triggerLabel = selectedOption?.label ?? label; const hasSelectedOption = selectedOption != null; const size: TabListSize = tabListCtx.size; const handleSelect = useCallback((value: string) => { tabListCtx.onChange(value); popover.hide(); }, [tabListCtx, popover]); const setButtonRef = useMergedRefs(popover.triggerRef, buttonRef, ref); return <> {popover.render(, { placement: 'below', alignment: 'start' })} ; } TabMenu.displayName = 'TabMenu';