본문 바로가기

Front-End

[Next.js + Vanilla Extract css + Storybook] 공용 컴포넌트 만들기 (feat. 절대경로)

페이지들을 구현하기 전에 공용 컴포넌트들을 만들어 두기로 했다.

스토리북을 쓰면 만든 컴포넌트들을 미리보기의 형태로 정리해 놓을 수 있어서 함께 사용하기로!

 

 

처음 storybook을 설치하면 작성되어있는 main.ts 이다.

 

import type { StorybookConfig } from '@storybook/nextjs'

const config: StorybookConfig = {
  stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
  addons: [
    '@storybook/addon-links',
    '@storybook/addon-essentials',
    '@storybook/addon-onboarding',
    '@storybook/addon-interactions',
    '@storybook/addon-mdx-gfm'
  ],
  framework: {
    name: '@storybook/nextjs',
    options: {},
  },
  docs: {
    autodocs: 'tag',
  },
}
export default config

 

 

 

이 프로젝트에서는 storybook도 vanilla extract 형식의 css를 읽어내야 하기 때문에 이 main.ts파일을 수정해야 한다.

 

npm install -D @vanilla-extract/webpack-plugin css-loader mini-css-extract-plugin style-loader
import type { StorybookConfig } from '@storybook/nextjs';

import { VanillaExtractPlugin } from "@vanilla-extract/webpack-plugin";
import MiniCssExtractPlugin from "mini-css-extract-plugin";

const config: StorybookConfig = {
  stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
  addons: [
    '@storybook/addon-links',
    '@storybook/addon-essentials',
    '@storybook/addon-onboarding',
    '@storybook/addon-interactions',
    '@storybook/addon-styling-webpack',
    ({
      name: "@storybook/addon-styling-webpack",
      options: {plugins: [new VanillaExtractPlugin(), new MiniCssExtractPlugin()],
        rules: [{
// css 파일 처리 규칙 1// Vanilla Extract에서 생성된 CSS 파일을 제외하고, style-loader 및 css-loader를 사용하여 CSS 파일을 모듈로 변환 및 번들링.
      test: /\\.css$/,
      sideEffects: true,
      use: [
          require.resolve("style-loader"),
          {
              loader: require.resolve("css-loader"),
              options: {},
          },
      ],exclude: /\\.vanilla\\.css$/,
    },{
// css 파일 처리 규칙 2// Vanilla Extract에서 생성된 CSS 파일을 MiniCssExtractPlugin.loader 및 css-loader를 사용하여 CSS 파일을 번들링하고 추출.
    test: /\\.vanilla\\.css$/i,
    sideEffects: true,
    use: [
      MiniCssExtractPlugin.loader,
      {
          loader: require.resolve('css-loader'),
          options: {
// Required as image imports should be handled via JS/TS import statements
              url: false,
          },
      },
    ],
    },],
      }
    })
  ],
  framework: {
    name: '@storybook/nextjs',
    options: {},
  },
  docs: {
    autodocs: 'tag',
  },
};
export default config;

public 폴더 속 svg파일을 불러와 로딩 컴포넌트로 사용하기 위해 tsconfig에서 작성한 TypeScript의 절대 경로를 해석할 수 있도록 설정하고, svg파일을 읽기 위한 webpack 규칙도 추가했다.

 

 

 

...그럼 이제 만들어야 할 공용 컴포넌트들!

 

세 타입으로 분류를 하긴 했는데 type2,3의 차이가 border radius밖에 없어서 SolidButton, Grad(ient)Button 두가지로만 분류해 만들기로 했다.

 

 

SolidButton은 그림자 유무fullWidth(width: 100%)를 적용할 건지를 옵션으로 받고 버튼의 상태가 isLoading 또는 disabled인지에 따라 스타일링을 달리 해준다.

GradButton은 버튼 색상, 모서리 둥글기, fullWidth를 옵션으로 받고 나머지 동일!

 

그리고 기본 버튼과 같은 역할을 할 수 있도록 button 태그가 받는 props들을 거의 다 가져오면 된다. 

 

 

 

그래서 나온 SolidButtonProps 와 GradButtonProps 타입

export type SolidButtonProps = {
  disabled?: boolean;
  form?: string;
  formaction?: string;
  formenctype?: string;
  formmethod?: string;
  formnovalidate?: boolean;
  formtarget?: string;
  name?: string;
  type?: 'button' | 'submit' | 'reset';
  value?: string;
  children?: React.ReactNode;
  onClick?: () => void;
  isLoading?: boolean;
  shadowExist?: boolean;
  fullWidth?: boolean;
};

export type GradButtonProps = {
  disabled?: boolean;
  form?: string;
  formaction?: string;
  formenctype?: string;
  formmethod?: string;
  formnovalidate?: boolean;
  formtarget?: string;
  name?: string;
  type?: 'button' | 'submit' | 'reset';
  value?: string;
  children?: React.ReactNode;
  onClick?: () => void;
  isLoading?: boolean;
  rounded?: boolean;
  fullWidth?: boolean;
  color?: 'primary' | 'secondary';
};

 

 

 

여기에 vanilla-extract의 recipe 함수를 이용하면 조건에 따라 여러 스타일을 선택적으로 적용시킬 수 있다.

 

https://vanilla-extract.style/documentation/packages/recipes/#recipe

 

Recipes — vanilla-extract

Zero-runtime Stylesheets-in-TypeScript.

vanilla-extract.style

 

// SolidButton.css.ts

import { recipe } from '@vanilla-extract/recipes';
import { globals } from '../globals.css';

export const solidButton = recipe({
  base: {
    minHeight: 60,
    position: 'relative',
    display: 'flex',
    justifyContent: 'center',
    alignItems: 'center',
    padding: '18px 18px',
    borderRadius: 12,
    backgroundColor: globals.color.blue_main,
    color: '#FFFFFF',
    fontWeight: '600',
    cursor: 'pointer',
    transition: 'background-color 0.2s, box-shadow 0.2s, transform 0.2s',
    ':active': {
      transform: 'scale(0.99)',
    },
  },
  variants: {
    shadowExist: {
      true: {
        boxShadow: '0px 0px 4px 2px rgba(23, 96, 171, 0.25)',
        ':hover': {
          boxShadow: '0px 0px 5px 3px rgba(23, 96, 171, 0.25)',
        },
      },
    },
    fullWidth: {
      true: {
        width: '100%',
      },
    },
    disabled: {
      true: {
        backgroundColor: globals.color.black_6,
        cursor: 'default',
        boxShadow: 'none',
        ':hover': {
          boxShadow: 'none',
        },
        ':active': {
          transform: 'none',
        },
      },
    },
    isLoading: {
      true: {
        cursor: 'default',
        pointerEvents: 'none',
      },
    },
  },
});
// GradButton.css.ts

import { recipe } from '@vanilla-extract/recipes';
import { globals } from '../globals.css';

export const gradButton = recipe({
  base: {
    minHeight: 60,
    position: 'relative',
    display: 'flex',
    justifyContent: 'center',
    alignItems: 'center',
    padding: '18px 18px',
    borderRadius: 12,
    color: '#FFFFFF',
    fontWeight: '600',
    cursor: 'pointer',
    transition: 'background-color 0.2s, box-shadow 0.2s, transform 0.2s',
    ':active': {
      transform: 'scale(0.99)',
    },
  },

  variants: {
    color: {
      primary: {
        background: `linear-gradient(180deg, rgba(38, 146, 255, 0.10) 0%, #2692FF 77.5%)`,
        boxShadow: '0px 2px 8px 2px rgba(23, 96, 171, 0.30)',
        ':hover': {
          boxShadow: '0px 3px 8px 3px rgba(23, 96, 171, 0.30)',
        },
        textShadow: `1px 1px 1px ${globals.color.blue_stroke}, 
          -1px -1px 1px ${globals.color.blue_stroke}, 
          1px -1px 1px ${globals.color.blue_stroke}, 
          -1px 1px 1px ${globals.color.blue_stroke}`,
      },
      secondary: {
        background:
          'linear-gradient(180deg, rgba(255, 181, 38, 0.10) 0%, #FFB526 77.5%)',
        boxShadow: '0px 2px 8px 2px rgba(169, 121, 29, 0.30)',
        ':hover': {
          boxShadow: '0px 3px 8px 3px rgba(169, 121, 29, 0.30)',
        },
        textShadow: `1px 1px 1px ${globals.color.sub_stroke}, 
          -1px -1px 1px ${globals.color.sub_stroke}, 
          1px -1px 1px ${globals.color.sub_stroke}, 
          -1px 1px 1px ${globals.color.sub_stroke}`,
      },
    },
    rounded: {
      true: {
        borderRadius: 30,
        padding: '18px 24px',
      },
    },
    fullWidth: {
      true: {
        width: '100%',
      },
    },
    disabled: {
      true: {
        background: `linear-gradient(180deg, rgba(230, 230, 230, 0.10) 0%, #DEDEDE 77.5%)`,
        cursor: 'default',
        boxShadow: 'none',
        ':hover': {
          boxShadow: 'none',
        },
        ':active': {
          transform: 'none',
        },
        textShadow: `1px 1px 1px ${globals.color.black_4}, 
        -1px -1px 1px ${globals.color.black_4}, 
        1px -1px 1px ${globals.color.black_4}, 
        -1px 1px 1px ${globals.color.black_4}`,
      },
    },
    isLoading: {
      true: {
        cursor: 'default',
        pointerEvents: 'none',
      },
    },
  },
});

 

 

 

이렇게 만든 recipe 스타일의 css를 적용한 최종 SolidButton과 GradButton!

 

// GradButton.tsx

import { SolidButtonProps } from '@/app/_types/SolidButtonProps';
import * as styles from './SolidButton.css';
import LoadingCircular from './LoadingCircular';

const SolidButton = ({
  disabled,
  form,
  formaction,
  formenctype,
  formmethod,
  formnovalidate,
  formtarget,
  name,
  type,
  value,
  children,
  onClick,
  isLoading,
  shadowExist = true,
  fullWidth = false,
}: SolidButtonProps) => {
  return (
    <button
      type={type}
      form={form}
      formAction={formaction}
      formEncType={formenctype}
      formMethod={formmethod}
      formNoValidate={formnovalidate}
      formTarget={formtarget}
      name={name}
      value={value}
      disabled={disabled || isLoading}
      onClick={onClick}
      className={styles.solidButton({
        shadowExist,
        fullWidth,
        isLoading,
        disabled,
      })}
    >
      {children}
      {isLoading && (
        <div className={styles.loadingContainer}>
          <LoadingCircular />
        </div>
      )}
    </button>
  );
};

export default SolidButton;
// SolidButton.tsx

import React from 'react';
import { SolidButtonProps } from '@/app/_types/SolidButtonProps';
import * as styles from './SolidButton.css';
import LoadingCircular from './LoadingCircular';

const SolidButton = ({
  disabled,
  form,
  formaction,
  formenctype,
  formmethod,
  formnovalidate,
  formtarget,
  name,
  type,
  value,
  children,
  onClick,
  isLoading,
  shadowExist = true,
  fullWidth = false,
}: SolidButtonProps) => {
  return (
    <button
      type={type}
      form={form}
      formAction={formaction}
      formEncType={formenctype}
      formMethod={formmethod}
      formNoValidate={formnovalidate}
      formTarget={formtarget}
      name={name}
      value={value}
      disabled={disabled || isLoading}
      onClick={onClick}
      className={styles.solidButton({
        shadowExist,
        fullWidth,
        isLoading,
        disabled,
      })}
    >
      {children}
      {isLoading && (
        <div className={styles.loadingContainer}>
          <LoadingCircular />
        </div>
      )}
    </button>
  );
};

export default SolidButton;

 

 

 

+ recipe함수를 알기 전엔 이렇게 스타일을 각각 선언한 뒤에 연산자로 템플릿 문자열을 조합했었는데... 훨씬 코드가 깔끔해졌다!

 

// recipe 적용전 GradButton

<button
  type={type}
  form={form}
  formAction={formaction}
  formEncType={formenctype}
  formMethod={formmethod}
  formNoValidate={formnovalidate}
  formTarget={formtarget}
  name={name}
  value={value}
  disabled={disabled || isLoading}
  onClick={onClick}
  className={`
    ${styles.gradButton}
    ${rounded && styles.rounded}
    ${fullWidth && styles.fullWidth}
    ${isLoading && `${styles.disabled} ${styles.loading}`}
    ${disabled && styles.disabled}
    ${color === 'primary' ? styles.primary : styles.secondary}
  `}
>
  {children}
  {isLoading && (
    <div className={`${styles.loadingContainer}`}>
      <LoadingCircular />
    </div>
  )}
</button>

 

 

 

 

마지막으로 스토리북을 위한 파일들을 작성해주면

 

// GradButton.stories.tsx

import { Meta, StoryObj } from '@storybook/react';
import GradButton from './GradButton';

const meta = {
  title: 'Components/GradButton',
  component: GradButton,
  argTypes: {
    disabled: { control: 'boolean' },
    form: { control: 'text' },
    formaction: { control: 'text' },
    formenctype: { control: 'text' },
    formmethod: { control: 'text' },
    formnovalidate: { control: 'boolean' },
    formtarget: { control: 'text' },
    name: { control: 'text' },
    type: { control: 'text' },
    value: { control: 'text' },
    onClick: { action: 'clicked' },
    isLoading: { control: 'boolean' },
    color: { control: 'inline-radio', options: ['primary', 'secondary'] },
    fullWidth: { control: 'boolean', defaultValue: false },
  },
} satisfies Meta<typeof GradButton>;

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

export const Primary: Story = {
  args: {
    children: 'Primary Button',
  },
};

export const Secondary: Story = {
  args: {
    children: 'Secondary Button',
    color: 'secondary',
  },
};

export const RoundedPrimary: Story = {
  args: {
    children: 'Rounded Primary Button',
    rounded: true,
  },
};

export const RoundedSecondary: Story = {
  args: {
    children: 'Rounded Secondary Button',
    rounded: true,
    color: 'secondary',
  },
};

export const Disabled: Story = {
  args: {
    children: 'Disabled Button',
    disabled: true,
  },
};

export const Loading: Story = {
  args: {
    children: 'Loading Button',
    isLoading: true,
  },
};

export const RoundedIsLoading: Story = {
  args: {
    children: 'Rounded Loading Button',
    rounded: true,
    isLoading: true,
  },
};

export const FullWidth: Story = {
  args: {
    children: 'FullWidth Button',
    fullWidth: true,
  },
};

 

완성~~

 

이제 각 페이지에 맘 편히 가져다 쓰면 된다!