From ec8d690d51d5475f27ba0b5663f656b50c747b22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bed=C5=99ich=20Schindler?= Date: Thu, 30 May 2024 21:02:57 +0200 Subject: [PATCH] Re-implement `Modal` component using HTMLDialogElement (#461) --- src/components/Grid/Grid.module.scss | 4 +- src/components/Modal/Modal.jsx | 119 +++++++++++------- src/components/Modal/Modal.module.scss | 34 ++--- src/components/Modal/README.md | 48 +++---- src/components/Modal/__tests__/Modal.test.jsx | 7 +- src/components/Modal/_animations.scss | 9 ++ .../Modal/_helpers/dialogOnCancelHandler.js | 12 ++ .../Modal/_helpers/dialogOnClickHandler.js | 32 +++++ .../Modal/_helpers/dialogOnCloseHandler.js | 9 ++ .../Modal/_helpers/dialogOnKeyDownHandler.js | 22 ++++ .../Modal/_helpers/getPositionClassName.js | 2 +- src/components/Modal/_hooks/useModalFocus.js | 37 +++--- src/components/Modal/_settings.scss | 3 - src/components/Modal/_theme.scss | 1 + src/styles/settings/_z-indexes.scss | 2 - src/theme.scss | 1 + webpack.config.babel.js | 2 +- 17 files changed, 230 insertions(+), 114 deletions(-) create mode 100644 src/components/Modal/_animations.scss 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 delete mode 100644 src/styles/settings/_z-indexes.scss diff --git a/src/components/Grid/Grid.module.scss b/src/components/Grid/Grid.module.scss index e8a520d2..bcc14f94 100644 --- a/src/components/Grid/Grid.module.scss +++ b/src/components/Grid/Grid.module.scss @@ -20,8 +20,8 @@ // // 2. Apply custom property value that is defined within current breakpoint, see 1. // -// 3. Intentionally use longhand properties because the custom property fallback mechanism evaluates `initial` values as -// empty. That makes the other value of the shorthand property unexpectedly used for both axes. +// 3. Intentionally use longhand properties because the custom property fallback mechanism evaluates `initial` values +// as empty. That makes the other value of the shorthand property unexpectedly used for both axes. @use "sass:map"; @use "../../styles/tools/spacing"; diff --git a/src/components/Modal/Modal.jsx b/src/components/Modal/Modal.jsx index d24b0f15..5c27f482 100644 --- a/src/components/Modal/Modal.jsx +++ b/src/components/Modal/Modal.jsx @@ -1,9 +1,17 @@ import PropTypes from 'prop-types'; -import React, { useRef } from 'react'; +import React, { + useCallback, + useEffect, + useRef, +} from 'react'; import { createPortal } from 'react-dom'; import { withGlobalProps } from '../../provider'; import { classNames } from '../../utils/classNames'; import { transferProps } from '../../utils/transferProps'; +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'; @@ -12,41 +20,30 @@ import styles from './Modal.module.scss'; const preRender = ( children, - childrenWrapperRef, - closeButtonRef, + dialogRef, position, - restProps, size, + events, + restProps, ) => ( -
{ - e.preventDefault(); - if (closeButtonRef?.current != null) { - closeButtonRef.current.click(); - } - }} - role="presentation" + -
{ - e.stopPropagation(); - }} - ref={childrenWrapperRef} - role="presentation" - > - {children} -
-
+ {children} + ); export const Modal = ({ + allowCloseOnBackdropClick, + allowCloseOnEscapeKey, + allowPrimaryActionOnEnterKey, autoFocus, children, closeButtonRef, @@ -57,42 +54,66 @@ export const Modal = ({ size, ...restProps }) => { - const childrenWrapperRef = useRef(); + const dialogRef = useRef(); - useModalFocus( - autoFocus, - childrenWrapperRef, - primaryButtonRef, - closeButtonRef, - ); + useEffect(() => { + dialogRef.current.showModal(); + }, []); + useModalFocus(allowPrimaryActionOnEnterKey, 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, + allowPrimaryActionOnEnterKey: true, autoFocus: true, children: null, closeButtonRef: null, @@ -104,6 +125,18 @@ 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`, the `Modal` can be submitted by pressing the Enter key. + */ + allowPrimaryActionOnEnterKey: 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`, @@ -121,7 +154,7 @@ Modal.propTypes = { */ children: PropTypes.node, /** - * Reference to close button element. It is used to close modal when Escape key is pressed or the backdrop is clicked. + * Reference to close button element. It is used to close modal when Escape key is pressed. */ closeButtonRef: PropTypes.shape({ // eslint-disable-next-line react/forbid-prop-types diff --git a/src/components/Modal/Modal.module.scss b/src/components/Modal/Modal.module.scss index d7fa011d..01c73a7c 100644 --- a/src/components/Modal/Modal.module.scss +++ b/src/components/Modal/Modal.module.scss @@ -1,9 +1,15 @@ +// 1. Modal uses element that uses the browser's built-in dialog functionality, so that: +// * visibility of the .root element and its backdrop is managed by the browser +// * positioning of the .root element and its backdrop is managed by the browser +// * z-index of the .root element and its backdrop is not needed as dialog is rendered in browser's Top layer + @use "sass:map"; @use "../../styles/theme/typography"; @use "../../styles/tools/accessibility"; @use "../../styles/tools/breakpoint"; @use "../../styles/tools/reset"; @use "../../styles/tools/spacing"; +@use "animations"; @use "settings"; @use "theme"; @@ -13,18 +19,16 @@ --rui-local-max-width: calc(100% - (2 * var(--rui-local-outer-spacing))); --rui-local-max-height: calc(100% - (2 * var(--rui-local-outer-spacing))); - position: fixed; - left: 50%; - z-index: settings.$z-index; - display: flex; flex-direction: column; max-width: var(--rui-local-max-width); max-height: var(--rui-local-max-height); + padding: 0; overflow-y: auto; + color: inherit; + border-width: 0; border-radius: settings.$border-radius; background: theme.$background; box-shadow: theme.$box-shadow; - transform: translateX(-50%); overscroll-behavior: contain; @include breakpoint.up(sm) { @@ -32,14 +36,14 @@ } } - .backdrop { - position: fixed; - top: 0; - left: 0; - z-index: settings.$backdrop-z-index; - width: 100vw; - height: 100vh; + .root[open] { + display: flex; + animation: fade-in theme.$animation-duration ease-out; + } + + .root[open]::backdrop { background: theme.$backdrop-background; + animation: inherit; } .isRootSizeSmall { @@ -69,12 +73,8 @@ max-width: min(var(--rui-local-max-width), #{map.get(theme.$sizes, auto, max-width)}); } - .isRootPositionCenter { - top: 50%; - transform: translate(-50%, -50%); - } - .isRootPositionTop { top: var(--rui-local-outer-spacing); + bottom: auto; } } diff --git a/src/components/Modal/README.md b/src/components/Modal/README.md index afe68af8..db2068e2 100644 --- a/src/components/Modal/README.md +++ b/src/components/Modal/README.md @@ -1019,8 +1019,7 @@ can specify **any HTML attribute you like.** All attributes that don't interfere with the API of the React component and that aren't filtered out by [`transferProps`](/docs/js-helpers/transferProps) helper are forwarded to: -- the `
` HTML element in case of the `Modal` component. This `
` is not - the root, but its first child which represents the modal window. +- the `` HTML element in case of the `Modal` component. - the root `
` HTML element in case of `ModalHeader`, `ModalBody`, `ModalContent` and `ModalFooter` components. - the heading (e.g. `

`) HTML element in case of the `ModalTitle` component. @@ -1031,6 +1030,7 @@ accessibility. 👉 For the full list of supported attributes refer to: +- [`` HTML element attributes][dialog-attributes]{:target="_blank"} - [`
` HTML element attributes][div-attributes]{:target="_blank"} - [`

`-`

` HTML element attributes][heading-attributes]{:target="_blank"} - [`