389 lines
10 KiB
TypeScript
389 lines
10 KiB
TypeScript
![]() |
// Copyright 2025 Signal Messenger, LLC
|
||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||
|
import React, { memo } from 'react';
|
||
|
import { ContextMenu } from 'radix-ui';
|
||
|
import type { FC } from 'react';
|
||
|
import { AxoSymbol } from './AxoSymbol';
|
||
|
import { AxoBaseMenu } from './_internal/AxoBaseMenu';
|
||
|
|
||
|
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 () => (
|
||
|
* <AxoContextMenu.Root>
|
||
|
* <AxoContextMenu.Trigger />
|
||
|
*
|
||
|
* <AxoContextMenu.Content>
|
||
|
* <AxoContextMenu.Label />
|
||
|
* <AxoContextMenu.Item />
|
||
|
*
|
||
|
* <AxoContextMenu.Group>
|
||
|
* <AxoContextMenu.Item />
|
||
|
* </AxoContextMenu.Group>
|
||
|
*
|
||
|
* <AxoContextMenu.CheckboxItem/>
|
||
|
*
|
||
|
* <AxoContextMenu.RadioGroup>
|
||
|
* <AxoContextMenu.RadioItem/>
|
||
|
* </AxoContextMenu.RadioGroup>
|
||
|
*
|
||
|
* <AxoContextMenu.Sub>
|
||
|
* <AxoContextMenu.SubTrigger />
|
||
|
* <AxoContextMenu.SubContent />
|
||
|
* </AxoContextMenu.Sub>
|
||
|
*
|
||
|
* <AxoContextMenu.Separator />
|
||
|
* </AxoContextMenu.Content>
|
||
|
* </AxoContextMenu.Root>
|
||
|
* )
|
||
|
* ```
|
||
|
*/
|
||
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||
|
export namespace AxoContextMenu {
|
||
|
/**
|
||
|
* Component: <AxoContextMenu.Root>
|
||
|
* --------------------------------
|
||
|
*/
|
||
|
|
||
|
export type RootProps = AxoBaseMenu.MenuRootProps;
|
||
|
|
||
|
export const Root: FC<RootProps> = memo(props => {
|
||
|
return <ContextMenu.Root>{props.children}</ContextMenu.Root>;
|
||
|
});
|
||
|
|
||
|
Root.displayName = `${Namespace}.Root`;
|
||
|
|
||
|
/**
|
||
|
* Component: <AxoContextMenu.Trigger>
|
||
|
* -----------------------------------
|
||
|
*/
|
||
|
|
||
|
export type TriggerProps = AxoBaseMenu.MenuTriggerProps;
|
||
|
|
||
|
export const Trigger: FC<TriggerProps> = memo(props => {
|
||
|
return <ContextMenu.Trigger>{props.children}</ContextMenu.Trigger>;
|
||
|
});
|
||
|
|
||
|
Trigger.displayName = `${Namespace}.Trigger`;
|
||
|
|
||
|
/**
|
||
|
* Component: <AxoContextMenu.Content>
|
||
|
* -----------------------------------
|
||
|
*/
|
||
|
|
||
|
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<ContentProps> = memo(props => {
|
||
|
return (
|
||
|
<ContextMenu.Portal>
|
||
|
<ContextMenu.Content
|
||
|
className={AxoBaseMenu.menuContentStyles}
|
||
|
alignOffset={-6}
|
||
|
collisionPadding={6}
|
||
|
>
|
||
|
{props.children}
|
||
|
</ContextMenu.Content>
|
||
|
</ContextMenu.Portal>
|
||
|
);
|
||
|
});
|
||
|
|
||
|
Content.displayName = `${Namespace}.Content`;
|
||
|
|
||
|
/**
|
||
|
* Component: <AxoContextMenu.Item>
|
||
|
* --------------------------------
|
||
|
*/
|
||
|
|
||
|
export type ItemProps = AxoBaseMenu.MenuItemProps;
|
||
|
|
||
|
/**
|
||
|
* The component that contains the context menu items.
|
||
|
* @example
|
||
|
* ```tsx
|
||
|
* <AxoContextMenu.Item icon={<svg/>}>
|
||
|
* {i18n("myContextMenuText")}
|
||
|
* </AxoContentMenu.Item>
|
||
|
* ````
|
||
|
*/
|
||
|
export const Item: FC<ItemProps> = memo(props => {
|
||
|
return (
|
||
|
<ContextMenu.Item
|
||
|
disabled={props.disabled}
|
||
|
textValue={props.textValue}
|
||
|
onSelect={props.onSelect}
|
||
|
className={AxoBaseMenu.menuItemStyles}
|
||
|
>
|
||
|
{props.symbol && (
|
||
|
<AxoBaseMenu.ItemLeadingSlot>
|
||
|
<AxoBaseMenu.ItemSymbol symbol={props.symbol} />
|
||
|
</AxoBaseMenu.ItemLeadingSlot>
|
||
|
)}
|
||
|
<AxoBaseMenu.ItemContentSlot>
|
||
|
<AxoBaseMenu.ItemText>{props.children}</AxoBaseMenu.ItemText>
|
||
|
{props.keyboardShortcut && (
|
||
|
<AxoBaseMenu.ItemKeyboardShortcut
|
||
|
keyboardShortcut={props.keyboardShortcut}
|
||
|
/>
|
||
|
)}
|
||
|
</AxoBaseMenu.ItemContentSlot>
|
||
|
</ContextMenu.Item>
|
||
|
);
|
||
|
});
|
||
|
|
||
|
Item.displayName = `${Namespace}.Item`;
|
||
|
|
||
|
/**
|
||
|
* Component: <AxoContextMenu.Group>
|
||
|
* ---------------------------------
|
||
|
*/
|
||
|
|
||
|
export type GroupProps = AxoBaseMenu.MenuGroupProps;
|
||
|
|
||
|
/**
|
||
|
* Used to group multiple {@link AxoContextMenu.Item}'s.
|
||
|
*/
|
||
|
export const Group: FC<GroupProps> = memo(props => {
|
||
|
return (
|
||
|
<ContextMenu.Group className={AxoBaseMenu.menuGroupStyles}>
|
||
|
{props.children}
|
||
|
</ContextMenu.Group>
|
||
|
);
|
||
|
});
|
||
|
|
||
|
Group.displayName = `${Namespace}.Group`;
|
||
|
|
||
|
/**
|
||
|
* Component: <AxoContextMenu.Label>
|
||
|
* ---------------------------------
|
||
|
*/
|
||
|
|
||
|
export type LabelProps = AxoBaseMenu.MenuLabelProps;
|
||
|
|
||
|
/**
|
||
|
* Used to render a label. It won't be focusable using arrow keys.
|
||
|
*/
|
||
|
export const Label: FC<LabelProps> = memo(props => {
|
||
|
return (
|
||
|
<ContextMenu.Label className={AxoBaseMenu.menuLabelStyles}>
|
||
|
<AxoBaseMenu.ItemContentSlot>
|
||
|
<AxoBaseMenu.ItemText>{props.children}</AxoBaseMenu.ItemText>
|
||
|
</AxoBaseMenu.ItemContentSlot>
|
||
|
</ContextMenu.Label>
|
||
|
);
|
||
|
});
|
||
|
|
||
|
Label.displayName = `${Namespace}.Label`;
|
||
|
|
||
|
/**
|
||
|
* Component: <AxoContextMenu.CheckboxItem>
|
||
|
* ----------------------------------------
|
||
|
*/
|
||
|
|
||
|
export type CheckboxItemProps = AxoBaseMenu.MenuCheckboxItemProps;
|
||
|
|
||
|
/**
|
||
|
* An item that can be controlled and rendered like a checkbox.
|
||
|
*/
|
||
|
export const CheckboxItem: FC<CheckboxItemProps> = memo(props => {
|
||
|
return (
|
||
|
<ContextMenu.CheckboxItem
|
||
|
textValue={props.textValue}
|
||
|
disabled={props.disabled}
|
||
|
checked={props.checked}
|
||
|
onCheckedChange={props.onCheckedChange}
|
||
|
onSelect={props.onSelect}
|
||
|
className={AxoBaseMenu.menuCheckboxItemStyles}
|
||
|
>
|
||
|
<AxoBaseMenu.ItemLeadingSlot>
|
||
|
<AxoBaseMenu.ItemCheckPlaceholder>
|
||
|
<ContextMenu.ItemIndicator>
|
||
|
<AxoBaseMenu.ItemCheck />
|
||
|
</ContextMenu.ItemIndicator>
|
||
|
</AxoBaseMenu.ItemCheckPlaceholder>
|
||
|
</AxoBaseMenu.ItemLeadingSlot>
|
||
|
<AxoBaseMenu.ItemContentSlot>
|
||
|
{props.symbol && (
|
||
|
<span className="mr-2">
|
||
|
<AxoBaseMenu.ItemSymbol symbol={props.symbol} />
|
||
|
</span>
|
||
|
)}
|
||
|
<AxoBaseMenu.ItemText>{props.children}</AxoBaseMenu.ItemText>
|
||
|
{props.keyboardShortcut && (
|
||
|
<AxoBaseMenu.ItemKeyboardShortcut
|
||
|
keyboardShortcut={props.keyboardShortcut}
|
||
|
/>
|
||
|
)}
|
||
|
</AxoBaseMenu.ItemContentSlot>
|
||
|
</ContextMenu.CheckboxItem>
|
||
|
);
|
||
|
});
|
||
|
|
||
|
CheckboxItem.displayName = `${Namespace}.CheckboxItem`;
|
||
|
|
||
|
/**
|
||
|
* Component: <AxoContextMenu.RadioGroup>
|
||
|
* --------------------------------------
|
||
|
*/
|
||
|
|
||
|
export type RadioGroupProps = AxoBaseMenu.MenuRadioGroupProps;
|
||
|
|
||
|
/**
|
||
|
* Used to group multiple {@link AxoContextMenu.RadioItem}'s.
|
||
|
*/
|
||
|
export const RadioGroup: FC<RadioGroupProps> = memo(props => {
|
||
|
return (
|
||
|
<ContextMenu.RadioGroup
|
||
|
value={props.value}
|
||
|
onValueChange={props.onValueChange}
|
||
|
className={AxoBaseMenu.menuRadioGroupStyles}
|
||
|
>
|
||
|
{props.children}
|
||
|
</ContextMenu.RadioGroup>
|
||
|
);
|
||
|
});
|
||
|
|
||
|
RadioGroup.displayName = `${Namespace}.RadioGroup`;
|
||
|
|
||
|
/**
|
||
|
* Component: <AxoContextMenu.RadioItem>
|
||
|
* -------------------------------------
|
||
|
*/
|
||
|
|
||
|
export type RadioItemProps = AxoBaseMenu.MenuRadioItemProps;
|
||
|
|
||
|
/**
|
||
|
* An item that can be controlled and rendered like a radio.
|
||
|
*/
|
||
|
export const RadioItem: FC<RadioItemProps> = memo(props => {
|
||
|
return (
|
||
|
<ContextMenu.RadioItem
|
||
|
value={props.value}
|
||
|
className={AxoBaseMenu.menuRadioItemStyles}
|
||
|
onSelect={props.onSelect}
|
||
|
>
|
||
|
<AxoBaseMenu.ItemLeadingSlot>
|
||
|
<AxoBaseMenu.ItemCheckPlaceholder>
|
||
|
<ContextMenu.ItemIndicator>
|
||
|
<AxoBaseMenu.ItemCheck />
|
||
|
</ContextMenu.ItemIndicator>
|
||
|
</AxoBaseMenu.ItemCheckPlaceholder>
|
||
|
</AxoBaseMenu.ItemLeadingSlot>
|
||
|
<AxoBaseMenu.ItemContentSlot>
|
||
|
{props.symbol && <AxoBaseMenu.ItemSymbol symbol={props.symbol} />}
|
||
|
<AxoBaseMenu.ItemText>{props.children}</AxoBaseMenu.ItemText>
|
||
|
{props.keyboardShortcut && (
|
||
|
<AxoBaseMenu.ItemKeyboardShortcut
|
||
|
keyboardShortcut={props.keyboardShortcut}
|
||
|
/>
|
||
|
)}
|
||
|
</AxoBaseMenu.ItemContentSlot>
|
||
|
</ContextMenu.RadioItem>
|
||
|
);
|
||
|
});
|
||
|
|
||
|
RadioItem.displayName = `${Namespace}.RadioItem`;
|
||
|
|
||
|
/**
|
||
|
* Component: <AxoContextMenu.Separator>
|
||
|
* -------------------------------------
|
||
|
*/
|
||
|
|
||
|
export type SeparatorProps = AxoBaseMenu.MenuSeparatorProps;
|
||
|
|
||
|
/**
|
||
|
* Used to visually separate items in the context menu.
|
||
|
*/
|
||
|
export const Separator: FC<SeparatorProps> = memo(() => {
|
||
|
return (
|
||
|
<ContextMenu.Separator className={AxoBaseMenu.menuSeparatorStyles} />
|
||
|
);
|
||
|
});
|
||
|
|
||
|
Separator.displayName = `${Namespace}.Separator`;
|
||
|
|
||
|
/**
|
||
|
* Component: <AxoContextMenu.Sub>
|
||
|
* -------------------------------
|
||
|
*/
|
||
|
|
||
|
export type SubProps = AxoBaseMenu.MenuSubProps;
|
||
|
|
||
|
/**
|
||
|
* Contains all the parts of a submenu.
|
||
|
*/
|
||
|
export const Sub: FC<SubProps> = memo(props => {
|
||
|
return <ContextMenu.Sub>{props.children}</ContextMenu.Sub>;
|
||
|
});
|
||
|
|
||
|
Sub.displayName = `${Namespace}.Sub`;
|
||
|
|
||
|
/**
|
||
|
* Component: <AxoContextMenu.SubTrigger>
|
||
|
* --------------------------------------
|
||
|
*/
|
||
|
|
||
|
export type SubTriggerProps = AxoBaseMenu.MenuSubTriggerProps;
|
||
|
|
||
|
/**
|
||
|
* An item that opens a submenu. Must be rendered inside
|
||
|
* {@link ContextMenu.Sub}.
|
||
|
*/
|
||
|
export const SubTrigger: FC<SubTriggerProps> = memo(props => {
|
||
|
return (
|
||
|
<ContextMenu.SubTrigger className={AxoBaseMenu.menuSubTriggerStyles}>
|
||
|
{props.symbol && (
|
||
|
<AxoBaseMenu.ItemLeadingSlot>
|
||
|
<AxoBaseMenu.ItemSymbol symbol={props.symbol} />
|
||
|
</AxoBaseMenu.ItemLeadingSlot>
|
||
|
)}
|
||
|
<AxoBaseMenu.ItemContentSlot>
|
||
|
<AxoBaseMenu.ItemText>{props.children}</AxoBaseMenu.ItemText>
|
||
|
<span className="ml-auto">
|
||
|
<AxoSymbol.Icon size={14} symbol="chevron-[end]" label={null} />
|
||
|
</span>
|
||
|
</AxoBaseMenu.ItemContentSlot>
|
||
|
</ContextMenu.SubTrigger>
|
||
|
);
|
||
|
});
|
||
|
|
||
|
SubTrigger.displayName = `${Namespace}.SubTrigger`;
|
||
|
|
||
|
/**
|
||
|
* Component: <AxoContextMenu.SubContent>
|
||
|
* --------------------------------------
|
||
|
*/
|
||
|
|
||
|
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<SubContentProps> = memo(props => {
|
||
|
return (
|
||
|
<ContextMenu.SubContent
|
||
|
alignOffset={-6}
|
||
|
collisionPadding={6}
|
||
|
className={AxoBaseMenu.menuSubContentStyles}
|
||
|
>
|
||
|
{props.children}
|
||
|
</ContextMenu.SubContent>
|
||
|
);
|
||
|
});
|
||
|
|
||
|
SubContent.displayName = `${Namespace}.SubContent`;
|
||
|
}
|