본문 바로가기

프론트엔드/React

UI library에서 spacing system props 구현하기

  • 프로젝트: loplat UI
  • 키워드: system props, spacing props, typescript
  • 상황
    1. 컴포넌트 종류와 상관없이 모든 컴포넌트가 공유하면 좋은 prop이 있다.
      • UI component에 margin이나 padding 값만 추가하고 싶은 경우가 많은데, 사소한 spacing css 때문에 class를 부여하거나 styled API를 사용하는 것은 수고롭다.
    2. loplat UI를 사내 프로젝트에 적용하면서 spacing(margin, padding) prop이 필요함을 느꼈기 때문에 spacing system prop을 만들고 적용하고자 한다.
  • 해결방법
    1. spacing system prop은 아래와 같이 사용 가능하다.
        // margin-top: 8px; margin-bottom: 8px; padding-left: 4px; padding-right: 4px;
      <Button my={2} px={1}>버튼</Button>
      // margin-left: 4px; padding-bottom: 4px;
      <Input ml={1} pb={1} />
      'spacing 1'은 곧 '4px'을 의미하며, 디자이너와의 협의를 통해 '홀수 px'은 허용하지 않기로 했다.
      8px, 12px, 16px 등 4의 배수를 spacing으로 많이 사용하고 있기 때문에 1 === 4px 로 계산하였다.

      먼저 prop으로 들어온 number를 '짝수 px' 로 바꾸는 함수를 구현했다.
        const STANDARD = 4;
      /**
       * input/output 예시
       * 1. spacing(1) === 4px
       * 2. spacing(2.5) === 10px
       * 3. spacing(1.75) === 8px (4 * 1.75 = 7이지만 홀수 픽셀을 허용하지 않고 가장 가까운 짝수로 반올림)
       * 4. spacing(1.45) === 6px (4 * 1.45 = 5.8이지만 홀수 픽셀을 허용하지 않고 가장 가까운 짝수로 반올림)
       */
      export const spacing = (operand: number): number => {
        if (Number.isInteger(operand * 2)) {
          // 0.5의 배수일 때
          return STANDARD * operand;
        } else {
          // 0.5의 배수가 아닌 소수일 때
          const integerPart = Math.floor(operand);
          const fractionalPart = operand - integerPart;
          const operandRoundedByOneHalf =
            fractionalPart < 0.75 && fractionalPart > 0.25 ? integerPart + 0.5 : Math.round(operand);
          return STANDARD * operandRoundedByOneHalf;
        }
      };
    2. 이제 margin spacing을 구현하고자 한다.
      'margin의 type', 컴포넌트가 받을 'margin props', 실제로 css 스타일을 입힐 'margin style' 3가지를 각각 구현해야한다.
        // margin type
      const marginSpacingOptions = ['mt', 'mb', 'ml', 'mr', 'my', 'mx'] as const;
      export type MarginSpacing = {
        [key in typeof marginSpacingOptions[number]]?: number;
      };
      
      // margin props
      export const marginSpacingProps = (props: MarginSpacing): MarginSpacing =>
        marginSpacingOptions.reduce(
          (prev, curr) => ({
            ...prev,
            [curr]: props[curr],
          }),
          {},
        );
      
      // margin style
      export const marginSpacingStyle = (props: MarginSpacing): SerializedStyles => {
        const { mx, my } = props;
        const { mt = my, mb = my, ml = mx, mr = mx } = props;
        const margins = Object.entries({ top: mt, bottom: mb, left: ml, right: mr });
      
        return css`
          ${margins
            .filter(isNumberValue)
            .map(([position, value]) => `margin-${position}: ${spacing(value)}px;`)
            .join('')}
        `;
      };
    3. 2번에서 만든 spacing type, props, style을 컴포넌트에 적용하면 되는데, Button 컴포넌트를 기준으로 설명하고자 한다.
      먼저 Button Component Props에 MarginSpacing type을 extends한다.
        // Button.tsx
      import { MarginSpacing } from ...;
        
      export type ButtonProps = MarginSpacing & ...

      그 후,  marginSpacingProps를 이용하여 styled button에 margin 관련 prop을 넘긴다.
        // Button.tsx
      import styled from '@emotion/styled';
      
      export const BaseButton = styled.button`
      ...
      `;
      ...
      
      export const Button = (props: ButtonProps): JSX.Element => {
        return (
          <BaseButton
            ...
            {...marginSpacingProps(props)}
            ...
          >
          {props.children}
          </BaseButton>
        );
      };

      마지막으로 marginSpacingStyle을 이용하여 styled 안에서 css style을 부여한다.
        // Button.tsx
      export const BaseButton = styled.button`
        ...
        ${marginSpacingStyle};
        ...
      `;


    4. 이제 Button 컴포넌트에서 mt/mb/ml/mr/mx/my prop을 사용할 수 있다!
      같은 방법으로 padding spacing props도 구현한다.
        // Padding
      const paddingSpacingOptions = ['pt', 'pb', 'pl', 'pr', 'py', 'px'] as const;
      export type PaddingSpacing = {
        [key in typeof paddingSpacingOptions[number]]?: number;
      };
      export const paddingSpacingProps = (props: PaddingSpacing): PaddingSpacing =>
        paddingSpacingOptions.reduce(
          (prev, curr) => ({
            ...prev,
            [curr]: props[curr],
          }),
          {},
        );
      export const paddingSpacingStyle = (props: PaddingSpacing): SerializedStyles => {
        const { px, py } = props;
        const { pt = py, pb = py, pl = px, pr = px } = props;
        const paddings = Object.entries({ top: pt, bottom: pb, left: pl, right: pr });
      
        return css`
          ${paddings
            .filter(isNumberValue)
            .map(([position, value]) => `padding-${position}: ${spacing(value)}px;`)
            .join('')}
        `;
      };
    5. 그리고 margin과 padding을 합친 box spacing props도 구현한다.
      box spacing만 적용하면 margin과 padding props을 둘 다 사용할 수 있다.
      // Box(Margin + Padding)
      export type BoxSpacing = MarginSpacing & PaddingSpacing;
      export const boxSpacingProps = (props: BoxSpacing): BoxSpacing => ({
        ...marginSpacingProps(props),
        ...paddingSpacingProps(props),
      });
      export const boxSpacingStyle = (props: BoxSpacing): SerializedStyles => css`
        ${marginSpacingStyle(props)};
        ${paddingSpacingStyle(props)};
      `;
    6. 이제 원하는 컴포넌트 어디서든 margin, padding props를 사용할 수 있다!