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.

Popover.web.tsx 16KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527
  1. import React, { Component, ReactNode } from 'react';
  2. import { FocusOn } from 'react-focus-on';
  3. import { connect } from 'react-redux';
  4. import { IReduxState } from '../../../app/types';
  5. import DialogPortal from '../../../toolbox/components/web/DialogPortal';
  6. import Drawer from '../../../toolbox/components/web/Drawer';
  7. import JitsiPortal from '../../../toolbox/components/web/JitsiPortal';
  8. import { isElementInTheViewport } from '../../ui/functions.web';
  9. import { getContextMenuStyle } from '../functions.web';
  10. /**
  11. * The type of the React {@code Component} props of {@link Popover}.
  12. */
  13. interface IProps {
  14. /**
  15. * Whether the child element can be clicked on.
  16. */
  17. allowClick?: boolean;
  18. /**
  19. * A child React Element to use as the trigger for showing the dialog.
  20. */
  21. children: ReactNode;
  22. /**
  23. * Additional CSS classnames to apply to the root of the {@code Popover}
  24. * component.
  25. */
  26. className?: string;
  27. /**
  28. * The ReactElement to display within the dialog.
  29. */
  30. content: ReactNode;
  31. /**
  32. * Whether displaying of the popover should be prevented.
  33. */
  34. disablePopover?: boolean;
  35. /**
  36. * Whether we can reach the popover element via keyboard or not when trigger is 'hover' (true by default).
  37. *
  38. * Only works when trigger is set to 'hover'.
  39. *
  40. * There are some rare cases where we want to set this to false,
  41. * when the popover content is not necessary for screen reader users, because accessible elsewhere.
  42. */
  43. focusable?: boolean;
  44. /**
  45. * The id of the dom element acting as the Popover label (matches aria-labelledby).
  46. */
  47. headingId?: string;
  48. /**
  49. * String acting as the Popover label (matches aria-label).
  50. *
  51. * If headingId is set, this will not be used.
  52. */
  53. headingLabel?: string;
  54. /**
  55. * An id attribute to apply to the root of the {@code Popover}
  56. * component.
  57. */
  58. id?: string;
  59. /**
  60. * Callback to invoke when the popover has closed.
  61. */
  62. onPopoverClose: Function;
  63. /**
  64. * Callback to invoke when the popover has opened.
  65. */
  66. onPopoverOpen?: Function;
  67. /**
  68. * Whether to display the Popover as a drawer.
  69. */
  70. overflowDrawer?: boolean;
  71. /**
  72. * Where should the popover content be placed.
  73. */
  74. position: string;
  75. /**
  76. * Whether the trigger for open/ close should be click or hover.
  77. */
  78. trigger?: 'hover' | 'click';
  79. /**
  80. * Whether the popover is visible or not.
  81. */
  82. visible: boolean;
  83. }
  84. /**
  85. * The type of the React {@code Component} state of {@link Popover}.
  86. */
  87. interface IState {
  88. /**
  89. * The style to apply to the context menu in order to position it correctly.
  90. */
  91. contextMenuStyle?: {
  92. bottom?: string;
  93. left?: string;
  94. position: string;
  95. top?: string;
  96. } | null;
  97. /**
  98. * Whether the popover should be focus locked or not.
  99. *
  100. * This is enabled if we notice the popover is interactive
  101. * (trigger is click or focusable is true).
  102. */
  103. enableFocusLock: boolean;
  104. }
  105. /**
  106. * Implements a React {@code Component} for showing an {@code Popover} on
  107. * mouseenter of the trigger and contents, and hiding the dialog on mouseleave.
  108. *
  109. * @augments Component
  110. */
  111. class Popover extends Component<IProps, IState> {
  112. /**
  113. * Default values for {@code Popover} component's properties.
  114. *
  115. * @static
  116. */
  117. static defaultProps = {
  118. className: '',
  119. focusable: true,
  120. id: '',
  121. trigger: 'hover'
  122. };
  123. /**
  124. * Reference to the dialog container.
  125. */
  126. _containerRef: React.RefObject<HTMLDivElement>;
  127. _contextMenuRef: HTMLElement;
  128. /**
  129. * Initializes a new {@code Popover} instance.
  130. *
  131. * @param {Object} props - The read-only properties with which the new
  132. * instance is to be initialized.
  133. */
  134. constructor(props: IProps) {
  135. super(props);
  136. this.state = {
  137. contextMenuStyle: null,
  138. enableFocusLock: false
  139. };
  140. // Bind event handlers so they are only bound once for every instance.
  141. this._enableFocusLock = this._enableFocusLock.bind(this);
  142. this._onHideDialog = this._onHideDialog.bind(this);
  143. this._onShowDialog = this._onShowDialog.bind(this);
  144. this._onKeyPress = this._onKeyPress.bind(this);
  145. this._containerRef = React.createRef();
  146. this._onEscKey = this._onEscKey.bind(this);
  147. this._onClick = this._onClick.bind(this);
  148. this._onTouchStart = this._onTouchStart.bind(this);
  149. this._setContextMenuRef = this._setContextMenuRef.bind(this);
  150. this._setContextMenuStyle = this._setContextMenuStyle.bind(this);
  151. this._getCustomDialogStyle = this._getCustomDialogStyle.bind(this);
  152. this._onOutsideClick = this._onOutsideClick.bind(this);
  153. }
  154. /**
  155. * Sets up a touch event listener to attach.
  156. *
  157. * @inheritdoc
  158. * @returns {void}
  159. */
  160. componentDidMount() {
  161. window.addEventListener('touchstart', this._onTouchStart);
  162. if (this.props.trigger === 'click') {
  163. // @ts-ignore
  164. window.addEventListener('click', this._onOutsideClick);
  165. }
  166. }
  167. /**
  168. * Removes the listener set up in the {@code componentDidMount} method.
  169. *
  170. * @inheritdoc
  171. * @returns {void}
  172. */
  173. componentWillUnmount() {
  174. window.removeEventListener('touchstart', this._onTouchStart);
  175. if (this.props.trigger === 'click') {
  176. // @ts-ignore
  177. window.removeEventListener('click', this._onOutsideClick);
  178. }
  179. }
  180. /**
  181. * Handles click outside the popover.
  182. *
  183. * @param {MouseEvent} e - The click event.
  184. * @returns {void}
  185. */
  186. _onOutsideClick(e: React.MouseEvent) {
  187. if (!this._containerRef?.current?.contains(e.target as Node) && this.props.visible) {
  188. this._onHideDialog();
  189. }
  190. }
  191. /**
  192. * Implements React's {@link Component#render()}.
  193. *
  194. * @inheritdoc
  195. * @returns {ReactElement}
  196. */
  197. render() {
  198. const { children,
  199. className,
  200. content,
  201. focusable,
  202. headingId,
  203. id,
  204. overflowDrawer,
  205. visible,
  206. trigger
  207. } = this.props;
  208. if (overflowDrawer) {
  209. return (
  210. <div
  211. className = { className }
  212. id = { id }
  213. onClick = { this._onShowDialog }>
  214. { children }
  215. <JitsiPortal>
  216. <Drawer
  217. headingId = { headingId }
  218. isOpen = { visible }
  219. onClose = { this._onHideDialog }>
  220. { content }
  221. </Drawer>
  222. </JitsiPortal>
  223. </div>
  224. );
  225. }
  226. return (
  227. <div
  228. className = { className }
  229. id = { id }
  230. onClick = { this._onClick }
  231. onKeyPress = { this._onKeyPress }
  232. { ...(trigger === 'hover' ? {
  233. onMouseEnter: this._onShowDialog,
  234. onMouseLeave: this._onHideDialog
  235. } : {}) }
  236. { ...(trigger === 'hover' && focusable && {
  237. role: 'button',
  238. tabIndex: 0
  239. }) }
  240. ref = { this._containerRef }>
  241. { visible && (
  242. <DialogPortal
  243. getRef = { this._setContextMenuRef }
  244. onVisible = { this._isInteractive() ? this._enableFocusLock : undefined }
  245. setSize = { this._setContextMenuStyle }
  246. style = { this.state.contextMenuStyle }
  247. targetSelector = '.popover-content'>
  248. <FocusOn
  249. // Use the `enabled` prop instead of conditionally rendering ReactFocusOn
  250. // to prevent UI stutter on dialog appearance. It seems the focus guards generated annoy
  251. // our DialogPortal positioning calculations.
  252. enabled = { Boolean(this._contextMenuRef) && this.state.enableFocusLock }
  253. returnFocus = {
  254. // If we return the focus to an element outside the viewport the page will scroll to
  255. // this element which in our case is undesirable and the element is outside of the
  256. // viewport on purpose (to be hidden). For example if we return the focus to the
  257. // toolbox when it is hidden the whole page will move up in order to show the
  258. // toolbox. This is usually followed up with displaying the toolbox (because now it
  259. // is on focus) but because of the animation the whole scenario looks like jumping
  260. // large video.
  261. isElementInTheViewport
  262. }
  263. shards = { this._contextMenuRef && [ this._contextMenuRef ] }>
  264. {this._renderContent()}
  265. </FocusOn>
  266. </DialogPortal>
  267. )}
  268. { children }
  269. </div>
  270. );
  271. }
  272. /**
  273. * Sets the context menu dialog style for positioning it on screen.
  274. *
  275. * @param {DOMRectReadOnly} size -The size info of the current context menu.
  276. *
  277. * @returns {void}
  278. */
  279. _setContextMenuStyle(size: DOMRectReadOnly) {
  280. const style = this._getCustomDialogStyle(size);
  281. this.setState({ contextMenuStyle: style });
  282. }
  283. /**
  284. * Sets the context menu's ref.
  285. *
  286. * @param {HTMLElement} elem -The html element of the context menu.
  287. *
  288. * @returns {void}
  289. */
  290. _setContextMenuRef(elem: HTMLElement) {
  291. if (!elem || document.body.contains(elem)) {
  292. this._contextMenuRef = elem;
  293. }
  294. }
  295. /**
  296. * Hide dialog on touch outside of the context menu.
  297. *
  298. * @param {TouchEvent} event - The touch event.
  299. * @private
  300. * @returns {void}
  301. */
  302. _onTouchStart(event: TouchEvent) {
  303. if (this.props.visible
  304. && !this.props.overflowDrawer
  305. && this._contextMenuRef
  306. && this._contextMenuRef.contains
  307. && !this._contextMenuRef.contains(event.target as Node)
  308. && !this._containerRef?.current?.contains(event.target as Node)) {
  309. this._onHideDialog();
  310. }
  311. }
  312. /**
  313. * Stops displaying the {@code Popover}.
  314. *
  315. * @private
  316. * @returns {void}
  317. */
  318. _onHideDialog() {
  319. this.setState({
  320. contextMenuStyle: null
  321. });
  322. if (this.props.onPopoverClose) {
  323. this.props.onPopoverClose();
  324. }
  325. }
  326. /**
  327. * Displays the {@code Popover} and calls any registered onPopoverOpen
  328. * callbacks.
  329. *
  330. * @param {Object} event - The mouse event or the keypress event to intercept.
  331. * @private
  332. * @returns {void}
  333. */
  334. _onShowDialog(event?: React.MouseEvent | React.KeyboardEvent) {
  335. event?.stopPropagation();
  336. if (!this.props.disablePopover) {
  337. this.props.onPopoverOpen?.();
  338. }
  339. }
  340. /**
  341. * Prevents switching from tile view to stage view on accidentally clicking
  342. * the popover thumbs.
  343. *
  344. * @param {Object} event - The mouse event or the keypress event to intercept.
  345. * @private
  346. * @returns {void}
  347. */
  348. _onClick(event: React.MouseEvent) {
  349. const { allowClick, trigger, focusable, visible } = this.props;
  350. if (!allowClick) {
  351. event.stopPropagation();
  352. }
  353. if (trigger === 'click' || focusable) {
  354. if (visible) {
  355. this._onHideDialog();
  356. } else {
  357. this._onShowDialog();
  358. }
  359. }
  360. }
  361. /**
  362. * KeyPress handler for accessibility.
  363. *
  364. * @param {Object} e - The key event to handle.
  365. *
  366. * @returns {void}
  367. */
  368. _onKeyPress(e: React.KeyboardEvent) {
  369. // first check that the element we pressed is the actual popover toggle or any of its descendant,
  370. // otherwise pressing space or enter in any child element of the popover _dialog_ will trigger this.
  371. if (e.currentTarget.contains(e.target as Node) && (e.key === ' ' || e.key === 'Enter')) {
  372. e.preventDefault();
  373. if (this.props.visible) {
  374. this._onHideDialog();
  375. } else {
  376. this._onShowDialog(e);
  377. }
  378. }
  379. }
  380. /**
  381. * KeyPress handler for accessibility.
  382. *
  383. * @param {Object} e - The key event to handle.
  384. *
  385. * @returns {void}
  386. */
  387. _onEscKey(e: React.KeyboardEvent) {
  388. if (e.key === 'Escape') {
  389. e.preventDefault();
  390. e.stopPropagation();
  391. if (this.props.visible) {
  392. this._onHideDialog();
  393. }
  394. }
  395. }
  396. /**
  397. * Gets style for positioning the context menu on screen in regards to the trigger's
  398. * position.
  399. *
  400. * @param {DOMRectReadOnly} size -The current context menu's size info.
  401. *
  402. * @returns {Object} - The new style of the context menu.
  403. */
  404. _getCustomDialogStyle(size: DOMRectReadOnly) {
  405. if (this._containerRef?.current) {
  406. const bounds = this._containerRef.current.getBoundingClientRect();
  407. return getContextMenuStyle(bounds, size, this.props.position);
  408. }
  409. }
  410. /**
  411. * Renders the React Element to be displayed in the {@code Popover}.
  412. * Also adds padding to support moving the mouse from the trigger to the
  413. * dialog to prevent mouseleave events.
  414. *
  415. * @private
  416. * @returns {ReactElement}
  417. */
  418. _renderContent() {
  419. const { content, position, trigger, headingId, headingLabel } = this.props;
  420. return (
  421. <div className = { `popover ${trigger}` }>
  422. <div
  423. className = { `popover-content ${position.split('-')[0]}` }
  424. data-autofocus = { this.state.enableFocusLock }
  425. onKeyDown = { this._onEscKey }
  426. { ...(this.state.enableFocusLock && {
  427. 'aria-modal': true,
  428. 'aria-label': !headingId && headingLabel ? headingLabel : undefined,
  429. 'aria-labelledby': headingId,
  430. role: 'dialog',
  431. tabIndex: -1
  432. }) }>
  433. { content }
  434. </div>
  435. </div>
  436. );
  437. }
  438. /**
  439. * Returns whether the popover is considered interactive or not.
  440. *
  441. * Interactive means the popover content is certainly composed of buttons, links…
  442. * Non-interactive popovers are mostly tooltips.
  443. *
  444. * @private
  445. * @returns {boolean}
  446. */
  447. _isInteractive() {
  448. return this.props.trigger === 'click' || Boolean(this.props.focusable);
  449. }
  450. /**
  451. * Enables the focus lock in the popover dialog.
  452. *
  453. * @private
  454. * @returns {void}
  455. */
  456. _enableFocusLock() {
  457. this.setState({ enableFocusLock: true });
  458. }
  459. }
  460. /**
  461. * Maps (parts of) the Redux state to the associated {@code Popover}'s props.
  462. *
  463. * @param {Object} state - The Redux state.
  464. * @param {Object} ownProps - The own props of the component.
  465. * @private
  466. * @returns {IProps}
  467. */
  468. function _mapStateToProps(state: IReduxState) {
  469. return {
  470. overflowDrawer: state['features/toolbox'].overflowDrawer
  471. };
  472. }
  473. export default connect(_mapStateToProps)(Popover);