Skip to content

Commit

Permalink
fixup! fixup! fixup! Re-implement Modal component using HTMLDialogE…
Browse files Browse the repository at this point in the history
…lement (#461)
  • Loading branch information
bedrich-schindler committed Sep 8, 2024
1 parent 64ae3d4 commit 06a7018
Show file tree
Hide file tree
Showing 8 changed files with 145 additions and 42 deletions.
81 changes: 54 additions & 27 deletions src/components/Modal/Modal.jsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import PropTypes from 'prop-types';
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';
Expand All @@ -15,35 +20,29 @@ import styles from './Modal.module.scss';

const preRender = (
children,
childrenWrapperRef,
closeButtonRef,
dialogRef,
position,
restProps,
size,
events,
restProps,
) => (
<dialog
{...transferProps(restProps)}
{...transferProps(events)}
className={classNames(
styles.root,
getSizeClassName(size, styles),
getPositionClassName(position, styles),
)}
onClick={(e) => {
e.stopPropagation();
}}
onClose={(e) => {
e.preventDefault();
if (closeButtonRef?.current != null) {
closeButtonRef.current.click();
}
}}
ref={childrenWrapperRef}
ref={dialogRef}
>
{children}
</dialog>
);

export const Modal = ({
allowCloseOnBackdropClick,
allowCloseOnEscapeKey,
autoFocus,
children,
closeButtonRef,
Expand All @@ -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,
Expand All @@ -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`,
Expand Down
2 changes: 1 addition & 1 deletion src/components/Modal/Modal.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
}

.isRootPositionTop {
position: sticky;
top: var(--rui-local-outer-spacing);
bottom: auto;
}
}
12 changes: 12 additions & 0 deletions src/components/Modal/_helpers/dialogOnCancelHandler.js
Original file line number Diff line number Diff line change
@@ -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();
}
};
32 changes: 32 additions & 0 deletions src/components/Modal/_helpers/dialogOnClickHandler.js
Original file line number Diff line number Diff line change
@@ -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();
}
};
9 changes: 9 additions & 0 deletions src/components/Modal/_helpers/dialogOnCloseHandler.js
Original file line number Diff line number Diff line change
@@ -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();
}
};
22 changes: 22 additions & 0 deletions src/components/Modal/_helpers/dialogOnKeyDownHandler.js
Original file line number Diff line number Diff line change
@@ -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();
}
};
25 changes: 13 additions & 12 deletions src/components/Modal/_hooks/useModalFocus.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useEffect } from 'react';

export const useModalFocus = (
autoFocus,
childrenWrapperRef,
dialogRef,
primaryButtonRef,
) => {
useEffect(
Expand All @@ -11,26 +11,26 @@ 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];
const lastFocusableElement = childrenFocusableElements[childrenFocusableElements.length - 1];

const resolveFocusBeforeListener = () => {
if (!autoFocus || childrenFocusableElements.length === 0) {
childrenWrapperElement.tabIndex = -1;
childrenWrapperElement.focus();
dialogElement.tabIndex = -1;
dialogElement.focus();
return;
}

Expand All @@ -43,7 +43,7 @@ export const useModalFocus = (
return;
}

if (primaryButtonRef?.current != null) {
if (primaryButtonRef?.current != null && primaryButtonRef?.current?.disabled === false) {
primaryButtonRef.current.focus();
return;
}
Expand All @@ -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;
Expand All @@ -70,15 +71,15 @@ export const useModalFocus = (
}

if (childrenFocusableElements.length === 0) {
childrenWrapperElement.focus();
dialogElement.focus();
e.preventDefault();
return;
}

if (
![
...childrenFocusableElements,
childrenWrapperElement,
dialogElement,
]
.includes(window.document.activeElement)
) {
Expand All @@ -96,7 +97,7 @@ export const useModalFocus = (
if (e.shiftKey
&& (
window.document.activeElement === firstFocusableElement
|| window.document.activeElement === childrenWrapperElement
|| window.document.activeElement === dialogElement
)
) {
lastFocusableElement.focus();
Expand All @@ -112,7 +113,7 @@ export const useModalFocus = (
},
[
autoFocus,
childrenWrapperRef,
dialogRef,
primaryButtonRef,
],
);
Expand Down
4 changes: 2 additions & 2 deletions webpack.config.babel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down

0 comments on commit 06a7018

Please sign in to comment.