From 06a70184b1b224bc53465f2c5a8a84213526c46c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bed=C5=99ich=20Schindler?= Date: Sun, 8 Sep 2024 10:59:00 +0200 Subject: [PATCH] fixup! fixup! fixup! Re-implement `Modal` component using HTMLDialogElement (#461) --- src/components/Modal/Modal.jsx | 81 ++++++++++++------- src/components/Modal/Modal.module.scss | 2 +- .../Modal/_helpers/dialogOnCancelHandler.js | 12 +++ .../Modal/_helpers/dialogOnClickHandler.js | 32 ++++++++ .../Modal/_helpers/dialogOnCloseHandler.js | 9 +++ .../Modal/_helpers/dialogOnKeyDownHandler.js | 22 +++++ src/components/Modal/_hooks/useModalFocus.js | 25 +++--- webpack.config.babel.js | 4 +- 8 files changed, 145 insertions(+), 42 deletions(-) create mode 100644 src/components/Modal/_helpers/dialogOnCancelHandler.js create mode 100644 src/components/Modal/_helpers/dialogOnClickHandler.js create mode 100644 src/components/Modal/_helpers/dialogOnCloseHandler.js create mode 100644 src/components/Modal/_helpers/dialogOnKeyDownHandler.js diff --git a/src/components/Modal/Modal.jsx b/src/components/Modal/Modal.jsx index f042cbd6..7248e30f 100644 --- a/src/components/Modal/Modal.jsx +++ b/src/components/Modal/Modal.jsx @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; import React, { + useCallback, useEffect, useRef, } from 'react'; @@ -7,6 +8,10 @@ import { createPortal } from 'react-dom'; import { withGlobalProps } from '../../provider'; import { transferProps } from '../_helpers/transferProps'; import { classNames } from '../../utils/classNames'; +import { dialogOnCancelHandler } from './_helpers/dialogOnCancelHandler'; +import { dialogOnClickHandler } from './_helpers/dialogOnClickHandler'; +import { dialogOnCloseHandler } from './_helpers/dialogOnCloseHandler'; +import { dialogOnKeyDownHandler } from './_helpers/dialogOnKeyDownHandler'; import { getPositionClassName } from './_helpers/getPositionClassName'; import { getSizeClassName } from './_helpers/getSizeClassName'; import { useModalFocus } from './_hooks/useModalFocus'; @@ -15,35 +20,29 @@ import styles from './Modal.module.scss'; const preRender = ( children, - childrenWrapperRef, - closeButtonRef, + dialogRef, position, - restProps, size, + events, + restProps, ) => ( { - e.stopPropagation(); - }} - onClose={(e) => { - e.preventDefault(); - if (closeButtonRef?.current != null) { - closeButtonRef.current.click(); - } - }} - ref={childrenWrapperRef} + ref={dialogRef} > {children} ); export const Modal = ({ + allowCloseOnBackdropClick, + allowCloseOnEscapeKey, autoFocus, children, closeButtonRef, @@ -54,45 +53,65 @@ export const Modal = ({ size, ...restProps }) => { - const childrenWrapperRef = useRef(); + const dialogRef = useRef(); useEffect(() => { - childrenWrapperRef.current.showModal(); + dialogRef.current.showModal(); }, []); - useModalFocus( - autoFocus, - childrenWrapperRef, - primaryButtonRef, - ); - + useModalFocus(autoFocus, dialogRef, primaryButtonRef); useModalScrollPrevention(preventScrollUnderneath); + const onCancel = useCallback( + (e) => dialogOnCancelHandler(e, closeButtonRef), + [closeButtonRef], + ); + const onClick = useCallback( + (e) => dialogOnClickHandler(e, closeButtonRef, dialogRef, allowCloseOnBackdropClick), + [allowCloseOnBackdropClick, closeButtonRef, dialogRef], + ); + const onClose = useCallback( + (e) => dialogOnCloseHandler(e, closeButtonRef), + [closeButtonRef], + ); + const onKeyDown = useCallback( + (e) => dialogOnKeyDownHandler(e, closeButtonRef, allowCloseOnEscapeKey), + [allowCloseOnEscapeKey, closeButtonRef], + ); + const events = { + onCancel, + onClick, + onClose, + onKeyDown, + }; + if (portalId === null) { return preRender( children, - childrenWrapperRef, - closeButtonRef, + dialogRef, position, - restProps, size, + events, + restProps, ); } return createPortal( preRender( children, - childrenWrapperRef, - closeButtonRef, + dialogRef, position, - restProps, size, + events, + restProps, ), document.getElementById(portalId), ); }; Modal.defaultProps = { + allowCloseOnBackdropClick: true, + allowCloseOnEscapeKey: true, autoFocus: true, children: null, closeButtonRef: null, @@ -104,6 +123,14 @@ Modal.defaultProps = { }; Modal.propTypes = { + /** + * If `true`, the `Modal` can be closed by clicking on the backdrop. + */ + allowCloseOnBackdropClick: PropTypes.bool, + /** + * If `true`, the `Modal` can be closed by pressing the Escape key. + */ + allowCloseOnEscapeKey: PropTypes.bool, /** * 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`, diff --git a/src/components/Modal/Modal.module.scss b/src/components/Modal/Modal.module.scss index 41bb2db0..7127b374 100644 --- a/src/components/Modal/Modal.module.scss +++ b/src/components/Modal/Modal.module.scss @@ -78,7 +78,7 @@ } .isRootPositionTop { - position: sticky; top: var(--rui-local-outer-spacing); + bottom: auto; } } diff --git a/src/components/Modal/_helpers/dialogOnCancelHandler.js b/src/components/Modal/_helpers/dialogOnCancelHandler.js new file mode 100644 index 00000000..facac0a6 --- /dev/null +++ b/src/components/Modal/_helpers/dialogOnCancelHandler.js @@ -0,0 +1,12 @@ +export const dialogOnCancelHandler = (e, closeButtonRef) => { + // Prevent the default behaviour of the event as we want to close dialog manually. + e.preventDefault(); + + // If the close button is not disabled, close the modal. + if ( + closeButtonRef?.current != null + && closeButtonRef?.current?.disabled === false + ) { + closeButtonRef.current.click(); + } +}; diff --git a/src/components/Modal/_helpers/dialogOnClickHandler.js b/src/components/Modal/_helpers/dialogOnClickHandler.js new file mode 100644 index 00000000..96d66e64 --- /dev/null +++ b/src/components/Modal/_helpers/dialogOnClickHandler.js @@ -0,0 +1,32 @@ +export const dialogOnClickHandler = ( + e, + closeButtonRef, + dialogRef, + allowCloseOnBackdropClick, +) => { + // If it is not allowed to close modal on backdrop click, do nothing. + if (!allowCloseOnBackdropClick) { + return; + } + + // Detection of the click on the backdrop is based on the following conditions: + // 1. The click target is the dialog itself. This prevents detection of clicks on the dialog's children. + // 2. The click is outside the dialog's boundaries. + const dialogRect = dialogRef.current.getBoundingClientRect(); + const isClickedOnBackdrop = dialogRef.current === e.target && ( + e.clientX < dialogRect.left + || e.clientX > dialogRect.right + || e.clientY < dialogRect.top + || e.clientY > dialogRect.bottom + ); + + // If user does not click on the backdrop, do nothing. + if (!isClickedOnBackdrop) { + return; + } + + // If the close button is not disabled, close the modal. + if (closeButtonRef?.current != null && closeButtonRef?.current?.disabled === false) { + closeButtonRef.current.click(); + } +}; diff --git a/src/components/Modal/_helpers/dialogOnCloseHandler.js b/src/components/Modal/_helpers/dialogOnCloseHandler.js new file mode 100644 index 00000000..f1b3eaaa --- /dev/null +++ b/src/components/Modal/_helpers/dialogOnCloseHandler.js @@ -0,0 +1,9 @@ +export const dialogOnCloseHandler = (e, closeButtonRef) => { + // Prevent the default behaviour of the event as we want to close dialog manually. + e.preventDefault(); + + // If the close button is not disabled, close the modal. + if (closeButtonRef?.current != null && closeButtonRef?.current?.disabled === false) { + closeButtonRef.current.click(); + } +}; diff --git a/src/components/Modal/_helpers/dialogOnKeyDownHandler.js b/src/components/Modal/_helpers/dialogOnKeyDownHandler.js new file mode 100644 index 00000000..a7f92acf --- /dev/null +++ b/src/components/Modal/_helpers/dialogOnKeyDownHandler.js @@ -0,0 +1,22 @@ +export const dialogOnKeyDownHandler = ( + e, + closeButtonRef, + allowCloseOnEscapeKey, +) => { + // When `allowCloseOnEscapeKey` is set to `false`, prevent closing the modal using the Escape key. + if ( + e.key === 'Escape' + && !allowCloseOnEscapeKey + ) { + e.preventDefault(); + } + + // When the close button is disabled, prevent closing the modal using the Escape key. + if ( + e.key === 'Escape' + && closeButtonRef?.current != null + && closeButtonRef?.current?.disabled === true + ) { + e.preventDefault(); + } +}; diff --git a/src/components/Modal/_hooks/useModalFocus.js b/src/components/Modal/_hooks/useModalFocus.js index 14f75587..1cd579e1 100644 --- a/src/components/Modal/_hooks/useModalFocus.js +++ b/src/components/Modal/_hooks/useModalFocus.js @@ -2,7 +2,7 @@ import { useEffect } from 'react'; export const useModalFocus = ( autoFocus, - childrenWrapperRef, + dialogRef, primaryButtonRef, ) => { useEffect( @@ -11,17 +11,17 @@ export const useModalFocus = ( // field element (input, textarea or select) or primary button and focuses it. This is // necessary to have focus on one of those elements to be able to submit the form // by pressing Enter key. If there are neither, it tries to focus any other focusable - // elements. In case there are none or `autoFocus` is disabled, childrenWrapperElement + // elements. In case there are none or `autoFocus` is disabled, dialogElement // (Modal itself) is focused. - const childrenWrapperElement = childrenWrapperRef.current; + const dialogElement = dialogRef.current; - if (childrenWrapperElement == null) { + if (dialogElement == null) { return () => {}; } const childrenFocusableElements = Array.from( - childrenWrapperElement.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'), + dialogElement.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'), ); const firstFocusableElement = childrenFocusableElements[0]; @@ -29,8 +29,8 @@ export const useModalFocus = ( const resolveFocusBeforeListener = () => { if (!autoFocus || childrenFocusableElements.length === 0) { - childrenWrapperElement.tabIndex = -1; - childrenWrapperElement.focus(); + dialogElement.tabIndex = -1; + dialogElement.focus(); return; } @@ -43,7 +43,7 @@ export const useModalFocus = ( return; } - if (primaryButtonRef?.current != null) { + if (primaryButtonRef?.current != null && primaryButtonRef?.current?.disabled === false) { primaryButtonRef.current.focus(); return; } @@ -58,6 +58,7 @@ export const useModalFocus = ( && e.target.nodeName !== 'TEXTAREA' && e.target.nodeName !== 'A' && primaryButtonRef?.current != null + && primaryButtonRef?.current?.disabled === false ) { primaryButtonRef.current.click(); return; @@ -70,7 +71,7 @@ export const useModalFocus = ( } if (childrenFocusableElements.length === 0) { - childrenWrapperElement.focus(); + dialogElement.focus(); e.preventDefault(); return; } @@ -78,7 +79,7 @@ export const useModalFocus = ( if ( ![ ...childrenFocusableElements, - childrenWrapperElement, + dialogElement, ] .includes(window.document.activeElement) ) { @@ -96,7 +97,7 @@ export const useModalFocus = ( if (e.shiftKey && ( window.document.activeElement === firstFocusableElement - || window.document.activeElement === childrenWrapperElement + || window.document.activeElement === dialogElement ) ) { lastFocusableElement.focus(); @@ -112,7 +113,7 @@ export const useModalFocus = ( }, [ autoFocus, - childrenWrapperRef, + dialogRef, primaryButtonRef, ], ); diff --git a/webpack.config.babel.js b/webpack.config.babel.js index 6130f7e7..c3f9a24c 100644 --- a/webpack.config.babel.js +++ b/webpack.config.babel.js @@ -4,8 +4,8 @@ const StyleLintPlugin = require('stylelint-webpack-plugin'); const TerserPlugin = require('terser-webpack-plugin'); const VisualizerPlugin = require('webpack-visualizer-plugin2'); -const MAX_DEVELOPMENT_OUTPUT_SIZE = 3300000; -const MAX_PRODUCTION_OUTPUT_SIZE = 420000; +const MAX_DEVELOPMENT_OUTPUT_SIZE = 3400000; +const MAX_PRODUCTION_OUTPUT_SIZE = 430000; module.exports = (env, argv) => ({ devtool: argv.mode === 'production'