Created
February 21, 2024 21:22
-
-
Save RStankov/8c05fef0104aa55305f272d60407d3f7 to your computer and use it in GitHub Desktop.
Modal System
This file contains hidden or 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
// The most complex part of the modal system | |
// There are lot of small details heres | |
// I extracted this from a real project | |
// Notice: Hooks hide a lot of complexity | |
import { IModalOpen } from './types'; | |
import { closeModal } from './triggers'; | |
import { useEffect, useMemo, useRef, useState } from 'react'; | |
import { useEventBus } from '../eventBus'; | |
import { InsideModalProvider } from './context'; | |
export function ModalContainer() { | |
const modal = useModalState(); | |
if (!modal) { | |
return null; | |
} | |
return <ModalContent modal={modal} />; | |
} | |
// I have this component, because can't have conditional hooks | |
function ModalContent({ modal }: { modal: IModal }) { | |
useScrollLock(); | |
useScrollPositionOf(modal); | |
useKeyUp('Escape', closeModal); | |
return ( | |
<InsideModalProvider> | |
<div data-modal="true" onClick={onOverlayClick}> | |
{modal.content} | |
</div> | |
</InsideModalProvider> | |
); | |
} | |
// Modal itself contains its trigger options and extra state | |
type IModal = { | |
// openModal options | |
content: React.ReactNode; | |
onClose?: () => void; | |
path?: string; | |
// page state | |
previousPath: string; | |
scrollTop: number; | |
}; | |
function useModalState(): IModal | null { | |
// Stacking is just an array of modals objects | |
const [modals, setModals] = useState<IModal[]>([]); | |
// TIP: Instead of having multiple useCallbacks, I use useMemo to memoize an object | |
const eventHandlers = useMemo( | |
() => ({ | |
open(options: IModalOpen) { | |
const modal: IModal = { | |
...options, | |
previousPath: getPath(), | |
scrollTop: 0, | |
}; | |
if (!isCurrentUrl(options)) { | |
// Replace doesn't push a new modal it replaces current | |
window.history[options.replace ? 'replaceState' : 'pushState']( | |
{}, | |
document.title, | |
options.path!, | |
); | |
} | |
setModals((currentModals) => { | |
if (!options.replace) { | |
const newState = [modal, ...currentModals]; | |
// This is how scroll position is restored when we close modal | |
// When new modal is open, we copy scroll position from previous modal | |
// Using dom directly | |
if (newState[1]) { | |
newState[1].scrollTop = | |
document?.querySelector('[data-modal="true"]')?.scrollTop || 0; | |
} | |
return newState; | |
} else { | |
const newState = [...currentModals]; | |
newState.shift(); | |
newState.push(modal); | |
return newState; | |
} | |
}); | |
}, | |
close() { | |
setModals((currentModals) => { | |
const [current, ...rest] = currentModals; | |
if (!current) { | |
return []; | |
} | |
current.onClose?.(); | |
return rest; | |
}); | |
}, | |
handleUrlChange() { | |
setModals((currentModals) => { | |
const index = currentModals.findIndex(isCurrentUrl); | |
if (index === -1) { | |
return []; | |
} else { | |
return currentModals.slice(index); | |
} | |
}); | |
}, | |
}), | |
// TIP: Don't need `modals`, because setModals accepts a function that receives the current state | |
// `setModals` is a constants. | |
[setModals], | |
); | |
useEventBus('modalOpen', eventHandlers.open); | |
useEventBus('modalClose', eventHandlers.close); | |
useEventListener(window, 'popstate', eventHandlers.handleUrlChange); | |
return modals[0] || null; | |
} | |
function isCurrentUrl({ path }: { path?: string }): boolean { | |
if (!path || typeof window === 'undefined' || !window.location) { | |
return false; | |
} | |
// This might break if path is URL with different domain | |
// In practice this is not a problem | |
const url = new URL( | |
window.location.protocol + '//' + window.location.host + path, | |
); | |
return ( | |
url.pathname === window.location.pathname && | |
url.search === window.location.search && | |
url.hash === window.location.hash | |
); | |
} | |
function getPath(): string { | |
if (typeof window === 'undefined' || !window.location) { | |
return '/'; | |
} | |
return ( | |
window.location.pathname + window.location.search + window.location.hash | |
); | |
} | |
type IHandle<T> = (e: T) => void; | |
// This is a generic hook | |
function useEventListener<T>( | |
element: Element | Window | null, | |
eventName: string, | |
handler: IHandle<T>, | |
) { | |
// TIP: Using `useRef` to keep the handler between renders | |
// So handler reference changing doesn't re-run the effect | |
const savedHandler = useRef<IHandle<T>>(handler); | |
if (savedHandler.current !== handler) { | |
savedHandler.current = handler; | |
} | |
useEffect(() => { | |
const target: Element | Window | null = | |
element || (typeof window !== 'undefined' && window) || null; | |
if (!target) { | |
return; | |
} | |
const eventListener = (event: any) => savedHandler.current(event); | |
target.addEventListener(eventName, eventListener); | |
return () => { | |
target.removeEventListener(eventName, eventListener); | |
}; | |
}, [savedHandler, eventName, element]); | |
} | |
function useScrollLock() { | |
useEffect(() => { | |
// TIP: Keeping old state here is fine | |
// Because we only run this effect once | |
const previousScroll = window.scrollY; | |
const previosOverflow = document.body.style.overflow; | |
const previosWidth = document.body.style.width; | |
document.body.style.overflow = 'hidden'; | |
document.body.style.width = '100%'; | |
return () => { | |
document.body.style.overflow = previosOverflow; | |
document.body.style.width = previosWidth; | |
window.scrollTo(0, previousScroll); | |
}; | |
}, []); | |
} | |
function onOverlayClick(event: { target: any }) { | |
// Second reason why we have `data-modal` attribute | |
if (event.target.getAttribute('data-modal')) { | |
closeModal(); | |
} | |
} | |
// This is how we restore scroll position when one modal (A) is closed | |
// and another (B) is opened. | |
// When A was open, we stored B scroll position | |
// When A is closed, we restore B scroll position | |
function useScrollPositionOf(modal: IModal) { | |
useEffect(() => { | |
const element = document.querySelector('[data-modal="true"]'); | |
if (element) { | |
element.scrollTop = modal.scrollTop; | |
} | |
}, [modal]); | |
} | |
// This is a generic hook | |
function useKeyUp(key: string, fn: (event: KeyboardEvent) => void) { | |
useEventListener(window, 'keyup', (event: KeyboardEvent) => { | |
// Make sure we don't run the handler when data input is focused | |
if (event.key === key && !isInput(event.target as Element)) { | |
fn(event); | |
} | |
}); | |
} | |
function isInput(element: Element): boolean { | |
if (!element) { | |
return false; | |
} | |
return ( | |
element.tagName === 'INPUT' || | |
element.tagName === 'TEXTAREA' || | |
!!element.getAttribute('contenteditable') | |
); | |
} |
This file contains hidden or 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
// Provide a way for components to know if they are inside a modal or not | |
// It is an optional feature for modal systems | |
import { createContext, useContext } from 'react'; | |
const Context = createContext<boolean>(false); | |
export function InsideModalProvider({ | |
children, | |
}: { | |
children: React.ReactNode; | |
}): any { | |
return <Context.Provider value={true}>{children}</Context.Provider>; | |
} | |
export function useIsInsideModal() { | |
return useContext(Context); | |
} |
This file contains hidden or 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
// The implementation here is very WIP | |
// It depends on your project fetching strategy | |
// It is decoupled from the main modal system | |
// There isn't a requirement for modals to use `createModal` | |
// Keys points: | |
// - handle data fetching states | |
// - handle title changes | |
// - type safe | |
// | |
import { | |
FetchMore, | |
Refetch, | |
OperationVariables, | |
DocumentNode, | |
useQuery, | |
} from '../apollo'; | |
import { useEffect } from 'react'; | |
type ICreateModalOptions<D, P> = { | |
query: DocumentNode; | |
queryVariables?: ((props: P) => OperationVariables) | OperationVariables; | |
renderComponent: React.FC<D extends null ? any : IModalProps<D, P>>; | |
title?: string | null | ((data: D) => string | null); | |
}; | |
type IModalProps<T, P> = P & { | |
data: T; | |
fetchMore: FetchMore<T>; | |
refetch: Refetch<T>; | |
variables: OperationVariables; | |
}; | |
export function createModal<D, P>({ | |
title, | |
query, | |
queryVariables, | |
renderComponent, | |
}: ICreateModalOptions<D, P>) { | |
const Component: any = renderComponent; | |
const ModalComponent = (props: P) => { | |
const variables = | |
typeof queryVariables === 'function' | |
? queryVariables(props) | |
: queryVariables; | |
// We use Appolo here, but you can use any other fetching library | |
const { | |
data, | |
loading, | |
refetch, | |
fetchMore, | |
error, | |
variables: apolloVariables, | |
} = useQuery<D>(query, { | |
variables, | |
}); | |
if (error) { | |
return 'error'; | |
} | |
if (loading || !data) { | |
return 'loading'; | |
} | |
// title can a string or a function based on fetched data | |
const titleValue = typeof title === 'function' ? title(data) : title; | |
// NOTE(rstankov): Here you can different frames and UI | |
return ( | |
<> | |
{titleValue && <Title key={titleValue} title={titleValue} />} | |
<Component | |
{...props} | |
data={data} | |
refetch={refetch} | |
fetchMore={fetchMore} | |
variables={apolloVariables} | |
/> | |
</> | |
); | |
}; | |
ModalComponent.displayName = | |
'modal(' + (Component.displayName || Component.name || 'Component') + ')'; | |
return ModalComponent; | |
} | |
function Title({ title }: { title: string }) { | |
useEffect(() => { | |
const prevTitle = document.title; | |
document.title = title; | |
return () => { | |
document.title = prevTitle; | |
}; | |
}, [title]); | |
return null; | |
} |
This file contains hidden or 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
'use client'; | |
// This is all public interface for the modal system | |
export { ModalContainer } from './container'; | |
export { createModal } from './create'; | |
export { openModal, closeModal } from './triggers'; | |
export { useIsInsideModal } from './context'; | |
// This is only needed for eventBus to work | |
// More about eventBus - https://gist.github.com/RStankov/93e49fb43b9043e7ff7be715185626eb | |
export type { IModalOpen } from './types'; |
This file contains hidden or 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
// Just a simple wrapper around the eventBus to open and close modals | |
// We keep eventBus as implementation detail of the modal system | |
// This way we can change it without changing the public interface | |
// More on eventBus -> https://gist.github.com/RStankov/93e49fb43b9043e7ff7be715185626eb | |
import { emit } from '../eventBus'; | |
import { IModalOpen } from './types'; | |
export function openModal(options: IModalOpen) { | |
emit('modalOpen', options); | |
} | |
export function closeModal() { | |
emit('modalClose'); | |
} |
This file contains hidden or 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
export type IModalOpen = { | |
content: React.ReactNode; | |
path?: string; | |
replace?: boolean; | |
onClose?: () => void; | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment