From 0d17b590ce382e578db154a94ed1a75d65d73586 Mon Sep 17 00:00:00 2001 From: hubacekj Date: Tue, 22 Nov 2022 08:05:00 +0100 Subject: [PATCH] Prevent page from scrolling when `Modal` is open and implement focus trap (#397) --- src/lib/components/Modal/Modal.jsx | 155 ++++----- src/lib/components/Modal/README.mdx | 300 +++++++++++++++++- .../components/Modal/__tests__/Modal.test.jsx | 170 +++++++++- .../Modal/_helpers/getPositionClassName.js | 7 + .../Modal/_helpers/getSizeClassName.js | 19 ++ .../components/Modal/_hooks/useModalFocus.js | 126 ++++++++ .../Modal/_hooks/useModalScrollPrevention.js | 35 ++ webpack.config.js | 2 +- 8 files changed, 695 insertions(+), 119 deletions(-) create mode 100644 src/lib/components/Modal/_helpers/getPositionClassName.js create mode 100644 src/lib/components/Modal/_helpers/getSizeClassName.js create mode 100644 src/lib/components/Modal/_hooks/useModalFocus.js create mode 100644 src/lib/components/Modal/_hooks/useModalScrollPrevention.js diff --git a/src/lib/components/Modal/Modal.jsx b/src/lib/components/Modal/Modal.jsx index 6c52cb1c..5ee494a5 100644 --- a/src/lib/components/Modal/Modal.jsx +++ b/src/lib/components/Modal/Modal.jsx @@ -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 = ( @@ -16,63 +17,34 @@ 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 ( +) => ( +
{ + e.preventDefault(); + if (closeButtonRef?.current != null) { + closeButtonRef.current.click(); + } + }} + role="presentation" + >
{ - if (closeButtonRef?.current != null) { - closeButtonRef.current.click(); - } + {...transferProps(restProps)} + className={classNames( + styles.root, + getSizeClassName(size, styles), + getPositionClassName(position, styles), + )} + onClick={(e) => { + e.stopPropagation(); }} role="presentation" + ref={childrenWrapperRef} > -
{ - e.stopPropagation(); - }} - role="presentation" - ref={childrenWrapperRef} - > - {children} -
+ {children}
- ); -}; +
+); export const Modal = ({ autoFocus, @@ -80,52 +52,21 @@ export const Modal = ({ 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( @@ -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, /** @@ -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. diff --git a/src/lib/components/Modal/README.mdx b/src/lib/components/Modal/README.mdx index 9fd25035..f100a27e 100644 --- a/src/lib/components/Modal/README.mdx +++ b/src/lib/components/Modal/README.mdx @@ -25,6 +25,7 @@ import { ModalTitle, Radio, ScrollView, + TextArea, TextField, Toolbar, ToolbarGroup, @@ -116,8 +117,9 @@ See [API](#api) for all available options. - Modal **automatically focuses the first non-disabled form field** by default which allows users to confirm the modal by hitting the enter key. When no - field is found then the primary button (in the footer) is focused. To turn - this feature off, set the `autofocus` prop to `false`. + field is found then the primary button (in the footer) is focused. If there + are neither, it tries to focus any other focusable elements. In case there + are none, or [autoFocus](#autoFocus) is disabled, Modal itself is focused. - **Avoid stacking** of modals. While it may technically work, the modal is just not designed for that. @@ -824,24 +826,147 @@ can be closed by pressing the `Escape` key. To enable it, you just need to pass a reference to the buttons using `primaryButtonRef` and `closeButtonRef` props on Modal. The advantage of passing -a reference to the button is that if the button is disabled, the key press will -not fire the event. - -👉 We strongly recommend using this feature together with -[Autofocus](#autofocus) for a better user experience. +the reference to the button is that if the button is disabled, the key press +will not fire the event. ## Autofocus Autofocus is implemented to enhance the user experience by automatically -focussing an element within the modal. +focusing an element within the Modal. How does it work? It tries to find `input`, `textarea`, and `select` elements inside of Modal and moves focus onto the first non-disabled one. If none is found and the `primaryButtonRef` prop on Modal is set, then the primary button -is focused. +is focused. If there are neither, it tries to focus any other focusable elements. +In case there are none or `autoFocus` is disabled, Modal itself is focused. -Autofocus is enabled by default, so if you want to control the focus of -elements manually, set the `autoFocus` prop on Modal to `false`. + + {() => { + const [modalOpen, setModalOpen] = React.useState(null); + const modalPrimaryButtonRef = React.useRef(); + const modalCloseButtonRef = React.useRef(); + return ( + <> +