// Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React, { memo, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import { ContextMenu } from 'radix-ui'; import type { FC, KeyboardEvent, KeyboardEventHandler, MouseEvent as ReactMouseEvent, } from 'react'; import { AxoSymbol } from './AxoSymbol.dom.js'; import { AxoBaseMenu } from './_internal/AxoBaseMenu.dom.js'; import { tw } from './tw.dom.js'; import { assert } from './_internal/assert.dom.js'; import { createStrictContext, useStrictContext, } from './_internal/StrictContext.dom.js'; const Namespace = 'AxoContextMenu'; /** * Displays a menu located at the pointer, triggered by a right click or a long press. * * Note: For menus that are triggered by a normal button press, you should use * `AxoDropdownMenu`. * * @example Anatomy * ```tsx * import { AxoContextMenu } from "./axo/ContextMenu/AxoContentMenu.tsx"; * * export default () => ( * * * * * * * * * * * * * * * * * * * * * * * * * * ) * ``` */ export namespace AxoContextMenu { type RootContextType = Readonly<{ open: boolean; }>; const RootContext = createStrictContext( `${Namespace}.RootContext` ); /** * Component: * -------------------------------- */ export type RootProps = AxoBaseMenu.MenuRootProps; export const Root: FC = memo(props => { const { onOpenChange } = props; const [open, setOpen] = useState(false); const handleOpenChange = useCallback( (nextOpen: boolean) => { setOpen(nextOpen); onOpenChange?.(nextOpen); }, [onOpenChange] ); const context = useMemo(() => { return { open }; }, [open]); return ( {props.children} ); }); Root.displayName = `${Namespace}.Root`; /** * Component: * ----------------------------------- */ type TriggerElementGetter = (event: KeyboardEvent) => Element; // eslint-disable-next-line no-inner-declarations function useContextMenuTriggerKeyboardEventHandler( getTriggerElement: TriggerElementGetter ) { const getTriggerElementRef = useRef(getTriggerElement); useEffect(() => { getTriggerElementRef.current = getTriggerElement; }, [getTriggerElement]); return useCallback( (event: KeyboardEvent) => { const isMacOS = window.platform === 'darwin'; if ( (isMacOS ? event.metaKey : !event.metaKey) && (isMacOS ? !event.ctrlKey : event.ctrlKey) && (isMacOS ? !event.shiftKey : event.shiftKey) && !event.altKey && (isMacOS ? event.key === 'F12' : event.key === 'F10') ) { event.preventDefault(); event.stopPropagation(); const trigger = getTriggerElement(event); const clientRect = trigger.getBoundingClientRect(); trigger.dispatchEvent( new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: clientRect.left, clientY: clientRect.bottom, }) ); } }, [getTriggerElement] ); } export type TriggerProps = AxoBaseMenu.MenuTriggerProps; export const Trigger: FC = memo(props => { const context = useStrictContext(RootContext); const [disableCurrentEvent, setDisableCurrentEvent] = useState(false); const handleContextMenuCapture = useCallback( (event: ReactMouseEvent) => { const { target, currentTarget } = event; if ( target instanceof HTMLElement && target.closest('a[href], [role=link]') != null ) { setDisableCurrentEvent(true); } const selection = window.getSelection(); if ( selection != null && !selection.isCollapsed && selection.containsNode(currentTarget, true) ) { setDisableCurrentEvent(true); } }, [] ); const handleContextMenu = useCallback(() => { setDisableCurrentEvent(false); }, []); const handleKeyDown = useContextMenuTriggerKeyboardEventHandler(event => { return event.currentTarget; }); return ( {props.children} ); }); Trigger.displayName = `${Namespace}.Trigger`; export function useAxoContextMenuOutsideKeyboardTrigger(): KeyboardEventHandler { return useContextMenuTriggerKeyboardEventHandler(event => { return assert( event.currentTarget.querySelector('[data-axo-contextmenu-trigger]'), `Couldn't find <${Namespace}.Trigger> element, did you forget to pass all html props through?` ); }); } /** * Component: * ----------------------------------- */ export type ContentProps = AxoBaseMenu.MenuContentProps; /** * The component that pops out in an open context menu. * Uses a portal to render the content part into the `body`. */ export const Content: FC = memo(props => { return ( {props.children} ); }); Content.displayName = `${Namespace}.Content`; /** * Component: * -------------------------------- */ export type ItemProps = AxoBaseMenu.MenuItemProps; /** * The component that contains the context menu items. * @example * ```tsx * }> * {i18n("myContextMenuText")} * * ```` */ export const Item: FC = memo(props => { return ( {props.symbol && ( )} {props.children} {props.keyboardShortcut && ( )} ); }); Item.displayName = `${Namespace}.Item`; /** * Component: * --------------------------------- */ export type GroupProps = AxoBaseMenu.MenuGroupProps; /** * Used to group multiple {@link AxoContextMenu.Item}'s. */ export const Group: FC = memo(props => { return ( {props.children} ); }); Group.displayName = `${Namespace}.Group`; /** * Component: * --------------------------------- */ export type LabelProps = AxoBaseMenu.MenuLabelProps; /** * Used to render a label. It won't be focusable using arrow keys. */ export const Label: FC = memo(props => { return ( {props.children} ); }); Label.displayName = `${Namespace}.Label`; /** * Component: * ---------------------------------------- */ export type CheckboxItemProps = AxoBaseMenu.MenuCheckboxItemProps; /** * An item that can be controlled and rendered like a checkbox. */ export const CheckboxItem: FC = memo(props => { return ( {props.symbol && ( )} {props.children} {props.keyboardShortcut && ( )} ); }); CheckboxItem.displayName = `${Namespace}.CheckboxItem`; /** * Component: * -------------------------------------- */ export type RadioGroupProps = AxoBaseMenu.MenuRadioGroupProps; /** * Used to group multiple {@link AxoContextMenu.RadioItem}'s. */ export const RadioGroup: FC = memo(props => { return ( {props.children} ); }); RadioGroup.displayName = `${Namespace}.RadioGroup`; /** * Component: * ------------------------------------- */ export type RadioItemProps = AxoBaseMenu.MenuRadioItemProps; /** * An item that can be controlled and rendered like a radio. */ export const RadioItem: FC = memo(props => { return ( {props.symbol && ( )} {props.children} {props.keyboardShortcut && ( )} ); }); RadioItem.displayName = `${Namespace}.RadioItem`; /** * Component: * ------------------------------------- */ export type SeparatorProps = AxoBaseMenu.MenuSeparatorProps; /** * Used to visually separate items in the context menu. */ export const Separator: FC = memo(() => { return ( ); }); Separator.displayName = `${Namespace}.Separator`; /** * Component: * ------------------------------- */ export type SubProps = AxoBaseMenu.MenuSubProps; /** * Contains all the parts of a submenu. */ export const Sub: FC = memo(props => { return {props.children}; }); Sub.displayName = `${Namespace}.Sub`; /** * Component: * -------------------------------------- */ export type SubTriggerProps = AxoBaseMenu.MenuSubTriggerProps; /** * An item that opens a submenu. Must be rendered inside * {@link ContextMenu.Sub}. */ export const SubTrigger: FC = memo(props => { return ( {props.symbol && ( )} {props.children} ); }); SubTrigger.displayName = `${Namespace}.SubTrigger`; /** * Component: * -------------------------------------- */ export type SubContentProps = AxoBaseMenu.MenuSubContentProps; /** * The component that pops out when a submenu is open. Must be rendered * inside {@link AxoContextMenu.Sub}. */ export const SubContent: FC = memo(props => { return ( {props.children} ); }); SubContent.displayName = `${Namespace}.SubContent`; }