// Copyright 2018-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { KeyboardEvent } from 'react'; import type { Options } from '@popperjs/core'; import FocusTrap from 'focus-trap-react'; import React, { useEffect, useState } from 'react'; import classNames from 'classnames'; import { usePopper } from 'react-popper'; import { noop } from 'lodash'; import type { Theme } from '../util/theme'; import type { LocalizerType } from '../types/Util'; import { themeClassName } from '../util/theme'; type OptionType = { readonly description?: string; readonly icon?: string; readonly label: string; readonly onClick: (value?: T) => unknown; readonly value?: T; }; export type ContextMenuPropsType = { readonly focusedIndex?: number; readonly isMenuShowing: boolean; readonly menuOptions: ReadonlyArray>; readonly onClose: () => unknown; readonly popperOptions?: Pick; readonly referenceElement: HTMLElement | null; readonly theme?: Theme; readonly title?: string; readonly value?: T; }; export type PropsType = { readonly buttonClassName?: string; readonly i18n: LocalizerType; } & Pick< ContextMenuPropsType, 'menuOptions' | 'popperOptions' | 'theme' | 'title' | 'value' >; export function ContextMenuPopper({ menuOptions, focusedIndex, isMenuShowing, popperOptions, onClose, referenceElement, title, value, }: ContextMenuPropsType): JSX.Element | null { const [popperElement, setPopperElement] = useState( null ); const { styles, attributes } = usePopper(referenceElement, popperElement, { placement: 'top-start', strategy: 'fixed', ...popperOptions, }); useEffect(() => { if (!isMenuShowing) { return noop; } const handleOutsideClick = (event: MouseEvent) => { if (!referenceElement?.contains(event.target as Node)) { onClose(); event.stopPropagation(); event.preventDefault(); } }; document.addEventListener('click', handleOutsideClick); return () => { document.removeEventListener('click', handleOutsideClick); }; }, [isMenuShowing, onClose, referenceElement]); if (!isMenuShowing) { return null; } return (
{title &&
{title}
} {menuOptions.map((option, index) => ( ))}
); } export function ContextMenu({ buttonClassName, i18n, menuOptions, popperOptions, theme, title, value, }: PropsType): JSX.Element { const [menuShowing, setMenuShowing] = useState(false); const [focusedIndex, setFocusedIndex] = useState( undefined ); const handleKeyDown = (ev: KeyboardEvent) => { if (!menuShowing) { if (ev.key === 'Enter') { setFocusedIndex(0); } return; } if (ev.key === 'ArrowDown') { const currFocusedIndex = focusedIndex || 0; const nextFocusedIndex = currFocusedIndex >= menuOptions.length - 1 ? 0 : currFocusedIndex + 1; setFocusedIndex(nextFocusedIndex); ev.stopPropagation(); ev.preventDefault(); } if (ev.key === 'ArrowUp') { const currFocusedIndex = focusedIndex || 0; const nextFocusedIndex = currFocusedIndex === 0 ? menuOptions.length - 1 : currFocusedIndex - 1; setFocusedIndex(nextFocusedIndex); ev.stopPropagation(); ev.preventDefault(); } if (ev.key === 'Enter') { if (focusedIndex !== undefined) { const focusedOption = menuOptions[focusedIndex]; focusedOption.onClick(focusedOption.value); } setMenuShowing(false); ev.stopPropagation(); ev.preventDefault(); } }; // We use regular MouseEvent below, and this one uses React.MouseEvent const handleClick = (ev: KeyboardEvent | React.MouseEvent) => { setMenuShowing(true); ev.stopPropagation(); ev.preventDefault(); }; const [referenceElement, setReferenceElement] = useState(null); return (
); }