//
// Copyright 2024 DXOS.org
//
// This is based upon `@radix-ui/react-dropdown-menu` fetched 25 Oct 2024 at https://github.com/radix-ui/primitives at commit 06de2d4.
import { composeEventHandlers } from '@radix-ui/primitive';
import { composeRefs } from '@radix-ui/react-compose-refs';
import { createContextScope } from '@radix-ui/react-context';
import type { Scope } from '@radix-ui/react-context';
import { useId } from '@radix-ui/react-id';
import * as MenuPrimitive from '@radix-ui/react-menu';
import { createMenuScope } from '@radix-ui/react-menu';
import { Primitive } from '@radix-ui/react-primitive';
import { Slot } from '@radix-ui/react-slot';
import { useControllableState } from '@radix-ui/react-use-controllable-state';
import React, {
type ReactNode,
type FC,
useRef,
type ElementRef,
useCallback,
type ComponentPropsWithoutRef,
forwardRef,
type ComponentPropsWithRef,
useEffect,
type MutableRefObject,
type RefObject,
} from 'react';
import { useElevationContext, useThemeContext } from '../../hooks';
import { useSafeCollisionPadding } from '../../hooks/useSafeCollisionPadding';
import { type ThemedClassName } from '../../util';
type Direction = 'ltr' | 'rtl';
/* -------------------------------------------------------------------------------------------------
* DropdownMenu
* ----------------------------------------------------------------------------------------------- */
const DROPDOWN_MENU_NAME = 'DropdownMenu';
type ScopedProps
= P & { __scopeDropdownMenu?: Scope };
const [createDropdownMenuContext, createDropdownMenuScope] = createContextScope(DROPDOWN_MENU_NAME, [createMenuScope]);
const useMenuScope = createMenuScope();
type DropdownMenuContextValue = {
triggerId: string;
triggerRef: MutableRefObject;
contentId: string;
open: boolean;
onOpenChange(open: boolean): void;
onOpenToggle(): void;
modal: boolean;
};
const [DropdownMenuProvider, useDropdownMenuContext] =
createDropdownMenuContext(DROPDOWN_MENU_NAME);
interface DropdownMenuRootProps {
children?: ReactNode;
dir?: Direction;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?(open: boolean): void;
modal?: boolean;
}
const DropdownMenuRoot: FC = (props: ScopedProps) => {
const { __scopeDropdownMenu, children, dir, open: openProp, defaultOpen, onOpenChange, modal = true } = props;
const menuScope = useMenuScope(__scopeDropdownMenu);
const triggerRef = useRef(null);
const [open = false, setOpen] = useControllableState({
prop: openProp,
defaultProp: defaultOpen,
onChange: onOpenChange,
});
return (
}
contentId={useId()}
open={open}
onOpenChange={setOpen}
onOpenToggle={useCallback(() => setOpen((prevOpen) => !prevOpen), [setOpen])}
modal={modal}
>
{children}
);
};
DropdownMenuRoot.displayName = DROPDOWN_MENU_NAME;
/* -------------------------------------------------------------------------------------------------
* DropdownMenuTrigger
* ----------------------------------------------------------------------------------------------- */
const TRIGGER_NAME = 'DropdownMenuTrigger';
type DropdownMenuTriggerElement = ElementRef;
type PrimitiveButtonProps = ComponentPropsWithoutRef;
interface DropdownMenuTriggerProps extends PrimitiveButtonProps {}
const DropdownMenuTrigger = forwardRef(
(props: ScopedProps, forwardedRef) => {
const { __scopeDropdownMenu, disabled = false, ...triggerProps } = props;
const context = useDropdownMenuContext(TRIGGER_NAME, __scopeDropdownMenu);
const menuScope = useMenuScope(__scopeDropdownMenu);
return (
{
// only call handler if it's the left button (mousedown gets triggered by all mouse buttons)
// but not when the control key is pressed (avoiding MacOS right click)
if (!disabled && event.button === 0 && event.ctrlKey === false) {
context.onOpenToggle();
// prevent trigger focusing when opening
// this allows the content to be given focus without competition
if (!context.open) {
event.preventDefault();
}
}
})}
onKeyDown={composeEventHandlers(props.onKeyDown, (event) => {
if (disabled) {
return;
}
if (['Enter', ' '].includes(event.key)) {
context.onOpenToggle();
}
if (event.key === 'ArrowDown') {
context.onOpenChange(true);
}
// prevent keydown from scrolling window / first focused item to execute
// that keydown (inadvertently closing the menu)
if (['Enter', ' ', 'ArrowDown'].includes(event.key)) {
event.preventDefault();
}
})}
/>
);
},
);
DropdownMenuTrigger.displayName = TRIGGER_NAME;
/* -------------------------------------------------------------------------------------------------
* DropdownMenuVirtualTrigger
* ----------------------------------------------------------------------------------------------- */
const VIRTUAL_TRIGGER_NAME = 'DropdownMenuVirtualTrigger';
interface DropdownMenuVirtualTriggerProps {
virtualRef: RefObject;
}
const DropdownMenuVirtualTrigger = (props: ScopedProps) => {
const { __scopeDropdownMenu, virtualRef } = props;
const context = useDropdownMenuContext(VIRTUAL_TRIGGER_NAME, __scopeDropdownMenu);
const menuScope = useMenuScope(__scopeDropdownMenu);
useEffect(() => {
if (virtualRef.current) {
context.triggerRef.current = virtualRef.current;
}
});
return ;
};
DropdownMenuVirtualTrigger.displayName = VIRTUAL_TRIGGER_NAME;
/* -------------------------------------------------------------------------------------------------
* DropdownMenuPortal
* ----------------------------------------------------------------------------------------------- */
const PORTAL_NAME = 'DropdownMenuPortal';
type MenuPortalProps = ComponentPropsWithoutRef;
interface DropdownMenuPortalProps extends MenuPortalProps {}
const DropdownMenuPortal: FC = (props: ScopedProps) => {
const { __scopeDropdownMenu, ...portalProps } = props;
const menuScope = useMenuScope(__scopeDropdownMenu);
return ;
};
DropdownMenuPortal.displayName = PORTAL_NAME;
/* -------------------------------------------------------------------------------------------------
* DropdownMenuViewport
* ----------------------------------------------------------------------------------------------- */
type DropdownMenuViewportProps = ThemedClassName> & {
asChild?: boolean;
};
const DropdownMenuViewport = forwardRef(
({ classNames, asChild, children, ...props }, forwardedRef) => {
const { tx } = useThemeContext();
const Root = asChild ? Slot : Primitive.div;
return (
{children}
);
},
);
/* -------------------------------------------------------------------------------------------------
* DropdownMenuContent
* ----------------------------------------------------------------------------------------------- */
const CONTENT_NAME = 'DropdownMenuContent';
type DropdownMenuContentElement = ElementRef;
type MenuContentProps = ThemedClassName>;
interface DropdownMenuContentProps extends Omit {}
const DropdownMenuContent = forwardRef(
(props: ScopedProps, forwardedRef) => {
const { __scopeDropdownMenu, classNames, collisionPadding = 8, ...contentProps } = props;
const { tx } = useThemeContext();
const context = useDropdownMenuContext(CONTENT_NAME, __scopeDropdownMenu);
const elevation = useElevationContext();
const menuScope = useMenuScope(__scopeDropdownMenu);
const hasInteractedOutsideRef = useRef(false);
const safeCollisionPadding = useSafeCollisionPadding(collisionPadding);
return (
{
if (!hasInteractedOutsideRef.current) {
context.triggerRef.current?.focus();
}
hasInteractedOutsideRef.current = false;
// Always prevent auto focus because we either focus manually or want user agent focus
event.preventDefault();
})}
onInteractOutside={composeEventHandlers(props.onInteractOutside, (event) => {
const originalEvent = event.detail.originalEvent as PointerEvent;
const ctrlLeftClick = originalEvent.button === 0 && originalEvent.ctrlKey === true;
const isRightClick = originalEvent.button === 2 || ctrlLeftClick;
if (!context.modal || isRightClick) {
hasInteractedOutsideRef.current = true;
}
})}
className={tx('menu.content', 'menu', { elevation }, classNames)}
style={{
...props.style,
// re-namespace exposed content custom properties
...{
'--radix-dropdown-menu-content-transform-origin': 'var(--radix-popper-transform-origin)',
'--radix-dropdown-menu-content-available-width': 'var(--radix-popper-available-width)',
'--radix-dropdown-menu-content-available-height': 'var(--radix-popper-available-height)',
'--radix-dropdown-menu-trigger-width': 'var(--radix-popper-anchor-width)',
'--radix-dropdown-menu-trigger-height': 'var(--radix-popper-anchor-height)',
},
}}
/>
);
},
);
DropdownMenuContent.displayName = CONTENT_NAME;
/* -------------------------------------------------------------------------------------------------
* DropdownMenuGroup
* ----------------------------------------------------------------------------------------------- */
const GROUP_NAME = 'DropdownMenuGroup';
type DropdownMenuGroupElement = ElementRef;
type MenuGroupProps = ComponentPropsWithoutRef;
interface DropdownMenuGroupProps extends MenuGroupProps {}
const DropdownMenuGroup = forwardRef(
(props: ScopedProps, forwardedRef) => {
const { __scopeDropdownMenu, ...groupProps } = props;
const menuScope = useMenuScope(__scopeDropdownMenu);
return ;
},
);
DropdownMenuGroup.displayName = GROUP_NAME;
/* -------------------------------------------------------------------------------------------------
* DropdownMenuLabel
* ----------------------------------------------------------------------------------------------- */
const LABEL_NAME = 'DropdownMenuLabel';
type DropdownMenuLabelElement = ElementRef;
type MenuLabelProps = ThemedClassName>;
interface DropdownMenuLabelProps extends MenuLabelProps {}
const DropdownMenuGroupLabel = forwardRef(
(props: ScopedProps, forwardedRef) => {
const { __scopeDropdownMenu, classNames, ...labelProps } = props;
const menuScope = useMenuScope(__scopeDropdownMenu);
const { tx } = useThemeContext();
return (
);
},
);
DropdownMenuGroupLabel.displayName = LABEL_NAME;
/* -------------------------------------------------------------------------------------------------
* DropdownMenuItem
* ----------------------------------------------------------------------------------------------- */
const ITEM_NAME = 'DropdownMenuItem';
type DropdownMenuItemElement = ElementRef;
type MenuItemProps = ThemedClassName>;
interface DropdownMenuItemProps extends MenuItemProps {}
const DropdownMenuItem = forwardRef(
(props: ScopedProps, forwardedRef) => {
const { __scopeDropdownMenu, classNames, ...itemProps } = props;
const menuScope = useMenuScope(__scopeDropdownMenu);
const { tx } = useThemeContext();
return (
);
},
);
DropdownMenuItem.displayName = ITEM_NAME;
/* -------------------------------------------------------------------------------------------------
* DropdownMenuCheckboxItem
* ----------------------------------------------------------------------------------------------- */
const CHECKBOX_ITEM_NAME = 'DropdownMenuCheckboxItem';
type DropdownMenuCheckboxItemElement = ElementRef;
type MenuCheckboxItemProps = ThemedClassName>;
interface DropdownMenuCheckboxItemProps extends MenuCheckboxItemProps {}
const DropdownMenuCheckboxItem = forwardRef(
(props: ScopedProps, forwardedRef) => {
const { __scopeDropdownMenu, classNames, ...checkboxItemProps } = props;
const menuScope = useMenuScope(__scopeDropdownMenu);
const { tx } = useThemeContext();
return (
);
},
);
DropdownMenuCheckboxItem.displayName = CHECKBOX_ITEM_NAME;
/* -------------------------------------------------------------------------------------------------
* DropdownMenuRadioGroup
* ----------------------------------------------------------------------------------------------- */
const RADIO_GROUP_NAME = 'DropdownMenuRadioGroup';
type DropdownMenuRadioGroupElement = ElementRef;
type MenuRadioGroupProps = ComponentPropsWithoutRef;
interface DropdownMenuRadioGroupProps extends MenuRadioGroupProps {}
const DropdownMenuRadioGroup = forwardRef(
(props: ScopedProps, forwardedRef) => {
const { __scopeDropdownMenu, ...radioGroupProps } = props;
const menuScope = useMenuScope(__scopeDropdownMenu);
return ;
},
);
DropdownMenuRadioGroup.displayName = RADIO_GROUP_NAME;
/* -------------------------------------------------------------------------------------------------
* DropdownMenuRadioItem
* ----------------------------------------------------------------------------------------------- */
const RADIO_ITEM_NAME = 'DropdownMenuRadioItem';
type DropdownMenuRadioItemElement = ElementRef;
type MenuRadioItemProps = ComponentPropsWithoutRef;
interface DropdownMenuRadioItemProps extends MenuRadioItemProps {}
const DropdownMenuRadioItem = forwardRef(
(props: ScopedProps, forwardedRef) => {
const { __scopeDropdownMenu, ...radioItemProps } = props;
const menuScope = useMenuScope(__scopeDropdownMenu);
return ;
},
);
DropdownMenuRadioItem.displayName = RADIO_ITEM_NAME;
/* -------------------------------------------------------------------------------------------------
* DropdownMenuItemIndicator
* ----------------------------------------------------------------------------------------------- */
const INDICATOR_NAME = 'DropdownMenuItemIndicator';
type DropdownMenuItemIndicatorElement = ElementRef;
type MenuItemIndicatorProps = ComponentPropsWithoutRef;
interface DropdownMenuItemIndicatorProps extends MenuItemIndicatorProps {}
const DropdownMenuItemIndicator = forwardRef(
(props: ScopedProps, forwardedRef) => {
const { __scopeDropdownMenu, ...itemIndicatorProps } = props;
const menuScope = useMenuScope(__scopeDropdownMenu);
return ;
},
);
DropdownMenuItemIndicator.displayName = INDICATOR_NAME;
/* -------------------------------------------------------------------------------------------------
* DropdownMenuSeparator
* ----------------------------------------------------------------------------------------------- */
const SEPARATOR_NAME = 'DropdownMenuSeparator';
type DropdownMenuSeparatorElement = ElementRef;
type MenuSeparatorProps = ThemedClassName>;
interface DropdownMenuSeparatorProps extends MenuSeparatorProps {}
const DropdownMenuSeparator = forwardRef(
(props: ScopedProps, forwardedRef) => {
const { __scopeDropdownMenu, classNames, ...separatorProps } = props;
const menuScope = useMenuScope(__scopeDropdownMenu);
const { tx } = useThemeContext();
return (
);
},
);
DropdownMenuSeparator.displayName = SEPARATOR_NAME;
/* -------------------------------------------------------------------------------------------------
* DropdownMenuArrow
* ----------------------------------------------------------------------------------------------- */
const ARROW_NAME = 'DropdownMenuArrow';
type DropdownMenuArrowElement = ElementRef;
type MenuArrowProps = ThemedClassName>;
interface DropdownMenuArrowProps extends MenuArrowProps {}
const DropdownMenuArrow = forwardRef(
(props: ScopedProps, forwardedRef) => {
const { __scopeDropdownMenu, classNames, ...arrowProps } = props;
const menuScope = useMenuScope(__scopeDropdownMenu);
const { tx } = useThemeContext();
return (
);
},
);
DropdownMenuArrow.displayName = ARROW_NAME;
/* -------------------------------------------------------------------------------------------------
* DropdownMenuSub
* ----------------------------------------------------------------------------------------------- */
interface DropdownMenuSubProps {
children?: ReactNode;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?(open: boolean): void;
}
const DropdownMenuSub: FC = (props: ScopedProps) => {
const { __scopeDropdownMenu, children, open: openProp, onOpenChange, defaultOpen } = props;
const menuScope = useMenuScope(__scopeDropdownMenu);
const [open = false, setOpen] = useControllableState({
prop: openProp,
defaultProp: defaultOpen,
onChange: onOpenChange,
});
return (
{children}
);
};
/* -------------------------------------------------------------------------------------------------
* DropdownMenuSubTrigger
* ----------------------------------------------------------------------------------------------- */
const SUB_TRIGGER_NAME = 'DropdownMenuSubTrigger';
type DropdownMenuSubTriggerElement = ElementRef;
type MenuSubTriggerProps = ComponentPropsWithoutRef;
interface DropdownMenuSubTriggerProps extends MenuSubTriggerProps {}
const DropdownMenuSubTrigger = forwardRef(
(props: ScopedProps, forwardedRef) => {
const { __scopeDropdownMenu, ...subTriggerProps } = props;
const menuScope = useMenuScope(__scopeDropdownMenu);
return ;
},
);
DropdownMenuSubTrigger.displayName = SUB_TRIGGER_NAME;
/* -------------------------------------------------------------------------------------------------
* DropdownMenuSubContent
* ----------------------------------------------------------------------------------------------- */
const SUB_CONTENT_NAME = 'DropdownMenuSubContent';
type DropdownMenuSubContentElement = ElementRef;
type MenuSubContentProps = ComponentPropsWithoutRef;
interface DropdownMenuSubContentProps extends MenuSubContentProps {}
const DropdownMenuSubContent = forwardRef(
(props: ScopedProps, forwardedRef) => {
const { __scopeDropdownMenu, ...subContentProps } = props;
const menuScope = useMenuScope(__scopeDropdownMenu);
return (
);
},
);
DropdownMenuSubContent.displayName = SUB_CONTENT_NAME;
/* ----------------------------------------------------------------------------------------------- */
export const DropdownMenu = {
Root: DropdownMenuRoot,
Trigger: DropdownMenuTrigger,
VirtualTrigger: DropdownMenuVirtualTrigger,
Portal: DropdownMenuPortal,
Content: DropdownMenuContent,
Viewport: DropdownMenuViewport,
Group: DropdownMenuGroup,
GroupLabel: DropdownMenuGroupLabel,
Item: DropdownMenuItem,
CheckboxItem: DropdownMenuCheckboxItem,
RadioGroup: DropdownMenuRadioGroup,
RadioItem: DropdownMenuRadioItem,
ItemIndicator: DropdownMenuItemIndicator,
Separator: DropdownMenuSeparator,
Arrow: DropdownMenuArrow,
Sub: DropdownMenuSub,
SubTrigger: DropdownMenuSubTrigger,
SubContent: DropdownMenuSubContent,
};
const useDropdownMenuMenuScope = useMenuScope;
export { createDropdownMenuScope, useDropdownMenuContext, useDropdownMenuMenuScope };
export type {
DropdownMenuRootProps,
DropdownMenuTriggerProps,
DropdownMenuVirtualTriggerProps,
DropdownMenuPortalProps,
DropdownMenuContentProps,
DropdownMenuViewportProps,
DropdownMenuGroupProps,
DropdownMenuLabelProps,
DropdownMenuItemProps,
DropdownMenuCheckboxItemProps,
DropdownMenuRadioGroupProps,
DropdownMenuRadioItemProps,
DropdownMenuItemIndicatorProps,
DropdownMenuSeparatorProps,
DropdownMenuArrowProps,
DropdownMenuSubProps,
DropdownMenuSubTriggerProps,
DropdownMenuSubContentProps,
};