This repository has been archived by the owner on Dec 20, 2024. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 44
Vinicius: Contra Frontend Assessment #94
Open
vi-hidden-alt
wants to merge
18
commits into
contra:main
Choose a base branch
from
vi-hidden-alt:main
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+1,948
−699
Open
Changes from all commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
e18ad1d
feat: placeholder modal component
1164b07
feat: isOpen prop
4c3be1a
feat: add basic modal behavior
628d661
feat: nested modals w/ close on esc (also simplified css)
f6b2566
refactor: move useIsRunningOnClient
5577283
feat: scroll lock
6deae65
feat: focus trap
e8eac1c
refactor: move some components around, clean up InnerModal
ab957e6
test: add test - and fix focus trap issue
ccdd766
chore: whoops, remove package-lock
e105d62
refactor: turn FocusTrap into a hook
45b30d2
test: simplify jest config
cfd9f66
feat: add infinitely stacking modals
3ff419f
chore: make long text more spaced
bd298c6
feat: custom container example
aa458fb
feat: adding modalId to focus trap calculation
5959227
chore: remove styling from custom container
cb04f4a
refactor: use variable for currently active element
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
31
frontend/src/components/modal/utils/useCloseOnPressingEsc.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
11
frontend/src/components/modal/utils/useIsRunningOnClient.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}; |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 ofdocument.body.addEventListener
- and I'm clicking directly ondocument.body
in this test. Either way, I was out of time at the moment I realized it.