Alert

Interactive UI Explorer

아래에서 각 컴포넌트의 다양한 Variants States를 문서화된 형태로 테스트해볼 수 있습니다.

Live Preview

저장 완료

변경사항이 정상적으로 저장되었습니다.

Alert / Confirm

Promise 기반 Alert / Confirm 모달을 사용하는 예시입니다. 각 버튼을 눌러 실제 동작을 확인할 수 있습니다.

기본 Alert

가장 단순한 정보용 Alert 예시

Source Code
const { alert } = useAlert();

await alert('가장 기본적인 알림(Alert) 예시입니다.', {
  title: '기본 Alert',
  variant: 'info',
});
        
Live Preview

여러 Variant Alert

success / warning / error 등의 다양한 Alert variant

Success
await alert('성공!', { title: 'Success', variant: 'success' });
Live Preview
Warning
await alert('주의!', { title: 'Warning', variant: 'warning' });
Live Preview
Error
await alert('에러!', { title: 'Error', variant: 'error' });
Live Preview

Confirm (확인 / 취소)

사용자의 선택에 따라 분기 처리하는 Confirm 예시

Source Code
const { alert, confirm } = useAlert();

const ok = await confirm('정말 삭제하시겠습니까?', {
  title: '삭제 확인',
  confirmText: '삭제',
  cancelText: '취소',
  variant: 'warning',
});

await alert(ok ? '삭제가 완료되었습니다.' : '삭제가 취소되었습니다.', {
  title: ok ? '완료' : '취소됨',
  variant: ok ? 'success' : 'info',
});
        
Live Preview

비동기 플로우 (Alert → Confirm → Alert)

Alert와 Confirm을 조합해 단계적인 비동기 플로우를 구성하는 예시

Source Code
const { alert, confirm } = useAlert();

await alert('비동기 플로우를 시작합니다.', {
  title: 'Step 1',
  variant: 'info',
});

const confirmed = await confirm('서버에 요청을 전송할까요?', {
  title: 'Step 2',
  confirmText: '전송',
  cancelText: '취소',
  variant: 'info',
});

if (!confirmed) {
  await alert('요청이 취소되었습니다.', {
    title: '중단',
    variant: 'warning',
  });
  return;
}

// 실제로는 여기서 await api 호출 등을 수행
await alert('요청이 성공적으로 처리되었습니다.', {
  title: '완료',
  variant: 'success',
});
        
Live Preview
Implementation

제작 코드

이 컴포넌트가 실제로 어떻게 구현되어 있는지 — 본체 .tsx 파일을 그대로 보여줍니다. variant 매핑·접근성 처리·forwardRef 패턴 등 디테일을 그대로 확인할 수 있어요.

AlertModaltypescript
/**
 * Alert Modal 컴포넌트
 *
 * 정보를 표시하는 Alert 모달입니다.
 * variant별 아이콘·색상·스타일로 시각적 구분을 줍니다.
 */
'use client';

import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { cn } from '@/lib/cn';
import { Button } from '@/components';
import type { AlertOptions } from './types';

interface AlertModalProps {
  options: AlertOptions;
  onClose: () => void;
}

type Variant = NonNullable<AlertOptions['variant']>;

const variantConfig: Record<
  Variant,
  {
    accent: string;
    iconBg: string;
    iconColor: string;
    titleColor: string;
    buttonVariant: 'primary' | 'danger' | 'secondary';
  }
> = {
  default: {
    accent: 'bg-slate-100 border-slate-200',
    iconBg: 'bg-slate-100',
    iconColor: 'text-slate-600',
    titleColor: 'text-slate-900',
    buttonVariant: 'primary',
  },
  info: {
    accent: 'bg-blue-50 border-blue-200',
    iconBg: 'bg-blue-100',
    iconColor: 'text-blue-600',
    titleColor: 'text-blue-900',
    buttonVariant: 'primary',
  },
  success: {
    accent: 'bg-emerald-50 border-emerald-200',
    iconBg: 'bg-emerald-100',
    iconColor: 'text-emerald-600',
    titleColor: 'text-emerald-900',
    buttonVariant: 'primary',
  },
  warning: {
    accent: 'bg-amber-50 border-amber-200',
    iconBg: 'bg-amber-100',
    iconColor: 'text-amber-600',
    titleColor: 'text-amber-900',
    buttonVariant: 'primary',
  },
  error: {
    accent: 'bg-red-50 border-red-200',
    iconBg: 'bg-red-100',
    iconColor: 'text-red-600',
    titleColor: 'text-red-900',
    buttonVariant: 'danger',
  },
};

function VariantIcon({ variant, className }: { variant: Variant; className?: string }) {
  const c = cn('size-6', className);
  switch (variant) {
    case 'success':
      return (
        <svg className={c} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
          <path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
        </svg>
      );
    case 'warning':
      return (
        <svg className={c} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
          <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
        </svg>
      );
    case 'error':
      return (
        <svg className={c} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
          <path strokeLinecap="round" strokeLinejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
        </svg>
      );
    case 'info':
    case 'default':
    default:
      return (
        <svg className={c} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
          <path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
        </svg>
      );
  }
}

export function AlertModal({ options, onClose }: AlertModalProps) {
  const modalRef = useRef<HTMLDivElement>(null);
  const confirmButtonRef = useRef<HTMLButtonElement>(null);
  const variant = options.variant ?? 'default';
  const config = variantConfig[variant];

  useEffect(() => {
    confirmButtonRef.current?.focus();

    const handleEscape = (e: KeyboardEvent) => {
      if (e.key === 'Escape') onClose();
    };
    const handleEnter = (e: KeyboardEvent) => {
      if (e.key === 'Enter' && !e.shiftKey) {
        e.preventDefault();
        onClose();
      }
    };

    document.addEventListener('keydown', handleEscape);
    document.addEventListener('keydown', handleEnter);
    document.body.style.overflow = 'hidden';

    return () => {
      document.removeEventListener('keydown', handleEscape);
      document.removeEventListener('keydown', handleEnter);
      document.body.style.overflow = '';
    };
  }, [onClose]);

  if (typeof window === 'undefined') return null;

  return createPortal(
    <div
      className="fixed inset-0 z-120 flex items-center justify-center p-4 animate-in fade-in duration-200"
      role="dialog"
      aria-modal="true"
      aria-labelledby={options.title ? 'alert-title' : undefined}
      aria-describedby="alert-message"
    >
      <div
        className="absolute inset-0 bg-black/40 backdrop-blur-md"
        onClick={onClose}
        aria-hidden="true"
      />

      <div
        ref={modalRef}
        className="relative z-10 w-full max-w-md overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 animate-in zoom-in-95 duration-200"
        onClick={(e) => e.stopPropagation()}
      >
        {/* 상단 악센트 + 아이콘 */}
        <div className={cn('border-b px-6 pt-6 pb-4', config.accent)}>
          <div className="flex items-start gap-4">
            <span className={cn('flex size-12 shrink-0 items-center justify-center rounded-xl', config.iconBg, config.iconColor)}>
              <VariantIcon variant={variant} />
            </span>
            <div className="min-w-0 flex-1 pt-0.5">
              {options.title && (
                <h2 id="alert-title" className={cn('text-lg font-bold', config.titleColor)}>
                  {options.title}
                </h2>
              )}
              <p id="alert-message" className={cn('mt-1 text-[15px] leading-relaxed text-slate-600', !options.title && 'mt-0')}>
                {options.message}
              </p>
            </div>
          </div>
        </div>

        {/* 액션 */}
        <div className="flex p-4">
          <Button
            ref={confirmButtonRef}
            onClick={onClose}
            variant={config.buttonVariant}
            className="w-full rounded-xl py-3 font-semibold shadow-sm"
          >
            {options.confirmText || '확인'}
          </Button>
        </div>
      </div>
    </div>,
    document.body
  );
}