Skip to content

Commit

Permalink
Feat/#566 overlay를 다룰 useOverlay 훅 구현 (#567)
Browse files Browse the repository at this point in the history
* feat: useOverlay 구현

* feat: OverlayProvider 감싸기
  • Loading branch information
cruelladevil authored Apr 30, 2024
1 parent 2364c0a commit b2cc79e
Show file tree
Hide file tree
Showing 6 changed files with 151 additions and 3 deletions.
9 changes: 6 additions & 3 deletions frontend/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: (
<LoginPopupProvider>
<Layout />
</LoginPopupProvider>
<OverlayProvider>
<LoginPopupProvider>
<Layout />
</LoginPopupProvider>
</OverlayProvider>
),
children: [
{
Expand Down
37 changes: 37 additions & 0 deletions frontend/src/shared/hooks/useOverlay/OverlayController.tsx
Original file line number Diff line number Diff line change
@@ -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<OverlayControlRef>
) {
const [isOpenOverlay, setIsOpenOverlay] = useState(false);

const handleOverlayClose = useCallback(() => setIsOpenOverlay(false), []);

useImperativeHandle(
ref,
() => {
return { close: handleOverlayClose };
},
[handleOverlayClose]
);

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

return <OverlayElement isOpen={isOpenOverlay} close={handleOverlayClose} exit={onExit} />;
});
42 changes: 42 additions & 0 deletions frontend/src/shared/hooks/useOverlay/OverlayProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<number>;
} | null>(null);

export function OverlayProvider({ children }: PropsWithChildren<{ containerId?: string }>) {
const [overlayById, setOverlayById] = useState<Map<string, ReactNode>>(new Map());

const elementIdRef = useRef(1);

const mount = useCallback<Mount>((id, element) => {
setOverlayById((overlayById) => {
const cloned = new Map(overlayById);
cloned.set(id, element);
return cloned;
});
}, []);

const unmount = useCallback<Unmount>((id) => {
setOverlayById((overlayById) => {
const cloned = new Map(overlayById);
cloned.delete(id);
return cloned;
});
}, []);

const context = useMemo(() => ({ mount, unmount, elementIdRef }), [mount, unmount]);

return (
<OverlayContext.Provider value={context}>
{children}
{[...overlayById.entries()].map(([id, element]) => (
<React.Fragment key={id}>{element}</React.Fragment>
))}
</OverlayContext.Provider>
);
}
2 changes: 2 additions & 0 deletions frontend/src/shared/hooks/useOverlay/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { OverlayProvider, OverlayContext } from './OverlayProvider';
export { useOverlay } from './useOverlay';
10 changes: 10 additions & 0 deletions frontend/src/shared/hooks/useOverlay/types.ts
Original file line number Diff line number Diff line change
@@ -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;
54 changes: 54 additions & 0 deletions frontend/src/shared/hooks/useOverlay/useOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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<OverlayControlRef>(null);

useEffect(() => {
return () => {
if (exitOnUnmount) {
unmount(id);
}
};
}, [exitOnUnmount, id, unmount]);

return useMemo(
() => ({
open: (overlayElement: CreateOverlayElement) => {
mount(
id,
<OverlayController
// NOTE: 오버레이를 열때마다 state를 초기화하기 위함입니다.
key={Date.now()}
ref={overlayRef}
overlayElement={overlayElement}
onExit={() => unmount(id)}
/>
);
},
close: () => {
overlayRef.current?.close();
},
exit: () => {
unmount(id);
},
}),
[id, mount, unmount]
);
}

0 comments on commit b2cc79e

Please sign in to comment.