diff --git a/src/components/Modal/Modal.jsx b/src/components/Modal/Modal.jsx index b152754b..431486d8 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 { 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'; @@ -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 95db9249..07697548 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,33 +19,30 @@ --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; overscroll-behavior: contain; + border-width: 0; border-radius: settings.$border-radius; background: theme.$background; box-shadow: theme.$box-shadow; - transform: translateX(-50%); @include breakpoint.up(sm) { --rui-local-outer-spacing: #{theme.$outer-spacing-sm}; } } - .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 +72,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 d4be9543..30b1b2f1 100644 --- a/src/components/Modal/README.md +++ b/src/components/Modal/README.md @@ -1065,27 +1065,29 @@ accessibility. ## Theming -| Custom Property | Description | -|------------------------------------------------------|---------------------------------------------------------------| -| `--rui-Modal__padding-x` | Inline padding of individual modal components | -| `--rui-Modal__padding-y` | Block padding of individual modal components | -| `--rui-Modal__background` | Modal background (including `url()` or gradient) | -| `--rui-Modal__box-shadow` | Modal box shadow | -| `--rui-Modal__separator__width` | Width of separator between modal header, body, and footer | -| `--rui-Modal__separator__color` | Color of separator between modal header, body, and footer | -| `--rui-Modal__outer-spacing-xs` | Spacing around modal, `xs` screen size | -| `--rui-Modal__outer-spacing-sm` | Spacing around modal, `sm` screen size and bigger | -| `--rui-Modal__header__gap` | Modal header gap between children | -| `--rui-Modal__footer__background` | Modal footer background (including `url()` or gradient) | -| `--rui-Modal__footer__gap` | Modal footer gap between children | -| `--rui-Modal__backdrop__background` | Modal backdrop background (including `url()` or gradient) | -| `--rui-Modal--auto__min-width` | Min width of auto-sized modal (when enough screen estate) | -| `--rui-Modal--auto__max-width` | Max width of auto-sized modal (when enough screen estate) | -| `--rui-Modal--small__width` | Width of small modal | -| `--rui-Modal--medium__width` | Width of medium modal | -| `--rui-Modal--large__width` | Width of large modal | -| `--rui-Modal--fullscreen__width` | Width of fullscreen modal | -| `--rui-Modal--fullscreen__height` | Height of fullscreen modal | +| Custom Property | Description | +|------------------------------------------------------|-------------------------------------------------------------| +| `--rui-Modal__padding-x` | Inline padding of individual modal components | +| `--rui-Modal__padding-y` | Block padding of individual modal components | +| `--rui-Modal__background` | Modal background (including `url()` or gradient) | +| `--rui-Modal__box-shadow` | Modal box shadow | +| `--rui-Modal__separator__width` | Width of separator between modal header, body, and footer | +| `--rui-Modal__separator__color` | Color of separator between modal header, body, and footer | +| `--rui-Modal__outer-spacing-xs` | Spacing around modal, `xs` screen size | +| `--rui-Modal__outer-spacing-sm` | Spacing around modal, `sm` screen size and bigger | +| `--rui-Modal__header__gap` | Modal header gap between children | +| `--rui-Modal__footer__background` | Modal footer background (including `url()` or gradient) | +| `--rui-Modal__footer__gap` | Modal footer gap between children | +| `--rui-Modal__backdrop__background` | Modal backdrop background (including `url()` or gradient) | +| `--rui-Modal--auto__min-width` | Min width of auto-sized modal (when enough screen estate) | +| `--rui-Modal--auto__max-width` | Max width of auto-sized modal (when enough screen estate) | +| `--rui-Modal--small__width` | Width of small modal | +| `--rui-Modal--medium__width` | Width of medium modal | +| `--rui-Modal--large__width` | Width of large modal | +| `--rui-Modal--fullscreen__width` | Width of fullscreen modal | +| `--rui-Modal--fullscreen__height` | Height of fullscreen modal | +| `--rui-Modal__animation__duration` | Duration of animation used (when opening modal) | + [button-attributes]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#attributes [div-attributes]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/div#attributes diff --git a/src/components/Modal/__tests__/Modal.test.jsx b/src/components/Modal/__tests__/Modal.test.jsx index a4a0f50b..1a2cf233 100644 --- a/src/components/Modal/__tests__/Modal.test.jsx +++ b/src/components/Modal/__tests__/Modal.test.jsx @@ -14,7 +14,10 @@ import { ModalContent } from '../ModalContent'; import { ModalFooter } from '../ModalFooter'; import { ModalHeader } from '../ModalHeader'; -describe('rendering', () => { +// Test suites skipped due to missing implementation of HTMLDialogElement in jsdom +// See https://github.com/jsdom/jsdom/issues/3294 + +describe.skip('rendering', () => { it('renders with "portalId" props', () => { document.body.innerHTML = '
'; render(( @@ -74,7 +77,7 @@ describe('rendering', () => { }); }); -describe('functionality', () => { +describe.skip('functionality', () => { it.each([ () => userEvent.keyboard('{Escape}'), () => userEvent.click(screen.getByTestId('id').parentNode), diff --git a/src/components/Modal/_animations.scss b/src/components/Modal/_animations.scss new file mode 100644 index 00000000..cb4e0be9 --- /dev/null +++ b/src/components/Modal/_animations.scss @@ -0,0 +1,9 @@ +@keyframes fade-in { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} 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/_helpers/getPositionClassName.js b/src/components/Modal/_helpers/getPositionClassName.js index f022c707..c2ef4896 100644 --- a/src/components/Modal/_helpers/getPositionClassName.js +++ b/src/components/Modal/_helpers/getPositionClassName.js @@ -3,5 +3,5 @@ export const getPositionClassName = (modalPosition, styles) => { return styles.isRootPositionTop; } - return styles.isRootPositionCenter; + return null; }; diff --git a/src/components/Modal/_hooks/useModalFocus.js b/src/components/Modal/_hooks/useModalFocus.js index aeb4a0f4..4e5936ec 100644 --- a/src/components/Modal/_hooks/useModalFocus.js +++ b/src/components/Modal/_hooks/useModalFocus.js @@ -1,10 +1,10 @@ import { useEffect } from 'react'; export const useModalFocus = ( + allowPrimaryActionOnEnterKey, autoFocus, - childrenWrapperRef, + dialogRef, primaryButtonRef, - closeButtonRef, ) => { useEffect( () => { @@ -12,17 +12,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]; @@ -30,8 +30,8 @@ export const useModalFocus = ( const resolveFocusBeforeListener = () => { if (!autoFocus || childrenFocusableElements.length === 0) { - childrenWrapperElement.tabIndex = -1; - childrenWrapperElement.focus(); + dialogElement.tabIndex = -1; + dialogElement.focus(); return; } @@ -44,7 +44,7 @@ export const useModalFocus = ( return; } - if (primaryButtonRef?.current != null) { + if (primaryButtonRef?.current != null && primaryButtonRef?.current?.disabled === false) { primaryButtonRef.current.focus(); return; } @@ -53,17 +53,14 @@ export const useModalFocus = ( }; const keyPressHandler = (e) => { - if (e.key === 'Escape' && closeButtonRef?.current != null) { - closeButtonRef.current.click(); - return; - } - if ( - e.key === 'Enter' + allowPrimaryActionOnEnterKey + && e.key === 'Enter' && e.target.nodeName !== 'BUTTON' && e.target.nodeName !== 'TEXTAREA' && e.target.nodeName !== 'A' && primaryButtonRef?.current != null + && primaryButtonRef?.current?.disabled === false ) { primaryButtonRef.current.click(); return; @@ -76,7 +73,7 @@ export const useModalFocus = ( } if (childrenFocusableElements.length === 0) { - childrenWrapperElement.focus(); + dialogElement.focus(); e.preventDefault(); return; } @@ -84,7 +81,7 @@ export const useModalFocus = ( if ( ![ ...childrenFocusableElements, - childrenWrapperElement, + dialogElement, ] .includes(window.document.activeElement) ) { @@ -102,7 +99,7 @@ export const useModalFocus = ( if (e.shiftKey && ( window.document.activeElement === firstFocusableElement - || window.document.activeElement === childrenWrapperElement + || window.document.activeElement === dialogElement ) ) { lastFocusableElement.focus(); @@ -117,10 +114,10 @@ export const useModalFocus = ( return () => window.document.removeEventListener('keydown', keyPressHandler, false); }, [ + allowPrimaryActionOnEnterKey, autoFocus, - childrenWrapperRef, + dialogRef, primaryButtonRef, - closeButtonRef, ], ); }; diff --git a/src/components/Modal/_settings.scss b/src/components/Modal/_settings.scss index db2a09f6..a5598260 100644 --- a/src/components/Modal/_settings.scss +++ b/src/components/Modal/_settings.scss @@ -1,9 +1,6 @@ @use "sass:map"; -@use "../../styles/settings/z-indexes"; @use "../../styles/theme/borders"; @use "../../styles/theme/typography"; $border-radius: borders.$radius-2; -$z-index: z-indexes.$modal; -$backdrop-z-index: z-indexes.$modal-backdrop; $title-font-size: map.get(typography.$font-size-values, 2); diff --git a/src/components/Modal/_theme.scss b/src/components/Modal/_theme.scss index 070fb409..c4c22ec5 100644 --- a/src/components/Modal/_theme.scss +++ b/src/components/Modal/_theme.scss @@ -10,6 +10,7 @@ $footer-gap: var(--rui-Modal__footer__gap); $backdrop-background: var(--rui-Modal__backdrop__background); $outer-spacing-xs: var(--rui-Modal__outer-spacing--xs); $outer-spacing-sm: var(--rui-Modal__outer-spacing--sm); +$animation-duration: var(--rui-Modal__animation__duration); $sizes: ( auto: ( diff --git a/src/styles/settings/_z-indexes.scss b/src/styles/settings/_z-indexes.scss deleted file mode 100644 index 8af49f16..00000000 --- a/src/styles/settings/_z-indexes.scss +++ /dev/null @@ -1,2 +0,0 @@ -$modal-backdrop: 2000; -$modal: 2100; diff --git a/src/theme.scss b/src/theme.scss index ae0ebb51..b847d445 100644 --- a/src/theme.scss +++ b/src/theme.scss @@ -947,6 +947,7 @@ --rui-Modal--large__width: 60rem; --rui-Modal--fullscreen__width: 100%; --rui-Modal--fullscreen__height: 100%; + --rui-Modal__animation__duration: 0.25s; // // Paper 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'