Context Menu
Right-click context menu for Tile UI.
packages/react/src/components/context-menu/context-menu.tsx
import React, { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Slot } from '@radix-ui/react-slot';
import { contextMenuStyleKeys, getContextMenuCheckState, getContextMenuPosition, getContextMenuState } from '../lib/core';
import type {
ContextMenuBaseProps,
ContextMenuCheckboxItemBaseProps,
ContextMenuItemBaseProps,
ContextMenuLabelBaseProps,
ContextMenuPosition,
ContextMenuRadioGroupBaseProps,
ContextMenuRadioItemBaseProps,
ContextMenuSubBaseProps,
ContextMenuSubTriggerBaseProps,
ContextMenuTriggerBaseProps,
} from '../lib/core';
import { PortalProvider, usePortalContainer, type PortalContainer } from '../portal';
import styles from './context-menu.module.scss';
interface ContextMenuContextValue {
open: boolean;
setOpen: (open: boolean) => void;
triggerRef: React.RefObject<HTMLElement | null>;
contentId: string;
position: ContextMenuPosition | null;
setPosition: (position: ContextMenuPosition) => void;
closeAll: () => void;
}
const ContextMenuContext = createContext<ContextMenuContextValue | null>(null);
function useContextMenuContext(): ContextMenuContextValue {
const context = useContext(ContextMenuContext);
if (!context) {
throw new Error('ContextMenu 子组件必须位于 <ContextMenu> 内部。');
}
return context;
}
interface ContextMenuContentContextValue {
itemsRef: React.RefObject<HTMLElement[]>;
close: () => void;
}
const ContextMenuContentContext = createContext<ContextMenuContentContextValue | null>(null);
function useContextMenuContentContext(): ContextMenuContentContextValue {
const context = useContext(ContextMenuContentContext);
if (!context) {
throw new Error('ContextMenu 菜单项必须位于 <ContextMenuContent> 或 <ContextMenuSubContent> 内部。');
}
return context;
}
interface ContextMenuSubContextValue {
open: boolean;
setOpen: (open: boolean) => void;
triggerRef: React.RefObject<HTMLElement | null>;
}
const ContextMenuSubContext = createContext<ContextMenuSubContextValue | null>(null);
function useContextMenuSubContext(): ContextMenuSubContextValue {
const context = useContext(ContextMenuSubContext);
if (!context) {
throw new Error('ContextMenu 子菜单组件必须位于 <ContextMenuSub> 内部。');
}
return context;
}
interface ContextMenuRadioGroupContextValue {
value: string | undefined;
setValue: (value: string) => void;
}
const ContextMenuRadioGroupContext = createContext<ContextMenuRadioGroupContextValue | null>(null);
function useContextMenuRadioGroupContext(): ContextMenuRadioGroupContextValue {
const context = useContext(ContextMenuRadioGroupContext);
if (!context) {
throw new Error('ContextMenuRadioItem 必须位于 <ContextMenuRadioGroup> 内部。');
}
return context;
}
function ContextMenuCheckIcon() {
return (
<svg
className={styles[contextMenuStyleKeys.checkIcon]}
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true">
<path d="M20 6 9 17l-5-5" />
</svg>
);
}
function ContextMenuRadioIcon() {
return (
<svg
className={styles[contextMenuStyleKeys.radioIcon]}
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true">
<circle cx="12" cy="12" r="6" />
</svg>
);
}
function ContextMenuChevronIcon() {
return (
<svg
className={styles[contextMenuStyleKeys.chevron]}
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true">
<path d="m9 18 6-6-6-6" />
</svg>
);
}
export interface ContextMenuProps extends React.HTMLAttributes<HTMLDivElement>, ContextMenuBaseProps {}
const ContextMenu = React.forwardRef<HTMLDivElement, ContextMenuProps>(({ className = '', open, defaultOpen = false, onOpenChange, children, ...props }, ref) => {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const isOpen = open !== undefined ? open : internalOpen;
const triggerRef = useRef<HTMLElement | null>(null);
const contentId = useId();
const [position, setPosition] = useState<ContextMenuPosition | null>(null);
const setOpen = useCallback(
(next: boolean) => {
if (open === undefined) {
setInternalOpen(next);
}
onOpenChange?.(next);
},
[open, onOpenChange],
);
const closeAll = useCallback(() => {
setOpen(false);
}, [setOpen]);
return (
<ContextMenuContext.Provider value={{ open: isOpen, setOpen, triggerRef, contentId, position, setPosition, closeAll }}>
<div ref={ref} className={`${styles[contextMenuStyleKeys.root]} ${className}`} {...props}>
{children}
</div>
</ContextMenuContext.Provider>
);
});
ContextMenu.displayName = 'ContextMenu';
export interface ContextMenuPortalProps {
container?: PortalContainer;
children?: React.ReactNode;
}
const ContextMenuPortal = ({ container, children }: ContextMenuPortalProps) => {
return <PortalProvider container={container}>{children}</PortalProvider>;
};
ContextMenuPortal.displayName = 'ContextMenuPortal';
export interface ContextMenuTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, ContextMenuTriggerBaseProps {}
const ContextMenuTrigger = React.forwardRef<HTMLButtonElement, ContextMenuTriggerProps>(
({ className = '', asChild = false, children, onContextMenu, onKeyDown, ...props }, ref) => {
const context = useContextMenuContext();
function setRef(element: HTMLElement | null) {
context.triggerRef.current = element;
if (typeof ref === 'function') {
ref(element as HTMLButtonElement | null);
} else if (ref) {
ref.current = element as HTMLButtonElement | null;
}
}
function handleContextMenu(event: React.MouseEvent<HTMLButtonElement>) {
onContextMenu?.(event);
if (event.defaultPrevented) {
return;
}
event.preventDefault();
context.setPosition({ top: event.clientY, left: event.clientX });
context.setOpen(true);
}
function handleKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
onKeyDown?.(event);
if (event.defaultPrevented) {
return;
}
if (event.key === 'ContextMenu' || (event.key === 'F10' && event.shiftKey)) {
event.preventDefault();
const rect = event.currentTarget.getBoundingClientRect();
context.setPosition({ top: rect.bottom, left: rect.left });
context.setOpen(true);
}
}
const Comp = asChild ? Slot : 'button';
const state = getContextMenuState(context.open);
return (
<Comp
ref={setRef}
type={asChild ? undefined : 'button'}
data-state={state}
aria-haspopup="menu"
aria-expanded={context.open}
aria-controls={context.contentId}
className={asChild ? undefined : `${styles[contextMenuStyleKeys.trigger]} ${className}`}
onContextMenu={handleContextMenu}
onKeyDown={handleKeyDown}
{...props}>
{children}
</Comp>
);
},
);
ContextMenuTrigger.displayName = 'ContextMenuTrigger';
export interface ContextMenuContentProps extends React.HTMLAttributes<HTMLDivElement> {
container?: PortalContainer;
}
const ContextMenuContent = React.forwardRef<HTMLDivElement, ContextMenuContentProps>(({ className = '', container, children, ...props }, ref) => {
const { open, position, setPosition, triggerRef, closeAll, contentId } = useContextMenuContext();
const portalContainer = usePortalContainer(container);
const contentRef = useRef<HTMLDivElement | null>(null);
const itemsRef = useRef<HTMLElement[]>([]);
const latestPositionRef = useRef(position);
latestPositionRef.current = position;
function setRef(element: HTMLDivElement | null) {
contentRef.current = element;
if (typeof ref === 'function') {
ref(element);
} else if (ref) {
ref.current = element;
}
}
useLayoutEffect(() => {
if (!open) {
return;
}
function updatePosition() {
const content = contentRef.current;
if (!content) {
return;
}
const contentSize = { width: content.offsetWidth, height: content.offsetHeight };
const viewport = { width: window.innerWidth, height: window.innerHeight };
const base = latestPositionRef.current ?? { top: 0, left: 0 };
setPosition(getContextMenuPosition({ x: base.left, y: base.top, contentSize, viewport }));
}
updatePosition();
window.addEventListener('resize', updatePosition);
document.addEventListener('scroll', updatePosition, true);
return () => {
window.removeEventListener('resize', updatePosition);
document.removeEventListener('scroll', updatePosition, true);
};
}, [open, setPosition]);
useEffect(() => {
if (!open) {
return;
}
const items = itemsRef.current;
if (items.length === 0) {
return;
}
items.forEach((item) => item.removeAttribute('data-highlighted'));
items[0].setAttribute('data-highlighted', 'true');
items[0].focus();
}, [open]);
useEffect(() => {
if (!open) {
return;
}
function handlePointerDown(event: PointerEvent) {
const target = event.target as Node | null;
if (!target) {
return;
}
const content = contentRef.current;
if (content && content.contains(target)) {
return;
}
closeAll();
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
closeAll();
triggerRef.current?.focus();
}
}
document.addEventListener('pointerdown', handlePointerDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('pointerdown', handlePointerDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [open, closeAll, triggerRef]);
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
const items = itemsRef.current.filter((item) => item.getAttribute('data-disabled') !== 'true');
if (items.length === 0) {
return;
}
const currentIndex = items.findIndex((item) => item.getAttribute('data-highlighted') === 'true');
const highlight = (next: number) => {
items.forEach((item) => item.removeAttribute('data-highlighted'));
items[next].setAttribute('data-highlighted', 'true');
items[next].focus();
};
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
highlight(currentIndex < 0 ? 0 : (currentIndex + 1) % items.length);
break;
case 'ArrowUp':
event.preventDefault();
highlight(currentIndex < 0 ? items.length - 1 : (currentIndex - 1 + items.length) % items.length);
break;
case 'Home':
event.preventDefault();
highlight(0);
break;
case 'End':
event.preventDefault();
highlight(items.length - 1);
break;
case 'Enter':
case ' ':
event.preventDefault();
(currentIndex >= 0 ? items[currentIndex] : items[0]).click();
break;
}
}
const state = getContextMenuState(open);
const classes = [styles[contextMenuStyleKeys.content], className].filter(Boolean).join(' ');
const content = (
<ContextMenuContentContext.Provider value={{ itemsRef, close: closeAll }}>
<div
ref={setRef}
id={contentId}
role="menu"
tabIndex={-1}
data-state={state}
className={classes}
style={position ? { top: `${position.top}px`, left: `${position.left}px` } : undefined}
onKeyDown={handleKeyDown}
{...props}>
{children}
</div>
</ContextMenuContentContext.Provider>
);
if (!open || !portalContainer) {
return null;
}
return createPortal(content, portalContainer);
});
ContextMenuContent.displayName = 'ContextMenuContent';
export interface ContextMenuGroupProps extends React.HTMLAttributes<HTMLDivElement> {}
const ContextMenuGroup = React.forwardRef<HTMLDivElement, ContextMenuGroupProps>(({ className = '', children, ...props }, ref) => {
return (
<div ref={ref} role="group" className={`${styles[contextMenuStyleKeys.group]} ${className}`} {...props}>
{children}
</div>
);
});
ContextMenuGroup.displayName = 'ContextMenuGroup';
export interface ContextMenuLabelProps extends React.HTMLAttributes<HTMLDivElement>, ContextMenuLabelBaseProps {}
const ContextMenuLabel = React.forwardRef<HTMLDivElement, ContextMenuLabelProps>(({ className = '', inset = false, children, ...props }, ref) => {
const classes = [styles[contextMenuStyleKeys.label], className].filter(Boolean).join(' ');
return (
<div ref={ref} data-inset={inset} className={classes} {...props}>
{children}
</div>
);
});
ContextMenuLabel.displayName = 'ContextMenuLabel';
export interface ContextMenuSeparatorProps extends React.HTMLAttributes<HTMLDivElement> {}
const ContextMenuSeparator = React.forwardRef<HTMLDivElement, ContextMenuSeparatorProps>(({ className = '', ...props }, ref) => {
const classes = [styles[contextMenuStyleKeys.separator], className].filter(Boolean).join(' ');
return <div ref={ref} role="separator" className={classes} {...props} />;
});
ContextMenuSeparator.displayName = 'ContextMenuSeparator';
export interface ContextMenuShortcutProps extends React.HTMLAttributes<HTMLSpanElement> {}
const ContextMenuShortcut = React.forwardRef<HTMLSpanElement, ContextMenuShortcutProps>(({ className = '', children, ...props }, ref) => {
const classes = [styles[contextMenuStyleKeys.shortcut], className].filter(Boolean).join(' ');
return (
<span ref={ref} className={classes} {...props}>
{children}
</span>
);
});
ContextMenuShortcut.displayName = 'ContextMenuShortcut';
export interface ContextMenuItemProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onSelect'>, ContextMenuItemBaseProps {}
const ContextMenuItem = React.forwardRef<HTMLDivElement, ContextMenuItemProps>(
({ className = '', inset = false, variant = 'default', disabled = false, onSelect, children, ...props }, ref) => {
const { itemsRef, close } = useContextMenuContentContext();
const itemRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const element = itemRef.current;
if (!element) {
return;
}
itemsRef.current.push(element);
return () => {
itemsRef.current = itemsRef.current.filter((item) => item !== element);
};
}, [itemsRef]);
function setRef(element: HTMLDivElement | null) {
itemRef.current = element;
if (typeof ref === 'function') {
ref(element);
} else if (ref) {
ref.current = element;
}
}
function handleClick(event: React.MouseEvent<HTMLDivElement>) {
if (disabled) {
return;
}
onSelect?.(event.nativeEvent);
close();
}
const classes = [styles[contextMenuStyleKeys.item], className].filter(Boolean).join(' ');
return (
<div ref={setRef} role="menuitem" tabIndex={-1} data-inset={inset} data-variant={variant} data-disabled={disabled} className={classes} onClick={handleClick} {...props}>
{children}
</div>
);
},
);
ContextMenuItem.displayName = 'ContextMenuItem';
export interface ContextMenuCheckboxItemProps extends React.HTMLAttributes<HTMLDivElement>, ContextMenuCheckboxItemBaseProps {}
const ContextMenuCheckboxItem = React.forwardRef<HTMLDivElement, ContextMenuCheckboxItemProps>(
({ className = '', checked, defaultChecked = false, onCheckedChange, disabled = false, children, ...props }, ref) => {
const { itemsRef } = useContextMenuContentContext();
const itemRef = useRef<HTMLDivElement | null>(null);
const [internalChecked, setInternalChecked] = useState(defaultChecked);
const isChecked = checked !== undefined ? checked : internalChecked;
useEffect(() => {
const element = itemRef.current;
if (!element) {
return;
}
itemsRef.current.push(element);
return () => {
itemsRef.current = itemsRef.current.filter((item) => item !== element);
};
}, [itemsRef]);
function setRef(element: HTMLDivElement | null) {
itemRef.current = element;
if (typeof ref === 'function') {
ref(element);
} else if (ref) {
ref.current = element;
}
}
function handleClick() {
if (disabled) {
return;
}
const next = !isChecked;
if (checked === undefined) {
setInternalChecked(next);
}
onCheckedChange?.(next);
}
const classes = [styles[contextMenuStyleKeys.checkboxItem], className].filter(Boolean).join(' ');
return (
<div
ref={setRef}
role="menuitemcheckbox"
tabIndex={-1}
aria-checked={isChecked}
data-checked={getContextMenuCheckState(isChecked)}
data-disabled={disabled}
className={classes}
onClick={handleClick}
{...props}>
<span className={styles[contextMenuStyleKeys.indicator]}>{isChecked && <ContextMenuCheckIcon />}</span>
{children}
</div>
);
},
);
ContextMenuCheckboxItem.displayName = 'ContextMenuCheckboxItem';
export interface ContextMenuRadioGroupProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'defaultValue'>, ContextMenuRadioGroupBaseProps {}
const ContextMenuRadioGroup = React.forwardRef<HTMLDivElement, ContextMenuRadioGroupProps>(({ className = '', value, defaultValue, onValueChange, children, ...props }, ref) => {
const [internalValue, setInternalValue] = useState(defaultValue);
const resolvedValue = value !== undefined ? value : internalValue;
const setValue = useCallback(
(next: string) => {
if (value === undefined) {
setInternalValue(next);
}
onValueChange?.(next);
},
[value, onValueChange],
);
const classes = [styles[contextMenuStyleKeys.radioGroup], className].filter(Boolean).join(' ');
return (
<ContextMenuRadioGroupContext.Provider value={{ value: resolvedValue, setValue }}>
<div ref={ref} role="group" className={classes} {...props}>
{children}
</div>
</ContextMenuRadioGroupContext.Provider>
);
});
ContextMenuRadioGroup.displayName = 'ContextMenuRadioGroup';
export interface ContextMenuRadioItemProps extends React.HTMLAttributes<HTMLDivElement>, ContextMenuRadioItemBaseProps {}
const ContextMenuRadioItem = React.forwardRef<HTMLDivElement, ContextMenuRadioItemProps>(({ className = '', value, disabled = false, children, ...props }, ref) => {
const { itemsRef } = useContextMenuContentContext();
const { value: groupValue, setValue } = useContextMenuRadioGroupContext();
const itemRef = useRef<HTMLDivElement | null>(null);
const isChecked = groupValue === value;
useEffect(() => {
const element = itemRef.current;
if (!element) {
return;
}
itemsRef.current.push(element);
return () => {
itemsRef.current = itemsRef.current.filter((item) => item !== element);
};
}, [itemsRef]);
function setRef(element: HTMLDivElement | null) {
itemRef.current = element;
if (typeof ref === 'function') {
ref(element);
} else if (ref) {
ref.current = element;
}
}
function handleClick() {
if (disabled) {
return;
}
setValue(value);
}
const classes = [styles[contextMenuStyleKeys.radioItem], className].filter(Boolean).join(' ');
return (
<div
ref={setRef}
role="menuitemradio"
tabIndex={-1}
aria-checked={isChecked}
data-checked={getContextMenuCheckState(isChecked)}
data-disabled={disabled}
className={classes}
onClick={handleClick}
{...props}>
<span className={styles[contextMenuStyleKeys.indicator]}>{isChecked && <ContextMenuRadioIcon />}</span>
{children}
</div>
);
});
ContextMenuRadioItem.displayName = 'ContextMenuRadioItem';
export interface ContextMenuSubProps extends ContextMenuSubBaseProps {
children?: React.ReactNode;
}
const ContextMenuSub = ({ open, defaultOpen = false, onOpenChange, children }: ContextMenuSubProps) => {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const isOpen = open !== undefined ? open : internalOpen;
const triggerRef = useRef<HTMLElement | null>(null);
const setOpen = useCallback(
(next: boolean) => {
if (open === undefined) {
setInternalOpen(next);
}
onOpenChange?.(next);
},
[open, onOpenChange],
);
return <ContextMenuSubContext.Provider value={{ open: isOpen, setOpen, triggerRef }}>{children}</ContextMenuSubContext.Provider>;
};
ContextMenuSub.displayName = 'ContextMenuSub';
export interface ContextMenuSubTriggerProps extends React.HTMLAttributes<HTMLDivElement>, ContextMenuSubTriggerBaseProps {}
const ContextMenuSubTrigger = React.forwardRef<HTMLDivElement, ContextMenuSubTriggerProps>(({ className = '', inset = false, disabled = false, children, ...props }, ref) => {
const { itemsRef } = useContextMenuContentContext();
const { open, setOpen, triggerRef } = useContextMenuSubContext();
const itemRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const element = itemRef.current;
if (!element) {
return;
}
itemsRef.current.push(element);
return () => {
itemsRef.current = itemsRef.current.filter((item) => item !== element);
};
}, [itemsRef]);
function setRef(element: HTMLDivElement | null) {
itemRef.current = element;
triggerRef.current = element;
if (typeof ref === 'function') {
ref(element);
} else if (ref) {
ref.current = element;
}
}
function handleMouseEnter() {
if (disabled) {
return;
}
setOpen(true);
}
function handleClick() {
if (disabled) {
return;
}
setOpen(!open);
}
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (disabled) {
return;
}
if (event.key === 'ArrowRight' || event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setOpen(true);
}
}
const state = getContextMenuState(open);
const classes = [styles[contextMenuStyleKeys.subTrigger], className].filter(Boolean).join(' ');
return (
<div
ref={setRef}
role="menuitem"
tabIndex={-1}
aria-haspopup="menu"
aria-expanded={open}
data-state={state}
data-inset={inset}
data-disabled={disabled}
className={classes}
onMouseEnter={handleMouseEnter}
onClick={handleClick}
onKeyDown={handleKeyDown}
{...props}>
{children}
<ContextMenuChevronIcon />
</div>
);
});
ContextMenuSubTrigger.displayName = 'ContextMenuSubTrigger';
export interface ContextMenuSubContentProps extends React.HTMLAttributes<HTMLDivElement> {
container?: PortalContainer;
}
const ContextMenuSubContent = React.forwardRef<HTMLDivElement, ContextMenuSubContentProps>(({ className = '', container, children, ...props }, ref) => {
const { open, setOpen, triggerRef } = useContextMenuSubContext();
const portalContainer = usePortalContainer(container);
const [position, setPosition] = useState<ContextMenuPosition | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const itemsRef = useRef<HTMLElement[]>([]);
function setRef(element: HTMLDivElement | null) {
contentRef.current = element;
if (typeof ref === 'function') {
ref(element);
} else if (ref) {
ref.current = element;
}
}
useLayoutEffect(() => {
if (!open) {
return;
}
function updatePosition() {
const trigger = triggerRef.current;
const content = contentRef.current;
if (!trigger || !content) {
return;
}
const triggerRect = trigger.getBoundingClientRect();
const contentSize = { width: content.offsetWidth, height: content.offsetHeight };
const viewport = { width: window.innerWidth, height: window.innerHeight };
setPosition(getContextMenuPosition({ x: triggerRect.right, y: triggerRect.top, contentSize, viewport }));
}
updatePosition();
window.addEventListener('resize', updatePosition);
document.addEventListener('scroll', updatePosition, true);
return () => {
window.removeEventListener('resize', updatePosition);
document.removeEventListener('scroll', updatePosition, true);
};
}, [open, triggerRef]);
useEffect(() => {
if (!open) {
return;
}
function handlePointerDown(event: PointerEvent) {
const target = event.target as Node | null;
if (!target) {
return;
}
const content = contentRef.current;
const trigger = triggerRef.current;
if (content && content.contains(target)) {
return;
}
if (trigger && trigger.contains(target)) {
return;
}
setOpen(false);
}
document.addEventListener('pointerdown', handlePointerDown);
return () => document.removeEventListener('pointerdown', handlePointerDown);
}, [open, triggerRef, setOpen]);
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
const items = itemsRef.current.filter((item) => item.getAttribute('data-disabled') !== 'true');
if (event.key === 'Escape' || event.key === 'ArrowLeft') {
event.preventDefault();
setOpen(false);
triggerRef.current?.focus();
return;
}
if (items.length === 0) {
return;
}
const currentIndex = items.findIndex((item) => item.getAttribute('data-highlighted') === 'true');
const highlight = (next: number) => {
items.forEach((item) => item.removeAttribute('data-highlighted'));
items[next].setAttribute('data-highlighted', 'true');
items[next].focus();
};
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
highlight(currentIndex < 0 ? 0 : (currentIndex + 1) % items.length);
break;
case 'ArrowUp':
event.preventDefault();
highlight(currentIndex < 0 ? items.length - 1 : (currentIndex - 1 + items.length) % items.length);
break;
case 'Enter':
case ' ':
event.preventDefault();
(currentIndex >= 0 ? items[currentIndex] : items[0]).click();
break;
}
}
const state = getContextMenuState(open);
const classes = [styles[contextMenuStyleKeys.subContent], className].filter(Boolean).join(' ');
const content = (
<ContextMenuContentContext.Provider value={{ itemsRef, close: () => setOpen(false) }}>
<div
ref={setRef}
role="menu"
tabIndex={-1}
data-state={state}
className={classes}
style={position ? { top: `${position.top}px`, left: `${position.left}px` } : undefined}
onKeyDown={handleKeyDown}
{...props}>
{children}
</div>
</ContextMenuContentContext.Provider>
);
if (!open || !portalContainer) {
return null;
}
return createPortal(content, portalContainer);
});
ContextMenuSubContent.displayName = 'ContextMenuSubContent';
export {
ContextMenu,
ContextMenuPortal,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuGroup,
ContextMenuLabel,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuSub,
ContextMenuSubTrigger,
ContextMenuSubContent,
};
export default ContextMenu;
- Interaction
- dropdown
DependenciesBundle weight · light
- ui-primitives
- @radix-ui/react-slot