You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

ContextMenu.tsx 12KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. import React, { KeyboardEvent, ReactNode,
  2. useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
  3. import { FocusOn } from 'react-focus-on';
  4. import { useSelector } from 'react-redux';
  5. import { makeStyles } from 'tss-react/mui';
  6. import Drawer from '../../../../toolbox/components/web/Drawer';
  7. import JitsiPortal from '../../../../toolbox/components/web/JitsiPortal';
  8. import { showOverflowDrawer } from '../../../../toolbox/functions.web';
  9. import participantsPaneTheme from '../../../components/themes/participantsPaneTheme.json';
  10. import { withPixelLineHeight } from '../../../styles/functions.web';
  11. import { spacing } from '../../Tokens';
  12. /**
  13. * Get a style property from a style declaration as a float.
  14. *
  15. * @param {CSSStyleDeclaration} styles - Style declaration.
  16. * @param {string} name - Property name.
  17. * @returns {number} Float value.
  18. */
  19. const getFloatStyleProperty = (styles: CSSStyleDeclaration, name: string) =>
  20. parseFloat(styles.getPropertyValue(name));
  21. /**
  22. * Gets the outer height of an element, including margins.
  23. *
  24. * @param {Element} element - Target element.
  25. * @returns {number} Computed height.
  26. */
  27. const getComputedOuterHeight = (element: HTMLElement) => {
  28. const computedStyle = getComputedStyle(element);
  29. return element.offsetHeight
  30. + getFloatStyleProperty(computedStyle, 'margin-top')
  31. + getFloatStyleProperty(computedStyle, 'margin-bottom');
  32. };
  33. interface IProps {
  34. /**
  35. * ARIA attributes.
  36. */
  37. [key: `aria-${string}`]: string;
  38. /**
  39. * Accessibility label for menu container.
  40. */
  41. accessibilityLabel?: string;
  42. /**
  43. * To activate the FocusOn component.
  44. */
  45. activateFocusTrap?: boolean;
  46. /**
  47. * Children of the context menu.
  48. */
  49. children: ReactNode;
  50. /**
  51. * Class name for context menu. Used to overwrite default styles.
  52. */
  53. className?: string;
  54. /**
  55. * The entity for which the context menu is displayed.
  56. */
  57. entity?: Object;
  58. /**
  59. * Whether or not the menu is hidden. Used to overwrite the internal isHidden.
  60. */
  61. hidden?: boolean;
  62. /**
  63. * Optional id.
  64. */
  65. id?: string;
  66. /**
  67. * Whether or not the menu is already in a drawer.
  68. */
  69. inDrawer?: boolean;
  70. /**
  71. * Whether or not drawer should be open.
  72. */
  73. isDrawerOpen?: boolean;
  74. /**
  75. * Target elements against which positioning calculations are made.
  76. */
  77. offsetTarget?: HTMLElement | null;
  78. /**
  79. * Callback for click on an item in the menu.
  80. */
  81. onClick?: (e?: React.MouseEvent) => void;
  82. /**
  83. * Callback for drawer close.
  84. */
  85. onDrawerClose?: (e?: React.MouseEvent) => void;
  86. /**
  87. * Keydown handler.
  88. */
  89. onKeyDown?: (e?: React.KeyboardEvent) => void;
  90. /**
  91. * Callback for the mouse entering the component.
  92. */
  93. onMouseEnter?: (e?: React.MouseEvent) => void;
  94. /**
  95. * Callback for the mouse leaving the component.
  96. */
  97. onMouseLeave?: (e?: React.MouseEvent) => void;
  98. /**
  99. * Container role.
  100. */
  101. role?: string;
  102. /**
  103. * Tab index for the menu.
  104. */
  105. tabIndex?: number;
  106. }
  107. const MAX_HEIGHT = 400;
  108. const useStyles = makeStyles()(theme => {
  109. return {
  110. contextMenu: {
  111. backgroundColor: theme.palette.ui01,
  112. border: `1px solid ${theme.palette.ui04}`,
  113. borderRadius: `${Number(theme.shape.borderRadius)}px`,
  114. boxShadow: '0px 1px 2px rgba(41, 41, 41, 0.25)',
  115. color: theme.palette.text01,
  116. ...withPixelLineHeight(theme.typography.bodyShortRegular),
  117. marginTop: '48px',
  118. position: 'absolute',
  119. right: `${participantsPaneTheme.panePadding}px`,
  120. top: 0,
  121. zIndex: 2,
  122. maxHeight: `${MAX_HEIGHT}px`,
  123. overflowY: 'auto',
  124. padding: `${theme.spacing(2)} 0`
  125. },
  126. contextMenuHidden: {
  127. pointerEvents: 'none',
  128. visibility: 'hidden'
  129. },
  130. drawer: {
  131. paddingTop: '16px',
  132. '& > div': {
  133. ...withPixelLineHeight(theme.typography.bodyShortRegularLarge),
  134. '& svg': {
  135. fill: theme.palette.icon01
  136. }
  137. }
  138. }
  139. };
  140. });
  141. const ContextMenu = ({
  142. accessibilityLabel,
  143. activateFocusTrap = false,
  144. children,
  145. className,
  146. entity,
  147. hidden,
  148. id,
  149. inDrawer,
  150. isDrawerOpen,
  151. offsetTarget,
  152. onClick,
  153. onKeyDown,
  154. onDrawerClose,
  155. onMouseEnter,
  156. onMouseLeave,
  157. role,
  158. tabIndex,
  159. ...aria
  160. }: IProps) => {
  161. const [ isHidden, setIsHidden ] = useState(true);
  162. const containerRef = useRef<HTMLDivElement | null>(null);
  163. const { classes: styles, cx } = useStyles();
  164. const _overflowDrawer = useSelector(showOverflowDrawer);
  165. useLayoutEffect(() => {
  166. if (_overflowDrawer) {
  167. return;
  168. }
  169. if (entity && offsetTarget
  170. && containerRef.current
  171. && offsetTarget?.offsetParent
  172. && offsetTarget.offsetParent instanceof HTMLElement
  173. ) {
  174. const { current: container } = containerRef;
  175. // make sure the max height is not set
  176. container.style.maxHeight = 'none';
  177. const { offsetTop, offsetParent: { offsetHeight, scrollTop } } = offsetTarget;
  178. let outerHeight = getComputedOuterHeight(container);
  179. let height = Math.min(MAX_HEIGHT, outerHeight);
  180. if (offsetTop + height > offsetHeight + scrollTop && height > offsetTop) {
  181. // top offset and + padding + border
  182. container.style.maxHeight = `${offsetTop - ((spacing[2] * 2) + 2)}px`;
  183. }
  184. // get the height after style changes
  185. outerHeight = getComputedOuterHeight(container);
  186. height = Math.min(MAX_HEIGHT, outerHeight);
  187. container.style.top = offsetTop + height > offsetHeight + scrollTop
  188. ? `${offsetTop - outerHeight}`
  189. : `${offsetTop}`;
  190. setIsHidden(false);
  191. } else {
  192. hidden === undefined && setIsHidden(true);
  193. }
  194. }, [ entity, offsetTarget, _overflowDrawer ]);
  195. useEffect(() => {
  196. if (hidden !== undefined) {
  197. setIsHidden(hidden);
  198. }
  199. }, [ hidden ]);
  200. const handleKeyDown = useCallback((event: KeyboardEvent) => {
  201. const { current: listRef } = containerRef;
  202. const currentFocusElement = document.activeElement;
  203. const moveFocus = (
  204. list: Element | null,
  205. currentFocus: Element | null,
  206. traversalFunction: (
  207. list: Element | null,
  208. currentFocus: Element | null
  209. ) => Element | null
  210. ) => {
  211. let wrappedOnce = false;
  212. let nextFocus = traversalFunction(list, currentFocus);
  213. /* eslint-disable no-unmodified-loop-condition */
  214. while (list && nextFocus) {
  215. // Prevent infinite loop.
  216. if (nextFocus === list.firstChild) {
  217. if (wrappedOnce) {
  218. return;
  219. }
  220. wrappedOnce = true;
  221. }
  222. // Same logic as useAutocomplete.js
  223. const nextFocusDisabled
  224. /* eslint-disable no-extra-parens */
  225. = (nextFocus as HTMLInputElement).disabled
  226. || nextFocus.getAttribute('aria-disabled') === 'true';
  227. if (!nextFocus.hasAttribute('tabindex') || nextFocusDisabled) {
  228. // Move to the next element.
  229. nextFocus = traversalFunction(list, nextFocus);
  230. } else {
  231. /* eslint-disable no-extra-parens */
  232. (nextFocus as HTMLElement).focus();
  233. return;
  234. }
  235. }
  236. };
  237. const previousItem = (
  238. list: Element | null,
  239. item: Element | null
  240. ): Element | null => {
  241. /**
  242. * To find the last child of the list.
  243. *
  244. * @param {Element | null} element - Element.
  245. * @returns {Element | null}
  246. */
  247. function lastChild(element: Element | null): Element | null {
  248. while (element?.lastElementChild) {
  249. /* eslint-disable no-param-reassign */
  250. element = element.lastElementChild;
  251. }
  252. return element;
  253. }
  254. if (!list) {
  255. return null;
  256. }
  257. if (list === item) {
  258. return list.lastElementChild;
  259. }
  260. if (item?.previousElementSibling) {
  261. return lastChild(item.previousElementSibling);
  262. }
  263. if (item && item?.parentElement !== list) {
  264. return item.parentElement;
  265. }
  266. return lastChild(list.lastElementChild);
  267. };
  268. const nextItem = (
  269. list: Element | null,
  270. item: Element | null
  271. ): Element | null => {
  272. if (!list) {
  273. return null;
  274. }
  275. if (list === item) {
  276. return list.firstElementChild;
  277. }
  278. if (item?.firstElementChild) {
  279. return item.firstElementChild;
  280. }
  281. if (item?.nextElementSibling) {
  282. return item.nextElementSibling;
  283. }
  284. while (item && item.parentElement !== list) {
  285. /* eslint-disable no-param-reassign */
  286. item = item.parentElement;
  287. if (item?.nextElementSibling) {
  288. return item.nextElementSibling;
  289. }
  290. }
  291. return list?.firstElementChild;
  292. };
  293. if (event.key === 'Escape') {
  294. // Close the menu
  295. setIsHidden(true);
  296. } else if (event.key === 'ArrowUp') {
  297. // Move focus to the previous menu item
  298. event.preventDefault();
  299. moveFocus(listRef, currentFocusElement, previousItem);
  300. } else if (event.key === 'ArrowDown') {
  301. // Move focus to the next menu item
  302. event.preventDefault();
  303. moveFocus(listRef, currentFocusElement, nextItem);
  304. }
  305. }, [ containerRef ]);
  306. const removeFocus = useCallback(() => {
  307. onDrawerClose?.();
  308. }, [ onMouseLeave ]);
  309. if (_overflowDrawer && inDrawer) {
  310. return (<div
  311. className = { styles.drawer }
  312. onClick = { onDrawerClose }>
  313. {children}
  314. </div>);
  315. }
  316. return _overflowDrawer
  317. ? <JitsiPortal>
  318. <Drawer
  319. isOpen = { Boolean(isDrawerOpen && _overflowDrawer) }
  320. onClose = { onDrawerClose }>
  321. <div
  322. className = { styles.drawer }
  323. onClick = { onDrawerClose }>
  324. {children}
  325. </div>
  326. </Drawer>
  327. </JitsiPortal>
  328. : <FocusOn
  329. // Use the `enabled` prop instead of conditionally rendering ReactFocusOn
  330. // to prevent UI stutter on dialog appearance. It seems the focus guards generated annoy
  331. // our DialogPortal positioning calculations.
  332. enabled = { activateFocusTrap && !isHidden }
  333. onClickOutside = { removeFocus }
  334. onEscapeKey = { removeFocus }>
  335. <div
  336. { ...aria }
  337. aria-label = { accessibilityLabel }
  338. className = { cx(styles.contextMenu,
  339. isHidden && styles.contextMenuHidden,
  340. className
  341. ) }
  342. id = { id }
  343. onClick = { onClick }
  344. onKeyDown = { onKeyDown ?? handleKeyDown }
  345. onMouseEnter = { onMouseEnter }
  346. onMouseLeave = { onMouseLeave }
  347. ref = { containerRef }
  348. role = { role }
  349. tabIndex = { tabIndex }>
  350. {children}
  351. </div>
  352. </FocusOn >;
  353. };
  354. export default ContextMenu;