// Copyright 2018-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { KeyboardEvent, ReactNode } 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 { getClassNamesFor } from '../util/getClassNamesFor'; import { themeClassName } from '../util/theme'; export type ContextMenuOptionType = { readonly description?: string; readonly icon?: string; readonly label: string; readonly onClick: (value?: T) => unknown; readonly value?: T; }; export type PropsType = { readonly children?: ReactNode; readonly i18n: LocalizerType; readonly menuOptions: ReadonlyArray>; readonly moduleClassName?: string; readonly onClick?: () => unknown; readonly onMenuShowingChanged?: (value: boolean) => unknown; readonly popperOptions?: Pick; readonly theme?: Theme; readonly title?: string; readonly value?: T; }; export function ContextMenu({ children, i18n, menuOptions, moduleClassName, onClick, onMenuShowingChanged, popperOptions, theme, title, value, }: PropsType): JSX.Element { const [isMenuShowing, setIsMenuShowing] = useState(false); const [focusedIndex, setFocusedIndex] = useState( undefined ); const [popperElement, setPopperElement] = useState( null ); const [referenceElement, setReferenceElement] = useState(null); const { styles, attributes } = usePopper(referenceElement, popperElement, { placement: 'top-start', strategy: 'fixed', ...popperOptions, }); useEffect(() => { if (onMenuShowingChanged) { onMenuShowingChanged(isMenuShowing); } }, [isMenuShowing, onMenuShowingChanged]); useEffect(() => { if (!isMenuShowing) { return noop; } const handleOutsideClick = (event: MouseEvent) => { if (!referenceElement?.contains(event.target as Node)) { setIsMenuShowing(false); event.stopPropagation(); event.preventDefault(); } }; document.addEventListener('click', handleOutsideClick); return () => { document.removeEventListener('click', handleOutsideClick); }; }, [isMenuShowing, referenceElement]); const handleKeyDown = (ev: KeyboardEvent) => { if (!isMenuShowing) { 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); } setIsMenuShowing(false); ev.stopPropagation(); ev.preventDefault(); } }; const handleClick = (ev: KeyboardEvent | React.MouseEvent) => { setIsMenuShowing(true); ev.stopPropagation(); ev.preventDefault(); }; const getClassName = getClassNamesFor('ContextMenu', moduleClassName); return (
{isMenuShowing && (
{title &&
{title}
} {menuOptions.map((option, index) => ( ))}
)}
); }