Skip to content

Commit 0d17b59

Browse files
hubacekjadamkudrna
authored andcommitted
Prevent page from scrolling when Modal is open and implement focus trap (#397)
1 parent a7168e1 commit 0d17b59

File tree

8 files changed

+695
-119
lines changed

8 files changed

+695
-119
lines changed

src/lib/components/Modal/Modal.jsx

Lines changed: 58 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import PropTypes from 'prop-types';
2-
import React, {
3-
useEffect,
4-
useRef,
5-
} from 'react';
2+
import React, { useRef } from 'react';
63
import { createPortal } from 'react-dom';
74
import { withGlobalProps } from '../../provider';
85
import { transferProps } from '../_helpers/transferProps';
96
import { classNames } from '../../utils/classNames';
7+
import { getPositionClassName } from './_helpers/getPositionClassName';
8+
import { getSizeClassName } from './_helpers/getSizeClassName';
9+
import { useModalFocus } from './_hooks/useModalFocus';
10+
import { useModalScrollPrevention } from './_hooks/useModalScrollPrevention';
1011
import styles from './Modal.scss';
1112

1213
const preRender = (
@@ -16,116 +17,56 @@ const preRender = (
1617
position,
1718
restProps,
1819
size,
19-
) => {
20-
const sizeClass = (modalSize) => {
21-
if (modalSize === 'small') {
22-
return styles.isRootSizeSmall;
23-
}
24-
25-
if (modalSize === 'medium') {
26-
return styles.isRootSizeMedium;
27-
}
28-
29-
if (modalSize === 'large') {
30-
return styles.isRootSizeLarge;
31-
}
32-
33-
if (modalSize === 'fullscreen') {
34-
return styles.isRootSizeFullscreen;
35-
}
36-
37-
return styles.isRootSizeAuto;
38-
};
39-
40-
const positionClass = (modalPosition) => {
41-
if (modalPosition === 'top') {
42-
return styles.isRootPositionTop;
43-
}
44-
45-
return styles.isRootPositionCenter;
46-
};
47-
48-
return (
20+
) => (
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+
>
4931
<div
50-
className={styles.backdrop}
51-
onClick={() => {
52-
if (closeButtonRef?.current != null) {
53-
closeButtonRef.current.click();
54-
}
32+
{...transferProps(restProps)}
33+
className={classNames(
34+
styles.root,
35+
getSizeClassName(size, styles),
36+
getPositionClassName(position, styles),
37+
)}
38+
onClick={(e) => {
39+
e.stopPropagation();
5540
}}
5641
role="presentation"
42+
ref={childrenWrapperRef}
5743
>
58-
<div
59-
{...transferProps(restProps)}
60-
className={classNames(
61-
styles.root,
62-
sizeClass(size),
63-
positionClass(position),
64-
)}
65-
onClick={(e) => {
66-
e.stopPropagation();
67-
}}
68-
role="presentation"
69-
ref={childrenWrapperRef}
70-
>
71-
{children}
72-
</div>
44+
{children}
7345
</div>
74-
);
75-
};
46+
</div>
47+
);
7648

7749
export const Modal = ({
7850
autoFocus,
7951
children,
8052
closeButtonRef,
8153
portalId,
8254
position,
55+
preventScrollUnderneath,
8356
primaryButtonRef,
8457
size,
8558
...restProps
8659
}) => {
8760
const childrenWrapperRef = useRef();
8861

89-
const keyPressHandler = (e) => {
90-
if (e.key === 'Escape' && closeButtonRef?.current != null) {
91-
closeButtonRef.current.click();
92-
}
93-
94-
if (e.key === 'Enter' && e.target.nodeName !== 'BUTTON' && primaryButtonRef?.current != null) {
95-
primaryButtonRef.current.click();
96-
}
97-
};
98-
99-
useEffect(() => {
100-
window.document.addEventListener('keydown', keyPressHandler, false);
101-
const removeKeyPressHandler = () => {
102-
window.document.removeEventListener('keydown', keyPressHandler, false);
103-
};
104-
105-
// If `autoFocus` is set to `true`, following code finds first form field element
106-
// (input, textarea or select) or primary button and auto focuses it. This is necessary
107-
// to have focus on one of those elements to be able to submit form by pressing Enter key.
108-
if (autoFocus) {
109-
if (childrenWrapperRef?.current != null) {
110-
const childrenWrapperElement = childrenWrapperRef.current;
111-
const childrenElements = childrenWrapperElement.querySelectorAll('*');
112-
const formFieldEl = Array.from(childrenElements).find(
113-
(element) => ['INPUT', 'TEXTAREA', 'SELECT'].includes(element.nodeName) && !element.disabled,
114-
);
115-
116-
if (formFieldEl) {
117-
formFieldEl.focus();
118-
return removeKeyPressHandler;
119-
}
120-
}
121-
122-
if (primaryButtonRef?.current != null) {
123-
primaryButtonRef.current.focus();
124-
}
125-
}
62+
useModalFocus(
63+
autoFocus,
64+
childrenWrapperRef,
65+
primaryButtonRef,
66+
closeButtonRef,
67+
);
12668

127-
return removeKeyPressHandler;
128-
}, []); // eslint-disable-line react-hooks/exhaustive-deps
69+
useModalScrollPrevention(preventScrollUnderneath);
12970

13071
if (portalId === null) {
13172
return preRender(
@@ -157,14 +98,16 @@ Modal.defaultProps = {
15798
closeButtonRef: null,
15899
portalId: null,
159100
position: 'center',
101+
preventScrollUnderneath: 'default',
160102
primaryButtonRef: null,
161103
size: 'medium',
162104
};
163105

164106
Modal.propTypes = {
165107
/**
166-
* If `true`, focus the first input element in the modal or primary button (referenced by the `primaryButtonRef` prop)
167-
* when the modal is opened.
108+
* If `true`, focus the first input element in the `Modal`, or primary button (referenced by the `primaryButtonRef`
109+
* prop), or other focusable element when the `Modal` is opened. If there are none or `autoFocus` is set to `false`,
110+
* focus the Modal itself.
168111
*/
169112
autoFocus: PropTypes.bool,
170113
/**
@@ -192,6 +135,24 @@ Modal.propTypes = {
192135
* Vertical position of the modal inside browser window.
193136
*/
194137
position: PropTypes.oneOf(['top', 'center']),
138+
/**
139+
* Mode in which Modal prevents scroll of elements bellow:
140+
* * `default` - Modal prevents scroll on the `body` element
141+
* * `off` - Modal does not prevent any scroll
142+
* * object
143+
* * * `reset` - method called on Modal's unmount to reset scroll prevention
144+
* * * `start` - method called on Modal's mount to custom scroll prevention
145+
*/
146+
preventScrollUnderneath: PropTypes.oneOfType([
147+
PropTypes.oneOf([
148+
'default',
149+
'off',
150+
]),
151+
PropTypes.shape({
152+
reset: PropTypes.func,
153+
start: PropTypes.func,
154+
}),
155+
]),
195156
/**
196157
* Reference to primary button element. It is used to submit modal when Enter key is pressed and as fallback
197158
* when `autoFocus` functionality does not find any input element to be focused.

0 commit comments

Comments
 (0)