diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 101b076c..608a4b46 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -12,14 +12,17 @@ import SongDetailListPage from './pages/SongDetailListPage'; import AuthLayout from './shared/components/Layout/AuthLayout'; import Layout from './shared/components/Layout/Layout'; import ROUTE_PATH from './shared/constants/path'; +import { OverlayProvider } from './shared/hooks/useOverlay'; const router = createBrowserRouter([ { path: ROUTE_PATH.ROOT, element: ( - - - + + + + + ), children: [ { diff --git a/frontend/src/shared/hooks/useOverlay/OverlayController.tsx b/frontend/src/shared/hooks/useOverlay/OverlayController.tsx new file mode 100644 index 00000000..5dab659f --- /dev/null +++ b/frontend/src/shared/hooks/useOverlay/OverlayController.tsx @@ -0,0 +1,37 @@ +import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from 'react'; +import type { CreateOverlayElement } from './types'; +import type { Ref } from 'react'; + +interface OverlayControllerProps { + overlayElement: CreateOverlayElement; + onExit: () => void; +} + +export interface OverlayControlRef { + close: () => void; +} + +export const OverlayController = forwardRef(function OverlayController( + { overlayElement: OverlayElement, onExit }: OverlayControllerProps, + ref: Ref +) { + const [isOpenOverlay, setIsOpenOverlay] = useState(false); + + const handleOverlayClose = useCallback(() => setIsOpenOverlay(false), []); + + useImperativeHandle( + ref, + () => { + return { close: handleOverlayClose }; + }, + [handleOverlayClose] + ); + + useEffect(() => { + requestAnimationFrame(() => { + setIsOpenOverlay(true); + }); + }, []); + + return ; +}); diff --git a/frontend/src/shared/hooks/useOverlay/OverlayProvider.tsx b/frontend/src/shared/hooks/useOverlay/OverlayProvider.tsx new file mode 100644 index 00000000..d9de553f --- /dev/null +++ b/frontend/src/shared/hooks/useOverlay/OverlayProvider.tsx @@ -0,0 +1,42 @@ +import React, { createContext, useCallback, useMemo, useRef, useState } from 'react'; +import type { Mount, Unmount } from './types'; +import type { MutableRefObject, PropsWithChildren, ReactNode } from 'react'; + +export const OverlayContext = createContext<{ + mount: Mount; + unmount: Unmount; + elementIdRef: MutableRefObject; +} | null>(null); + +export function OverlayProvider({ children }: PropsWithChildren<{ containerId?: string }>) { + const [overlayById, setOverlayById] = useState>(new Map()); + + const elementIdRef = useRef(1); + + const mount = useCallback((id, element) => { + setOverlayById((overlayById) => { + const cloned = new Map(overlayById); + cloned.set(id, element); + return cloned; + }); + }, []); + + const unmount = useCallback((id) => { + setOverlayById((overlayById) => { + const cloned = new Map(overlayById); + cloned.delete(id); + return cloned; + }); + }, []); + + const context = useMemo(() => ({ mount, unmount, elementIdRef }), [mount, unmount]); + + return ( + + {children} + {[...overlayById.entries()].map(([id, element]) => ( + {element} + ))} + + ); +} diff --git a/frontend/src/shared/hooks/useOverlay/index.ts b/frontend/src/shared/hooks/useOverlay/index.ts new file mode 100644 index 00000000..98b244da --- /dev/null +++ b/frontend/src/shared/hooks/useOverlay/index.ts @@ -0,0 +1,2 @@ +export { OverlayProvider, OverlayContext } from './OverlayProvider'; +export { useOverlay } from './useOverlay'; diff --git a/frontend/src/shared/hooks/useOverlay/types.ts b/frontend/src/shared/hooks/useOverlay/types.ts new file mode 100644 index 00000000..de64338e --- /dev/null +++ b/frontend/src/shared/hooks/useOverlay/types.ts @@ -0,0 +1,10 @@ +import type { ReactNode } from 'react'; + +export type Mount = (id: string, element: ReactNode) => void; +export type Unmount = (id: string) => void; + +export type CreateOverlayElement = (props: { + isOpen: boolean; + close: () => void; + exit: () => void; +}) => ReactNode; diff --git a/frontend/src/shared/hooks/useOverlay/useOverlay.tsx b/frontend/src/shared/hooks/useOverlay/useOverlay.tsx new file mode 100644 index 00000000..9577710b --- /dev/null +++ b/frontend/src/shared/hooks/useOverlay/useOverlay.tsx @@ -0,0 +1,54 @@ +import { useContext, useEffect, useMemo, useRef } from 'react'; +import { OverlayController } from './OverlayController'; +import { OverlayContext } from './OverlayProvider'; +import type { OverlayControlRef } from './OverlayController'; +import type { CreateOverlayElement } from './types'; + +interface Options { + exitOnUnmount?: boolean; +} + +export function useOverlay({ exitOnUnmount = true }: Options = {}) { + const context = useContext(OverlayContext); + + if (context === null) { + throw new Error('useOverlay는 OverlayProvider 내부에서 사용 가능합니다.'); + } + + const { mount, unmount, elementIdRef } = context; + + const id = String(elementIdRef.current++); + const overlayRef = useRef(null); + + useEffect(() => { + return () => { + if (exitOnUnmount) { + unmount(id); + } + }; + }, [exitOnUnmount, id, unmount]); + + return useMemo( + () => ({ + open: (overlayElement: CreateOverlayElement) => { + mount( + id, + unmount(id)} + /> + ); + }, + close: () => { + overlayRef.current?.close(); + }, + exit: () => { + unmount(id); + }, + }), + [id, mount, unmount] + ); +}