Skip to content

Commit

Permalink
Added separate components for different behaviours of Modal vs Non-Mo…
Browse files Browse the repository at this point in the history
…dal.
  • Loading branch information
lucasff committed Aug 2, 2023
1 parent 3a5157d commit 69caaef
Show file tree
Hide file tree
Showing 14 changed files with 599 additions and 137 deletions.
4 changes: 3 additions & 1 deletion frontend/.prettierignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
package.json
.eslintrc
.gitignore
.eslintrc
.next
2 changes: 1 addition & 1 deletion frontend/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const customJestConfig = {
// setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
// if using TypeScript with a baseUrl set to the root directory then you need the below for alias' to work
moduleDirectories: ['node_modules', '<rootDir>/'],
testEnvironment: 'jest-environment-jsdom'
testEnvironment: 'jest-environment-jsdom',
};

// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
Expand Down
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"lint": "yarn lint:eslint && yarn lint:prettier",
"lint:eslint": "eslint './src/**/*.{js,ts,tsx}' --cache",
"lint:prettier": "prettier --ignore-path .gitignore --check './**/*.{js,ts,tsx}'",
"lint:prettier-write": "prettier --ignore-path .gitignore --ignore-path .next './**/*.{js,ts,tsx}' -w",
"lint:prettier-write": "prettier './**/*.{js,ts,tsx}' -w",
"test": "NODE_ENV=test jest --watch",
"test:ci": "NODE_ENV=test jest --ci --reporters jest-silent-reporter"
},
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/components/Dialog/Close.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
align-items: center;
justify-content: center;
transition: all .2s;
text-indent: -9999px;
}
.close:hover:before {
box-shadow: white 0 0 5px;
Expand All @@ -26,6 +27,7 @@
background-color: rgba(0, 0, 0, 1);
box-shadow: white 0 0 4px;
border-radius: 100%;
padding: 2px;
padding: 1px 2px 3px;
line-height: 100%;
text-indent: 0;
}
47 changes: 37 additions & 10 deletions frontend/src/components/Dialog/Dialog.module.css
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
.dialogRoot {
width: 100%;
height: 100%;
width: 100vw;
height: 100vh;
display: flex;
position: fixed;
top: 0;
Expand All @@ -9,26 +9,53 @@
justify-content: center;
align-items: center;
transition: all 0.25s ease-in;
visibility: visible;
opacity: 1;
backdrop-filter: blur(1px);
opacity: 0;
}

.dialogRootFadeOut {
visibility: visible;
opacity: 0;
.dialogRootShow {
opacity: 1;
}

.dialog[open]::backdrop {
animation-name: backdrop-fade;
animation-duration: 0.25s;
animation-timing-function: ease-in;
animation-direction: alternate;
animation-fill-mode: forwards;
}

.dialog.close::backdrop {
animation-name: backdrop-fade;
animation-duration: 0.5s;
animation-timing-function: ease-out;
animation-direction: alternate-reverse;
animation-fill-mode: backwards;
animation-delay: 0.5s;
background: transparent;
}

.dialogRoot.hide {
display: none;
@keyframes backdrop-fade {
from {
background: transparent;
}
to {
background: rgba(0, 0, 0, 0.5);
}
}

.dialog {
background-color: #fff;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
padding: 16px;
max-width: 400px;
max-width: 80vh;
overflow: visible;
}

.dialog::backdrop {
transition: all 0.25s ease-in-out;
/*background: transparent;*/
}

.blockScrolling {
Expand Down
20 changes: 16 additions & 4 deletions frontend/src/components/Dialog/Dialog.test.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { render, screen, fireEvent } from '@testing-library/react';
import React from 'react';
import { Dialog } from '.';
import '@testing-library/jest-dom'
import '@testing-library/jest-dom';

test('renders and opens the dialog when isOpen is true', () => {
const onClose = jest.fn();
const children = <div>Test Content</div>;

render(<Dialog isOpen={true} onClose={onClose}>{children}</Dialog>);
render(
<Dialog isOpen={true} onClose={onClose}>
{children}
</Dialog>
);

// Dialog should be in the document
const dialogElement = screen.getByRole('dialog');
Expand All @@ -25,7 +29,11 @@ test('does not render and closes the dialog when isOpen is false', () => {
const onClose = jest.fn();
const children = <div>Test Content</div>;

render(<Dialog isOpen={false} onClose={onClose}>{children}</Dialog>);
render(
<Dialog isOpen={false} onClose={onClose}>
{children}
</Dialog>
);

// Dialog should not be in the document
const dialogElement = screen.queryByRole('dialog');
Expand All @@ -36,7 +44,11 @@ test('calls onClose when clicking outside the dialog', () => {
const onClose = jest.fn();
const children = <div>Test Content</div>;

render(<Dialog isOpen={true} onClose={onClose}>{children}</Dialog>);
render(
<Dialog isOpen={true} onClose={onClose}>
{children}
</Dialog>
);

// Click outside the dialog
fireEvent.click(screen.getByTestId('dialog-root'));
Expand Down
112 changes: 112 additions & 0 deletions frontend/src/components/Dialog/Dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import React, { useEffect, useRef, useCallback, type ReactNode } from 'react';
import closeStyle from './Close.module.css';
import styles from './Dialog.module.css';
import { modalStack } from './DialogManager';

type ModalProps = {
children: ReactNode;
isOpen: boolean;
onClose: () => void;
};

export const Dialog: React.FC<ModalProps> = (props: ModalProps) => {
const dialogRef = useRef<HTMLDialogElement>(null);
const dialogRootRef = useRef<HTMLDivElement>(null);
const { isOpen, onClose, children } = props;

// Handle tab key press
const handleTabKey = useCallback((event: KeyboardEvent) => {
if (event.key === 'Tab') {
const focusableElements =
dialogRef.current?.querySelectorAll<HTMLElement>(
'a[href], button, textarea, input[type="text"], input[type="radio"], input[type="checkbox"], select'
);

if (!focusableElements || focusableElements.length === 0) return;

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

if (event.shiftKey && document.activeElement === firstFocusableElement) {
event.preventDefault();
lastFocusableElement?.focus();
} else if (
!event.shiftKey &&
document.activeElement === lastFocusableElement
) {
event.preventDefault();
firstFocusableElement?.focus();
}
}
}, []);

useEffect(() => {
if (isOpen) {
dialogRootRef.current?.classList.add(
styles['dialogRootShow'] as string
);
modalStack.add(dialogRef.current as HTMLDialogElement);
document.addEventListener('keydown', handleTabKey);
document.body.classList.add(styles['blockScrolling'] as string);
} else {
modalStack.delete(dialogRef.current as HTMLDialogElement);
document.removeEventListener('keydown', handleTabKey);
document.body.classList.remove(styles['blockScrolling'] as string);
}

return () => {
document.removeEventListener('keydown', handleTabKey);
document.body.classList.remove(styles['blockScrolling'] as string);
};
}, [isOpen, handleTabKey]);

// Close modal, either by clicking on the backdrop or the "close" icon.
const handleClose = useCallback(() => {
// @ts-expect-error will be refactored
dialogRootRef.current?.classList.add(styles.dialogRootFadeOut);
setTimeout(() => {
// @ts-expect-error will be refactored
dialogRootRef.current?.classList.toggle(styles.dialogRootFadeOut);
// @ts-expect-error will be refactored
dialogRootRef.current?.classList.add(styles.hide);
}, 250);

// Calls the user of this component passed event handler.
setTimeout(onClose, 500);
modalStack.delete(dialogRef.current as HTMLDialogElement);
}, [onClose]);

// Close modal when clicking outside of it
const handleOutsideClick = useCallback(
(event: React.MouseEvent) => {
event.stopPropagation();

// eslint-disable-next-line eqeqeq
const modalStackArray = [...modalStack];
if (
event.target === dialogRootRef.current &&
modalStackArray.at(-1) === dialogRef.current
) {
handleClose();
}
},
[handleClose]
);

if (!isOpen) return null;

return (
<div
className={styles['dialogRoot']}
data-testid="dialog-root"
onClick={handleOutsideClick}
ref={dialogRootRef}
>
<dialog className={styles['dialog']} open={isOpen} ref={dialogRef}>
<a className={closeStyle['close']} onClick={handleClose}></a>
{children}
</dialog>
</div>
);
};
73 changes: 73 additions & 0 deletions frontend/src/components/Dialog/DialogModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { render, screen, fireEvent } from '@testing-library/react';
import React from 'react';
import { DialogModal as Dialog } from './DialogModal';
import '@testing-library/jest-dom';
import {describe} from "@jest/globals";

describe('DialogModal', () => {
test('renders and opens the dialog when isOpen is true', () => {
const onClose = jest.fn();
const children = <div>Test Content</div>;

render(
<Dialog isOpen={true} onClose={onClose}>
{children}
</Dialog>
);

// Dialog should be in the document
const dialogElement = screen.getByTestId('dialog');
expect(dialogElement).toBeInTheDocument();

// Dialog should have the "open" attribute set to true
setTimeout(() => {
expect(dialogElement).toHaveAttribute('open', '');
}, 500);

// Children should be rendered inside the dialog
const childElement = screen.getByText('Test Content');
expect(childElement).toBeInTheDocument();
});

test('does not render and closes the dialog when isOpen is false', () => {
const onClose = jest.fn();
const children = <div>Test Content</div>;

render(
<Dialog isOpen={false} onClose={onClose}>
{children}
</Dialog>
);

// Dialog should not be in the document
const dialogElement = screen.queryByRole('dialog');
expect(dialogElement).not.toBeInTheDocument();
});

test('calls onClose when clicking outside the dialog', () => {
const onClose = jest.fn();
const children = <div>Test Content</div>;

render(
<Dialog isOpen={true} onClose={onClose}>
{children}
</Dialog>
);

// Click outside the dialog
fireEvent(
document,
new MouseEvent('click', {
bubbles: true,
cancelable: true,
clientX: 25,
clientY: 25,
})
);

// onClose should be called
setTimeout(() => {
expect(onClose).toHaveBeenCalledTimes(1);
}, 500);
});
})

0 comments on commit 69caaef

Please sign in to comment.