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

Vinicius: Contra Frontend Assessment #94

Open
wants to merge 18 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
3 changes: 3 additions & 0 deletions frontend/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ const ruleOverrides = {
'default-case': 0,
'default-case-last': 0,
'import/extensions': 0,
'import/no-unassigned-import': [2, {
"allow": ["**/*.css", '@testing-library/jest-dom/*']
}],
'jest/prefer-strict-equal': 0,
'jsx-a11y/anchor-is-valid': 0,
'jsx-a11y/mouse-events-have-key-events': 0,
Expand Down
2 changes: 1 addition & 1 deletion frontend/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ const createJestConfig = nextJest({
// Add any custom config to be passed to Jest
const customJestConfig = {
// Add more setup options before each test is run
// 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>/'],
setupFilesAfterEnv: ['@testing-library/jest-dom'],
testEnvironment: 'jest-environment-jsdom',
};

Expand Down
6 changes: 5 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
"react-dom": "18.2.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.4.3",
"@types/jest": "28.1.3",
"@types/node": "18.0.0",
"@types/react": "18.0.14",
Expand All @@ -26,7 +29,8 @@
"eslint-config-canonical": "35.0.1",
"eslint-config-next": "12.1.6",
"eslint-config-prettier": "8.5.0",
"jest": "28.1.1",
"jest": "^28.1.1",
"jest-environment-jsdom": "^29.5.0",
"lint-staged": "13.0.3",
"prettier": "2.7.1",
"typescript": "4.7.4"
Expand Down
38 changes: 38 additions & 0 deletions frontend/src/components/modal/InnerModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { type ReactNode, useState } from 'react';
import { useModalId } from './utils/modalHierarchy';
import { useCloseOnPressingEsc } from './utils/useCloseOnPressingEsc';
import { useFocusTrap } from './utils/useFocusTrap';
import { useScrollLock } from './utils/useScrollLock';

type InnerModalProps = {
children: ReactNode;
onClose: () => void;
};

export const InnerModal = (props: InnerModalProps) => {
const { children, onClose } = props;

const modalId = useModalId();

useCloseOnPressingEsc(modalId, onClose);

useScrollLock();

// We need to run effects in useFocusTrap when this changes, so we're using state instead of a ref.
const [modalContentElement, setModalContentElement] =
useState<HTMLDivElement | null>(null);
useFocusTrap(modalId, modalContentElement);

return (
<div
className="contra--modal-wrapper"
ref={setModalContentElement}
role="presentation"
>
{/* We're listening to Esc events on document.body itself. */}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */}
<div className="contra--modal-background" onClick={onClose} />
<div className="contra--modal-content">{children}</div>
</div>
);
};
35 changes: 35 additions & 0 deletions frontend/src/components/modal/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { InnerModal } from './InnerModal';
import { useIsRunningOnClient } from './utils/useIsRunningOnClient';

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

export const Modal = (props: ModalProps) => {
const { children, container, isOpen, onClose } = props;

/**
* We don't have access to the document on the server, so we just don't render the modal.
* Doing this comes at a cost of not having the modal on the server-generated HTML
* if said modal is open immediately, but I consider that OK for our purposes.
*/
const isRunningOnClient = useIsRunningOnClient();
const mountElement = isRunningOnClient ? container ?? document.body : null;
if (!mountElement) {
return null;
}

if (!isOpen) {
return null;
}

return createPortal(
<InnerModal onClose={onClose}>{children}</InnerModal>,
mountElement
);
};
28 changes: 28 additions & 0 deletions frontend/src/components/modal/modal.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@

.contra--modal-wrapper {
position: fixed;
z-index: 1500;
top: 0;
left: 0;
right: 0;
bottom: 0;
}

.contra--modal-background {
position: absolute;
background-color: rgba(0, 0, 0, 0.3);
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: -1;
}

.contra--modal-content {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: white;
z-index: 1100;
}
93 changes: 93 additions & 0 deletions frontend/src/components/modal/modal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { Modal } from '.';

describe('Modal component', () => {
const ToggleableModal = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<div>
<button onClick={() => setIsModalOpen(true)} type="button">
Toggle modal
</button>
<Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)}>
Modal content
</Modal>
</div>
);
};

it('can be visible by default', () => {
render(
<Modal isOpen onClose={() => {}}>
Modal content
</Modal>
);

expect(screen.getByText('Modal content')).toBeInTheDocument();
});

it('is toggleable', async () => {
expect.assertions(2);

render(<ToggleableModal />);

expect(screen.queryByText('Modal content')).not.toBeInTheDocument();

await userEvent.click(screen.getByText('Toggle modal'));

expect(screen.getByText('Modal content')).toBeInTheDocument();
});

it('is tabbable only within itself', async () => {
expect.assertions(4);

const TabbableModal = () => {
return (
<div>
<button type="button">Button outside modal</button>
<Modal isOpen onClose={() => {}}>
<button type="button">Button 1</button>
<button type="button">Button 2</button>
<button type="button">Button 3</button>
</Modal>
</div>
);
};

render(<TabbableModal />);

await userEvent.tab();

expect(document.activeElement).toHaveTextContent('Button 1');

await userEvent.tab();

expect(document.activeElement).toHaveTextContent('Button 2');

await userEvent.tab();

expect(document.activeElement).toHaveTextContent('Button 3');

await userEvent.tab();

expect(document.activeElement).toHaveTextContent('Button 1');
});

it('closes on clicking outside the modal', async () => {
expect.assertions(3);

render(<ToggleableModal />);

expect(screen.queryByText('Modal content')).not.toBeInTheDocument();

await userEvent.click(screen.getByText('Toggle modal'));

expect(screen.getByText('Modal content')).toBeInTheDocument();

await userEvent.click(document.body);

expect(screen.getByText('Modal content')).toBeInTheDocument();
});
Comment on lines +78 to +92
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The last assertion in this test is incorrect - it's inverted. Looking back I assume the correct way wouldn't pass because I accidentally used document.addEventListener instead of document.body.addEventListener - and I'm clicking directly on document.body in this test. Either way, I was out of time at the moment I realized it.

});
45 changes: 45 additions & 0 deletions frontend/src/components/modal/utils/modalHierarchy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { useEffect, useId } from 'react';

/**
* Used for verifying what's the top modal - the one
* that should be closed in operations like pressing Esc, etc.
*/
class ModalHierarchy {
public modalIds: string[] = [];

public add(modalId: string) {
this.modalIds.push(modalId);
}

public remove(modalId: string) {
this.modalIds = this.modalIds.filter((id) => id !== modalId);
}

public isTopModal(modalId: string) {
if (!this.modalIds.length) {
return false;
}

return this.modalIds[this.modalIds.length - 1] === modalId;
}
}

export const modalHierarchy = new ModalHierarchy();

export const useModalId = () => {
/**
* We're using React IDs as they're guaranteed to be
* different across different rendered React nodes.
* Note that this would break on microfrontends.
*/
const modalId = useId();

useEffect(() => {
modalHierarchy.add(modalId);
return () => {
modalHierarchy.remove(modalId);
};
}, [modalId]);

return modalId;
};
31 changes: 31 additions & 0 deletions frontend/src/components/modal/utils/useCloseOnPressingEsc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { useEffect, useRef } from 'react';
import { modalHierarchy } from './modalHierarchy';

export const useCloseOnPressingEsc = (
modalId: string,
closeCallback: () => void
) => {
/**
* Since closeCallback can be defined inline by the user
* and we don't wanna keep this useEffect running all the time,
* we just store the latest value in a ref.
*/
const latestCloseCallbackRef = useRef(closeCallback);
latestCloseCallbackRef.current = closeCallback;

useEffect(() => {
const keydownHandler = (event: KeyboardEvent) => {
// Only the top modal should be closed if there's more than one modal currently active
if (event.key === 'Escape' && modalHierarchy.isTopModal(modalId)) {
event.preventDefault();
latestCloseCallbackRef.current();
}
};

document.body.addEventListener('keydown', keydownHandler);

return () => {
document.body.removeEventListener('keydown', keydownHandler);
};
}, [modalId]);
};
63 changes: 63 additions & 0 deletions frontend/src/components/modal/utils/useFocusTrap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { useEffect } from 'react';
import { modalHierarchy } from './modalHierarchy';

const focusableElements =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const getFocusableElementInfo = (element: HTMLElement) => {
const focusableContent = element.querySelectorAll(focusableElements);
return {
first: focusableContent[0] as HTMLElement | undefined,
last: focusableContent[focusableContent.length - 1] as
| HTMLElement
| undefined,
};
};

// Adapted from https://uxdesign.cc/how-to-trap-focus-inside-modal-to-make-it-ada-compliant-6a50f9a70700
export const useFocusTrap = (modalId: string, element: HTMLElement | null) => {
useEffect(() => {
if (element === null) {
return () => {};
}

const tabHandler = (event: KeyboardEvent) => {
if (event.key !== 'Tab') {
return;
}

if (!modalHierarchy.isTopModal(modalId)) {
return;
}

const currentlyActiveElement = document.activeElement;
const focusableElementInfo = getFocusableElementInfo(element);

// If it's trying to tab outside of the element, fallback to our first element
if (!element.contains(currentlyActiveElement)) {
focusableElementInfo.first?.focus();
event.preventDefault();
return;
}

// On Shift+Tab, if we're in the first element, wrap focus into the last element
if (event.shiftKey) {
if (currentlyActiveElement === focusableElementInfo.first) {
focusableElementInfo.last?.focus();
event.preventDefault();
}
// On Tab, if we're in the last element, wrap focus back into the first element
} else if (currentlyActiveElement === focusableElementInfo.last) {
focusableElementInfo.first?.focus();
event.preventDefault();
}

// Otherwise, we're just tabbing somewhere in the middle and it's OK to proceed
};

document.addEventListener('keydown', tabHandler);

return () => {
document.removeEventListener('keydown', tabHandler);
};
}, [element, modalId]);
};
11 changes: 11 additions & 0 deletions frontend/src/components/modal/utils/useIsRunningOnClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { useEffect, useState } from 'react';

export const useIsRunningOnClient = (): boolean => {
const [isClientSide, setIsClientSide] = useState(false);

useEffect(() => {
setIsClientSide(true);
}, []);

return isClientSide;
};