From c8259b63d86878a5a1aecca75a535a3fd555bd5b Mon Sep 17 00:00:00 2001 From: Taeyeon Yoon Date: Mon, 25 Nov 2024 17:06:14 +0900 Subject: [PATCH] feat: Add Toast system with accessibility and animations (#116) * feat: Implement Toast component with headless pattern - Add base Toast UI component with variant styles - Create cva variants for different toast types (default, error, success, warning) - Add close button with hover effect - Keep component headless by separating styling from logic * feat: Add ToastContainer with slide animation - Create ToastContainer to manage toast stack - Implement smooth slide animations for toast enter/exit - Add state management for animation timing - Handle multiple toasts with proper stacking * feat: Add toast store using Zustand for global state access - Use global state management for showing toasts from anywhere in app - Implement toast CRUD operations - Add auto-dismiss with configurable duration * feat: Integrate toast system into app root - Add ToastContainer to App component * feat: Enhance Toast components accessibility - Add ARIA roles and live regions for screen reader support - Implement keyboard navigation and focus management - Add ESC key to dismiss toasts - Add focus styles consistent with design system - Connect aria-labelledby/describedby with unique IDs - Improve close button accessibility with contextual labels * feat: Implement max toast limit and FIFO behavior - Add MAX_TOASTS constant to limit the number of toasts to 5 --- client/src/App.tsx | 8 +- .../src/components/toast/ToastContainer.tsx | 102 ++++++++++++++++++ client/src/components/ui/Toast.tsx | 75 +++++++++++++ client/src/stores/toast.store.ts | 86 +++++++++++++++ 4 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 client/src/components/toast/ToastContainer.tsx create mode 100644 client/src/components/ui/Toast.tsx create mode 100644 client/src/stores/toast.store.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index db0000d2..3ab59d2f 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,5 +1,6 @@ import { ReactNode } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ToastContainer } from './components/toast/ToastContainer'; // React Query 클라이언트 인스턴스 생성 const queryClient = new QueryClient({ @@ -21,7 +22,12 @@ const AppProvider = ({ children }: AppChildrenProps) => { // ErrorBoundary, 모달 등 추가할 예정 const App = ({ children }: AppChildrenProps) => { - return {children}; + return ( + + {children} + + + ); }; export default App; diff --git a/client/src/components/toast/ToastContainer.tsx b/client/src/components/toast/ToastContainer.tsx new file mode 100644 index 00000000..10ad4e9c --- /dev/null +++ b/client/src/components/toast/ToastContainer.tsx @@ -0,0 +1,102 @@ +import { useEffect, useState } from 'react'; +import { Toast } from '@/components/ui/Toast'; +import { ToastConfig, useToastStore } from '@/stores/toast.store'; +import { cn } from '@/utils/cn'; + +interface AnimatedToast extends ToastConfig { + isVisible: boolean; + isLeaving: boolean; +} + +export const ToastContainer = () => { + const { toasts, actions } = useToastStore(); + const [animatedToasts, setAnimatedToasts] = useState([]); + + // 토스트 추가 처리 + useEffect(() => { + const newToasts = toasts.filter((toast) => !animatedToasts.find((aToast) => aToast.id === toast.id)); + + if (newToasts.length > 0) { + // 먼저 invisible 상태로 추가 + setAnimatedToasts((prev) => [ + ...prev, + ...newToasts.map((toast) => ({ + ...toast, + isVisible: false, // 처음에는 false로 설정 + isLeaving: false, + })), + ]); + + // 새로 추가된 토스트만 약간의 딜레이 후 visible로 변경 + const timeoutId = setTimeout(() => { + setAnimatedToasts((prev) => + prev.map((toast) => ({ + ...toast, + isVisible: true, + })), + ); + }, 50); // 약간의 지연 추가 + + return () => clearTimeout(timeoutId); + } + }, [toasts]); // animatedToasts는 의존성에서 제거 + + // 토스트 제거 처리 + useEffect(() => { + const toastIds = new Set(toasts.map((t) => t.id)); + + setAnimatedToasts((prev) => + prev.map((toast) => ({ + ...toast, + isLeaving: toast.id ? !toastIds.has(toast.id) : false, + })), + ); + }, [toasts]); + + // 퇴장 애니메이션 완료 후 cleanup + useEffect(() => { + const leavingToasts = animatedToasts.filter((toast) => toast.isLeaving); + + if (leavingToasts.length > 0) { + const timer = setTimeout(() => { + setAnimatedToasts((prev) => prev.filter((toast) => !toast.isLeaving)); + }, 300); + + return () => clearTimeout(timer); + } + }, [animatedToasts]); + + if (animatedToasts.length === 0) return null; + + return ( +
+ {animatedToasts.map((toast) => ( +
+ toast.id && actions.removeToast(toast.id)} + aria-describedby={toast.description ? `toast-description-${toast.id}` : undefined} + aria-labelledby={toast.title ? `toast-title-${toast.id}` : undefined} + /> +
+ ))} +
+ ); +}; diff --git a/client/src/components/ui/Toast.tsx b/client/src/components/ui/Toast.tsx new file mode 100644 index 00000000..8ec7dcbe --- /dev/null +++ b/client/src/components/ui/Toast.tsx @@ -0,0 +1,75 @@ +import { HTMLAttributes, KeyboardEvent, forwardRef } from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@/utils/cn'; + +const toastVariants = cva('flex items-center justify-between rounded-lg border-2 border-violet-950 p-4 shadow-lg', { + variants: { + variant: { + default: 'bg-violet-200 text-violet-950', + error: 'bg-red-100 text-red-900 border-red-900', + success: 'bg-green-100 text-green-900 border-green-900', + warning: 'bg-yellow-100 text-yellow-900 border-yellow-900', + }, + }, + defaultVariants: { + variant: 'default', + }, +}); + +interface ToastProps extends HTMLAttributes, VariantProps { + title?: string; + description?: string; + onClose?: () => void; +} + +const Toast = forwardRef( + ({ className, variant, title, description, onClose, ...props }, ref) => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && onClose) { + onClose(); + } + }; + return ( +
+ {/* Content */} +
+ {title && ( +
+ {title} +
+ )} + {description && ( +
+ {description} +
+ )} +
+ + {/* Close Button */} + {onClose && ( + + )} +
+ ); + }, +); + +Toast.displayName = 'Toast'; + +export { Toast, type ToastProps, toastVariants }; diff --git a/client/src/stores/toast.store.ts b/client/src/stores/toast.store.ts new file mode 100644 index 00000000..d8accdd2 --- /dev/null +++ b/client/src/stores/toast.store.ts @@ -0,0 +1,86 @@ +import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; + +const MAX_TOASTS = 5; + +export interface ToastConfig { + id?: string; + title?: string; + description?: string; + duration?: number; + variant?: 'default' | 'error' | 'success' | 'warning'; +} + +interface ToastState { + toasts: ToastConfig[]; + actions: { + addToast: (config: ToastConfig) => void; + removeToast: (id: string) => void; + clearToasts: () => void; + }; +} + +/** + * 토스트 알림을 전역적으로 관리하는 Store입니다. + * + * @example + * ```typescript + * const { toasts, actions } = useToastStore(); + * + * // 토스트 추가 + * actions.addToast({ + * title: '성공!', + * description: '작업이 완료되었습니다.', + * variant: 'success', + * duration: 3000 + * }); + * ``` + */ +export const useToastStore = create()( + devtools( + (set) => ({ + toasts: [], + actions: { + addToast: (config) => { + const id = crypto.randomUUID(); + // 새로운 토스트 준비 + const newToast = { + ...config, + id, + }; + + set((state) => { + if (config.duration !== Infinity) { + setTimeout(() => { + set((state) => ({ + toasts: state.toasts.filter((t) => t.id !== id), + })); + }, config.duration || 3000); + } + + // 현재 토스트가 최대 개수에 도달한 경우 + if (state.toasts.length >= MAX_TOASTS) { + // 가장 오래된 토스트를 제외하고 새 토스트 추가 + return { + toasts: [...state.toasts.slice(1), newToast], + }; + } + + // 최대 개수에 도달하지 않은 경우 단순 추가 + return { + toasts: [...state.toasts, newToast], + }; + }); + }, + + removeToast: (id) => + set((state) => ({ + toasts: state.toasts.filter((toast) => toast.id !== id), + })), + + clearToasts: () => set({ toasts: [] }), + }, + }), + { name: 'ToastStore' }, + ), +);