Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added Modal component + example usage #98

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
57 changes: 57 additions & 0 deletions frontend/src/components/Modal/Modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { createPortal } from 'react-dom';
import styles from './styles.module.css';
import { useModalFunctionality } from './utils';

type ModalBaseProps = {
children: React.ReactNode;
onClose?: (x: React.MouseEvent) => void;
};

type OptionalProps = {
[K in Exclude<
keyof JSX.IntrinsicElements['div'],
keyof ModalBaseProps
>]?: JSX.IntrinsicElements['div'][K];
};

type ModalProps = ModalBaseProps & OptionalProps;

const Modal = ({
children,
onClose,
className,

...rest
}: ModalProps) => {
useModalFunctionality();

return (
<>
{createPortal(
<div className={styles['modalOuter']}>
<div
aria-modal="true"
role="dialog"
{...rest}
className={`${className} ${styles['modalInner']}`}
>
{children}
{onClose && (
<button
aria-label="close"
className={styles['closeButton']}
onClick={(event) => onClose(event)}
type="button"
>
&times;
</button>
)}
</div>
</div>,
document.querySelector('#contra_modal_container') as HTMLElement
)}
</>
);
};

export default Modal;
1 change: 1 addition & 0 deletions frontend/src/components/Modal/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './Modal';
37 changes: 37 additions & 0 deletions frontend/src/components/Modal/styles.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
.modalOuter {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;

display: flex;
align-items: center;
justify-content: center;
}

.modalInner {
position: relative;

padding: 20px;
background: white;
min-width: 150px;
max-width: 100px;
}

.closeButton {
position: absolute;
top: 2px;
right: 7px;

background: none;
color: inherit;
border: none;
padding: 0;
font: inherit;
cursor: pointer;

font-size: 25px;
font-weight: 900;
color: black;
}
87 changes: 87 additions & 0 deletions frontend/src/components/Modal/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { useEffect } from 'react';

let count = 0;

const focusableElementsSelector =
'a[href], button, textarea, input, select, details, [tabindex]:not([tabindex="-1"])';

const getTopModal = function () {
const container = document.querySelector('#contra_modal_container');
if (container === null) throw new Error('No #contra_modal_container in DOM');

const modals = container.childNodes;
if (modals.length === 0) return null;
const topModal = modals[modals.length - 1];

return topModal as HTMLElement;
};

const getFocusableElements = function (element: HTMLElement | null) {
if (!element) return [];

return Array.from(element.querySelectorAll(focusableElementsSelector)).filter(
(focusableElement) =>
!focusableElement.hasAttribute('disabled') &&
!focusableElement.getAttribute('aria-hidden')
) as HTMLElement[];
};

const keyDownListener = function (event: KeyboardEvent) {
if (count === 0 || event.key !== 'Tab') return;

const topModal = getTopModal();
const focusableElements = getFocusableElements(topModal);

const first = focusableElements[0];
const last = focusableElements[focusableElements.length - 1];

if (event.shiftKey && document.activeElement === first) {
last?.focus();
event.preventDefault();
} else if (
(!event.shiftKey && document.activeElement === last) ||
!topModal?.contains(document.activeElement)
) {
first?.focus();
event.preventDefault();
}
};

export const useModalFunctionality = function () {
useEffect(() => {
count++;

const isFirst = count === 1;
if (isFirst) {
document.documentElement.classList.add('noscroll' as string);
document.querySelector('#__next')?.setAttribute('aria-hidden', 'true');
window.addEventListener('keydown', keyDownListener);
}

// If something outside the modal is focused
if (!getTopModal()?.contains(document.activeElement)) {
// remove focus entirely so that the first focusable element in the modal is selected the next time the user presses tab
const activeElement = document.activeElement;
if (activeElement instanceof HTMLElement) activeElement.blur();

/*
// Alternative option: focus the first element in the modal
const firstFocusableElement = getFocusableElements(getTopModal())[0];
if (firstFocusableElement instanceof HTMLElement) {
firstFocusableElement.focus();
}
*/
}

return () => {
count--;

const noModalsLeft = count === 0;
if (noModalsLeft) {
document.querySelector('#__next')?.setAttribute('aria-hidden', 'false');
document.documentElement.classList.remove('noscroll' as string);
window.removeEventListener('keydown', keyDownListener);
}
};
}, []);
};
40 changes: 40 additions & 0 deletions frontend/src/pages/_document.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Html, Head, Main, NextScript } from 'next/document';

export default function Document() {
return (
<Html>
<Head />
<body>
<div id="contra_modal_container" />
<style>{`
html {
height: 100%;
}

body {
min-height: 100%;
width: 100%;
margin: 0;
overflow-y: scroll;
}

html.noscroll #contra_modal_container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(47,47,47,0.6);
z-index: 999999;
}

html.noscroll, html.noscroll > body {
position: fixed;
}
`}</style>
<Main />
<NextScript />
</body>
</Html>
);
}
79 changes: 78 additions & 1 deletion frontend/src/pages/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,85 @@
/* eslint-disable canonical/filename-match-exported */

import { type NextPage } from 'next';
import { useState } from 'react';
import Modal from 'components/Modal';

const Index: NextPage = () => {
return <h1>Welcome to Contra!</h1>;
const [modal1active, setModal1active] = useState(false);
const [modal2active, setModal2active] = useState(false);
const [modal3active, setModal3active] = useState(false);

return (
<div
style={{
alignItems: 'baseline',
display: 'flex',
flexDirection: 'column',
}}
>
<h1>Welcome to Contra!</h1>
<button onClick={() => setModal1active(true)} type="button">
Open Modal 1
</button>
<button onClick={() => setModal2active(true)} type="button">
Open Modal 2
</button>
<button onClick={() => setModal3active(true)} type="button">
Open Modal 3, which has an autofocus attribute
</button>
<button
onClick={() => {
setModal1active(true);
setTimeout(() => setModal2active(true), 1_000);
}}
type="button"
>
Open Modal 1 and one second later modal 2
</button>
<button
onClick={() => {
setModal2active(true);
setTimeout(() => setModal1active(true), 1_000);
}}
type="button"
>
Open Modal 2 and one second later modal 1
</button>
<button
onClick={() => {
setModal1active(true);
setModal2active(true);
setModal3active(true);
}}
type="button"
>
Open all three modals at once
</button>
{modal1active && (
<Modal onClose={() => setModal1active(false)}>
Hi there from modal 1!
<button type="button">A</button>
<button type="button">B</button>
</Modal>
)}
{modal2active && (
<Modal onClose={() => setModal2active(false)}>
Hi there from modal 2!
<button type="button">A</button>
<button type="button">B</button>
</Modal>
)}
{modal3active && (
<Modal onClose={() => setModal3active(false)}>
Hi there from modal 3!
<button type="button">A</button>
<button autoFocus type="button">
B - Autofocus
</button>
</Modal>
)}
</div>
);
};

export default Index;