Skip to content

Commit 425fa15

Browse files
Re-implement Modal component using HTMLDialogElement (#461)
1 parent 87ee7df commit 425fa15

17 files changed

+739
-202
lines changed

src/components/Grid/Grid.module.scss

+2-2
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@
2020
//
2121
// 2. Apply custom property value that is defined within current breakpoint, see 1.
2222
//
23-
// 3. Intentionally use longhand properties because the custom property fallback mechanism evaluates `initial` values as
24-
// empty. That makes the other value of the shorthand property unexpectedly used for both axes.
23+
// 3. Intentionally use longhand properties because the custom property fallback mechanism evaluates `initial` values
24+
// as empty. That makes the other value of the shorthand property unexpectedly used for both axes.
2525

2626
@use "sass:map";
2727
@use "../../styles/tools/spacing";

src/components/Modal/Modal.jsx

+103-43
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
11
import PropTypes from 'prop-types';
2-
import React, { useRef } from 'react';
2+
import React, {
3+
useCallback,
4+
useEffect,
5+
useImperativeHandle,
6+
useRef,
7+
} from 'react';
38
import { createPortal } from 'react-dom';
49
import { withGlobalProps } from '../../providers/globalProps';
510
import { classNames } from '../../utils/classNames';
611
import { transferProps } from '../../utils/transferProps';
12+
import { dialogOnCancelHandler } from './_helpers/dialogOnCancelHandler';
13+
import { dialogOnClickHandler } from './_helpers/dialogOnClickHandler';
14+
import { dialogOnCloseHandler } from './_helpers/dialogOnCloseHandler';
15+
import { dialogOnKeyDownHandler } from './_helpers/dialogOnKeyDownHandler';
716
import { getPositionClassName } from './_helpers/getPositionClassName';
817
import { getSizeClassName } from './_helpers/getSizeClassName';
918
import { useModalFocus } from './_hooks/useModalFocus';
@@ -12,90 +21,121 @@ import styles from './Modal.module.scss';
1221

1322
const preRender = (
1423
children,
15-
childrenWrapperRef,
16-
closeButtonRef,
24+
dialogRef,
1725
position,
18-
restProps,
1926
size,
27+
events,
28+
restProps,
2029
) => (
21-
<div
22-
className={styles.backdrop}
23-
onClick={(e) => {
24-
e.preventDefault();
25-
if (closeButtonRef?.current != null) {
26-
closeButtonRef.current.click();
27-
}
28-
}}
29-
role="presentation"
30+
<dialog
31+
{...transferProps(restProps)}
32+
{...transferProps(events)}
33+
className={classNames(
34+
styles.root,
35+
getSizeClassName(size, styles),
36+
getPositionClassName(position, styles),
37+
)}
38+
ref={dialogRef}
3039
>
31-
<div
32-
{...transferProps(restProps)}
33-
className={classNames(
34-
styles.root,
35-
getSizeClassName(size, styles),
36-
getPositionClassName(position, styles),
37-
)}
38-
onClick={(e) => {
39-
e.stopPropagation();
40-
}}
41-
ref={childrenWrapperRef}
42-
role="presentation"
43-
>
44-
{children}
45-
</div>
46-
</div>
40+
{children}
41+
</dialog>
4742
);
4843

4944
export const Modal = ({
45+
allowCloseOnBackdropClick,
46+
allowCloseOnEscapeKey,
47+
allowPrimaryActionOnEnterKey,
5048
autoFocus,
5149
children,
5250
closeButtonRef,
51+
dialogRef,
5352
portalId,
5453
position,
5554
preventScrollUnderneath,
5655
primaryButtonRef,
5756
size,
5857
...restProps
5958
}) => {
60-
const childrenWrapperRef = useRef();
59+
const internalDialogRef = useRef();
6160

62-
useModalFocus(
63-
autoFocus,
64-
childrenWrapperRef,
65-
primaryButtonRef,
66-
closeButtonRef,
67-
);
61+
useEffect(() => {
62+
internalDialogRef.current.showModal();
63+
}, []);
6864

65+
// We need to have a reference to the dialog element to be able to call its methods,
66+
// but at the same time we want to expose this reference to the parent component for
67+
// case someone wants to call dialog methods from outside the component.
68+
useImperativeHandle(dialogRef, () => internalDialogRef.current);
69+
70+
useModalFocus(autoFocus, internalDialogRef, primaryButtonRef);
6971
useModalScrollPrevention(preventScrollUnderneath);
7072

73+
const onCancel = useCallback(
74+
(e) => dialogOnCancelHandler(e, closeButtonRef, restProps.onCancel),
75+
[closeButtonRef, restProps.onCancel],
76+
);
77+
const onClick = useCallback(
78+
(e) => dialogOnClickHandler(e, closeButtonRef, internalDialogRef, allowCloseOnBackdropClick),
79+
[allowCloseOnBackdropClick, closeButtonRef, internalDialogRef],
80+
);
81+
const onClose = useCallback(
82+
(e) => dialogOnCloseHandler(e, closeButtonRef, restProps.onClose),
83+
[closeButtonRef, restProps.onClose],
84+
);
85+
const onKeyDown = useCallback(
86+
(e) => dialogOnKeyDownHandler(
87+
e,
88+
closeButtonRef,
89+
primaryButtonRef,
90+
allowCloseOnEscapeKey,
91+
allowPrimaryActionOnEnterKey,
92+
),
93+
[
94+
allowCloseOnEscapeKey,
95+
allowPrimaryActionOnEnterKey,
96+
closeButtonRef,
97+
primaryButtonRef,
98+
],
99+
);
100+
const events = {
101+
onCancel,
102+
onClick,
103+
onClose,
104+
onKeyDown,
105+
};
106+
71107
if (portalId === null) {
72108
return preRender(
73109
children,
74-
childrenWrapperRef,
75-
closeButtonRef,
110+
internalDialogRef,
76111
position,
77-
restProps,
78112
size,
113+
events,
114+
restProps,
79115
);
80116
}
81117

82118
return createPortal(
83119
preRender(
84120
children,
85-
childrenWrapperRef,
86-
closeButtonRef,
121+
internalDialogRef,
87122
position,
88-
restProps,
89123
size,
124+
events,
125+
restProps,
90126
),
91127
document.getElementById(portalId),
92128
);
93129
};
94130

95131
Modal.defaultProps = {
132+
allowCloseOnBackdropClick: true,
133+
allowCloseOnEscapeKey: true,
134+
allowPrimaryActionOnEnterKey: true,
96135
autoFocus: true,
97136
children: null,
98137
closeButtonRef: null,
138+
dialogRef: null,
99139
portalId: null,
100140
position: 'center',
101141
preventScrollUnderneath: window.document.body,
@@ -104,6 +144,18 @@ Modal.defaultProps = {
104144
};
105145

106146
Modal.propTypes = {
147+
/**
148+
* If `true`, the `Modal` can be closed by clicking on the backdrop.
149+
*/
150+
allowCloseOnBackdropClick: PropTypes.bool,
151+
/**
152+
* If `true`, the `Modal` can be closed by pressing the Escape key.
153+
*/
154+
allowCloseOnEscapeKey: PropTypes.bool,
155+
/**
156+
* If `true`, the `Modal` can be submitted by pressing the Enter key.
157+
*/
158+
allowPrimaryActionOnEnterKey: PropTypes.bool,
107159
/**
108160
* If `true`, focus the first input element in the `Modal`, or primary button (referenced by the `primaryButtonRef`
109161
* prop), or other focusable element when the `Modal` is opened. If there are none or `autoFocus` is set to `false`,
@@ -121,12 +173,20 @@ Modal.propTypes = {
121173
*/
122174
children: PropTypes.node,
123175
/**
124-
* Reference to close button element. It is used to close modal when Escape key is pressed or the backdrop is clicked.
176+
* Reference to close button element. It is used to close modal when Escape key is pressed
177+
* or the backdrop is clicked.
125178
*/
126179
closeButtonRef: PropTypes.shape({
127180
// eslint-disable-next-line react/forbid-prop-types
128181
current: PropTypes.any,
129182
}),
183+
/**
184+
* Reference to dialog element
185+
*/
186+
dialogRef: PropTypes.shape({
187+
// eslint-disable-next-line react/forbid-prop-types
188+
current: PropTypes.any,
189+
}),
130190
/**
131191
* If set, modal is rendered in the React Portal with that ID.
132192
*/

src/components/Modal/Modal.module.scss

+23-18
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1+
// 1. Modal uses <dialog> element that uses the browser's built-in dialog functionality, so that:
2+
// * visibility of the .root element and its backdrop is managed by the browser
3+
// * positioning of the .root element and its backdrop is managed by the browser
4+
// * z-index of the .root element and its backdrop is not needed as dialog is rendered in browser's Top layer
5+
16
@use "sass:map";
27
@use "../../styles/theme/typography";
38
@use "../../styles/tools/accessibility";
49
@use "../../styles/tools/breakpoint";
510
@use "../../styles/tools/reset";
611
@use "../../styles/tools/spacing";
12+
@use "animations";
713
@use "settings";
814
@use "theme";
915

@@ -13,33 +19,37 @@
1319
--rui-local-max-width: calc(100% - (2 * var(--rui-local-outer-spacing)));
1420
--rui-local-max-height: calc(100% - (2 * var(--rui-local-outer-spacing)));
1521

16-
position: fixed;
17-
left: 50%;
18-
z-index: settings.$z-index;
19-
display: flex;
2022
flex-direction: column;
2123
max-width: var(--rui-local-max-width);
2224
max-height: var(--rui-local-max-height);
25+
padding: 0;
2326
overflow-y: auto;
27+
color: inherit;
28+
border-width: 0;
2429
border-radius: settings.$border-radius;
2530
background: theme.$background;
2631
box-shadow: theme.$box-shadow;
27-
transform: translateX(-50%);
2832
overscroll-behavior: contain;
2933

3034
@include breakpoint.up(sm) {
3135
--rui-local-outer-spacing: #{theme.$outer-spacing-sm};
3236
}
3337
}
3438

35-
.backdrop {
36-
position: fixed;
37-
top: 0;
38-
left: 0;
39-
z-index: settings.$backdrop-z-index;
40-
width: 100vw;
41-
height: 100vh;
39+
.root[open] {
40+
display: flex;
41+
42+
@media (prefers-reduced-motion: no-preference) {
43+
animation: fade-in theme.$animation-duration ease-out;
44+
}
45+
}
46+
47+
.root[open]::backdrop {
4248
background: theme.$backdrop-background;
49+
50+
@media (prefers-reduced-motion: no-preference) {
51+
animation: inherit;
52+
}
4353
}
4454

4555
.isRootSizeSmall {
@@ -64,17 +74,12 @@
6474
}
6575

6676
.isRootSizeAuto {
67-
width: auto;
6877
min-width: min(var(--rui-local-max-width), #{map.get(theme.$sizes, auto, min-width)});
6978
max-width: min(var(--rui-local-max-width), #{map.get(theme.$sizes, auto, max-width)});
7079
}
7180

72-
.isRootPositionCenter {
73-
top: 50%;
74-
transform: translate(-50%, -50%);
75-
}
76-
7781
.isRootPositionTop {
7882
top: var(--rui-local-outer-spacing);
83+
bottom: auto;
7984
}
8085
}

0 commit comments

Comments
 (0)