나만의 디자인 시스템을 만들어보자! - First Component

흑우·2024년 2월 19일
0

저번 시간까지는 디자인 시스템의 기반을 다졌다면 이번 글에서는 드디어 첫 번째 컴포넌트를 개발해볼 예정입니다.

디자인 시스템의 기본이라고 생각되는 Button 컴포넌트를 개발할 예정이며 간단한 테스트 코드와 Storybook 코드를 작성해볼 예정입니다.

Button 컴포넌트

제가 구현하고 싶은 Button 컴포넌트의 기능은 다음과 같습니다.

  • 기본적인 button 태그가 수행할 수 있는 기능은 모두 포함되어야 합니다.
  • isLoading prop이 전달된 경우 Spinner 컴포넌트를 렌더링 해야합니다.
  • isLoading과 loadingText prop이 전달된 경우 Spinner 컴포넌트와 loadingText를 렌더링 해야합니다.
  • spinnerPlacement prop을 통해 spinner의 위치를 변경할 수 있어야 합니다.
  • leftIcon 또는 rightIcon prop이 전달된 경우 prop에 맞는 icon이 지정된 위치에 렌더링되어야 합니다.
  • 다양한 variants에 맞는 디자인을 지원해야 합니다.

Button.tsx

import {
  ButtonHTMLAttributes,
  ForwardedRef,
  JSXElementConstructor,
  ReactElement,
  forwardRef,
} from "react";
import { ButtonVariants, button, content } from "./Button.css";
import { Spinner } from "@/components/Feedback";

export type ButtonProps = ButtonVariants &
  ButtonHTMLAttributes<HTMLButtonElement> & {
    leftIcon?: ReactElement<any, string | JSXElementConstructor<any>>;
    rightIcon?: ReactElement<any, string | JSXElementConstructor<any>>;
    isLoading?: boolean;
    isDisabled?: boolean;
    loadingText?: string;
    spinner?: ReactElement;
    spinnerPlacement?: "left" | "right";
  };

const Button = forwardRef(
  (
    {
      variant = "solid",
      size = "md",
      color = "gray",
      leftIcon,
      rightIcon,
      onClick,
      isLoading,
      isDisabled,
      loadingText,
      spinner,
      spinnerPlacement = "left",
      ...props
    }: ButtonProps,
    ref: ForwardedRef<HTMLButtonElement>,
  ) => {
    return (
      <button
        onClick={onClick}
        ref={ref}
        {...props}
        disabled={isDisabled || isLoading}
        aria-disabled={isDisabled || isLoading}
        className={button({
          size,
          variant,
          color,
        })}
      >
        <div className={content}>
          {isLoading ? (
            <>
              {spinnerPlacement === "left" ? (
                <>
                  {spinner || <Spinner size={size} />} {loadingText}
                </>
              ) : (
                <>
                  {loadingText} {spinner || <Spinner size={size} />}
                </>
              )}
            </>
          ) : (
            <>
              {leftIcon}
              {props.children}
              {rightIcon}
            </>
          )}
        </div>
      </button>
    );
  },
);

export default Button;

Button.css.ts

다양한 variants에 맞는 디자인을 제공하기 위해 Vanilla Extract에서 제공하는 recipe라는 패키지를 사용했습니다.

recipe는 컴파일 타임 또는 런타임에 스타일을 동적으로 생성하는 데 사용할 수 있습니다.

recipe는 variants와 상관없이 기본적으로 적용되는 css 속성인 base

전달되는 다양한 변수와 그에 대응되는 css 속성을 뜻하는 variants

variants의 기본값을 정의하는 defaultVariants

2개 이상의 variants 조합으로 스타일링을 할 수 있는 compoundVariants로 이루어져 있습니다.

위와 같은 코드를 작성함으로써 Button 컴포넌트에 전달된 props 따라 다양한 디자인을 제공할 수 있습니다.

Button.tsx을 보시면 아시겠지만 button({ size, variant })이런식으로 button 함수에 prop을 전달하는 방식으로 사용하시면 됩니다.

RecipeVariants는 recipe에서 사용하는 variants들을 type으로 정의해주는 기능입니다.

RecipeVariants을 통해 Button 컴포넌트이 props를 정의할 수 있습니다.

Vanilla Extract의 Recipe에 대해서 더 자세히 알고 싶으시다면 공식 문서를 참고해주세요!

export const button = recipe({
  base: sprinkles({
    display: "inline-flex",
    appearance: "none",
    alignItems: "center",
    justifyContent: "center",
    userSelect: "none",
    position: "relative",
    outline: "none",
    borderStyle: "none",
    fontWeight: "bold",
    whiteSpace: "nowrap",
    borderRadius: "lg",
    cursor: {
      base: "pointer",
      disabled: "not-allowed",
    },
    opacity: {
      base: "1",
      disabled: "0.5",
    },
  }),
  variants: {
    size: {
      xs: sprinkles({
        paddingX: "2",
        paddingY: "0",
        height: "6",
        fontSize: "0",
      }),
      sm: sprinkles({
        paddingX: "3",
        paddingY: "0",
        height: "8",
        fontSize: "1",
      }),
      md: sprinkles({
        paddingX: "4",
        paddingY: "0",
        height: "10",
        fontSize: "2",
      }),
      lg: sprinkles({
        paddingX: "6",
        paddingY: "0",
        height: "12",
        fontSize: "3",
      }),
    },
    variant: {
      solid: sprinkles({
        color: "white",
      }),
      outline: sprinkles({
        borderStyle: "solid",
        borderWidth: "px",
      }),
      ghost: {},
    },
    color: {
      gray: {},
      red: {},
      green: {},
      blue: {},
      orange: {},
    },
  },
  compoundVariants: [
    {
      variants: { variant: "solid", color: "gray" },
      style: sprinkles({
        backgroundColor: {
          base: "gray700",
          hover: "gray800",
          disabled: "gray",
        },
      }),
    },
    {
      variants: { variant: "outline", color: "gray" },
      style: sprinkles({
        backgroundColor: {
          base: "white",
          hover: "gray100",
        },
        borderColor: "gray800",
        color: "gray800",
      }),
    },
    {
      variants: { variant: "ghost", color: "gray" },
      style: sprinkles({
        backgroundColor: {
          base: "white",
          hover: "gray100",
        },
        color: "gray800",
      }),
    },
    {
      variants: { variant: "solid", color: "blue" },
      style: sprinkles({
        backgroundColor: {
          base: "blueSecondary",
          hover: "bluePrimary",
        },
      }),
    },
    {
      variants: { variant: "outline", color: "blue" },
      style: sprinkles({
        backgroundColor: {
          base: "white",
          hover: "blueBackground",
        },
        borderColor: "bluePrimary",
        color: "bluePrimary",
      }),
    },
    {
      variants: { variant: "ghost", color: "blue" },
      style: sprinkles({
        backgroundColor: {
          base: "white",
          hover: "blueBackground",
        },
        color: "bluePrimary",
      }),
    },
    {
      variants: { variant: "solid", color: "green" },
      style: sprinkles({
        backgroundColor: {
          base: "greenSecondary",
          hover: "greenPrimary",
        },
      }),
    },
    {
      variants: { variant: "outline", color: "green" },
      style: sprinkles({
        backgroundColor: {
          base: "white",
          hover: "greenBackground",
        },
        borderColor: "greenPrimary",
        color: "greenPrimary",
      }),
    },
    {
      variants: { variant: "ghost", color: "green" },
      style: sprinkles({
        backgroundColor: {
          base: "white",
          hover: "greenBackground",
        },
        color: "greenPrimary",
      }),
    },
    {
      variants: { variant: "solid", color: "orange" },
      style: sprinkles({
        backgroundColor: {
          base: "orangeSecondary",
          hover: "orangePrimary",
        },
      }),
    },
    {
      variants: { variant: "outline", color: "orange" },
      style: sprinkles({
        backgroundColor: {
          base: "white",
          hover: "orangeBackground",
        },
        borderColor: "orangePrimary",
        color: "orangePrimary",
      }),
    },
    {
      variants: { variant: "ghost", color: "orange" },
      style: sprinkles({
        backgroundColor: {
          base: "white",
          hover: "orangeBackground",
        },
        color: "orangePrimary",
      }),
    },
    {
      variants: { variant: "solid", color: "red" },
      style: sprinkles({
        backgroundColor: {
          base: "redSecondary",
          hover: "redPrimary",
        },
      }),
    },
    {
      variants: { variant: "outline", color: "red" },
      style: sprinkles({
        backgroundColor: {
          base: "white",
          hover: "redBackground",
        },
        borderColor: "redPrimary",
        color: "redPrimary",
      }),
    },
    {
      variants: { variant: "ghost", color: "red" },
      style: sprinkles({
        backgroundColor: {
          base: "white",
          hover: "redBackground",
        },
        color: "redPrimary",
      }),
    },
  ],
  defaultVariants: {
    size: "md",
    variant: "solid",
    color: "gray",
  },
});

export const content = sprinkles({
  display: "flex",
  justifyContent: "space-between",
  alignItems: "center",
  gap: "1",
});

export type ButtonVariants = RecipeVariants<typeof button>;

Button.stories.ts

저는 Storybook이라는 라이브러리를 통해 컴포넌트를 문서화하고 있습니다.

이전 글에서 Storybook을 자동으로 배포하는 글을 작성했었기 때문에 해당 코드를 작성하고 push를 하게되면 자동으로 배포가 진행됩니다.

Storybook은 호불호가 많이 갈리는 라이브러리인 거 같습니다.

누군가는 일이 너무 많아진다고 하지만 일반적인 웹 애플리케이션이 아니라 제가 만드는 디자인 시스템의 경우 Storybook은 필수라고 생각됩니다.

import type { Meta, StoryObj } from "@storybook/react";
import Button from "./Button";
import { IoMdSettings } from "react-icons/io";

const meta = {
  title: "Component/Form/Button",
  component: Button,
  tags: ["autodocs"],
  parameters: { layout: "centered" },
} satisfies Meta<typeof Button>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Outline: Story = {
  args: { size: "md", children: "Click Me", variant: "outline" },
};

export const Solid: Story = {
  args: { size: "md", children: "Click Me", variant: "solid" },
};

export const Ghost: Story = {
  args: {
    size: "md",
    children: "Click Me",
    variant: "ghost",
    onClick: () => alert("Clicked!"),
  },
};

export const LeftIcon: Story = {
  args: { size: "md", children: "Click Me", leftIcon: <IoMdSettings /> },
};

export const RightIcon: Story = {
  args: { size: "md", children: "Click Me", rightIcon: <IoMdSettings /> },
};

export const Disabled: Story = {
  args: { size: "md", children: "Click Me", isDisabled: true },
};

export const Loading: Story = {
  args: { size: "md", children: "Click Me", isLoading: true },
};

export const LoadingText: Story = {
  args: {
    size: "md",
    children: "Click Me",
    isLoading: true,
    loadingText: "Loading",
  },
};

Button.test.tsx

저는 사실 본격적으로 테스트 코드를 작성한 것은 이번 프로젝트가 처음입니다.

그래서 테스트 코드를 어떻게 작성하는 것이 맞는지 정말 많은 글과 영상을 보고, 유명한 디자인 시스템 오픈 소스도 많이 뜯어본 거 같습니다.

이런 삽질의 결과는 아래와 같지만 제가 맞는 방식으로 테스트 코드를 작성했는지는 아직까지 의문입니다.

유명한 오픈 소스도 테스트 코드를 대충 작성한 것처럼 보이는 부분이 많아서 테스트 코드에 대한 부분은 아직까지 고민되는 거 같습니다.

import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import Button from "./Button";
import { IoMdClose } from "react-icons/io";

describe("Button 컴포넌트 테스트", () => {
  afterEach(() => cleanup());

  it("Button 컴포넌트가 정상적으로 렌더링된다.", () => {
    render(<Button data-testid="btn">Button</Button>);
    const button = screen.getByTestId("btn");

    expect(button).toBeInTheDocument();
  });

  it("onClick 이벤트가 정상적으로 트리거된다.", () => {
    const fn = jest.fn();
    render(
      <Button onClick={fn} data-testid="btn">
        Button
      </Button>,
    );

    const button = screen.getByTestId("btn");
    fireEvent.click(button);
    expect(fn).toHaveBeenCalled();
  });

  test("icons와 함께 렌더링되어야 한다.", () => {
    render(
      <>
        <Button leftIcon={<IoMdClose />}>Left Icon</Button>
        <Button rightIcon={<IoMdClose />}>Right Icon</Button>
      </>,
    );
    expect(screen.getByText("Left Icon")).toBeTruthy();
    expect(screen.getByText("Right Icon")).toBeTruthy();
  });

  test("loadingText가 렌더링된다.", () => {
    render(
      <Button isLoading loadingText="loading...">
        Spinner
      </Button>,
    );
    expect(screen.getByRole("button")).toBeDisabled();
  });

  test("비활성화 된다.", () => {
    render(<Button isDisabled>Disabled</Button>);
    expect(screen.getByRole("button")).toBeDisabled();
  });

  test("Spinner가 렌더링된다.", () => {
    render(
      <Button isLoading data-testid="loading">
        Spinner
      </Button>,
    );
    expect(screen.getByRole("button")).toBeDisabled();
  });

  test("aria 속성이 정상적으로 적용된다.", () => {
    render(<Button>Aria Button</Button>);
    expect(screen.getByText("Aria Button")).not.toHaveAttribute(
      "aria-disabled",
    );

    render(
      <Button isLoading data-testid="loading-button">
        Loading Button
      </Button>,
    );
    expect(screen.getByTestId("loading-button")).toHaveAttribute(
      "aria-disabled",
    );

    render(
      <Button isDisabled data-testid="disabled-button">
        Disabled Button
      </Button>,
    );
    expect(screen.getByTestId("disabled-button")).toHaveAttribute(
      "aria-disabled",
    );
  });
});

정리

이번 디자인 시스템 프로젝트를 진행하면서 정말 다양한 오픈 소스를 뜯어보고 있습니다. 이러한 과정이 저에게 많은 도움이 된다는 게 느껴지고 있기 때문에 해당 프로젝트에 대한 만족도가 굉장히 큽니다. 지금까지 진행했던 모든 프로젝트 중에서 저에게 가장 많은 도움이 되는 프로젝트라고 생각되며 앞으로 해당 프로젝트를 꾸준히 업데이트할 예정입니다.

Reference

profile
흑우 모르는 흑우 없제~

0개의 댓글