Skip to content

Commit

Permalink
Prevent page from scrolling when Modal is open (#397)
Browse files Browse the repository at this point in the history
  • Loading branch information
hubacekj committed Apr 11, 2023
1 parent 6213b0a commit 6eafb35
Show file tree
Hide file tree
Showing 2 changed files with 258 additions and 27 deletions.
137 changes: 110 additions & 27 deletions src/lib/components/Modal/Modal.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import PropTypes from 'prop-types';
import React, {
useEffect,
useLayoutEffect,
useRef,
} from 'react';
import { createPortal } from 'react-dom';
Expand Down Expand Up @@ -80,52 +81,117 @@ export const Modal = ({
closeButtonRef,
portalId,
position,
preventScrollUnderneath,
primaryButtonRef,
size,
...restProps
}) => {
const childrenWrapperRef = useRef();

const keyPressHandler = (e) => {
if (e.key === 'Escape' && closeButtonRef?.current != null) {
closeButtonRef.current.click();
useEffect(() => {
if (!autoFocus) {
return () => {};
}

if (e.key === 'Enter' && e.target.nodeName !== 'BUTTON' && primaryButtonRef?.current != null) {
primaryButtonRef.current.click();
// If `autoFocus` is set to `true`, following code finds first form field element
// (input, textarea or select) or primary button and auto focuses it. This is necessary
// to have focus on one of those elements to be able to submit form by pressing Enter key.

if (childrenWrapperRef?.current == null) {
return () => {};
}
};

useEffect(() => {
const childrenWrapperElement = childrenWrapperRef.current;
const childrenFocusableElements = Array.from(childrenWrapperElement.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'));

const firstFormFieldEl = childrenFocusableElements.find(
(element) => ['INPUT', 'TEXTAREA', 'SELECT'].includes(element.nodeName) && !element.disabled,
);

if (childrenFocusableElements.length === 0) {
childrenWrapperElement.tabIndex = 1;
childrenFocusableElements.push(childrenWrapperElement);
}

const firstFocusableElement = childrenFocusableElements[0];
const lastFocusableElement = childrenFocusableElements[childrenFocusableElements.length - 1];

if (firstFormFieldEl) {
firstFormFieldEl.focus();
} else if (primaryButtonRef?.current != null) {
primaryButtonRef.current.focus();
} else if (closeButtonRef?.current != null) {
closeButtonRef.current.focus();
} else {
firstFocusableElement.focus();
}

const keyPressHandler = (e) => {
if (e.key === 'Escape' && closeButtonRef?.current != null) {
closeButtonRef.current.click();
}

if (
e.key === 'Enter'
&& e.target.nodeName !== 'BUTTON'
&& e.target.nodeName !== 'TEXTAREA'
&& e.target.nodeName !== 'A'
&& primaryButtonRef?.current != null
) {
primaryButtonRef.current.click();
}

if (e.key !== 'Tab' && e.keyCode !== 9) {
return;
}

if (!e.shiftKey && window.document.activeElement === lastFocusableElement) {
firstFocusableElement.focus();
e.preventDefault();
}

if (e.shiftKey && window.document.activeElement === firstFocusableElement) {
lastFocusableElement.focus();
e.preventDefault();
}
};

window.document.addEventListener('keydown', keyPressHandler, false);

const removeKeyPressHandler = () => {
window.document.removeEventListener('keydown', keyPressHandler, false);
};

// If `autoFocus` is set to `true`, following code finds first form field element
// (input, textarea or select) or primary button and auto focuses it. This is necessary
// to have focus on one of those elements to be able to submit form by pressing Enter key.
if (autoFocus) {
if (childrenWrapperRef?.current != null) {
const childrenWrapperElement = childrenWrapperRef.current;
const childrenElements = childrenWrapperElement.querySelectorAll('*');
const formFieldEl = Array.from(childrenElements).find(
(element) => ['INPUT', 'TEXTAREA', 'SELECT'].includes(element.nodeName) && !element.disabled,
);

if (formFieldEl) {
formFieldEl.focus();
return removeKeyPressHandler;
}
}
return removeKeyPressHandler;
}, [autoFocus, closeButtonRef, primaryButtonRef]);

useLayoutEffect(() => {
if (preventScrollUnderneath === 'off') {
return () => {};
}

if (preventScrollUnderneath === 'default') {
const scrollbarWidth = Math.abs(window.innerWidth - window.document.documentElement.clientWidth);
const prevOverflow = window.document.body.style.overflow;
const prevPaddingRight = window.document.body.style.paddingRight;

if (primaryButtonRef?.current != null) {
primaryButtonRef.current.focus();
window.document.body.style.overflow = 'hidden';
if (Number.isNaN(parseInt(prevPaddingRight, 10))) {
window.document.body.style.paddingRight = `${scrollbarWidth}px`;
} else {
window.document.body.style.paddingRight = `calc(${prevPaddingRight} + ${scrollbarWidth}px)`;
}

return () => {
window.document.body.style.overflow = prevOverflow;
window.document.body.style.paddingRight = prevPaddingRight;
};
}

return removeKeyPressHandler;
}, []); // eslint-disable-line react-hooks/exhaustive-deps
preventScrollUnderneath?.start();

return preventScrollUnderneath?.reset;
}, [preventScrollUnderneath]);

if (portalId === null) {
return preRender(
Expand Down Expand Up @@ -157,6 +223,7 @@ Modal.defaultProps = {
closeButtonRef: null,
portalId: null,
position: 'center',
preventScrollUnderneath: 'default',
primaryButtonRef: null,
size: 'medium',
};
Expand Down Expand Up @@ -192,6 +259,22 @@ Modal.propTypes = {
* Vertical position of the modal inside browser window.
*/
position: PropTypes.oneOf(['top', 'center']),
/**
* Mode in which Modal prevents scroll of elements bellow:
* * `default` - Modal prevents scroll on the `body` element
* * `off` - Modal does not prevent any scroll
* * object
* * * `reset` - method called on Modal's unmount to reset scroll prevention
* * * `start` - method called on Modal's mount to custom scroll prevention
*/
preventScrollUnderneath: PropTypes.oneOf([
'default',
'off',
PropTypes.shape({
reset: PropTypes.func,
start: PropTypes.func,
}),
]),
/**
* Reference to primary button element. It is used to submit modal when Enter key is pressed and as fallback
* when `autoFocus` functionality does not find any input element to be focused.
Expand Down
148 changes: 148 additions & 0 deletions src/lib/components/Modal/README.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1007,6 +1007,154 @@ independent of the page itself. This can be done in three ways using the
off to prevent the modal from scrolling to the end immediately after being
opened.

## Prevent scrolling underneath the Modal

You can choose the mode in which Modal prevents scroll of the page underneath. Default
mode prevents scroll on `<body>` element. If you choose `off`, there will be no scroll
prevention. If you need more flexibility, define your own methods `start` (called
on Modal's mount) and `reset` (called on Modal's unmount) wrapped by an object
and handle scroll prevention yourself.

<Playground>
{() => {
const [modalOpen, setModalOpen] = React.useState(null);
const modalPrimaryButtonRef = React.useRef();
const modalCloseButtonRef = React.useRef();
const customScrollPreventionObject = {
start: () => {
// YOUR CUSTOM SCROLL PREVENTING LOGIC GOES HERE
window.document.body.style.overflowY = 'hidden'
},
reset: () => {
// YOUR CUSTOM SCROLL RE-ENABLING LOGIC GOES HERE
window.document.body.style.overflowY = 'auto'
},
};
return (
<>
<Button
label="Launch modal with default scroll prevention"
onClick={() => setModalOpen(1)}
/>
<Button
label="Launch modal with no scroll prevention"
onClick={() => setModalOpen(2)}
/>
<Button
label="Launch modal with custom scroll prevention"
onClick={() => setModalOpen(3)}
/>
<div>
{modalOpen === 1 && (
<Modal
closeButtonRef={modalOpen === 1 ? modalCloseButtonRef : null}
primaryButtonRef={modalOpen === 1 ? modalPrimaryButtonRef : null}
>
<ModalHeader>
<ModalTitle>Delete the user?</ModalTitle>
<ModalCloseButton onClick={() => setModalOpen(false)} />
</ModalHeader>
<ModalBody>
<ModalContent>
<p>
Do you really want to delete the user <code>admin</code>?
This cannot be undone.
</p>
</ModalContent>
</ModalBody>
<ModalFooter>
<Button
color="danger"
label="Delete"
onClick={() => setModalOpen(false)}
ref={modalOpen === 1 ? modalPrimaryButtonRef : null}
/>
<Button
color="secondary"
label="Close"
onClick={() => setModalOpen(false)}
priority="outline"
ref={modalOpen === 1 ? modalCloseButtonRef : null}
/>
</ModalFooter>
</Modal>
)}
{modalOpen === 2 && (
<Modal
preventScrollUnderneath="off"
closeButtonRef={modalOpen === 2 ? modalCloseButtonRef : null}
primaryButtonRef={modalOpen === 2 ? modalPrimaryButtonRef : null}
>
<ModalHeader>
<ModalTitle>Delete the user?</ModalTitle>
<ModalCloseButton onClick={() => setModalOpen(false)} />
</ModalHeader>
<ModalBody>
<ModalContent>
<p>
Do you really want to delete the user <code>admin</code>?
This cannot be undone.
</p>
</ModalContent>
</ModalBody>
<ModalFooter>
<Button
color="danger"
label="Delete"
onClick={() => setModalOpen(false)}
ref={modalOpen === 2 ? modalPrimaryButtonRef : null}
/>
<Button
color="secondary"
label="Close"
onClick={() => setModalOpen(false)}
priority="outline"
ref={modalOpen === 2 ? modalCloseButtonRef : null}
/>
</ModalFooter>
</Modal>
)}
{modalOpen === 3 && (
<Modal
closeButtonRef={modalCloseButtonRef}
primaryButtonRef={modalPrimaryButtonRef}
preventScrollUnderneath={customScrollPreventionObject}
>
<ModalHeader>
<ModalTitle>Delete the user?</ModalTitle>
<ModalCloseButton onClick={() => setModalOpen(false)} />
</ModalHeader>
<ModalBody>
<ModalContent>
<p>
Do you really want to delete the user <code>admin</code>?
This cannot be undone.
</p>
</ModalContent>
</ModalBody>
<ModalFooter>
<Button
color="danger"
label="Delete"
onClick={() => setModalOpen(false)}
ref={modalOpen === 3 ? modalPrimaryButtonRef : null}
/>
<Button
color="secondary"
label="Close"
onClick={() => setModalOpen(false)}
priority="outline"
ref={modalOpen === 3 ? modalCloseButtonRef : null}
/>
</ModalFooter>
</Modal>
)}
</div>
</>
);
}}
</Playground>

<!-- markdownlint-disable MD024 -->

## Forwarding HTML Attributes
Expand Down

0 comments on commit 6eafb35

Please sign in to comment.