Skip to content
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

Prevent page from scrolling when Modal is open and implement focus trap (#397) #432

Merged
merged 1 commit into from
May 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 58 additions & 97 deletions src/lib/components/Modal/Modal.jsx
hubacekj marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import PropTypes from 'prop-types';
import React, {
useEffect,
useRef,
} from 'react';
import React, { useRef } from 'react';
import { createPortal } from 'react-dom';
import { withGlobalProps } from '../../provider';
import { transferProps } from '../_helpers/transferProps';
import { classNames } from '../../utils/classNames';
import { getPositionClassName } from './_helpers/getPositionClassName';
import { getSizeClassName } from './_helpers/getSizeClassName';
import { useModalFocus } from './_hooks/useModalFocus';
import { useModalScrollPrevention } from './_hooks/useModalScrollPrevention';
import styles from './Modal.scss';

const preRender = (
Expand All @@ -16,116 +17,56 @@ const preRender = (
position,
restProps,
size,
) => {
const sizeClass = (modalSize) => {
if (modalSize === 'small') {
return styles.isRootSizeSmall;
}

if (modalSize === 'medium') {
return styles.isRootSizeMedium;
}

if (modalSize === 'large') {
return styles.isRootSizeLarge;
}

if (modalSize === 'fullscreen') {
return styles.isRootSizeFullscreen;
}

return styles.isRootSizeAuto;
};

const positionClass = (modalPosition) => {
if (modalPosition === 'top') {
return styles.isRootPositionTop;
}

return styles.isRootPositionCenter;
};

return (
) => (
<div
className={styles.backdrop}
onClick={(e) => {
e.preventDefault();
if (closeButtonRef?.current != null) {
closeButtonRef.current.click();
}
}}
role="presentation"
>
<div
className={styles.backdrop}
onClick={() => {
if (closeButtonRef?.current != null) {
closeButtonRef.current.click();
}
{...transferProps(restProps)}
className={classNames(
styles.root,
getSizeClassName(size, styles),
getPositionClassName(position, styles),
)}
onClick={(e) => {
e.stopPropagation();
}}
hubacekj marked this conversation as resolved.
Show resolved Hide resolved
role="presentation"
ref={childrenWrapperRef}
>
<div
{...transferProps(restProps)}
className={classNames(
styles.root,
sizeClass(size),
positionClass(position),
)}
onClick={(e) => {
e.stopPropagation();
}}
role="presentation"
ref={childrenWrapperRef}
>
{children}
</div>
{children}
</div>
);
};
</div>
);

export const Modal = ({
autoFocus,
children,
closeButtonRef,
portalId,
position,
preventScrollUnderneath,
primaryButtonRef,
size,
...restProps
}) => {
const childrenWrapperRef = useRef();

const keyPressHandler = (e) => {
if (e.key === 'Escape' && closeButtonRef?.current != null) {
closeButtonRef.current.click();
}

if (e.key === 'Enter' && e.target.nodeName !== 'BUTTON' && primaryButtonRef?.current != null) {
primaryButtonRef.current.click();
}
};

useEffect(() => {
window.document.addEventListener('keydown', keyPressHandler, false);
const removeKeyPressHandler = () => {
window.document.removeEventListener('keydown', keyPressHandler, false);
};

// If `autoFocus` is set to `true`, following code finds first form field element
// (input, textarea or select) or primary button and auto focuses it. This is necessary
// to have focus on one of those elements to be able to submit form by pressing Enter key.
if (autoFocus) {
if (childrenWrapperRef?.current != null) {
const childrenWrapperElement = childrenWrapperRef.current;
const childrenElements = childrenWrapperElement.querySelectorAll('*');
const formFieldEl = Array.from(childrenElements).find(
(element) => ['INPUT', 'TEXTAREA', 'SELECT'].includes(element.nodeName) && !element.disabled,
);

if (formFieldEl) {
formFieldEl.focus();
return removeKeyPressHandler;
}
}

if (primaryButtonRef?.current != null) {
primaryButtonRef.current.focus();
}
}
useModalFocus(
autoFocus,
childrenWrapperRef,
primaryButtonRef,
closeButtonRef,
);

return removeKeyPressHandler;
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useModalScrollPrevention(preventScrollUnderneath);

if (portalId === null) {
return preRender(
Expand Down Expand Up @@ -157,14 +98,16 @@ Modal.defaultProps = {
closeButtonRef: null,
portalId: null,
position: 'center',
preventScrollUnderneath: 'default',
primaryButtonRef: null,
size: 'medium',
};

Modal.propTypes = {
/**
* If `true`, focus the first input element in the modal or primary button (referenced by the `primaryButtonRef` prop)
* when the modal is opened.
* If `true`, focus the first input element in the `Modal`, or primary button (referenced by the `primaryButtonRef`
* prop), or other focusable element when the `Modal` is opened. If there are none or `autoFocus` is set to `false`,
* focus the Modal itself.
*/
autoFocus: PropTypes.bool,
/**
Expand Down Expand Up @@ -192,6 +135,24 @@ Modal.propTypes = {
* Vertical position of the modal inside browser window.
*/
position: PropTypes.oneOf(['top', 'center']),
/**
* Mode in which Modal prevents scroll of elements bellow:
* * `default` - Modal prevents scroll on the `body` element
* * `off` - Modal does not prevent any scroll
* * object
* * * `reset` - method called on Modal's unmount to reset scroll prevention
* * * `start` - method called on Modal's mount to custom scroll prevention
*/
preventScrollUnderneath: PropTypes.oneOfType([
PropTypes.oneOf([
'default',
'off',
]),
PropTypes.shape({
reset: PropTypes.func,
start: PropTypes.func,
}),
]),
/**
* Reference to primary button element. It is used to submit modal when Enter key is pressed and as fallback
* when `autoFocus` functionality does not find any input element to be focused.
Expand Down
Loading