356 lines
10 KiB
TypeScript
356 lines
10 KiB
TypeScript
// Copyright 2018 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import type { KeyboardEvent, ReactNode } from 'react';
|
|
import type { Options, VirtualElement } from '@popperjs/core';
|
|
import React, { useEffect, useRef, useState } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
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';
|
|
import { handleOutsideClick } from '../util/handleOutsideClick';
|
|
|
|
export type ContextMenuOptionType<T> = Readonly<{
|
|
description?: string;
|
|
icon?: string;
|
|
label: string;
|
|
group?: string;
|
|
onClick: (value?: T) => unknown;
|
|
value?: T;
|
|
}>;
|
|
|
|
type RenderButtonProps = Readonly<{
|
|
openMenu: (ev: React.MouseEvent) => void;
|
|
onKeyDown: (ev: KeyboardEvent) => void;
|
|
isMenuShowing: boolean;
|
|
ref: React.Ref<HTMLButtonElement> | null;
|
|
menuNode: ReactNode;
|
|
}>;
|
|
|
|
export type PropsType<T> = Readonly<{
|
|
ariaLabel?: string;
|
|
// contents of the button OR a function that will render the whole button
|
|
children?: ReactNode | ((props: RenderButtonProps) => JSX.Element);
|
|
i18n: LocalizerType;
|
|
menuOptions: ReadonlyArray<ContextMenuOptionType<T>>;
|
|
moduleClassName?: string;
|
|
button?: () => JSX.Element;
|
|
onClick?: (ev: React.MouseEvent) => unknown;
|
|
onMenuShowingChanged?: (value: boolean) => unknown;
|
|
popperOptions?: Pick<Options, 'placement' | 'strategy'>;
|
|
portalToRoot?: boolean;
|
|
theme?: Theme;
|
|
title?: string;
|
|
value?: T;
|
|
}>;
|
|
|
|
let closeCurrentOpenContextMenu: undefined | (() => unknown);
|
|
|
|
// https://popper.js.org/docs/v2/virtual-elements/
|
|
// Generating a virtual element here so that we can make the menu pop up
|
|
// right under the mouse cursor.
|
|
function generateVirtualElement(x: number, y: number): VirtualElement {
|
|
return {
|
|
getBoundingClientRect: () => new DOMRect(x, y),
|
|
};
|
|
}
|
|
|
|
export function ContextMenu<T>({
|
|
ariaLabel,
|
|
children,
|
|
i18n,
|
|
menuOptions,
|
|
moduleClassName,
|
|
onClick,
|
|
onMenuShowingChanged,
|
|
popperOptions,
|
|
portalToRoot,
|
|
theme,
|
|
title,
|
|
value,
|
|
}: PropsType<T>): JSX.Element {
|
|
const [isMenuShowing, setIsMenuShowing] = useState<boolean>(false);
|
|
const [focusedIndex, setFocusedIndex] = useState<number | undefined>(
|
|
undefined
|
|
);
|
|
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
|
|
null
|
|
);
|
|
|
|
const virtualElement = useRef<VirtualElement>(generateVirtualElement(0, 0));
|
|
|
|
const [referenceElement, setReferenceElement] =
|
|
useState<HTMLButtonElement | null>(null);
|
|
|
|
const { styles, attributes } = usePopper(
|
|
virtualElement.current,
|
|
popperElement,
|
|
{
|
|
placement: 'top-start',
|
|
strategy: 'fixed',
|
|
...popperOptions,
|
|
}
|
|
);
|
|
|
|
// In Electron v23+, new elements added to the DOM may not trigger a recalculation of
|
|
// draggable regions, so if a ContextMenu is shown on top of a draggable region, its
|
|
// buttons may be unclickable. We add a class so that we can disable those draggable
|
|
// regions while the context menu is shown. It has the added benefit of ensuring that
|
|
// click events outside of the context menu onto an otherwise draggable region are
|
|
// propagated and trigger the menu to close.
|
|
useEffect(() => {
|
|
document.body.classList.toggle('context-menu-open', isMenuShowing);
|
|
}, [isMenuShowing]);
|
|
|
|
useEffect(() => {
|
|
// Remove it on unmount in case the component is unmounted when the menu is open
|
|
return () => document.body.classList.remove('context-menu-open');
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (onMenuShowingChanged) {
|
|
onMenuShowingChanged(isMenuShowing);
|
|
}
|
|
}, [isMenuShowing, onMenuShowingChanged]);
|
|
|
|
useEffect(() => {
|
|
if (!isMenuShowing) {
|
|
return noop;
|
|
}
|
|
|
|
return handleOutsideClick(
|
|
() => {
|
|
setIsMenuShowing(false);
|
|
closeCurrentOpenContextMenu = undefined;
|
|
return true;
|
|
},
|
|
{
|
|
containerElements: [referenceElement, popperElement],
|
|
name: 'ContextMenu',
|
|
}
|
|
);
|
|
}, [isMenuShowing, referenceElement, popperElement]);
|
|
|
|
const [portalNode, setPortalNode] = React.useState<HTMLElement | null>(null);
|
|
useEffect(() => {
|
|
if (!portalToRoot || !isMenuShowing) {
|
|
return noop;
|
|
}
|
|
|
|
const div = document.createElement('div');
|
|
document.body.appendChild(div);
|
|
setPortalNode(div);
|
|
|
|
return () => {
|
|
document.body.removeChild(div);
|
|
};
|
|
}, [portalToRoot, isMenuShowing]);
|
|
|
|
const handleKeyDown = (ev: KeyboardEvent) => {
|
|
if ((ev.key === 'Enter' || ev.key === 'Space') && !isMenuShowing) {
|
|
closeCurrentOpenContextMenu?.();
|
|
closeCurrentOpenContextMenu = () => setIsMenuShowing(false);
|
|
if (referenceElement) {
|
|
const box = referenceElement.getBoundingClientRect();
|
|
virtualElement.current = generateVirtualElement(box.x, box.y);
|
|
}
|
|
setIsMenuShowing(true);
|
|
setFocusedIndex(0);
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
}
|
|
|
|
if (!isMenuShowing) {
|
|
return;
|
|
}
|
|
|
|
if (ev.key === 'ArrowDown' || ev.key === 'Tab') {
|
|
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);
|
|
closeCurrentOpenContextMenu = undefined;
|
|
ev.stopPropagation();
|
|
ev.preventDefault();
|
|
}
|
|
|
|
if (ev.key === 'Escape') {
|
|
setIsMenuShowing(false);
|
|
closeCurrentOpenContextMenu = undefined;
|
|
ev.stopPropagation();
|
|
ev.preventDefault();
|
|
}
|
|
};
|
|
|
|
const handleClick = (ev: React.MouseEvent) => {
|
|
closeCurrentOpenContextMenu?.();
|
|
closeCurrentOpenContextMenu = () => setIsMenuShowing(false);
|
|
virtualElement.current = generateVirtualElement(ev.clientX, ev.clientY);
|
|
setIsMenuShowing(true);
|
|
ev.stopPropagation();
|
|
ev.preventDefault();
|
|
};
|
|
|
|
const getClassName = getClassNamesFor('ContextMenu', moduleClassName);
|
|
|
|
const optionElements = new Array<JSX.Element>();
|
|
const isAnyOptionSelected = typeof value !== 'undefined';
|
|
|
|
for (const [index, option] of menuOptions.entries()) {
|
|
const previous = menuOptions[index - 1];
|
|
|
|
const needsDivider = previous && previous.group !== option.group;
|
|
|
|
if (needsDivider) {
|
|
optionElements.push(
|
|
<div
|
|
className={getClassName('__divider')}
|
|
key={`${option.label}-divider`}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// eslint-disable-next-line no-loop-func
|
|
const onElementClick = (ev: React.MouseEvent): void => {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
|
|
option.onClick(option.value);
|
|
setIsMenuShowing(false);
|
|
|
|
closeCurrentOpenContextMenu = undefined;
|
|
};
|
|
|
|
const isOptionSelected = isAnyOptionSelected && value === option.value;
|
|
optionElements.push(
|
|
<button
|
|
aria-label={option.label}
|
|
className={classNames(
|
|
getClassName('__option'),
|
|
focusedIndex === index ? getClassName('__option--focused') : undefined
|
|
)}
|
|
key={option.label}
|
|
type="button"
|
|
onClick={onElementClick}
|
|
>
|
|
<div
|
|
className={classNames(
|
|
getClassName('__option--container'),
|
|
isAnyOptionSelected
|
|
? getClassName('__option--container--with-selection')
|
|
: undefined,
|
|
isOptionSelected
|
|
? getClassName('__option--container--selected')
|
|
: undefined
|
|
)}
|
|
>
|
|
{option.icon && (
|
|
<div
|
|
className={classNames(
|
|
getClassName('__option--icon'),
|
|
option.icon
|
|
)}
|
|
/>
|
|
)}
|
|
<div>
|
|
<div className={getClassName('__option--title')}>
|
|
{option.label}
|
|
</div>
|
|
{option.description && (
|
|
<div className={getClassName('__option--description')}>
|
|
{option.description}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
const menuNode = isMenuShowing ? (
|
|
<div className={theme ? themeClassName(theme) : undefined}>
|
|
<div
|
|
className={classNames(
|
|
getClassName('__popper'),
|
|
menuOptions.length === 1
|
|
? getClassName('__popper--single-item')
|
|
: undefined
|
|
)}
|
|
ref={setPopperElement}
|
|
style={styles.popper}
|
|
{...attributes.popper}
|
|
>
|
|
{title && <div className={getClassName('__title')}>{title}</div>}
|
|
{optionElements}
|
|
</div>
|
|
</div>
|
|
) : undefined;
|
|
|
|
let buttonNode: JSX.Element;
|
|
|
|
if (typeof children === 'function') {
|
|
buttonNode = (
|
|
<>
|
|
{(children as (props: RenderButtonProps) => JSX.Element)({
|
|
openMenu: onClick || handleClick,
|
|
onKeyDown: handleKeyDown,
|
|
isMenuShowing,
|
|
ref: setReferenceElement,
|
|
menuNode,
|
|
})}
|
|
{portalNode ? createPortal(menuNode, portalNode) : menuNode}
|
|
</>
|
|
);
|
|
} else {
|
|
buttonNode = (
|
|
<div
|
|
className={classNames(
|
|
getClassName('__container'),
|
|
|
|
theme ? themeClassName(theme) : undefined
|
|
)}
|
|
>
|
|
<button
|
|
aria-label={ariaLabel || i18n('icu:ContextMenu--button')}
|
|
className={classNames(
|
|
getClassName('__button'),
|
|
isMenuShowing ? getClassName('__button--active') : undefined
|
|
)}
|
|
onClick={onClick || handleClick}
|
|
onContextMenu={handleClick}
|
|
onKeyDown={handleKeyDown}
|
|
ref={setReferenceElement}
|
|
type="button"
|
|
>
|
|
{children}
|
|
</button>
|
|
{portalNode ? createPortal(menuNode, portalNode) : menuNode}
|
|
</div>
|
|
);
|
|
}
|
|
return buttonNode;
|
|
}
|