Skip to content
This repository was archived by the owner on Dec 20, 2024. It is now read-only.

Commit 97e7056

Browse files
committed
Added separate components for different behaviours of Modal vs Non-Modal.
1 parent 3a5157d commit 97e7056

14 files changed

+596
-137
lines changed

frontend/.prettierignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
package.json
2-
.eslintrc
2+
.gitignore
3+
.eslintrc
4+
.next

frontend/jest.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const customJestConfig = {
1212
// setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
1313
// if using TypeScript with a baseUrl set to the root directory then you need the below for alias' to work
1414
moduleDirectories: ['node_modules', '<rootDir>/'],
15-
testEnvironment: 'jest-environment-jsdom'
15+
testEnvironment: 'jest-environment-jsdom',
1616
};
1717

1818
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async

frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"lint": "yarn lint:eslint && yarn lint:prettier",
1010
"lint:eslint": "eslint './src/**/*.{js,ts,tsx}' --cache",
1111
"lint:prettier": "prettier --ignore-path .gitignore --check './**/*.{js,ts,tsx}'",
12-
"lint:prettier-write": "prettier --ignore-path .gitignore --ignore-path .next './**/*.{js,ts,tsx}' -w",
12+
"lint:prettier-write": "prettier './**/*.{js,ts,tsx}' -w",
1313
"test": "NODE_ENV=test jest --watch",
1414
"test:ci": "NODE_ENV=test jest --ci --reporters jest-silent-reporter"
1515
},

frontend/src/components/Dialog/Close.module.css

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
align-items: center;
1111
justify-content: center;
1212
transition: all .2s;
13+
text-indent: -9999px;
1314
}
1415
.close:hover:before {
1516
box-shadow: white 0 0 5px;
@@ -26,6 +27,7 @@
2627
background-color: rgba(0, 0, 0, 1);
2728
box-shadow: white 0 0 4px;
2829
border-radius: 100%;
29-
padding: 2px;
30+
padding: 1px 2px 3px;
3031
line-height: 100%;
32+
text-indent: 0;
3133
}

frontend/src/components/Dialog/Dialog.module.css

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
.dialogRoot {
2-
width: 100%;
3-
height: 100%;
2+
width: 100vw;
3+
height: 100vh;
44
display: flex;
55
position: fixed;
66
top: 0;
@@ -9,26 +9,53 @@
99
justify-content: center;
1010
align-items: center;
1111
transition: all 0.25s ease-in;
12-
visibility: visible;
13-
opacity: 1;
1412
backdrop-filter: blur(1px);
13+
opacity: 0;
1514
}
1615

17-
.dialogRootFadeOut {
18-
visibility: visible;
19-
opacity: 0;
16+
.dialogRootShow {
17+
opacity: 1;
18+
}
19+
20+
.dialog[open]::backdrop {
21+
animation-name: backdrop-fade;
22+
animation-duration: 0.25s;
23+
animation-timing-function: ease-in;
24+
animation-direction: alternate;
25+
animation-fill-mode: forwards;
26+
}
27+
28+
.dialog.close::backdrop {
29+
animation-name: backdrop-fade;
30+
animation-duration: 0.5s;
31+
animation-timing-function: ease-out;
32+
animation-direction: alternate-reverse;
33+
animation-fill-mode: backwards;
34+
animation-delay: 0.5s;
35+
background: transparent;
2036
}
2137

22-
.dialogRoot.hide {
23-
display: none;
38+
@keyframes backdrop-fade {
39+
from {
40+
background: transparent;
41+
}
42+
to {
43+
background: rgba(0, 0, 0, 0.5);
44+
}
2445
}
2546

2647
.dialog {
2748
background-color: #fff;
2849
border-radius: 4px;
2950
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
3051
padding: 16px;
31-
max-width: 400px;
52+
max-width: 80vh;
53+
overflow: visible;
54+
}
55+
56+
.dialog::backdrop {
57+
transition: all 0.25s ease-in-out;
58+
/*background: transparent;*/
3259
}
3360

3461
.blockScrolling {

frontend/src/components/Dialog/Dialog.test.tsx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import { render, screen, fireEvent } from '@testing-library/react';
22
import React from 'react';
33
import { Dialog } from '.';
4-
import '@testing-library/jest-dom'
4+
import '@testing-library/jest-dom';
55

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

10-
render(<Dialog isOpen={true} onClose={onClose}>{children}</Dialog>);
10+
render(
11+
<Dialog isOpen={true} onClose={onClose}>
12+
{children}
13+
</Dialog>
14+
);
1115

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

28-
render(<Dialog isOpen={false} onClose={onClose}>{children}</Dialog>);
32+
render(
33+
<Dialog isOpen={false} onClose={onClose}>
34+
{children}
35+
</Dialog>
36+
);
2937

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

39-
render(<Dialog isOpen={true} onClose={onClose}>{children}</Dialog>);
47+
render(
48+
<Dialog isOpen={true} onClose={onClose}>
49+
{children}
50+
</Dialog>
51+
);
4052

4153
// Click outside the dialog
4254
fireEvent.click(screen.getByTestId('dialog-root'));
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import React, { useEffect, useRef, useCallback, type ReactNode } from 'react';
2+
import closeStyle from './Close.module.css';
3+
import styles from './Dialog.module.css';
4+
import { modalStack } from './DialogManager';
5+
6+
type ModalProps = {
7+
children: ReactNode;
8+
isOpen: boolean;
9+
onClose: () => void;
10+
};
11+
12+
export const Dialog: React.FC<ModalProps> = (props: ModalProps) => {
13+
const dialogRef = useRef<HTMLDialogElement>(null);
14+
const dialogRootRef = useRef<HTMLDivElement>(null);
15+
const { isOpen, onClose, children } = props;
16+
17+
// Handle tab key press
18+
const handleTabKey = useCallback((event: KeyboardEvent) => {
19+
if (event.key === 'Tab') {
20+
const focusableElements =
21+
dialogRef.current?.querySelectorAll<HTMLElement>(
22+
'a[href], button, textarea, input[type="text"], input[type="radio"], input[type="checkbox"], select'
23+
);
24+
25+
if (!focusableElements || focusableElements.length === 0) return;
26+
27+
const firstFocusableElement = focusableElements[0];
28+
const lastFocusableElement =
29+
focusableElements[focusableElements.length - 1];
30+
31+
if (event.shiftKey && document.activeElement === firstFocusableElement) {
32+
event.preventDefault();
33+
lastFocusableElement?.focus();
34+
} else if (
35+
!event.shiftKey &&
36+
document.activeElement === lastFocusableElement
37+
) {
38+
event.preventDefault();
39+
firstFocusableElement?.focus();
40+
}
41+
}
42+
}, []);
43+
44+
useEffect(() => {
45+
if (isOpen) {
46+
modalStack.add(dialogRef.current as HTMLDialogElement);
47+
document.addEventListener('keydown', handleTabKey);
48+
document.body.classList.add(styles['blockScrolling'] as string);
49+
} else {
50+
modalStack.delete(dialogRef.current as HTMLDialogElement);
51+
document.removeEventListener('keydown', handleTabKey);
52+
document.body.classList.remove(styles['blockScrolling'] as string);
53+
}
54+
55+
return () => {
56+
document.removeEventListener('keydown', handleTabKey);
57+
document.body.classList.remove(styles['blockScrolling'] as string);
58+
};
59+
}, [isOpen, handleTabKey]);
60+
61+
// Close modal, either by clicking on the backdrop or the "close" icon.
62+
const handleClose = useCallback(() => {
63+
// @ts-expect-error will be refactored
64+
dialogRootRef.current?.classList.add(styles.dialogRootFadeOut);
65+
setTimeout(() => {
66+
// @ts-expect-error will be refactored
67+
dialogRootRef.current?.classList.toggle(styles.dialogRootFadeOut);
68+
// @ts-expect-error will be refactored
69+
dialogRootRef.current?.classList.add(styles.hide);
70+
}, 250);
71+
72+
// Calls the user of this component passed event handler.
73+
setTimeout(onClose, 500);
74+
modalStack.delete(dialogRef.current as HTMLDialogElement);
75+
}, [onClose]);
76+
77+
// Close modal when clicking outside of it
78+
const handleOutsideClick = useCallback(
79+
(event: React.MouseEvent) => {
80+
event.stopPropagation();
81+
82+
// eslint-disable-next-line eqeqeq
83+
const modalStackArray = [...modalStack];
84+
if (
85+
event.target === dialogRootRef.current &&
86+
modalStackArray.at(-1) === dialogRef.current
87+
) {
88+
handleClose();
89+
}
90+
},
91+
[handleClose]
92+
);
93+
94+
if (!isOpen) return null;
95+
96+
return (
97+
<div
98+
className={styles['dialogRoot']}
99+
data-testid="dialog-root"
100+
onClick={handleOutsideClick}
101+
ref={dialogRootRef}
102+
>
103+
<dialog className={styles['dialog']} open={isOpen} ref={dialogRef}>
104+
<a className={closeStyle['close']} onClick={handleClose}></a>
105+
{children}
106+
</dialog>
107+
</div>
108+
);
109+
};
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { render, screen, fireEvent } from '@testing-library/react';
2+
import React from 'react';
3+
import { DialogModal as Dialog } from './DialogModal';
4+
import '@testing-library/jest-dom';
5+
import {describe} from "@jest/globals";
6+
7+
describe('DialogModal', () => {
8+
test('renders and opens the dialog when isOpen is true', () => {
9+
const onClose = jest.fn();
10+
const children = <div>Test Content</div>;
11+
12+
render(
13+
<Dialog isOpen={true} onClose={onClose}>
14+
{children}
15+
</Dialog>
16+
);
17+
18+
// Dialog should be in the document
19+
const dialogElement = screen.getByTestId('dialog');
20+
expect(dialogElement).toBeInTheDocument();
21+
22+
// Dialog should have the "open" attribute set to true
23+
setTimeout(() => {
24+
expect(dialogElement).toHaveAttribute('open', '');
25+
}, 500);
26+
27+
// Children should be rendered inside the dialog
28+
const childElement = screen.getByText('Test Content');
29+
expect(childElement).toBeInTheDocument();
30+
});
31+
32+
test('does not render and closes the dialog when isOpen is false', () => {
33+
const onClose = jest.fn();
34+
const children = <div>Test Content</div>;
35+
36+
render(
37+
<Dialog isOpen={false} onClose={onClose}>
38+
{children}
39+
</Dialog>
40+
);
41+
42+
// Dialog should not be in the document
43+
const dialogElement = screen.queryByRole('dialog');
44+
expect(dialogElement).not.toBeInTheDocument();
45+
});
46+
47+
test('calls onClose when clicking outside the dialog', () => {
48+
const onClose = jest.fn();
49+
const children = <div>Test Content</div>;
50+
51+
render(
52+
<Dialog isOpen={true} onClose={onClose}>
53+
{children}
54+
</Dialog>
55+
);
56+
57+
// Click outside the dialog
58+
fireEvent(
59+
document,
60+
new MouseEvent('click', {
61+
bubbles: true,
62+
cancelable: true,
63+
clientX: 25,
64+
clientY: 25,
65+
})
66+
);
67+
68+
// onClose should be called
69+
setTimeout(() => {
70+
expect(onClose).toHaveBeenCalledTimes(1);
71+
}, 500);
72+
});
73+
})

0 commit comments

Comments
 (0)