Skip to content

Convert Overlay to a function component, shares logic with Modal #2309

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Nov 29, 2023
45 changes: 45 additions & 0 deletions client/common/useModalClose.js
Original file line number Diff line number Diff line change
@@ -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<HTMLElement | null>} [passedRef]
* @return {React.MutableRefObject<HTMLElement | null>}
*/
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;
}
34 changes: 6 additions & 28 deletions client/components/Nav/NavBar.jsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,18 @@
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 }) {
const [dropdownOpen, setDropdownOpen] = useState('none');

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) {
Expand Down
136 changes: 58 additions & 78 deletions client/modules/App/components/Overlay.jsx
Original file line number Diff line number Diff line change
@@ -1,102 +1,83 @@
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 (
<div
className={`overlay ${isFixedHeight ? 'overlay--is-fixed-height' : ''}`}
>
<div className="overlay__content">
<section
role="main"
aria-label={ariaLabel}
ref={(node) => {
this.node = node;
}}
className="overlay__body"
>
<header className="overlay__header">
<h2 className="overlay__title">{title}</h2>
<div className="overlay__actions">
{actions}
<button
className="overlay__close-button"
onClick={this.close}
aria-label={this.props.t('Overlay.AriaLabel', { title })}
>
<ExitIcon focusable="false" aria-hidden="true" />
</button>
</div>
</header>
{children}
<DocumentKeyDown handlers={{ escape: () => this.close() }} />
</section>
</div>
return (
<div
className={`overlay ${isFixedHeight ? 'overlay--is-fixed-height' : ''}`}
>
<div className="overlay__content">
<section
role="main"
aria-label={ariaLabel}
ref={ref}
className="overlay__body"
>
<header className="overlay__header">
<h2 className="overlay__title">{title}</h2>
<div className="overlay__actions">
{actions}
<button
className="overlay__close-button"
onClick={close}
aria-label={t('Overlay.AriaLabel', { title })}
>
<ExitIcon focusable="false" aria-hidden="true" />
</button>
</div>
</header>
{children}
</section>
</div>
);
}
}
</div>
);
};

Overlay.propTypes = {
children: PropTypes.element,
actions: PropTypes.element,
closeOverlay: PropTypes.func,
title: PropTypes.string,
ariaLabel: PropTypes.string,
previousPath: PropTypes.string,
isFixedHeight: PropTypes.bool,
t: PropTypes.func.isRequired
isFixedHeight: PropTypes.bool
};

Overlay.defaultProps = {
Expand All @@ -105,8 +86,7 @@ Overlay.defaultProps = {
title: 'Modal',
closeOverlay: null,
ariaLabel: 'modal',
previousPath: '/',
isFixedHeight: false
};

export default withTranslation()(Overlay);
export default Overlay;
2 changes: 1 addition & 1 deletion client/modules/IDE/components/IDEKeyHandlers.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
24 changes: 3 additions & 21 deletions client/modules/IDE/components/Modal.jsx
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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 (
<section className="modal" ref={modalRef}>
Expand Down
27 changes: 5 additions & 22 deletions client/modules/User/components/CollectionShareButton.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="collection-share" ref={node}>
<div className="collection-share" ref={ref}>
<Button
onClick={() => setShowURL(!showURL)}
iconAfter={<DropdownArrowIcon />}
Expand Down