Skip to content

Commit

Permalink
Re-implement Modal component using HTMLDialogElement (#461)
Browse files Browse the repository at this point in the history
  • Loading branch information
bedrich-schindler committed Jan 13, 2025
1 parent 684d5ab commit ec8d690
Show file tree
Hide file tree
Showing 17 changed files with 230 additions and 114 deletions.
4 changes: 2 additions & 2 deletions src/components/Grid/Grid.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
119 changes: 76 additions & 43 deletions src/components/Modal/Modal.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -12,41 +20,30 @@ import styles from './Modal.module.scss';

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

export const Modal = ({
allowCloseOnBackdropClick,
allowCloseOnEscapeKey,
allowPrimaryActionOnEnterKey,
autoFocus,
children,
closeButtonRef,
Expand All @@ -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,
Expand All @@ -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`,
Expand All @@ -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
Expand Down
34 changes: 17 additions & 17 deletions src/components/Modal/Modal.module.scss
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
// 1. Modal uses <dialog> 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";

Expand All @@ -13,33 +19,31 @@
--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) {
--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 {
Expand Down Expand Up @@ -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;
}
}
48 changes: 25 additions & 23 deletions src/components/Modal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<div>` HTML element in case of the `Modal` component. This `<div>` is not
the root, but its first child which represents the modal window.
- the `<dialog>` HTML element in case of the `Modal` component.
- the root `<div>` HTML element in case of `ModalHeader`, `ModalBody`, `ModalContent`
and `ModalFooter` components.
- the heading (e.g. `<h1>`) HTML element in case of the `ModalTitle` component.
Expand All @@ -1031,6 +1030,7 @@ accessibility.

👉 For the full list of supported attributes refer to:

- [`<dialog>` HTML element attributes][dialog-attributes]{:target="_blank"}
- [`<div>` HTML element attributes][div-attributes]{:target="_blank"}
- [`<h1>`-`<h6>` HTML element attributes][heading-attributes]{:target="_blank"}
- [`<button>` HTML element attributes][button-attributes]{:target="_blank"}
Expand Down Expand Up @@ -1066,29 +1066,31 @@ 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
[dialog-attributes]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog#attributes
[div-attributes]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/div#attributes
[heading-attributes]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Heading_Elements#attributes
[React common props]: https://react.dev/reference/react-dom/components/common#common-props
7 changes: 5 additions & 2 deletions src/components/Modal/__tests__/Modal.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<div id="portal-id" />';
render((
Expand Down Expand Up @@ -74,7 +77,7 @@ describe('rendering', () => {
});
});

describe('functionality', () => {
describe.skip('functionality', () => {
it.each([
() => userEvent.keyboard('{Escape}'),
() => userEvent.click(screen.getByTestId('id').parentNode),
Expand Down
9 changes: 9 additions & 0 deletions src/components/Modal/_animations.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
@keyframes fade-in {
0% {
opacity: 0;
}

100% {
opacity: 1;
}
}
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();
}
};
Loading

0 comments on commit ec8d690

Please sign in to comment.