Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/#566 overlay를 다룰 useOverlay 훅 구현 #567

Merged
merged 3 commits into from
Apr 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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]
);
}
Loading