diff --git a/src/components/checkbox/constants/constants.ts b/src/components/checkbox/constants/constants.ts new file mode 100644 index 0000000..a6d999c --- /dev/null +++ b/src/components/checkbox/constants/constants.ts @@ -0,0 +1,20 @@ +import checkedLarge from '@/assets/icons/check/checkedLarge.svg'; +import checkedSmall from '@/assets/icons/check/checkedSmall.svg'; +import nonCheckedLarge from '@/assets/icons/check/nonCheckedLarge.svg'; +import nonCheckedSmall from '@/assets/icons/check/nonCheckedSmall.svg'; + +export const CHECKBOX_ICON = { + checked: { + large: checkedLarge, + small: checkedSmall, + }, + unchecked: { + large: nonCheckedLarge, + small: nonCheckedSmall, + }, +} as const; + +export const ICON_SIZE = { + large: 18, + small: 16, +} as const; diff --git a/src/components/toast/Toast.tsx b/src/components/toast/Toast.tsx new file mode 100644 index 0000000..c0c67a6 --- /dev/null +++ b/src/components/toast/Toast.tsx @@ -0,0 +1,56 @@ +'use client'; + +import clsx from 'clsx'; + +import useToastLifecycle from './hooks/useToastLifecycle'; +import styles from './styles/Toast.module.css'; +import type { ToastProps } from './types/types'; + +const DEFAULT_AUTO_DISMISS_MS = 3000; +const DEFAULT_ANIMATION_MS = 600; + +export default function Toast({ + message = '저장하지 않은 변경사항이 있어요!', + actionLabel = '변경사항 저장하기', + isOpen = true, + autoDismissMs = DEFAULT_AUTO_DISMISS_MS, + enterDurationMs = DEFAULT_ANIMATION_MS, + exitDurationMs = DEFAULT_ANIMATION_MS, + className, + actionClassName, + onAction, + onDismiss, +}: ToastProps) { + const { isRendered, isClosing } = useToastLifecycle({ + isOpen, + autoDismissMs, + exitDurationMs, + onDismiss, + }); + + return isRendered ? ( +
+
+ + {message} +
+ {actionLabel ? ( + + ) : null} +
+ ) : null; +} diff --git a/src/components/toast/hooks/useToastLifecycle.ts b/src/components/toast/hooks/useToastLifecycle.ts new file mode 100644 index 0000000..a479fc2 --- /dev/null +++ b/src/components/toast/hooks/useToastLifecycle.ts @@ -0,0 +1,83 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +interface UseToastLifecycleOptions { + isOpen: boolean; + autoDismissMs: number; + exitDurationMs: number; + onDismiss?: () => void; +} + +export default function useToastLifecycle({ + isOpen, + autoDismissMs, + exitDurationMs, + onDismiss, +}: UseToastLifecycleOptions) { + const [isRendered, setIsRendered] = useState(isOpen); + const [isClosing, setIsClosing] = useState(false); + const autoTimerRef = useRef(null); + const exitTimerRef = useRef(null); + const prevIsOpenRef = useRef(isOpen); + + const clearTimers = useCallback(() => { + if (autoTimerRef.current) { + window.clearTimeout(autoTimerRef.current); + autoTimerRef.current = null; + } + if (exitTimerRef.current) { + window.clearTimeout(exitTimerRef.current); + exitTimerRef.current = null; + } + }, []); + + const startDismiss = useCallback(() => { + if (exitTimerRef.current) return; + setIsClosing(true); + exitTimerRef.current = window.setTimeout(() => { + setIsRendered(false); + setIsClosing(false); + exitTimerRef.current = null; + onDismiss?.(); + }, exitDurationMs); + }, [exitDurationMs, onDismiss]); + + useEffect(() => { + let rafId: number | null = null; + const wasOpen = prevIsOpenRef.current; + + if (isOpen) { + prevIsOpenRef.current = true; + const isOpening = !wasOpen; + if (isOpening) { + clearTimers(); + rafId = window.requestAnimationFrame(() => { + setIsRendered(true); + setIsClosing(false); + }); + } + + if (isRendered && !isClosing && autoDismissMs > 0) { + clearTimers(); + autoTimerRef.current = window.setTimeout(startDismiss, autoDismissMs); + } + + return () => { + if (rafId !== null) window.cancelAnimationFrame(rafId); + }; + } + + if (isRendered && !isClosing) { + clearTimers(); + rafId = window.requestAnimationFrame(() => startDismiss()); + } + prevIsOpenRef.current = false; + + return () => { + if (rafId !== null) window.cancelAnimationFrame(rafId); + }; + }, [autoDismissMs, clearTimers, isClosing, isOpen, isRendered, startDismiss]); + + useEffect(() => () => clearTimers(), [clearTimers]); + + return { isRendered, isClosing }; +} diff --git a/src/components/toast/styles/Toast.module.css b/src/components/toast/styles/Toast.module.css new file mode 100644 index 0000000..fdcf741 --- /dev/null +++ b/src/components/toast/styles/Toast.module.css @@ -0,0 +1,116 @@ +.toast { + position: fixed; + top: 20px; + left: 20px; + z-index: 1000; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + width: 868px; + height: 57px; + padding: 12px 12px 12px 24px; + border-radius: 16px; + box-sizing: border-box; + background: var(--Color-Brand-Primary, #5189fa); + color: #ffffff; +} + +.content { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + flex: 0 0 auto; +} + +.message { + font-size: 14px; + line-height: 1.4; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.action { + flex: 0 0 auto; + border: none; + border-radius: 12px; + padding: 8px 12px; + background: #ffffff; + color: #3f6fe5; + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +.action:focus-visible { + outline: 2px solid #ffffff; + outline-offset: 2px; +} + +.enter { + animation: toast-pop-in 600ms ease-out; +} + +.exit { + animation: toast-fade-out 600ms ease-in forwards; +} + +@keyframes toast-pop-in { + 0% { + opacity: 0; + transform: translateY(-8px) scale(0.96); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes toast-fade-out { + 0% { + opacity: 1; + transform: translateY(0) scale(1); + } + 100% { + opacity: 0; + transform: translateY(-6px) scale(0.98); + } +} + +@media (max-width: 768px) { + .toast { + top: 97px; + left: 20px; + width: 343px; + height: 49px; + padding: 8px 8px 8px 12px; + gap: 8px; + } + + .message { + font-size: 13px; + } + + .action { + padding: 6px 10px; + font-size: 12px; + } +} + +@media (prefers-reduced-motion: reduce) { + .enter, + .exit { + animation: none; + } +} diff --git a/src/components/toast/types/types.ts b/src/components/toast/types/types.ts new file mode 100644 index 0000000..bfe1bad --- /dev/null +++ b/src/components/toast/types/types.ts @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react'; + +export interface ToastProps { + message?: ReactNode; + actionLabel?: ReactNode; + isOpen?: boolean; + autoDismissMs?: number; + enterDurationMs?: number; + exitDurationMs?: number; + className?: string; + actionClassName?: string; + onAction?: () => void; + onDismiss?: () => void; +}