(꼼꼼 프로젝트) 공통 버튼 컴포넌트의 종류를 쉽게 관리하고 누구나 쉽게 확인할 수 있게 하는 방법

Updated:

배경

이번 프로젝트의 디자인 시안이다.

오른쪽 property를 확인해보면 style, size, state로 세가지의 기준을 따라서 버튼의 종류가 바뀌는 것을 확인할 수 있다.

현재 상황에서의 문제를 생각해보면 두가지 정도가 있다.

  1. 우리는 tailwind를 사용하기 때문에 다양한 종류를 간편하게 관리하는 것에 대한 문제가 있다.
  2. 디자인이 아직 완성되기 전이기 때문에 디자인이 변경될 가능성이 아주 높은 상황이다. 코드 변경에 따른 디자인 변화를 쉽게 볼 수 있어야 한다.

구현

tailwind로 구현된 버튼의 다양한 종류를 간편하게 관리

tailwind는 이미 존재하는 자체 클래스를 활용하여 스타일링을 한다.

이 때문에 CSS 파일이 늘어난다거나 class name을 신경써야 되는 상황을 만들지 않는다는 장점이 있으나

class name은 결국 string이기 때문에 조건부 스타일링이 불편하다는 단점을 갖고 있다.

styled Component 같은 CSS-in-JS 같은 경우 prop으로 스타일을 내려서 조건부로 처리하는 방법이 있어서 편리하다. https://styled-components.com/docs/basics#attaching-additional-props

이 문제를 해결하기 위해서는 단순하게 객체로 처리하면 된다.

const STYLE = {
  white: "bg-white text-primary",
  orange: "bg-primary text-white hover:bg-[#d43914] active:scale-[0.99]",
};

const BUTTON =
  "w-full rounded-md border-[1.5px] border-primary py-2 text-center font-bold duration-200 hover:scale-[1.005] disabled:scale-[1] disabled:border-none disabled:bg-gray-40 disabled:text-white md:py-[10px] lg:py-[14px]";

//...
export default function Button({
  children,
  className,
  btnColor = "orange",
  btnType = "button",
  ...rest
}: ButtonProps) {
  return (
    <button
      color={btnType}
      className={`${BUTTON} ${STYLE[btnColor]} ${className}`}
      {...rest}
    >
      {children}
    </button>
  );
}

하지만 이 방법은 vscode의 intellisense 지원을 받을 수 없어서 element의 className에 적어놨다가 다시 옮기는 방식으로 해야 한다.

개발의 세계에선 이런 불편한 과정을 거치지 않을 것이라는 확신과 함께 연구를 시작했다.

그러다 cva를 알게 되었다.

cva (Class Variance Authority)

문서를 읽어보니 tailwind에서 조건부로 css를 입히기 위해서 존재하는 라이브러리라고 한다.

사용하는 걸 일단 봐보자.

import { cva } from "class-variance-authority";

export const BUTTON_STYLE = cva(
// 기본적으로 적용될 스타일
  [
    "flex",
    //...
    "disabled:scale-100",
  ],
  // variants는 하나의 큰 객체로 보면 된다. 최 하위 요소들은 스타일 하나를 의미한다. 예를 들면 btnSize는 large, x-small이라는 스타일을 갖고 있다.
  {
    variants: {
      btnSize: {
        large: ["h-[48px]", "min-w-[280px]", "text-base", "py-3"],
        "x-small": ["h-[32px]", "min-w-[74px]", "text-sm", "py-1.5"],
      },
      btnStyle: {
        solid: [
          "bg-brand-primary",
          "text-white",
          " hover:bg-interaction-hover",
          "active:bg-interaction-pressed",
          "disabled:bg-interaction-inactive",
        ],
        outlined: [
          "border-brand-primary",
          //...
    },
  },
);

// 사용하는 쪽
buttonVariants({ className:"flex", btnStyle:large, btnSize:solid }) 
// flex라는 className이 추가된 large solid button의 스타일 (string)

intellisense 지원

아래의 코드를 vscode에 넣어주면 intellisense의 지원도 받을 수 있다고 한다.

//settings.json
{
  "tailwindCSS.experimental.classRegex": [
    ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
    ["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
  ]
}

여기에 더해서 cva를 사용할 때 가끔 일어나는 스타일 충돌을 해결하기 위한 tw-merge (tailwind-merge) 라이브러리도 함께 사용하면 더 안정성을 높힐 수 있다고 한다.

import { cva, type VariantProps } from "class-variance-authority";
import { twMerge } from "tailwind-merge";
 
const buttonVariants = cva(["your", "base", "classes"], {
  variants: {
    intent: {
      primary: ["your", "primary", "classes"],
    },
  },
  defaultVariants: {
    intent: "primary",
  },
});
 
export interface ButtonVariants extends VariantProps<typeof buttonVariants> {}
 
export const button = (variants: ButtonVariants) =>
	  twMerge(buttonVariants(variants));

clsx + twMerge + cva

여기서 cva를 이용하여 조건부 처리된 객체를 배열을 string으로 만들어주는 라이브러리인 clsx를 이용하여 사용하기 편하게 만든 유틸 함수를 가져왔다. (shadcn 만만세)

import clsx from "clsx";
import { ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

/**
 * 조건부 처리 및 우선순위 충돌 해결된 tailwind 코드를 반환합니다.
 * @param inputs cva variants
 * @returns 조건부 처리 완료 및 우선순위 충돌 해결된 tailwind 코드
 */
export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

이를 활용하면 아래 코드처럼 공통 버튼 컴포넌트를 구현할 수 있다.

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  children?: React.ReactNode;
  btnStyle:
    | "solid"
    | "outlined"
    | "outlined_secondary"
    | "danger"
    | "none_background";
  btnSize: "large" | "x-small";
}

/**
 * 피그마에 정의된 대로 구성되어 있는 버튼 컴포넌트입니다. 
 * className으로 추가적인 버튼의 스타일을 결정할 수 있습니다.
 * 기본 버튼 태그의 속성 전부 들어갈 수 있습니다.
 * 버튼의 색상은 테마에 맞게 설정되어 있습니다.
 * @author 정지현, 이승현
 * @param children : 버튼 안에 담을 내용을 적습니다. (ex. "버튼")
 * @param btnStyle : 버튼의 스타일을 결정합니다.
 * @param btnSize : 버튼의 크기를 결정합니다.
 * @returns 버튼 컴포넌트를 반환합니다.
 * @example
 *  <Button btnSize="x-small" btnStyle="solid">
            solid x-small
          </Button>
    <Button btnSize="x-small" btnStyle="solid" disabled>
            disabled
          </Button>
 */
export default function Button({
  children,
  btnSize,
  btnStyle,
  className,
  ...rest
}: ButtonProps) {
  return (
    <button
      type="submit"
      className={cn(
        buttonVariants({
          btnStyle,
          btnSize,
          className,
        }),
      )}
      {...rest}
    >
      {children}
    </button>
  );
}

이러한 처리가 도움이 된 경험

중간에 디자인 시안이 바뀌었다.

none_background 라는 btnStyle이 하나 추가되었는데 기존 컴포넌트에 해당 스타일을 추가해야 하는 상황이 생긴 것이다.

이때를 위해서 최적화를 했지 라고 생각하면서 다음의 딱 두 줄을 추가하여 상황을 해결했다.

타입에 하나

varaiants에 하나

현업에서도 자주 사용되는 유틸 함수라고 하여 좋을 것이라고 생각은 했으나 유지 보수하는 과정이 굉장히 와닿았던 경험이었다.

지금은 실제 프로덕트에 비하면 작고 귀여운 프로젝트이지만 대형 프로젝트의 경우 유지 보수성이 좋은 건 엄청나게 큰 장점이다.

모든 컴포넌트에 적용하는 것은 사실 보일러 플레이트가 적진 않기 때문에 (특히나 cva variants) 다 적용하는 것이 좋다고는 생각하지 않지만

공통 컴포넌트나 디자인적으로 변경될 가능성이 큰 ui 컴포넌트의 경우에는 좋은 마음가짐으로 먼저 검토해보는 게 좋을 것 같다.

코드 변경으로 인하여 변경된 사항들을 손쉽게 확인하는 방법

여기서 끝이 아니었다. 디자인 시안이 변경된다는 것을 알게 된 시점에서 모든 종류를 쉽게 확인할 수 있는 방법이 있다면 좋겠다고 생각했다.

이전에 검토했었던 storybook을 사용해서 이 문제를 해결하기로 했다.

storybook

storybook이란 타 직종과의 협업에서 두각을 드러내는 툴 중에 하나이다.

코드만 봐도 개발자는 어떤 모양을 가지게 될지 감이 올 수 있지만 타 직종은 모를 수 있기 때문에 시각적으로 보여주는 것이 중요하기 때문이다.

결론적으로는 일의 “효율성”이 늘어나게 된다. 간단하게 연동 과정을 설명하고 넘어가겠다.

설치

npx storybook@latest init

각종 세팅을 쳐낸 후에 storybook 설치에 대한 안내가 나올 것이다.

npm run storybook

6006번 포트에서 개발 모드를 킨걸 볼 수 있다.

들어가면 이런 웹사이트로 이동한다.

그 후 파일에는 다음과 같은 파일들을 가지게 된다.

.storybook은 스토리북의 전체적인 설정과 관련된 파일들

stories 폴더에 파일들을 실제 스토리 파일을 작성하는 곳이다.

args 라는 곳에 작성한 것들이 스토리북 웹 사이트에 반영된다.

코드 생성

	// /components/CustomButton.tsx
import "./customButton.css";
import PropTypes from "prop-types";

export const CustomButton = ({
  size,
  label,
  variant,
  backgroundColor,
  color,
}) => {
  const style = {
    backgroundColor,
    color,
  };
  return (
    <button
      className={[
        "custom-button",
        `custom-button--${size}`,
        `custom-button--${variant}`,
      ].join(" ")}
      style={style}
    >
      {label}
    </button>
  );
};

// 스토리북 설정 웹사이트에 해당 설정을 조작할 수 있다
CustomButton.propTypes = {
  size: PropTypes.oneOf(["sm", "md", "lg"]),
  backgroundColor: PropTypes.string,
  color: PropTypes.string,
  label: PropTypes.string.isRequired,
};

// props가 전달되지 않았다면 기본 값 설정이다.
CustomButton.defaultProps = {
  backgroundColor: null,
  color: null,
  size: "md",
  variant: "outline",
};

.custom-button {
  font-family: "Nunito Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
  font-weight: 700;
  border: 0;
  border-radius: 3em;
  cursor: pointer;
  display: inline-block;
  line-height: 1;
}

.custom-button--outline {
  background-color: white;
  border: 1px solid black;
}

.custom-button--solid {
  background-color: black;
  border: none;
  color: white;
}

.custom-button--sm {
  font-size: 12px;
  padding: 10px 16px;
}

.custom-button--md {
  font-size: 14px;
  padding: 11px 20px;
}

.custom-button--lg {
  font-size: 16px;
  padding: 12px 24px;
}

props에 따라서 class가 변경되어 다른 css가 들어가게 된다.

스토리 생성

stories 폴더의 컴포넌트이름.stories.ts를 만든다. (스토리 이다.)

import { CustomButton } from "../components/CustomButton";

export default {
  title: "Test/CustomButton", // story 이름 설정
  component: CustomButton, // Component 가져오기
  args:{
	  label: "Button", // 공통적으로 쓰이는 props 지정, 여기서는 버튼 안에 있는 text
  }
};

export const Solid = {
// args로 스토리북에 해당 props를 내려준다.
  args: {
    variant: "solid",
  },
};

export const Outline = {
  args: {
    variant: "outline",
  },
};

export const Small = {
  args: {
    size: "sm",
  },
};

export const Medium = {
  args: {
    size: "md",
  },
};

export const Large = {
  args: {
    size: "lg",
  },
};

툴바 확인

배경 아이콘을 누르면 테마를 변경할 수 있다.

여기서 light, black 이외의 색으로 테마를 변경하고 싶으면 parameters를 사용하면 된다.

export const Solid = {
  args: {
    variant: "solid",
  },
  parameters: {
    backgrounds: {
      values: [
        {
          name: "blue",
          value: "blue",
        },
        {
          name: "red",
          value: "red",
        },
      ],
    },
  },
};

위처럼 특정 컴포넌트만 툴바 설정을 변경시킬 수 있다.

export default를 변경해주면

export default {
  title: "Test/CustomButton",
  component: CustomButton,
  args: {
    label: "Button",
  },
  parameters: {
    backgrounds: {
      values: [
        {
          name: "blue",
          value: "blue",
        },
        {
          name: "red",
          value: "red",
        },
      ],
    },
  },
};

만약 모든 컴포넌트에 넣고 싶다! 하면 스토리북의 전체 설정을 담당하는 .storybook 디렉토리에 preview.js 파일에 추가해주면 된다.

// .storybook/preview.js
/** @type { import('@storybook/react').Preview } */
const preview = {
  parameters: {
    actions: { argTypesRegex: "^on[A-Z].*" },
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/,
      },
    },
    // parameters 내 추가
    backgrounds: {
      values: [
        {
          name: "blue",
          value: "blue",
        },
        {
          name: "red",
          value: "red",
        },
      ],
    },
  },
};

export default preview;

연동이 끝난 storybook은 6006 포트에 storybook관련 사이트를 띄워준다. (명령어 입력시)

버튼의 종류가 수정되어도 명령어만 입력하면 버튼의 상태를 쉽게 확인할 수 있다.

트러블 슈팅

분명히 정상적으로 연동했다고 생각했는데 개발이 늘 그렇듯 트러블 슈팅은 따라왔다.

대 스토리북은 잘못 없고 일단 내 잘못이다 생각하고 내 실수를 찾아보기 시작했다.

(우리 프로젝트의 공통 버튼 컴포넌트입니다 짜자잔 ㅎㅎ)

이런 상황이 생기면 일단 조급한 마음을 지우고 경우의 수를 만드는 게 내 마음속의 메뉴얼이다.

크게 세가지 있는 것 같았다.

  1. next.js와의 호환성 > 깃 허브 이슈에서도, 최근 포스트에서도 문제 없어보임
  2. tailwind와의 호환성 > 흐름을 따라서 찾아보았다.
  3. storybook 자체의 문제 > 제일 확률이 적으니 우선순위 뒤로 뺌.

2번 상황을 생각하고 찾아본 결과 답을 찾게 되었다.

import type { Preview } from "@storybook/react";
  
import "../styles/globals.css";
  
const preview: Preview = {
  parameters: {
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/i,
      },
    },
  },
};
  
export default preview;

preview도 일종의 컴포넌트 파일이라고 생각해야 되는 것이었다.

즉, globals.css를 넣지 않으면 className을 넣는다고 자동으로 스타일을 넣지 않는다. (tailwind가 연결되지 않는다.)

해결! 이제 pnpm storybook 명령어만 입력하면 현재 우리 프로젝트의 storybook과 연결된 컴포넌트의 상태가 어떻게 되어 있는지 쉽게 파악할 수 있다.

꼼꼼 프로젝트 진행 중에 겪은 경험들입니다. 오늘도 많이 성장한 것 같아요!

읽어주셔서 감사합니다! 지켜봐주세요!

감사합니다.

Categories:

Updated:

Leave a comment