import * as React from 'react'; import classnames from 'classnames'; import { useEffect, useRef, useState } from 'react'; import randomId from '../../../utils/randomId'; import styles from './ContextMenu.scss'; import { FunctionGeneric } from '../../../common/structures/Generics'; import { Tooltip, TooltipProps, hideTooltip, showTooltip } from '../../overlays/Tooltip/Tooltip'; import { ITextButtonProps, TextButton } from '../../buttons/TextButton/TextButton'; import { ThreeDotButton } from '../../buttons/ThreeDotButton/ThreeDotButton'; import { ArrowRightIcon, CheckmarkIcon } from '../../icons/Icons'; export interface IMenuItem { /** Color for a textbutton item (not a separator) */ color?: 'red' | 'none'; /** Classname to add to the item component */ className?: string; /** Will be rendered next to the label in the item's textButton */ content?: React.ReactNode; /** Text to show in the item's TextButton */ label?: string; /** Function to run when clicking the item - not run for items with submenus or separators */ onClick?: FunctionGeneric; /** Set to false for a disabled textButton */ enabled?: boolean; /** Available item types, checkbox will be styled with a check */ type?: 'normal' | 'separator' | 'checkbox'; /** Determines check icon for checkbox type items */ checked?: boolean; /** Array of IMenuItem items that will recursively render a submenu to the side */ submenu?: IMenuItem[]; } export interface IContextMenuProps extends Omit { /** className for the list items' container */ classNameList?: string; /** className for an individual list item */ classNameListItem?: string; /** array of menu items */ items: IMenuItem[]; /** whether to have background behind 3 dots - set to false for low key 3 dot dropdowns */ noBG?: boolean; /** whether to have background behind 3 dots on hover only */ bgOnHover?: boolean; /** Private property used to render submenus recursively, should be no need to set this */ isSubmenu?: boolean; /** Private property used to render submenus recursively, should be no need to set this */ parentMenuId?: string; } interface ContextMenuTextButtonProps extends ITextButtonProps { item: IMenuItem; menuToClose: string; } const ContextMenuTextButton = ({ item, menuToClose, ...restProps }: ContextMenuTextButtonProps) => { const onClickItem = (_event: React.MouseEvent, menuItem: IMenuItem) => { if (menuItem.onClick) { menuItem.onClick.call(null); document.getElementById(`${menuToClose}-ThreeDotButton`)?.focus(); hideTooltip(menuToClose); } }; return ( !item.submenu && onClickItem(event, item)} tabIndex={-1} {...restProps} > {item.label} {item.content} ); }; const ContextMenu = (props: IContextMenuProps) => { const { className, classNameList, classNameListItem, items, onShow, onHide, noBG, bgOnHover, id, isSubmenu, children, onKeyDown, parentMenuId, ...otherProps } = props; const menuId = useRef(id ?? `${'contextMenu'}-${randomId()}`); const [isShowing, setIsShowing] = useState(false); const triggerRef = useRef(null); const contextMenuRef = useRef(null); const [, setFocusedItemIndex] = useState(-1); const enabledItemsWithRefs = useRef>([]); const renderItem = (item: IMenuItem, index: number) => { const maybeSetItemRef = (el: HTMLElement) => { enabledItemsWithRefs.current[index] = item.enabled === false ? undefined : { ...item, ref: el }; }; const parentMenu = parentMenuId ?? menuId.current; if (item.submenu) { return ( ); } switch (item.type) { case 'separator': return
; case 'checkbox': return ( ); default: return ; } }; const content = (
    {items .filter((item: IMenuItem) => Object.keys(item).length !== 0) .map((item: IMenuItem, i: number) => (
  • {renderItem(item, i)}
  • ))}
); const handleEscapeOrTab = (e: KeyboardEvent) => { if (e.key === 'Escape' || e.key === 'Tab') { triggerRef.current?.focus(); hideTooltip(menuId.current); } }; useEffect(() => { if (isShowing) { document.addEventListener('keydown', handleEscapeOrTab); } return () => document.removeEventListener('keydown', handleEscapeOrTab); }, [isShowing]); const focusNextItem = () => { setFocusedItemIndex((prev) => { const arr = enabledItemsWithRefs.current; for (let i = prev + 1; i < arr.length; i += 1) { const item = arr[i]; if (item && item.enabled !== false) { // The 1ms timeout handles focusing a submenu's first item after the 1ms transition open setTimeout(() => item.ref.focus(), 1); return i; } } return prev; }); }; const focusPreviousItem = () => { setFocusedItemIndex((prev) => { const arr = enabledItemsWithRefs.current; for (let i = prev - 1; i >= 0; i -= 1) { const item = arr[i]; if (item && item.enabled !== false) { item.ref.focus(); return i; } } return prev; }); }; const handleKeyDown = (e: React.KeyboardEvent) => { e.persist(); onKeyDown?.(e); switch (e.key) { case 'ArrowDown': case 'ArrowUp': if (isShowing) { document.body.classList.remove('using-mouse'); if (e.key === 'ArrowDown') { focusNextItem(); } else { focusPreviousItem(); } if (isSubmenu) { e.stopPropagation(); } } break; case 'ArrowRight': if (isSubmenu) { e.stopPropagation(); showTooltip(menuId.current); } break; case 'ArrowLeft': if (isSubmenu && !e.currentTarget.contains(e.target as Node)) { e.stopPropagation(); hideTooltip(menuId.current); (contextMenuRef.current?.firstChild as HTMLElement)?.focus(); } break; default: } }; return ( { onShow?.call(null); setIsShowing(true); if (!isSubmenu) { triggerRef.current?.focus(); } else { focusNextItem(); } }} onHide={() => { onHide?.call(null); setIsShowing(false); setFocusedItemIndex(-1); }} useClickInsteadOfHover id={menuId.current} onKeyDown={handleKeyDown} content={content} ref={contextMenuRef} {...otherProps} > {children ?? ( { triggerRef.current = el; }} aria-label="Open context menu" active={isShowing} noBG={noBG} bgOnHover={bgOnHover} aria-haspopup id={`${menuId.current}-ThreeDotButton`} /> )} ); }; ContextMenu.defaultProps = { noAnimations: true, items: [], position: 'bottom-start', hideArrow: true, showDelay: 0, hideDelay: 0, } as Partial; export default ContextMenu;