Skip to content

Commit

Permalink
feat(auth): make Modal component more generic
Browse files Browse the repository at this point in the history
  • Loading branch information
ChristiaanScheermeijer committed Jul 19, 2021
1 parent a1dfad7 commit 9474f87
Show file tree
Hide file tree
Showing 12 changed files with 172 additions and 259 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"react-router-dom": "^5.2.0",
"react-virtualized": "^9.22.3",
"tile-dock": "https://github.com/RCVZ/tile-dock-temp",
"wicg-inert": "^3.1.1",
"yup": "^0.32.9"
},
"devDependencies": {
Expand Down
46 changes: 17 additions & 29 deletions src/components/Modal/Modal.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2,68 +2,56 @@
@use '../../styles/theme';
@use '../../styles/mixins/responsive';

.overlay {
.modal {
position: fixed;
top: 0;
left: 0;
z-index: 1;
width: 100vw;
height: 100vh;
}

.backdrop {
width: inherit;
height: inherit;
background: rgba(0, 0, 0, 0.6);
}
.modalContainer {
position: absolute;
top: 0;
left: 0;
display: flex;
justify-content: center;
align-items: center;
width: 100vw;
height: 100vh;
width: 100%;
height: 100%;
background-color: theme.$modal-backdrop-bg;
cursor: pointer;
}
.modalBackground {

.container {
position: absolute;
width: 80vw;
height: calc(80vw / 16 * 9);
background-color: rgba(0, 0, 0, 0.9);
@include responsive.mobile-only() {
width: 100vw;
height: calc(100vw / 16 * 9);
}
}
.modal {
position: relative;
width: 80vw;
height: calc(80vw / 16 * 9);
@include responsive.mobile-only() {
width: 100vw;
height: calc(100vw / 16 * 9);
}
top: 50%;
left: 50%;
width: 100%;
transform: translate(-50%, -50%);
}

.close {
position: absolute;
top: 16px;
right: 16px;
width: 48px;
height: 48px;
opacity: 1;

> svg {
width: 28px;
height: 28px;
-webkit-filter: drop-shadow(2px 2px 2px rgba(0, 0, 0, 0.4)); /* stylelint-disable-line */
filter: drop-shadow(2px 2px 2px rgba(0, 0, 0, 0.4));
}

&.hidden {
opacity: 0;
transition: opacity 0.6s ease;
}

@include responsive.mobile-only() {
width: 24px;
height: 24px;

> svg {
width: 14px;
height: 14px;
Expand Down
52 changes: 51 additions & 1 deletion src/components/Modal/Modal.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { render } from '@testing-library/react';
import { fireEvent, render } from '@testing-library/react';

import Modal from './Modal';

Expand All @@ -13,4 +13,54 @@ describe('<Modal>', () => {

expect(container).toMatchSnapshot();
});

test('calls the onClose function when clicking the backdrop', () => {
const onClose = jest.fn();
const { getByTestId } = render(<Modal open={true} onClose={onClose} />);

fireEvent.click(getByTestId('backdrop'));

expect(onClose).toBeCalledTimes(1);
});

test('calls the onClose function when clicking the close icon', () => {
const onClose = jest.fn();
const { getByLabelText } = render(<Modal open={true} onClose={onClose} closeButtonVisible />);

fireEvent.click(getByLabelText('close_modal'));

expect(onClose).toBeCalledTimes(1);
});

test('should add aria-hidden and inert attributes on the root div when open', () => {
const onClose = jest.fn();
const { getByTestId, rerender } = render(
<div id="root" data-testid="root">
<Modal open={true} onClose={onClose} closeButtonVisible />
</div>,
);

expect(getByTestId('root')).toHaveAttribute('aria-hidden', 'true');
expect(getByTestId('root')).toHaveProperty('inert', true);

rerender(
<div id="root" data-testid="root">
<Modal open={false} onClose={onClose} closeButtonVisible />
</div>,
);

expect(getByTestId('root')).not.toHaveAttribute('aria-hidden', 'true');
expect(getByTestId('root')).toHaveProperty('inert', false);
});

test('should add overflowY hidden on the body element when open', () => {
const onClose = jest.fn();
const { container, rerender } = render(<Modal open={true} onClose={onClose} closeButtonVisible />);

expect(container.parentNode).toHaveStyle({ overflowY: 'hidden' });

rerender(<Modal open={false} onClose={onClose} closeButtonVisible />);

expect(container.parentNode).not.toHaveStyle({ overflowY: 'hidden' });
});
});
108 changes: 71 additions & 37 deletions src/components/Modal/Modal.tsx
Original file line number Diff line number Diff line change
@@ -1,63 +1,97 @@
import React, { ReactFragment, useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import React, { useEffect, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';

import Fade from '../Animation/Fade/Fade';
import IconButton from '../IconButton/IconButton';
import Close from '../../icons/Close';
import Fade from '../Animation/Fade/Fade';
import Grow from '../Animation/Grow/Grow';

import styles from './Modal.module.scss';

type Props = {
className?: string;
children?: React.ReactNode;
open: boolean;
onClose: () => void;
onClose?: () => void;
closeButtonVisible?: boolean;
children: ReactFragment;
};

const Modal: React.FC<Props> = ({ open, onClose, closeButtonVisible = true, children }: Props) => {
const Modal: React.FC<Props> = ({ className, open, onClose, children, closeButtonVisible }: Props) => {
const { t } = useTranslation('common');
const [doRender, setDoRender] = useState<boolean>(false);
const [visible, setVisible] = useState(open);
const lastFocus = useRef<HTMLElement>() as React.MutableRefObject<HTMLElement>;
const modalRef = useRef<HTMLDivElement>() as React.MutableRefObject<HTMLDivElement>;

const keyDownEventHandler = (event: React.KeyboardEvent) => {
if (event.key === 'Escape' && onClose) {
onClose();
}
};

// delay the transition state so the CSS transition kicks in after toggling the `open` prop
useEffect(() => {
const listener = (event: KeyboardEvent) => event.keyCode === 27 && onClose();
const activeElement = document.activeElement as HTMLElement;
const appView = document.querySelector('#root') as HTMLDivElement;

if (open) {
// store last focussed element
if (activeElement) {
lastFocus.current = activeElement;
}

// reset the visible state
setVisible(true);

// make sure main content is hidden for screen readers and inert
if (appView) {
appView.setAttribute('aria-hidden', 'true');
appView.inert = true;
}

// prevent scrolling under the modal
document.body.style.overflowY = 'hidden';
document.addEventListener('keydown', listener);

// focus the first element in the modal
if (modalRef.current) {
const interactiveElement = modalRef.current.querySelectorAll('a, button, [tabindex="0"]')[0] as HTMLElement | null;

if (interactiveElement) interactiveElement.focus();
}
} else {
if (appView) {
appView.removeAttribute('aria-hidden');
appView.inert = false;
}

document.body.style.overflowY = '';
document.removeEventListener('keydown', listener);

// restore last focussed element
if (lastFocus.current) {
lastFocus.current.focus();
}
}
}, [open, onClose]);

return (
<Fade open={open} duration={300}>
<div className={classNames(styles.overlay)} onClick={onClose}>
<div className={classNames(styles.backdrop)} />
<div className={classNames(styles.modalContainer)}>
<Grow
open={open}
delay={100}
duration={200}
onOpenAnimationEnd={() => setDoRender(true)}
onCloseAnimationEnd={() => setDoRender(false)}
}, [open]);

if (!open && !visible) return null;

return ReactDOM.createPortal(
<Fade open={open} duration={300} onCloseAnimationEnd={() => setVisible(false)}>
<div className={styles.modal} onKeyDown={keyDownEventHandler} ref={modalRef}>
<div className={styles.backdrop} onClick={onClose} data-testid="backdrop" />
<div className={classNames(styles.container, className)}>
{children}
<IconButton
onClick={onClose}
aria-label={t('close_modal')}
className={classNames(styles.close, { [styles.hidden]: !closeButtonVisible })}
>
<div className={classNames(styles.modalBackground)} />
<div className={classNames(styles.modal)} onClick={(event) => event.stopPropagation()}>
{doRender && children}
<IconButton
onClick={onClose}
aria-label={t('close_modal')}
className={classNames(styles.close, { [styles.hidden]: !closeButtonVisible })}
>
<Close />
</IconButton>
</div>
</Grow>
<Close />
</IconButton>
</div>
</div>
</Fade>
</Fade>,
document.querySelector('body') as HTMLElement,
);
};

Expand Down
55 changes: 1 addition & 54 deletions src/components/Modal/__snapshots__/Modal.test.tsx.snap
Original file line number Diff line number Diff line change
@@ -1,56 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`<Modal> renders and matches snapshot 1`] = `
<div>
<div
style="transition: opacity 0.3s ease-in-out; opacity: 0;"
>
<div
class="overlay"
>
<div
class="backdrop"
/>
<div
class="modalContainer"
>
<div
style="transition: transform 0.2s ease-out; transform: scale(0.1);"
>
<div
class="modalBackground"
/>
<div
class="modal"
>
<div
aria-label="close_modal"
class="iconButton close"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
class="icon"
focusable="false"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<g>
<path
d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"
/>
<path
d="M0 0h24v24H0z"
fill="none"
/>
</g>
</svg>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;
exports[`<Modal> renders and matches snapshot 1`] = `<div />`;
Loading

0 comments on commit 9474f87

Please sign in to comment.