Button Component

예진·2024년 10월 31일

개발팀 인턴

목록 보기
2/8

Button Component 구현

Typescript & Tailwind CSS 사용해 만든 재사용 가능한 Button Components 구현
Button component는 자주 사용되는 UI 요소 중 하나로, 재사용 가능하고 유지보수성 높은 코드를 작성해 개발 생산성을 높이도록 해보자!

1. 버튼 컴포넌트 요구사항

  • 다양한 스타일(Variants): primary, secondary, stroke, ghost 등의 다양한 스타일 지원
  • 크기(Sizes): xs, sm, default, lg 크기 지원
  • 아이콘 유뮤(Icon) : 버튼 좌우에 아이콘이 들어가거나, 아이콘만 들어가는 경우
  • 로딩 상태(Loading): 버튼이 비활성화되면 로딩 스피너 표시
  • 사용자 정의 스타일: 추가적인 className을 통해 커스터마이징

2. 버튼 컴포넌트 작성

import React, { ReactNode } from "react";
import { Loader2 } from 'lucide-react';
import Typography from './typography';

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  children: ReactNode;
  variant?:
    | 'primary'
    | 'secondary'
    | 'stroke'
    | 'ghost'
	...;
  size?: 'default' | 'xs' | 'sm' | 'lg';
  icon?: {
    position?: 'left' | 'right';
    children: ReactNode;
    size?: 'iconXS' | 'iconS' | 'iconM' | 'iconL';
  };
  iconOnly?: boolean;
  className?: string;
  loading?: boolean;
  asChild?: boolean;
}

const variantStyle = {
  primary: 
  	'bg-primary hover: active: disabled: ...',
  secondary:
    'bg-primary-10% hover: active: disabled: ...',
  stroke:
    'bg-white hover: active: disabled: ...',
  ghost:
    'text-primary hover: active: disabled: ...',
	...
}

const sizeStyle = {
  xs: 'h-6 px-3 py-1',
  sm: '...',
  default: '...',
  lg: '...',
};

const iconSizeStyle = {
  iconXS: 'h-6 p-2',
  iconS: '...',
  iconM: '...',
  iconL: '...',
};

const typographyVariants = {
  xs: 'buttonXS',
  sm: 'buttonS',
  default: 'buttonM',
  lg: 'buttonL',
} as const;

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      variant = 'primary',
      size = 'default',
      icon,
      className,
      children,
      loading = false,
      asChild = false,
      iconOnly = false,
      ...props
    },
    ref,
  ) => {
    const calcPadding = () => {
      if (iconOnly) return '';
      if (icon && icon.position) {
        if (icon.position === 'right') {
          if (size === 'xs') return 'pr-2';
          if (size === 'sm') return '...';
          if (size === 'default') return '...';
          if (size === 'lg') return '...';
        }
        if (icon.position === 'left') {
          if (size === 'xs') return 'pl-2';
          if (size === 'sm') return '...';
          if (size === 'default') return '...';
          if (size === 'lg') return '...';
        }
      }
      return '';
    };
  
     const calcSize = iconOnly
      ? iconSizeStyle[icon?.size || 'iconM']
      : sizeStyle[size];

    return (
      <button
        ref={ref}
        className={
          baseStyle,
          variantStyle[variant],
          sizeStyle[size],
          calcSize,
          calcPadding(),
          className,
          loading && 'pointer-events-none',
        }
        disabled={loading}
        {...props}
      >
        {loading && <Loader2 className="animate-spin" />}
        <span
          className={
            'flex items-center',
            children ? 'gap-2' : '',
            icon?.position === 'right' ? 'flex-row-reverse' : 'flex-row',
          }
        >
          {icon && <span>{icon.children}</span>}
		  <Typography variant={typographyVariants[size]}>{children}</Typography>
        </span>
      </button>
    );
  },
);

Button.displayName = 'Button';

export default Button;

주요 속성

  • baseStyle → 버튼 공통 스타일 적용
  • variantStyle → variant에 따른 스타일 적용 (색상, 상태)
  • sizeStyle → size에 따른 스타일 적용
  • iconSizeStyle → 아이콘 size에 따른 스타일 적용
  • typographyVariants → typography 크기에 따른 스타일 적용
  • calcPadding() → 아이콘 위치에 따른 padding 스타일 적용 함수

3. 리팩토링

리팩토링 된 Button 컴포넌트

스타일 상수화
→ 스타일 관련 객체 이름을 BUTTON_VARIANTS, BUTTON_SIZES 등으로 변경해 명확한 의미 전달

as const 추가
→ 타입 안전성 강화, 객체 값들을 불변으로 처리

const BUTTON_VARIANTS = {
  base: 'flex items-center justify-center whitespace-nowrap rounded-lg',
  primary:
    'bg-primary hover: active: disabled: ...',
  ...
} as const;
  
  const BUTTON_SIZES = {
  xs: 'h-6 px-3 py-1',
  ...
} as const;

const ICON_SIZES = {
  iconXS: 'h-6 p-2',
  ...
} as const;

const TYPOGRAPHY_VARIANTS = {
  xs: 'buttonXS',
  ...
} as const;

계산 로직 간소화
paddingMap 객체를 사용해 중복 코드 제거

const calcPadding = () => {
      if (iconOnly) return '';
      if (icon && icon.position) {
        const paddingMap = { xs: '2', sm: '', default: '', lg: '' };
        return icon.position === 'right'
          ? `pr-${paddingMap[size]}`
          : `pl-${paddingMap[size]}`;
      }
      return '';
};

리팩토링 된 Button 컴포넌트

import React, { ReactNode } from 'react';
import { Loader2 } from 'lucide-react';
import Typography from './typography';

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  children: ReactNode;
  variant?:
    | 'primary'
    | 'secondary'
    | 'stroke'
    | 'ghost'
    ;
  size?: 'default' | 'xs' | 'sm' | 'lg';
  icon?: {
    position?: 'left' | 'right';
    children: ReactNode;
    size?: 'iconXS' | 'iconS' | 'iconM' | 'iconL';
  };
  iconOnly?: boolean;
  className?: string;
  loading?: boolean;
  asChild?: boolean;
}

const BUTTON_VARIANTS = {
  base: 'flex items-center justify-center whitespace-nowrap rounded-lg',
  primary:
    'bg-primary hover: active: disabled: ...',
  secondary:
    'bg-primary-10% hover: active: disabled: ...',
  stroke:
    'bg-white hover: active: disabled: ...',
  ghost:
    'text-primary hover: active: disabled: ...',
    ...
} as const;

const BUTTON_SIZES = {
  xs: 'h-6 px-3 py-1',
  sm: '...',
  default: '...',
  lg: '...',
} as const;

const ICON_SIZES = {
  iconXS: 'h-6 p-2',
  iconS: '...',
  iconM: '...',
  iconL: '...',
} as const;

const TYPOGRAPHY_VARIANTS = {
  xs: 'buttonXS',
  sm: 'buttonS',
  default: 'buttonM',
  lg: 'buttonL',
} as const;

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      variant = 'primary',
      size = 'default',
      icon,
      className,
      children,
      loading = false,
      asChild = false,
      iconOnly = false,
      ...props
    },
    ref,
  ) => {
    const calcPadding = () => {
      if (iconOnly) return '';
      if (icon && icon.position) {
        const paddingMap = { xs: '2', sm: '', default: '', lg: '' };
        return icon.position === 'right'
          ? `pr-${paddingMap[size]}`
          : `pl-${paddingMap[size]}`;
      }
      return '';
    };

    const calcSize = iconOnly
      ? ICON_SIZES[icon?.size || 'iconM']
      : BUTTON_SIZES[size];

    return (
      <button
        ref={ref}
        className={
          BUTTON_VARIANTS.base,
          BUTTON_VARIANTS[variant],
          BUTTON_SIZES[size],
          calcSize,
          calcPadding(),
          className,
          loading && 'pointer-events-none',
        }
        disabled={loading}
        {...props}
      >
        {loading && <Loader2 className="w-5 h-5 mr-2 animate-spin" />}
        <span
          className={
            'flex items-center',
            children ? 'gap-2' : '',
            icon?.position === 'right' ? 'flex-row-reverse' : 'flex-row',
          }
        >
          {icon && <span>{icon.children}</span>}
          <Typography variant={TYPOGRAPHY_VARIANTS[size]}>
            {children}
          </Typography>
        </span>
      </button>
    );
  },
);

Button.displayName = 'Button';

export default Button;

4. 활용 예시

Button 컴포넌트를 활용하여 다양한 텍스트 스타일을 쉽게 적용할 수 있음

import Typography from '@/components/Typography';

export default function Page() {
  return (
    <div>
      <Button variant="primary" size="default">Primary Button</Button>
      <Button variant="stroke" size="sm" icon={{ position: 'left', children: <IconName /> }}>
        Button with Icon
      </Button>
      <Button iconOnly icon={{ size: 'iconM', children: <IconName /> }} variant="ghost" />
      <Button variant="primary" size="lg" loading>Loading...</Button>
      <Button
        variant="secondary"
        size="default"
        className="bg-blue-500 hover:bg-blue-600 text-white"
      >
        Customized Button
      </Button>
    </div>
  );
}

개발하면서 느낀점

Button 컴포넌트를 구현하면서, 단순히 버튼을 클릭하는 기능만 고려하는 것이 아닌 다양한 상태와 아이콘, 사용자 정의 스타일을 지원하고 유지보수하기 쉽도록 구조화하는데 많은 고민을 하였다.

1. 재사용성

  • variant, size 등 다양한 속성을 선언적으로 관리함으로써 코드 중복을 최소화하고, 새로운 요구사항이 생겼을 때도 쉽게 확장할 수 있었다.
  • 스타일 로직을 variantStyle, sizeStyle, iconSizeStyle로 구분해, 코드가 명확해지고 다른 컴포넌트에서도 유사한 패턴으로 쉽게 재활용할 수 있었다

2. 유연성

  • className 프로퍼티를 활용해 기본 스타일을 유지하면서도, 상황에 맞는 커스텀 스타일을 덮어씌울 수 있도록 설계했다.
  • 아이콘의 위치나 사이즈를 유연하게 지정하고, 로딩 상태를 추가하는 등 버튼 하나로 다양한 요구사항을 처리할 수 있게 되어 개발 생산성이 높아졌다.

3. 코드의 가독성

  • 스타일 관련 객체(BUTTON_VARIANTS, BUTTON_SIZES, ICON_SIZES 등)를 명확한 상수로 분리하면서, 전체적인 코드 구조가 명료해지고 이해하기 쉬워졌다.
  • 파일과 로직이 명확하게 분리해, 프로젝트 규모가 커져도 코드 유지보수가 쉬워지고 가독성이 개선되었다.

4. 타입 안전성

  • TypeScript로 ButtonProps 인터페이스를 정의하고, as const를 사용해 객체의 불변성을 보장함으로써 타입 관련 오류를 사전에 방지할 수 있었다.
profile
😊

0개의 댓글