diff --git a/client/modules/IDE/hooks/useKeyDownHandlers.js b/client/common/useKeyDownHandlers.js similarity index 100% rename from client/modules/IDE/hooks/useKeyDownHandlers.js rename to client/common/useKeyDownHandlers.js diff --git a/client/common/useModalClose.js b/client/common/useModalClose.js new file mode 100644 index 0000000000..2bab24b5de --- /dev/null +++ b/client/common/useModalClose.js @@ -0,0 +1,45 @@ +import { useEffect, useRef } from 'react'; +import useKeyDownHandlers from './useKeyDownHandlers'; + +/** + * Common logic for Modal, Overlay, etc. + * + * Pass in the `onClose` handler. + * + * Can optionally pass in a ref, in case the `onClose` function needs to use the ref. + * + * Calls the provided `onClose` function on: + * - Press Escape key. + * - Click outside the element. + * + * Returns a ref to attach to the outermost element of the modal. + * + * @param {() => void} onClose + * @param {React.MutableRefObject} [passedRef] + * @return {React.MutableRefObject} + */ +export default function useModalClose(onClose, passedRef) { + const createdRef = useRef(null); + const modalRef = passedRef || createdRef; + + useEffect(() => { + modalRef.current?.focus(); + + function handleClick(e) { + // ignore clicks on the component itself + if (modalRef.current && !modalRef.current.contains(e.target)) { + onClose?.(); + } + } + + document.addEventListener('click', handleClick, false); + + return () => { + document.removeEventListener('click', handleClick, false); + }; + }, [onClose, modalRef]); + + useKeyDownHandlers({ escape: onClose }); + + return modalRef; +} diff --git a/client/components/Nav/NavBar.jsx b/client/components/Nav/NavBar.jsx index 16d92b8689..e57a627c83 100644 --- a/client/components/Nav/NavBar.jsx +++ b/client/components/Nav/NavBar.jsx @@ -1,12 +1,6 @@ import PropTypes from 'prop-types'; -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState -} from 'react'; -import useKeyDownHandlers from '../../modules/IDE/hooks/useKeyDownHandlers'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import useModalClose from '../../common/useModalClose'; import { MenuOpenContext, NavBarContext } from './contexts'; function NavBar({ children, className }) { @@ -14,27 +8,11 @@ function NavBar({ children, className }) { const timerRef = useRef(null); - const nodeRef = useRef(null); + const handleClose = useCallback(() => { + setDropdownOpen('none'); + }, [setDropdownOpen]); - useEffect(() => { - function handleClick(e) { - if (!nodeRef.current) { - return; - } - if (nodeRef.current.contains(e.target)) { - return; - } - setDropdownOpen('none'); - } - document.addEventListener('mousedown', handleClick, false); - return () => { - document.removeEventListener('mousedown', handleClick, false); - }; - }, [nodeRef, setDropdownOpen]); - - useKeyDownHandlers({ - escape: () => setDropdownOpen('none') - }); + const nodeRef = useModalClose(handleClose); const clearHideTimeout = useCallback(() => { if (timerRef.current) { diff --git a/client/modules/App/components/Overlay.jsx b/client/modules/App/components/Overlay.jsx index 6fa1e8ef75..45a6ae9176 100644 --- a/client/modules/App/components/Overlay.jsx +++ b/client/modules/App/components/Overlay.jsx @@ -1,92 +1,75 @@ import PropTypes from 'prop-types'; -import React from 'react'; -import { withTranslation } from 'react-i18next'; +import React, { useCallback, useRef } from 'react'; +import { useSelector } from 'react-redux'; +import { useHistory } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import useModalClose from '../../../common/useModalClose'; -import browserHistory from '../../../browserHistory'; import ExitIcon from '../../../images/exit.svg'; -import { DocumentKeyDown } from '../../IDE/hooks/useKeyDownHandlers'; -class Overlay extends React.Component { - constructor(props) { - super(props); - this.close = this.close.bind(this); - this.handleClick = this.handleClick.bind(this); - this.handleClickOutside = this.handleClickOutside.bind(this); - } +const Overlay = ({ + actions, + ariaLabel, + children, + closeOverlay, + isFixedHeight, + title +}) => { + const { t } = useTranslation(); - componentWillMount() { - document.addEventListener('mousedown', this.handleClick, false); - } + const previousPath = useSelector((state) => state.ide.previousPath); - componentDidMount() { - this.node.focus(); - } + const ref = useRef(null); - componentWillUnmount() { - document.removeEventListener('mousedown', this.handleClick, false); - } + const browserHistory = useHistory(); - handleClick(e) { - if (this.node.contains(e.target)) { - return; - } - - this.handleClickOutside(e); - } - - handleClickOutside() { - this.close(); - } - - close() { + const close = useCallback(() => { + const node = ref.current; + if (!node) return; // Only close if it is the last (and therefore the topmost overlay) const overlays = document.getElementsByClassName('overlay'); - if (this.node.parentElement.parentElement !== overlays[overlays.length - 1]) + if (node.parentElement.parentElement !== overlays[overlays.length - 1]) return; - if (!this.props.closeOverlay) { - browserHistory.push(this.props.previousPath); + if (!closeOverlay) { + browserHistory.push(previousPath); } else { - this.props.closeOverlay(); + closeOverlay(); } - } + }, [previousPath, closeOverlay, ref]); + + useModalClose(close, ref); - render() { - const { ariaLabel, title, children, actions, isFixedHeight } = this.props; - return ( -
-
-
{ - this.node = node; - }} - className="overlay__body" - > -
-

{title}

-
- {actions} - -
-
- {children} - this.close() }} /> -
-
+ return ( +
+
+
+
+

{title}

+
+ {actions} + +
+
+ {children} +
- ); - } -} +
+ ); +}; Overlay.propTypes = { children: PropTypes.element, @@ -94,9 +77,7 @@ Overlay.propTypes = { closeOverlay: PropTypes.func, title: PropTypes.string, ariaLabel: PropTypes.string, - previousPath: PropTypes.string, - isFixedHeight: PropTypes.bool, - t: PropTypes.func.isRequired + isFixedHeight: PropTypes.bool }; Overlay.defaultProps = { @@ -105,8 +86,7 @@ Overlay.defaultProps = { title: 'Modal', closeOverlay: null, ariaLabel: 'modal', - previousPath: '/', isFixedHeight: false }; -export default withTranslation()(Overlay); +export default Overlay; diff --git a/client/modules/IDE/components/IDEKeyHandlers.jsx b/client/modules/IDE/components/IDEKeyHandlers.jsx index 6578753f88..c7a285e627 100644 --- a/client/modules/IDE/components/IDEKeyHandlers.jsx +++ b/client/modules/IDE/components/IDEKeyHandlers.jsx @@ -12,7 +12,7 @@ import { } from '../actions/ide'; import { setAllAccessibleOutput } from '../actions/preferences'; import { cloneProject, saveProject } from '../actions/project'; -import useKeyDownHandlers from '../hooks/useKeyDownHandlers'; +import useKeyDownHandlers from '../../../common/useKeyDownHandlers'; import { getAuthenticated, getIsUserOwner, diff --git a/client/modules/IDE/components/Modal.jsx b/client/modules/IDE/components/Modal.jsx index 876168e393..831527b266 100644 --- a/client/modules/IDE/components/Modal.jsx +++ b/client/modules/IDE/components/Modal.jsx @@ -1,8 +1,8 @@ import classNames from 'classnames'; import PropTypes from 'prop-types'; -import React, { useEffect, useRef } from 'react'; +import React from 'react'; +import useModalClose from '../../../common/useModalClose'; import ExitIcon from '../../../images/exit.svg'; -import useKeyDownHandlers from '../hooks/useKeyDownHandlers'; // Common logic from NewFolderModal, NewFileModal, UploadFileModal @@ -13,25 +13,7 @@ const Modal = ({ contentClassName, children }) => { - const modalRef = useRef(null); - - const handleOutsideClick = (e) => { - // ignore clicks on the component itself - if (modalRef.current?.contains?.(e.target)) return; - - onClose(); - }; - - useEffect(() => { - modalRef.current?.focus(); - document.addEventListener('click', handleOutsideClick, false); - - return () => { - document.removeEventListener('click', handleOutsideClick, false); - }; - }, []); - - useKeyDownHandlers({ escape: onClose }); + const modalRef = useModalClose(onClose); return (
diff --git a/client/modules/User/components/CollectionShareButton.jsx b/client/modules/User/components/CollectionShareButton.jsx index c4e0bba915..c4fd06bcb6 100644 --- a/client/modules/User/components/CollectionShareButton.jsx +++ b/client/modules/User/components/CollectionShareButton.jsx @@ -1,37 +1,20 @@ import PropTypes from 'prop-types'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; import Button from '../../../common/Button'; import { DropdownArrowIcon } from '../../../common/icons'; +import useModalClose from '../../../common/useModalClose'; import CopyableInput from '../../IDE/components/CopyableInput'; const ShareURL = ({ value }) => { const [showURL, setShowURL] = useState(false); - const node = useRef(); const { t } = useTranslation(); - - const handleClickOutside = (e) => { - if (node.current?.contains(e.target)) { - return; - } - setShowURL(false); - }; - - useEffect(() => { - if (showURL) { - document.addEventListener('mousedown', handleClickOutside); - } else { - document.removeEventListener('mousedown', handleClickOutside); - } - - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [showURL]); + const close = useCallback(() => setShowURL(false), [setShowURL]); + const ref = useModalClose(close); return ( -
+