123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413 |
- import React, { KeyboardEvent, ReactNode,
- useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
- import { FocusOn } from 'react-focus-on';
- import { useSelector } from 'react-redux';
- import { makeStyles } from 'tss-react/mui';
-
- import Drawer from '../../../../toolbox/components/web/Drawer';
- import JitsiPortal from '../../../../toolbox/components/web/JitsiPortal';
- import { showOverflowDrawer } from '../../../../toolbox/functions.web';
- import participantsPaneTheme from '../../../components/themes/participantsPaneTheme.json';
- import { withPixelLineHeight } from '../../../styles/functions.web';
- import { spacing } from '../../Tokens';
-
-
- /**
- * Get a style property from a style declaration as a float.
- *
- * @param {CSSStyleDeclaration} styles - Style declaration.
- * @param {string} name - Property name.
- * @returns {number} Float value.
- */
- const getFloatStyleProperty = (styles: CSSStyleDeclaration, name: string) =>
- parseFloat(styles.getPropertyValue(name));
-
- /**
- * Gets the outer height of an element, including margins.
- *
- * @param {Element} element - Target element.
- * @returns {number} Computed height.
- */
- const getComputedOuterHeight = (element: HTMLElement) => {
- const computedStyle = getComputedStyle(element);
-
- return element.offsetHeight
- + getFloatStyleProperty(computedStyle, 'margin-top')
- + getFloatStyleProperty(computedStyle, 'margin-bottom');
- };
-
- interface IProps {
-
- /**
- * ARIA attributes.
- */
- [key: `aria-${string}`]: string;
-
- /**
- * Accessibility label for menu container.
- */
- accessibilityLabel?: string;
-
- /**
- * To activate the FocusOn component.
- */
- activateFocusTrap?: boolean;
-
- /**
- * Children of the context menu.
- */
- children: ReactNode;
-
- /**
- * Class name for context menu. Used to overwrite default styles.
- */
- className?: string;
-
- /**
- * The entity for which the context menu is displayed.
- */
- entity?: Object;
-
- /**
- * Whether or not the menu is hidden. Used to overwrite the internal isHidden.
- */
- hidden?: boolean;
-
- /**
- * Optional id.
- */
- id?: string;
-
- /**
- * Whether or not the menu is already in a drawer.
- */
- inDrawer?: boolean;
-
- /**
- * Whether or not drawer should be open.
- */
- isDrawerOpen?: boolean;
-
- /**
- * Target elements against which positioning calculations are made.
- */
- offsetTarget?: HTMLElement | null;
-
- /**
- * Callback for click on an item in the menu.
- */
- onClick?: (e?: React.MouseEvent) => void;
-
- /**
- * Callback for drawer close.
- */
- onDrawerClose?: (e?: React.MouseEvent) => void;
-
- /**
- * Keydown handler.
- */
- onKeyDown?: (e?: React.KeyboardEvent) => void;
-
- /**
- * Callback for the mouse entering the component.
- */
- onMouseEnter?: (e?: React.MouseEvent) => void;
-
- /**
- * Callback for the mouse leaving the component.
- */
- onMouseLeave?: (e?: React.MouseEvent) => void;
-
- /**
- * Container role.
- */
- role?: string;
-
- /**
- * Tab index for the menu.
- */
- tabIndex?: number;
- }
-
- const MAX_HEIGHT = 400;
-
- const useStyles = makeStyles()(theme => {
- return {
- contextMenu: {
- backgroundColor: theme.palette.ui01,
- border: `1px solid ${theme.palette.ui04}`,
- borderRadius: `${Number(theme.shape.borderRadius)}px`,
- boxShadow: '0px 1px 2px rgba(41, 41, 41, 0.25)',
- color: theme.palette.text01,
- ...withPixelLineHeight(theme.typography.bodyShortRegular),
- marginTop: '48px',
- position: 'absolute',
- right: `${participantsPaneTheme.panePadding}px`,
- top: 0,
- zIndex: 2,
- maxHeight: `${MAX_HEIGHT}px`,
- overflowY: 'auto',
- padding: `${theme.spacing(2)} 0`
- },
-
- contextMenuHidden: {
- pointerEvents: 'none',
- visibility: 'hidden'
- },
-
- drawer: {
- paddingTop: '16px',
-
- '& > div': {
- ...withPixelLineHeight(theme.typography.bodyShortRegularLarge),
-
- '& svg': {
- fill: theme.palette.icon01
- }
- }
- }
- };
- });
-
- const ContextMenu = ({
- accessibilityLabel,
- activateFocusTrap = false,
- children,
- className,
- entity,
- hidden,
- id,
- inDrawer,
- isDrawerOpen,
- offsetTarget,
- onClick,
- onKeyDown,
- onDrawerClose,
- onMouseEnter,
- onMouseLeave,
- role,
- tabIndex,
- ...aria
- }: IProps) => {
- const [ isHidden, setIsHidden ] = useState(true);
- const containerRef = useRef<HTMLDivElement | null>(null);
- const { classes: styles, cx } = useStyles();
- const _overflowDrawer = useSelector(showOverflowDrawer);
-
- useLayoutEffect(() => {
- if (_overflowDrawer) {
- return;
- }
- if (entity && offsetTarget
- && containerRef.current
- && offsetTarget?.offsetParent
- && offsetTarget.offsetParent instanceof HTMLElement
- ) {
- const { current: container } = containerRef;
-
- // make sure the max height is not set
- container.style.maxHeight = 'none';
- const { offsetTop, offsetParent: { offsetHeight, scrollTop } } = offsetTarget;
- let outerHeight = getComputedOuterHeight(container);
- let height = Math.min(MAX_HEIGHT, outerHeight);
-
- if (offsetTop + height > offsetHeight + scrollTop && height > offsetTop) {
- // top offset and + padding + border
- container.style.maxHeight = `${offsetTop - ((spacing[2] * 2) + 2)}px`;
- }
-
- // get the height after style changes
- outerHeight = getComputedOuterHeight(container);
- height = Math.min(MAX_HEIGHT, outerHeight);
-
- container.style.top = offsetTop + height > offsetHeight + scrollTop
- ? `${offsetTop - outerHeight}`
- : `${offsetTop}`;
-
- setIsHidden(false);
- } else {
- hidden === undefined && setIsHidden(true);
- }
- }, [ entity, offsetTarget, _overflowDrawer ]);
-
- useEffect(() => {
- if (hidden !== undefined) {
- setIsHidden(hidden);
- }
- }, [ hidden ]);
-
- const handleKeyDown = useCallback((event: KeyboardEvent) => {
- const { current: listRef } = containerRef;
- const currentFocusElement = document.activeElement;
-
- const moveFocus = (
- list: Element | null,
- currentFocus: Element | null,
- traversalFunction: (
- list: Element | null,
- currentFocus: Element | null
- ) => Element | null
- ) => {
- let wrappedOnce = false;
- let nextFocus = traversalFunction(list, currentFocus);
-
- /* eslint-disable no-unmodified-loop-condition */
- while (list && nextFocus) {
- // Prevent infinite loop.
- if (nextFocus === list.firstChild) {
- if (wrappedOnce) {
- return;
- }
- wrappedOnce = true;
- }
-
- // Same logic as useAutocomplete.js
- const nextFocusDisabled
- /* eslint-disable no-extra-parens */
- = (nextFocus as HTMLInputElement).disabled
- || nextFocus.getAttribute('aria-disabled') === 'true';
-
- if (!nextFocus.hasAttribute('tabindex') || nextFocusDisabled) {
- // Move to the next element.
- nextFocus = traversalFunction(list, nextFocus);
- } else {
- /* eslint-disable no-extra-parens */
- (nextFocus as HTMLElement).focus();
-
- return;
- }
- }
- };
-
- const previousItem = (
- list: Element | null,
- item: Element | null
- ): Element | null => {
- /**
- * To find the last child of the list.
- *
- * @param {Element | null} element - Element.
- * @returns {Element | null}
- */
- function lastChild(element: Element | null): Element | null {
- while (element?.lastElementChild) {
- /* eslint-disable no-param-reassign */
- element = element.lastElementChild;
- }
-
- return element;
- }
-
- if (!list) {
- return null;
- }
- if (list === item) {
- return list.lastElementChild;
- }
- if (item?.previousElementSibling) {
- return lastChild(item.previousElementSibling);
- }
- if (item && item?.parentElement !== list) {
- return item.parentElement;
- }
-
- return lastChild(list.lastElementChild);
- };
-
- const nextItem = (
- list: Element | null,
- item: Element | null
- ): Element | null => {
- if (!list) {
- return null;
- }
-
- if (list === item) {
- return list.firstElementChild;
- }
- if (item?.firstElementChild) {
- return item.firstElementChild;
- }
- if (item?.nextElementSibling) {
- return item.nextElementSibling;
- }
- while (item && item.parentElement !== list) {
- /* eslint-disable no-param-reassign */
- item = item.parentElement;
- if (item?.nextElementSibling) {
- return item.nextElementSibling;
- }
- }
-
- return list?.firstElementChild;
- };
-
- if (event.key === 'Escape') {
- // Close the menu
- setIsHidden(true);
-
- } else if (event.key === 'ArrowUp') {
- // Move focus to the previous menu item
- event.preventDefault();
- moveFocus(listRef, currentFocusElement, previousItem);
-
- } else if (event.key === 'ArrowDown') {
- // Move focus to the next menu item
- event.preventDefault();
- moveFocus(listRef, currentFocusElement, nextItem);
- }
- }, [ containerRef ]);
-
- const removeFocus = useCallback(() => {
- onDrawerClose?.();
- }, [ onMouseLeave ]);
-
- if (_overflowDrawer && inDrawer) {
- return (<div
- className = { styles.drawer }
- onClick = { onDrawerClose }>
- {children}
- </div>);
- }
-
- return _overflowDrawer
- ? <JitsiPortal>
- <Drawer
- isOpen = { Boolean(isDrawerOpen && _overflowDrawer) }
- onClose = { onDrawerClose }>
- <div
- className = { styles.drawer }
- onClick = { onDrawerClose }>
- {children}
- </div>
- </Drawer>
- </JitsiPortal>
- : <FocusOn
-
- // Use the `enabled` prop instead of conditionally rendering ReactFocusOn
- // to prevent UI stutter on dialog appearance. It seems the focus guards generated annoy
- // our DialogPortal positioning calculations.
- enabled = { activateFocusTrap && !isHidden }
- onClickOutside = { removeFocus }
- onEscapeKey = { removeFocus }>
- <div
- { ...aria }
- aria-label = { accessibilityLabel }
- className = { cx(styles.contextMenu,
- isHidden && styles.contextMenuHidden,
- className
- ) }
- id = { id }
- onClick = { onClick }
- onKeyDown = { onKeyDown ?? handleKeyDown }
- onMouseEnter = { onMouseEnter }
- onMouseLeave = { onMouseLeave }
- ref = { containerRef }
- role = { role }
- tabIndex = { tabIndex }>
- {children}
- </div>
- </FocusOn >;
- };
-
- export default ContextMenu;
|