'use no memo'; // composition props usually invalidate the generated wrapper caches
import type { ComponentType, ReactElement, ReactNode, RefObject, SVGProps } from 'react';
import { Menu as BaseMenu } from '@base-ui/react/menu';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import { clsx } from 'clsx';
import { useConstant } from '#/lib/hooks/use-constant';
import * as styles from '#/components/Menu/Menu.css';
import { Text } from '#/components/Text';
import CheckmarkIcon from '#/icons/central/Checkmark2_round_outlined_radius1_stroke2.svg';
// a dropdown menu built on Base UI's Menu. Root + Trigger associate the menu with its anchor; pass a
// `handle` (from `createHandle`/`useMenuHandle`) to drive a detached Trigger or open it imperatively.
export const Root = BaseMenu.Root;
export const Trigger = BaseMenu.Trigger;
export const Group = BaseMenu.Group;
/** Creates a detached handle to associate a Trigger with a Root or open/close the menu imperatively. */
export const createHandle = BaseMenu.createHandle;
/** A detached handle for associating a Trigger with a Root or opening/closing a Menu */
export type MenuHandle<T = void> = BaseMenu.Handle<T>;
/** Component-local menu handle. */
export function useMenuHandle<T = void>(): MenuHandle<T> {
const handle = useConstant(createHandle<T>);
return handle;
}
/** Portalled positioner + themed popup card. Put `Item`/`Group`/`Separator` inside. */
export function Popup({
children,
label,
align = 'start',
side = 'bottom',
minWidth,
container,
}: {
children: ReactNode;
/** Accessible name for the menu. */
label?: string;
align?: BaseMenu.Positioner.Props['align'];
side?: BaseMenu.Positioner.Props['side'];
/** Floor on the popup width, so a short item list still reads as a menu rather than a tooltip. */
minWidth?: number;
/** portal target, such as the fullscreen container. */
container?: RefObject<HTMLElement | null>;
}) {
return (
<BaseMenu.Portal className={styles.portal} container={container}>
<BaseMenu.Positioner align={align} side={side} sideOffset={5} collisionPadding={5}>
<BaseMenu.Popup
aria-label={label}
className={styles.popup}
style={
minWidth !== undefined ? assignInlineVars({ [styles.minWidthVar]: `${minWidth}px` }) : undefined
}
>
{children}
</BaseMenu.Popup>
</BaseMenu.Positioner>
</BaseMenu.Portal>
);
}
export function Item({
children,
label,
onClick,
onMouseEnter,
destructive = false,
disabled,
render,
}: {
children: ReactNode;
/** Overrides the label used for keyboard typeahead (defaults to the item's text). */
label?: string;
onClick?: () => void;
/** Hover hook, e.g. to prefetch the data a dialog opened by the item will need. */
onMouseEnter?: () => void;
destructive?: boolean;
disabled?: boolean;
/** renders the item as a custom element, keeping menu-item styling, keyboard navigation, and close-on-click */
render?: ReactElement;
}) {
return (
<BaseMenu.Item
className={clsx(styles.item, destructive && styles.itemDestructive)}
label={label}
onClick={onClick}
onMouseEnter={onMouseEnter}
disabled={disabled}
render={render}
>
{children}
</BaseMenu.Item>
);
}
/**
* renders a menu checkbox without closing the menu.
*
* @param props the item props
* @returns the checkbox item
*/
export function CheckboxItem({
children,
label,
checked,
onCheckedChange,
disabled,
}: {
children: ReactNode;
label?: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled?: boolean;
}) {
return (
<BaseMenu.CheckboxItem
className={styles.item}
label={label}
checked={checked}
onCheckedChange={onCheckedChange}
disabled={disabled}
>
{children}
</BaseMenu.CheckboxItem>
);
}
/** @returns a checkbox indicator */
export function ItemCheckbox() {
return (
<span className={styles.itemCheckbox}>
<BaseMenu.CheckboxItemIndicator>
<CheckmarkIcon className={styles.itemCheckboxMark} />
</BaseMenu.CheckboxItemIndicator>
</span>
);
}
export function ItemText({ children }: { children: ReactNode }) {
return (
<Text size="md_sub" color="textContrastHigh" weight="medium" className={styles.itemText}>
{children}
</Text>
);
}
export function ItemIcon({
icon: Icon,
position = 'left',
}: {
icon: ComponentType<SVGProps<SVGSVGElement>>;
position?: 'left' | 'right';
}) {
return <Icon className={clsx(styles.itemIcon, position === 'right' && styles.itemIconRight)} />;
}
/** A radio/selection indicator for an item — an outlined circle, filled when `selected`. */
export function ItemRadio({ selected }: { selected: boolean }) {
return <span className={styles.itemRadio}>{selected && <span className={styles.itemRadioDot} />}</span>;
}
export function LabelText({ children, maxWidth }: { children: ReactNode; maxWidth?: number }) {
return (
<BaseMenu.GroupLabel
className={styles.groupLabel}
style={maxWidth !== undefined ? assignInlineVars({ [styles.maxWidthVar]: `${maxWidth}px` }) : undefined}
>
{children}
</BaseMenu.GroupLabel>
);
}
export function Separator() {
return <BaseMenu.Separator className={styles.separator} />;
}