React TypeScript 컴포넌트

재사용 가능한 React 함수형 컴포넌트 템플릿. Props 타입, 상태 관리, 이벤트 핸들러 패턴 포함.

Gist
import React, { useState, useCallback } from 'react';

type Variant = 'primary' | 'secondary' | 'danger' | 'ghost';

interface ButtonProps {
  label: string;
  variant?: Variant;
  size?: 'sm' | 'md' | 'lg';
  disabled?: boolean;
  loading?: boolean;
  fullWidth?: boolean;
  onClick?: () => void | Promise<void>;
}

const variantClasses: Record<Variant, string> = {
  primary:   'bg-blue-600 text-white hover:bg-blue-700 border-blue-600',
  secondary: 'bg-gray-100 text-gray-800 hover:bg-gray-200 border-gray-300',
  danger:    'bg-red-600 text-white hover:bg-red-700 border-red-600',
  ghost:     'bg-transparent text-gray-700 hover:bg-gray-100 border-transparent',
};

const sizeClasses = {
  sm: 'px-3 py-1.5 text-sm',
  md: 'px-4 py-2 text-base',
  lg: 'px-6 py-3 text-lg',
};

const Spinner: React.FC = () => (
  <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
    <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
    <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z" />
  </svg>
);

const Button: React.FC<ButtonProps> = ({
  label,
  variant = 'primary',
  size = 'md',
  disabled = false,
  loading = false,
  fullWidth = false,
  onClick,
}) => {
  const [isPressed, setIsPressed] = useState(false);
  const isDisabled = disabled || loading;

  const handleClick = useCallback(async () => {
    if (isDisabled) return;
    setIsPressed(true);
    try {
      await onClick?.();
    } finally {
      setTimeout(() => setIsPressed(false), 100);
    }
  }, [isDisabled, onClick]);

  return (
    <button
      type="button"
      onClick={handleClick}
      disabled={isDisabled}
      aria-busy={loading}
      aria-disabled={isDisabled}
      className={[
        'inline-flex items-center justify-center gap-2 rounded-md border font-medium',
        'transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500',
        variantClasses[variant],
        sizeClasses[size],
        fullWidth ? 'w-full' : '',
        isPressed ? 'scale-95' : '',
        isDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
      ].join(' ')}
    >
      {loading && <Spinner />}
      {label}
    </button>
  );
};

export default Button;