-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
- Loading branch information
Showing
4 changed files
with
270 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<AnimatedToast[]>([]); | ||
|
||
// 토스트 추가 처리 | ||
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 ( | ||
<div | ||
role="region" | ||
aria-label="알림" | ||
className="fixed inset-0 right-1 top-1 z-[100] flex flex-col items-end justify-start gap-1 p-4 sm:right-4 sm:top-4 sm:gap-2" | ||
> | ||
{animatedToasts.map((toast) => ( | ||
<div | ||
key={toast.id} | ||
className={cn( | ||
'w-full max-w-80 transform transition-all duration-300 ease-out', | ||
// 초기 상태 (마운트 전) | ||
'translate-y-3 opacity-0', | ||
// 진입 애니메이션 (마운트) | ||
toast.isVisible && !toast.isLeaving && 'translate-y-0 opacity-100', | ||
// 퇴장 애니메이션 | ||
toast.isLeaving && '-translate-y-3 opacity-0', | ||
)} | ||
> | ||
<Toast | ||
variant={toast.variant} | ||
title={toast.title} | ||
description={toast.description} | ||
onClose={() => toast.id && actions.removeToast(toast.id)} | ||
aria-describedby={toast.description ? `toast-description-${toast.id}` : undefined} | ||
aria-labelledby={toast.title ? `toast-title-${toast.id}` : undefined} | ||
/> | ||
</div> | ||
))} | ||
</div> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<HTMLDivElement>, VariantProps<typeof toastVariants> { | ||
title?: string; | ||
description?: string; | ||
onClose?: () => void; | ||
} | ||
|
||
const Toast = forwardRef<HTMLDivElement, ToastProps>( | ||
({ className, variant, title, description, onClose, ...props }, ref) => { | ||
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => { | ||
if (e.key === 'Escape' && onClose) { | ||
onClose(); | ||
} | ||
}; | ||
return ( | ||
<div | ||
ref={ref} | ||
role="alert" | ||
aria-live="polite" | ||
tabIndex={0} | ||
onKeyDown={handleKeyDown} | ||
className={cn(toastVariants({ variant }), className)} | ||
{...props} | ||
> | ||
{/* Content */} | ||
<div className="flex flex-1 flex-col gap-1"> | ||
{title && ( | ||
<div className="text-base font-semibold" id={`toast-title-${props.id}`}> | ||
{title} | ||
</div> | ||
)} | ||
{description && ( | ||
<div className="text-sm opacity-90" id={`toast-description-${props.id}`}> | ||
{description} | ||
</div> | ||
)} | ||
</div> | ||
|
||
{/* Close Button */} | ||
{onClose && ( | ||
<button | ||
onClick={onClose} | ||
className="ml-4 flex h-6 w-6 items-center justify-center rounded-full transition-colors hover:bg-black/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2" | ||
aria-label="닫기" | ||
> | ||
<span className="text-xl leading-none" aria-hidden={false}> | ||
× | ||
</span> | ||
</button> | ||
)} | ||
</div> | ||
); | ||
}, | ||
); | ||
|
||
Toast.displayName = 'Toast'; | ||
|
||
export { Toast, type ToastProps, toastVariants }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<ToastState>()( | ||
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' }, | ||
), | ||
); |