저번 시간까지는 디자인 시스템의 기반을 다졌다면 이번 글에서는 드디어 첫 번째 컴포넌트를 개발해볼 예정입니다.
디자인 시스템의 기본이라고 생각되는 Button 컴포넌트를 개발할 예정이며 간단한 테스트 코드와 Storybook 코드를 작성해볼 예정입니다.
제가 구현하고 싶은 Button 컴포넌트의 기능은 다음과 같습니다.
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;
다양한 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>;
저는 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",
},
};
저는 사실 본격적으로 테스트 코드를 작성한 것은 이번 프로젝트가 처음입니다.
그래서 테스트 코드를 어떻게 작성하는 것이 맞는지 정말 많은 글과 영상을 보고, 유명한 디자인 시스템 오픈 소스도 많이 뜯어본 거 같습니다.
이런 삽질의 결과는 아래와 같지만 제가 맞는 방식으로 테스트 코드를 작성했는지는 아직까지 의문입니다.
유명한 오픈 소스도 테스트 코드를 대충 작성한 것처럼 보이는 부분이 많아서 테스트 코드에 대한 부분은 아직까지 고민되는 거 같습니다.
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",
);
});
});
이번 디자인 시스템 프로젝트를 진행하면서 정말 다양한 오픈 소스를 뜯어보고 있습니다. 이러한 과정이 저에게 많은 도움이 된다는 게 느껴지고 있기 때문에 해당 프로젝트에 대한 만족도가 굉장히 큽니다. 지금까지 진행했던 모든 프로젝트 중에서 저에게 가장 많은 도움이 되는 프로젝트라고 생각되며 앞으로 해당 프로젝트를 꾸준히 업데이트할 예정입니다.