Skip to content

Commit

Permalink
feat: Add Toast system with accessibility and animations (#116)
Browse files Browse the repository at this point in the history
* 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
rhino-ty authored Nov 25, 2024
1 parent 1349b3b commit c8259b6
Show file tree
Hide file tree
Showing 4 changed files with 270 additions and 1 deletion.
8 changes: 7 additions & 1 deletion client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -21,7 +22,12 @@ const AppProvider = ({ children }: AppChildrenProps) => {

// ErrorBoundary, 모달 등 추가할 예정
const App = ({ children }: AppChildrenProps) => {
return <AppProvider>{children}</AppProvider>;
return (
<AppProvider>
{children}
<ToastContainer />
</AppProvider>
);
};

export default App;
102 changes: 102 additions & 0 deletions client/src/components/toast/ToastContainer.tsx
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>
);
};
75 changes: 75 additions & 0 deletions client/src/components/ui/Toast.tsx
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 };
86 changes: 86 additions & 0 deletions client/src/stores/toast.store.ts
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' },
),
);

0 comments on commit c8259b6

Please sign in to comment.