// 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`;
}