signal-desktop/ts/axo/_internal/AxoBaseMenu.tsx

365 lines
9.1 KiB
TypeScript

// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { ReactNode } from 'react';
import { tw } from '../tw.js';
import { AxoSymbol, type AxoSymbolName } from '../AxoSymbol.js';
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace AxoBaseMenu {
// <Content/SubContent>
const baseContentStyles = tw(
'max-w-[300px] min-w-[200px]',
'select-none',
'rounded-xl bg-elevated-background-tertiary shadow-elevation-3',
'data-[state=closed]:animate-fade-out',
'forced-colors:border',
'forced-colors:bg-[Canvas]',
'forced-colors:text-[CanvasText]'
);
const baseContentGridStyles = tw('grid grid-cols-[min-content_auto] p-1.5');
// <Group/RadioGroup>
const baseGroupStyles = tw('col-span-full grid grid-cols-subgrid');
// <Item/RadioItem/CheckboxItem/SubTrigger/Label/Separator>
const baseItemStyles = tw(
'col-span-full grid grid-cols-subgrid items-center'
);
// <Item/RadioItem/CheckboxItem/SubTrigger/Label> (not Separator)
const labeledItemStyles = tw(baseItemStyles, 'truncate p-1.5');
// <Item/RadioItem/CheckboxItem/SubTrigger> (not Label/Separator)
const navigableItemStyles = tw(
labeledItemStyles,
'rounded-md type-body-medium',
'outline-0 data-[highlighted]:bg-fill-secondary-pressed',
'data-[disabled]:text-label-disabled',
'outline-0 outline-border-focused focused:outline-[2.5px]',
'forced-colors:text-[CanvasText]',
'forced-colors:data-[highlighted]:bg-[Highlight]',
'forced-colors:data-[highlighted]:text-[HighlightText]',
'forced-colors:data-[disabled]:text-[GrayText]',
'forced-color-adjust-none'
);
/**
* <Item/RadioItem/CheckboxItem/SubTrigger> (not Label/Separator)
*/
type BaseNavigableItemProps = Readonly<{
/**
* When true, prevents the user from interacting with the item.
*/
disabled?: boolean;
/**
* Optional text used for typeahead purposes. By default the typeahead
* behavior will use the .textContent of the item. Use this when the
* content is complex, or you have non-textual content inside.
*/
textValue?: string;
/**
* An icon that should be rendered before the text.
*/
symbol?: AxoSymbolName;
}>;
// <Item/RadioItem/CheckboxItem> (not SubTrigger/Label/Separator)
const selectableItemStyles = tw(navigableItemStyles);
/**
* Used for any selectable content node such as Item, CheckboxItem, or RadioItem,
* But not nodes like SubTrigger, Separator, Group, etc.
*/
type BaseSelectableItemProps = BaseNavigableItemProps &
Readonly<{
keyboardShortcut?: string;
onSelect?: (event: Event) => void;
}>;
/**
* AxoBaseMenu: Item Slots
* -----------------------
*/
export type ItemLeadingSlotProps = Readonly<{
children: ReactNode;
}>;
export function ItemLeadingSlot(props: ItemLeadingSlotProps): JSX.Element {
return (
<span
className={tw('col-start-1 col-end-1 me-1.5 flex items-center gap-1.5')}
>
{props.children}
</span>
);
}
export type ItemContentSlotProps = Readonly<{
children: ReactNode;
}>;
export function ItemContentSlot(props: ItemContentSlotProps): JSX.Element {
return (
<span className={tw('col-start-2 col-end-2 flex min-w-0 items-center')}>
{props.children}
</span>
);
}
/**
* AxoBaseMenu: Item Parts
* -----------------------
*/
export const itemTextStyles = tw('flex-1 truncate text-start');
export type ItemTextProps = Readonly<{
children: ReactNode;
}>;
export function ItemText(props: ItemTextProps): JSX.Element {
return <span className={itemTextStyles}>{props.children}</span>;
}
export type ItemCheckPlaceholderProps = Readonly<{
children: ReactNode;
}>;
export function ItemCheckPlaceholder(
props: ItemCheckPlaceholderProps
): JSX.Element {
return <span className={tw('w-3.5')}>{props.children}</span>;
}
export function ItemCheck(): JSX.Element {
return <AxoSymbol.Icon size={14} symbol="check" label={null} />;
}
export function ItemSymbol(props: { symbol: AxoSymbolName }): JSX.Element {
return <AxoSymbol.Icon size={16} symbol={props.symbol} label={null} />;
}
export type ItemKeyboardShortcutProps = Readonly<{
keyboardShortcut: string;
}>;
export function ItemKeyboardShortcut(
props: ItemKeyboardShortcutProps
): JSX.Element {
return (
<span
dir="auto"
className={tw(
'ms-auto px-1 type-body-medium text-label-secondary forced-colors:text-[inherit]'
)}
>
{props.keyboardShortcut}
</span>
);
}
/**
* AxoBaseMenu: Root
* -----------------
*/
export type MenuRootProps = Readonly<{
children: ReactNode;
}>;
/**
* AxoBaseMenu: Trigger
* --------------------
*/
export type MenuTriggerProps = Readonly<{
/**
* When true, the context menu won't open when right-clicking.
* Note that this will also restore the native context menu.
*/
disabled?: boolean;
children: ReactNode;
}>;
/**
* AxoBaseMenu: Content
* --------------------
*/
export type MenuContentProps = Readonly<{
children: ReactNode;
}>;
export const menuContentStyles = tw(
baseContentStyles,
baseContentGridStyles,
'max-h-(--radix-popper-available-height) overflow-auto [scrollbar-width:none]',
'overflow-auto [scrollbar-width:none]'
);
export const selectContentStyles = tw(baseContentStyles);
export const selectContentViewportStyles = tw(baseContentGridStyles);
/**
* AxoBaseMenu: Item
* -----------------
*/
export type MenuItemProps = BaseSelectableItemProps &
Readonly<{
/**
* Event handler called when the user selects an item (via mouse or
* keyboard). Calling event.preventDefault in this handler will prevent the
* context menu from closing when selecting that item.
*/
onSelect: (event: Event) => void;
children: ReactNode;
}>;
export const menuItemStyles = tw(selectableItemStyles);
export const selectItemStyles = tw(selectableItemStyles);
/**
* AxoBaseMenu: Group
* ------------------
*/
export type MenuGroupProps = Readonly<{
children: ReactNode;
}>;
export const menuGroupStyles = tw(baseGroupStyles);
export const selectGroupStyles = tw(baseGroupStyles);
/**
* AxoBaseMenu: Label
* ------------------
*/
export type MenuLabelProps = Readonly<{
children: ReactNode;
}>;
const baseLabelStyles = tw(
labeledItemStyles,
'type-body-small text-label-secondary'
);
export const menuLabelStyles = tw(baseLabelStyles);
export const selectLabelStyles = tw(baseLabelStyles);
/**
* AxoBaseMenu: CheckboxItem
* -------------------------
*/
export type MenuCheckboxItemProps = BaseSelectableItemProps &
Readonly<{
/**
* The controlled checked state of the item. Must be used in conjunction
* with `onCheckedChange`.
*/
checked: boolean;
/**
* Event handler called when the checked state changes.
*/
onCheckedChange: (checked: boolean) => void;
children: ReactNode;
}>;
export const menuCheckboxItemStyles = tw(selectableItemStyles);
/**
* AxoBaseMenu: RadioGroup
* -----------------------
*/
export type MenuRadioGroupProps = Readonly<{
/**
* The value of the selected item in the group.
*/
value: string;
/**
* Event handler called when the value changes.
*/
onValueChange: (value: string) => void;
children: ReactNode;
}>;
export const menuRadioGroupStyles = tw(baseGroupStyles);
/**
* AxoBaseMenu: RadioItem
* ----------------------
*/
export type MenuRadioItemProps = BaseSelectableItemProps &
Readonly<{
value: string;
children: ReactNode;
}>;
export const menuRadioItemStyles = tw(selectableItemStyles);
/**
* AxoBaseMenu: Separator
* ----------------------
*/
export type MenuSeparatorProps = Readonly<{
// N/A
}>;
const baseSeparatorStyles = tw(
baseItemStyles,
'mx-0.5 my-1 border-t-[0.5px] border-border-primary'
);
export const menuSeparatorStyles = tw(baseSeparatorStyles);
export const selectSeperatorStyles = tw(baseSeparatorStyles);
/**
* AxoBaseMenu: Sub
* ----------------
*/
export type MenuSubProps = Readonly<{
children: ReactNode;
}>;
/**
* AxoBaseMenu: SubTrigger
* -----------------------
*/
export type MenuSubTriggerProps = BaseNavigableItemProps &
Readonly<{
children: ReactNode;
}>;
export const menuSubTriggerStyles = tw(
navigableItemStyles,
'data-[state=open]:not-data-[highlighted]:bg-fill-secondary',
'forced-colors:data-[state=open]:not-data-[highlighted]:bg-[Highlight]',
'forced-colors:data-[state=open]:not-data-[highlighted]:text-[HighlightText]'
);
/**
* AxoBaseMenu: SubContent
* -----------------------
*/
export type MenuSubContentProps = Readonly<{
children: ReactNode;
}>;
export const menuSubContentStyles = tw(
baseContentStyles,
'max-h-(--radix-popper-available-height) overflow-auto [scrollbar-width:none]',
baseContentGridStyles
);
}