feat: 전역 피드백 상태 구조와 마운트 지점 추가
Co-Authored-By: Claude Opus 5
@ac8def5c88837fba371f6e0ab100f8e5853181c0
+++ app/_components/feedback-host.tsx
... | ... | @@ -0,0 +1,54 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { Alert } from '@/components/ui/alert'; | |
| 4 | +import { Spinner } from '@/components/ui/spinner'; | |
| 5 | +import { Toast } from '@/components/ui/toast'; | |
| 6 | +import { useFeedback } from '@/app/_hooks/use-feedback'; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * FeedbackProvider의 상태를 design 레인의 Toast·Alert·Spinner 비주얼에 연결만 하는 역할 — | |
| 10 | + * 위치·스택·표시 타이머 등 정책은 FeedbackProvider가 가지고, 이 컴포넌트는 마운트만 담당한다. | |
| 11 | + */ | |
| 12 | +export function FeedbackHost() { | |
| 13 | + const { toasts, alert, spinnerCount, hideAlert } = useFeedback(); | |
| 14 | + | |
| 15 | + return ( | |
| 16 | + <> | |
| 17 | + {toasts.length > 0 && ( | |
| 18 | + <div className="fixed inset-x-0 top-4 z-50 flex flex-col items-center gap-2 px-4"> | |
| 19 | + {toasts.map((toast) => ( | |
| 20 | + <Toast key={toast.id} variant={toast.variant}> | |
| 21 | + {toast.message} | |
| 22 | + </Toast> | |
| 23 | + ))} | |
| 24 | + </div> | |
| 25 | + )} | |
| 26 | + | |
| 27 | + {alert && ( | |
| 28 | + <Alert | |
| 29 | + variant={alert.variant} | |
| 30 | + title={alert.title} | |
| 31 | + actions={ | |
| 32 | + alert.actions ?? ( | |
| 33 | + <button | |
| 34 | + type="button" | |
| 35 | + onClick={hideAlert} | |
| 36 | + className="rounded-md bg-primary px-4 py-2 text-body-md text-on-primary" | |
| 37 | + > | |
| 38 | + 확인 | |
| 39 | + </button> | |
| 40 | + ) | |
| 41 | + } | |
| 42 | + > | |
| 43 | + {alert.message} | |
| 44 | + </Alert> | |
| 45 | + )} | |
| 46 | + | |
| 47 | + {spinnerCount > 0 && ( | |
| 48 | + <div className="fixed inset-0 z-50 flex items-center justify-center bg-scrim"> | |
| 49 | + <Spinner size="lg" tone="inverse" /> | |
| 50 | + </div> | |
| 51 | + )} | |
| 52 | + </> | |
| 53 | + ); | |
| 54 | +} |
+++ app/_components/feedback-provider.tsx
... | ... | @@ -0,0 +1,136 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { | |
| 4 | + createContext, | |
| 5 | + useCallback, | |
| 6 | + useMemo, | |
| 7 | + useRef, | |
| 8 | + useState, | |
| 9 | + type ReactNode, | |
| 10 | +} from 'react'; | |
| 11 | + | |
| 12 | +export type ToastVariant = 'info' | 'success' | 'danger' | 'warning'; | |
| 13 | +export type AlertVariant = 'info' | 'danger'; | |
| 14 | + | |
| 15 | +const DEFAULT_TOAST_DURATION_MS = 3000; | |
| 16 | + | |
| 17 | +export interface ToastItem { | |
| 18 | + id: number; | |
| 19 | + variant: ToastVariant; | |
| 20 | + message: ReactNode; | |
| 21 | +} | |
| 22 | + | |
| 23 | +export interface AlertItem { | |
| 24 | + variant: AlertVariant; | |
| 25 | + title: string; | |
| 26 | + message?: ReactNode; | |
| 27 | + actions?: ReactNode; | |
| 28 | +} | |
| 29 | + | |
| 30 | +export interface ShowToastOptions { | |
| 31 | + variant?: ToastVariant; | |
| 32 | + message: ReactNode; | |
| 33 | + /** 자동 소멸까지의 시간(ms). 기본 3000ms. */ | |
| 34 | + durationMs?: number; | |
| 35 | +} | |
| 36 | + | |
| 37 | +export interface ShowAlertOptions { | |
| 38 | + variant?: AlertVariant; | |
| 39 | + title: string; | |
| 40 | + message?: ReactNode; | |
| 41 | + actions?: ReactNode; | |
| 42 | +} | |
| 43 | + | |
| 44 | +export interface FeedbackContextValue { | |
| 45 | + toasts: ToastItem[]; | |
| 46 | + alert: AlertItem | null; | |
| 47 | + spinnerCount: number; | |
| 48 | + showToast: (options: ShowToastOptions) => void; | |
| 49 | + dismissToast: (id: number) => void; | |
| 50 | + showAlert: (options: ShowAlertOptions) => void; | |
| 51 | + hideAlert: () => void; | |
| 52 | + showSpinner: () => void; | |
| 53 | + hideSpinner: () => void; | |
| 54 | +} | |
| 55 | + | |
| 56 | +export const FeedbackContext = createContext<FeedbackContextValue | null>(null); | |
| 57 | + | |
| 58 | +/** | |
| 59 | + * 전역 피드백(토스트 큐 / 알럿 1건 / 스피너 카운트) 상태 보유 — 루트 레이아웃에 한 번만 | |
| 60 | + * 마운트한다. 실제 비주얼(Toast·Alert·Spinner)은 FeedbackHost가 이 상태를 구독해 그린다. | |
| 61 | + * children은 Server Component가 렌더한 결과를 그대로 통과시켜 RSC를 유지한다. | |
| 62 | + */ | |
| 63 | +export function FeedbackProvider({ children }: { children: ReactNode }) { | |
| 64 | + const [toasts, setToasts] = useState<ToastItem[]>([]); | |
| 65 | + const [alert, setAlert] = useState<AlertItem | null>(null); | |
| 66 | + const [spinnerCount, setSpinnerCount] = useState(0); | |
| 67 | + const nextToastId = useRef(0); | |
| 68 | + | |
| 69 | + const dismissToast = useCallback((id: number) => { | |
| 70 | + setToasts((prev) => prev.filter((toast) => toast.id !== id)); | |
| 71 | + }, []); | |
| 72 | + | |
| 73 | + const showToast = useCallback( | |
| 74 | + (options: ShowToastOptions) => { | |
| 75 | + const id = nextToastId.current++; | |
| 76 | + setToasts((prev) => [ | |
| 77 | + ...prev, | |
| 78 | + { id, variant: options.variant ?? 'info', message: options.message }, | |
| 79 | + ]); | |
| 80 | + setTimeout(() => { | |
| 81 | + dismissToast(id); | |
| 82 | + }, options.durationMs ?? DEFAULT_TOAST_DURATION_MS); | |
| 83 | + }, | |
| 84 | + [dismissToast] | |
| 85 | + ); | |
| 86 | + | |
| 87 | + const showAlert = useCallback((options: ShowAlertOptions) => { | |
| 88 | + setAlert({ | |
| 89 | + variant: options.variant ?? 'info', | |
| 90 | + title: options.title, | |
| 91 | + message: options.message, | |
| 92 | + actions: options.actions, | |
| 93 | + }); | |
| 94 | + }, []); | |
| 95 | + | |
| 96 | + const hideAlert = useCallback(() => { | |
| 97 | + setAlert(null); | |
| 98 | + }, []); | |
| 99 | + | |
| 100 | + const showSpinner = useCallback(() => { | |
| 101 | + setSpinnerCount((prev) => prev + 1); | |
| 102 | + }, []); | |
| 103 | + | |
| 104 | + const hideSpinner = useCallback(() => { | |
| 105 | + setSpinnerCount((prev) => Math.max(0, prev - 1)); | |
| 106 | + }, []); | |
| 107 | + | |
| 108 | + const value = useMemo<FeedbackContextValue>( | |
| 109 | + () => ({ | |
| 110 | + toasts, | |
| 111 | + alert, | |
| 112 | + spinnerCount, | |
| 113 | + showToast, | |
| 114 | + dismissToast, | |
| 115 | + showAlert, | |
| 116 | + hideAlert, | |
| 117 | + showSpinner, | |
| 118 | + hideSpinner, | |
| 119 | + }), | |
| 120 | + [ | |
| 121 | + toasts, | |
| 122 | + alert, | |
| 123 | + spinnerCount, | |
| 124 | + showToast, | |
| 125 | + dismissToast, | |
| 126 | + showAlert, | |
| 127 | + hideAlert, | |
| 128 | + showSpinner, | |
| 129 | + hideSpinner, | |
| 130 | + ] | |
| 131 | + ); | |
| 132 | + | |
| 133 | + return ( | |
| 134 | + <FeedbackContext.Provider value={value}>{children}</FeedbackContext.Provider> | |
| 135 | + ); | |
| 136 | +} |
+++ app/_hooks/use-feedback.ts
... | ... | @@ -0,0 +1,19 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useContext } from 'react'; | |
| 4 | +import { | |
| 5 | + FeedbackContext, | |
| 6 | + type FeedbackContextValue, | |
| 7 | +} from '@/app/_components/feedback-provider'; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * 전역 피드백(토스트/알럿/스피너) 상태 소비 훅. FeedbackProvider 밖에서 호출되면 원인 불명의 | |
| 11 | + * 런타임 오류 대신 명시적으로 에러를 던진다. | |
| 12 | + */ | |
| 13 | +export function useFeedback(): FeedbackContextValue { | |
| 14 | + const context = useContext(FeedbackContext); | |
| 15 | + if (!context) { | |
| 16 | + throw new Error('useFeedback은 FeedbackProvider 내부에서만 사용할 수 있습니다.'); | |
| 17 | + } | |
| 18 | + return context; | |
| 19 | +} |
--- app/layout.tsx
+++ app/layout.tsx
... | ... | @@ -1,6 +1,8 @@ |
| 1 | 1 |
import type { Metadata } from "next";
|
| 2 | 2 |
import { Geist, Geist_Mono } from "next/font/google";
|
| 3 | 3 |
import { ViewTransition } from "react";
|
| 4 |
+import { FeedbackHost } from "@/app/_components/feedback-host";
|
|
| 5 |
+import { FeedbackProvider } from "@/app/_components/feedback-provider";
|
|
| 4 | 6 |
import "./globals.css"; |
| 5 | 7 |
|
| 6 | 8 |
const geistSans = Geist({
|
... | ... | @@ -44,10 +46,14 @@ |
| 44 | 46 |
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
| 45 | 47 |
> |
| 46 | 48 |
<body className="min-h-full flex flex-col"> |
| 47 |
- {/* 라우트 그룹 간 이동을 포함한 모든 전환에 크로스페이드를 적용한다. 클래스명(layout-transition)에
|
|
| 48 |
- 대한 `::view-transition-old/new` 규칙은 design 레인이 app/globals.css에 제공한다 — 합류 |
|
| 49 |
- 전에는 브라우저 기본 크로스페이드로 동작한다. */} |
|
| 50 |
- <ViewTransition default="layout-transition">{children}</ViewTransition>
|
|
| 49 |
+ <FeedbackProvider> |
|
| 50 |
+ {/* 라우트 그룹 간 이동을 포함한 모든 전환에 크로스페이드를 적용한다. 클래스명(layout-transition)에
|
|
| 51 |
+ 대한 `::view-transition-old/new` 규칙은 design 레인이 app/globals.css에 제공한다 — 합류 |
|
| 52 |
+ 전에는 브라우저 기본 크로스페이드로 동작한다. */} |
|
| 53 |
+ <ViewTransition default="layout-transition">{children}</ViewTransition>
|
|
| 54 |
+ {/* 피드백 오버레이는 전환 크로스페이드 대상이 아니므로 ViewTransition 바깥에 둔다. */}
|
|
| 55 |
+ <FeedbackHost /> |
|
| 56 |
+ </FeedbackProvider> |
|
| 51 | 57 |
</body> |
| 52 | 58 |
</html> |
| 53 | 59 |
); |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?