Skip to content

Instantly share code, notes, and snippets.

@RStankov
Created February 21, 2024 21:22
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save RStankov/8c05fef0104aa55305f272d60407d3f7 to your computer and use it in GitHub Desktop.
Save RStankov/8c05fef0104aa55305f272d60407d3f7 to your computer and use it in GitHub Desktop.
Modal System
// 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')
);
}
// 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);
}
// 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;
}
'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';
// 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');
}
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