Checkbox 컴포넌트 개발기? (React, Typescript, styled-components)

늘보·2021년 10월 29일
4

트러블 슈팅

목록 보기
1/1

작성 이유?

난 현재 React, Typescript, styled-components를 사용해 사이드 프로젝트를 진행하고 있다. 프로젝트의 공통 컴포넌트(Checkbox) 개발 중 어려웠던 부분, 버그 상황등을 다른 사람들은 겪지 않는 바램으로 글을 작성한다.

  • 아직 현업 경험이 없는 취준생의 코드 작성입니다. 부족한 부분이 많습니다!

  • 컴포넌트 개발을 위한 도구로는 storybook을 사용했습니다!

초기 개발

// Checkbox.stories.tsx
import React, { useState } from 'react';
import Checkbox from './Checkbox';
export default {
  component: Checkbox,
  title: 'Components/Inputs/Checkbox',
};
export const BasicCheckbox = () => {
    const [isChecked, setIsChecked] = useState<boolean>(false);
    const onChange = () => {
        setIsChecked((prev: boolean) => !prev)
    };
    return (
      <Checkbox checked={isChecked} onChange={onChange} />
    );
}
export const LabeledCheckbox = () => {
    const [isChecked, setIsChecked] = useState<boolean>(false);
    const onChange = () => {
        setIsChecked((prev: boolean) => !prev)
    };
    return (
      <Checkbox checked={isChecked} onChange={onChange} label="늘보" />
    );
}
// Checkbox.tsx
import React from 'react'
import styled from 'styled-components';
import { ReactComponent as CheckIcon } from '@common/components/icons/check.svg';
import { rem } from '@common/styles/utils';
import { themeColors } from '@common/styles/themes';
const CheckboxContainer = styled.div`
  display: flex;
  align-items: center;
  & > label {
   display:inline-block; 
   line-height: 16px;
   margin-left: ${rem(6)}
  }
`;
const CheckBox = styled.div<{checked: boolean}>`
  width: 16px;
  height: 16px;
  border: 1px solid ${themeColors.primary_100};
  display: inline-block;
  background: ${({checked}) => checked ? `${themeColors.primary_100}` : `${themeColors.gray_09}`};
  border-radius: ${rem(5)};
  cursor: pointer;
  & > svg {
    position: absolute;
  }
`;
interface CheckboxProps {
    checked: boolean;
    onChange: () => void;
    label?: string | null;
}
export default function Checkbox({checked, onChange, label}: CheckboxProps) {
    return (
      <CheckboxContainer>
        <CheckBox checked={checked} onClick={onChange}>
          <CheckIcon width="16px" height="16px" fill={themeColors.gray_09} />
        </CheckBox>
        <label>{label}</label>
      </CheckboxContainer>
    )
}
  • 위의 코드를 보면, 난 div 태그를 이용해 Checkbox 컴포넌트를 만들었다. 여기서 왜 HTML input element를 사용하지 않았느냐? 라고 궁금할 수도 있는데, 이에 대해선 아래에서 나오는 이유를 보자.

문제점: input type="checkbox" 미사용

  • 내가 처음 생각했던 방법은 HiddenCheckbox라는 styled-componenet를 만들고(이 styled-component는 input element, type="checkbox"로 생각했었다), onChange 이벤트를 이 HiddenCheckbox(화면에 보이지 않도록 만든 input type="checkbox" element)에 적용, 그리고 실제 보이는 CheckBox는 위에 보이는대로 구현할 예정이었다.

  • 문제는 JS에서는 이렇게 구현해도 문제가 되지 않는다. 그러나 TS에서는?

interface CheckboxProps {
    checked: boolean;
    onChange: () => void;
    label?: string | null;
}
  • 아래처럼 interface를 만들어 진행하게 된다. 그런데 문제는 input elementonChange 이벤트이니 원래대로라면 이렇게 작성하는 것이 TS를 사용하는 목적에 더 맞다.
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
  • 그러나 내가 초기 코드에서 저렇게 이벤트 타입(=== e)을 지정하지 못한 이유는, CheckBoxdiv 태그로, 이벤트를 발생시키려면 onClick을 적용해야 했는데, onClick은 알다시피 mouseEvent이기 때문..

  • 상황이 이렇게 되니, 함수 두개를 만들어 서로 다른 이벤트 타입을 지정해줘야 하나? 라는 생각이 들었는데, 아무리 생각해도 그건 아닌 것 같아서 임시 방편으로 이벤트 타입을 일단 없앴다..(게으름 === 기술부채)

onChange: () => void;
  • 그래서 어차피 input type="checkbox"를 쓰지 못할 상황이 만들어져서, 만들었던HiddenCheckbox 컴포넌트 또한 제거하였다.

  • 추가적으로, 아래와 같이 &:checked - background 속성을 이용하면 더 간단하고 깔끔하게 개발이 가능하지만, 현재 우리 프로젝트의 프론트엔드 팀은 컴포넌트화된 아이콘을 사용하기로 결정했기 때문에, 아래 방법을 사용할 수 없었다.

import check from '@common/assets/check.svg';

const CheckBox = styled.input.attrs({
  type: 'checkbox',
})`
  appearance: none;
  width: 16px;
  height: 16px;
  border: 1px solid blue;
  border-radius: 4px;
  cursor: pointer;
  
  &:checked {
    background: blue url(${check}) 50% 50% no-repeat;
  }
`;

그래서 이렇게 1차적으로 개발을 완료해서 PR을 올렸다. 결과는?

여러 선배 개발자분들이 리뷰를 남겨주셨다! (감사합니다) 그 중 몇 가지를 추려보면 다음과 같다.

  1. input type="checkbox"를 활용하는게 좋을 것 같습니다.

  2. label을 눌렀을 때에도 동일하게 Checkbox에 이벤트가 적용되면 좋겠습니다.

2번은 label 태그의 htmlFor 속성을 활용해서 해결하였다.

1번은 역시나 걱정했던 부분이었는데.. 바로 수정 요청을 받았다! 그래서 어떻게 개발을 해야 하나.. 하고 계속 고민하고 있었다. 결국 혼자 해결하지 못하고 선배 개발자님의 도움을 받아서 코드를 수정했다. 최종 수정된 코드는 다음과 같다.

수정된 코드

// Checkbox.stories.tsx
import React, { useState } from 'react';
import Checkbox from './Checkbox';

export default {
  component: Checkbox,
  title: 'Components/Inputs/Checkbox',
};

export const BasicCheckbox = () => {
  const [isChecked, setIsChecked] = useState<boolean>(false);

  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setIsChecked(e.target.checked);
  };
  return (
    <Checkbox id="checkbox" checked={isChecked} onChange={onChange} />
  );
};

export const LabeledCheckbox = () => {
  const [isChecked, setIsChecked] = useState<boolean>(false);

  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setIsChecked(e.target.checked);
  };
  return (
    <Checkbox id="checkbox_labeled" checked={isChecked} onChange={onChange} label="늘보" />
  );
};
// Checkbox.tsx
import React from 'react';
import styled from 'styled-components';
import { ReactComponent as CheckIcon } from '@common/components/icons/check.svg';
import { rem } from '@common/styles/utils';
import { themeColors } from '@common/styles/themes';

const CheckboxContainer = styled.div`
  display: flex;
  align-items: center;
`;

const CheckBox = styled.label<{ checked: boolean }>`
  display: inline-block;
  width: 14px;
  height: 14px;
  border: 1px solid ${themeColors.primary_100};
  background: ${({ checked }) => (checked ? `${themeColors.primary_100}` : `${themeColors.gray_09}`)};
  border-radius: ${rem(5)};
  cursor: pointer;

  & > svg {
    position: absolute;
  }
`;

const HiddenCheckbox = styled.input`
  border: 0;
  clip: rect(0 0 0 0);
  height: 1px;
  margin: 0;
  padding: 0;
  overflow: hidden;
  position: absolute;
  white-space: nowrap;
  width: 1px;
`;

const Label = styled.label`
   display:inline-block; 
   line-height: 16px;
   padding-left: ${rem(6)};
   cursor: pointer;
`;

interface CheckboxProps {
  id: string;
  checked: boolean;
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
  label?: string | null;
}

export default function Checkbox({
  id, checked, onChange, label,
}: CheckboxProps) {
  return (
    <CheckboxContainer>
      <CheckBox checked={checked} htmlFor={id}>
        <HiddenCheckbox id={id} type="checkbox" onChange={onChange} checked={checked} />
        <CheckIcon width="14px" height="14px" fill={themeColors.gray_09} />
      </CheckBox>
      {label ? <Label htmlFor={id}>{label}</Label> : null}
    </CheckboxContainer>

  );
}

이 코드도 최선은 아닐 수 있습니다. 더 좋은 방법이 있다면 말씀주시면 감사하겠습니다!

  • 핵심은 label 태그를 사용했다는 것이다. label 태그의 for 속성을 id에 연계해서 사용하는 방법이다. label 태그의 for값과 id값이 동일하다면 label 태그를 클릭하더라도 input 태그의 이벤트가 작동하게 된다.

해결: input type="checkbox" 사용

  • 먼저, 기존에 사용하려고 했던 HiddenCheckbox 컴포넌트를 사용해서 input type="checkbox"를 사용했다. 그에 따라 onChange 이벤트의 타입 또한 e: React.ChangeEvent<HTMLInputElement>로 명시할 수 있었다.

  • 기존의 CheckBox 컴포넌트는 그대로 두되, onClick 이벤트를 삭제하고, 그걸 CheckboxContainer 컴포넌트로 옮겼다. CheckboxContainer 컴포넌트는 label 태그로 만들었고, htmlFor 속성을 사용하였다.

  • htmlFor 속성에 필요한 id 값은 외부에서 string 타입으로 받아오도록 하였다. 아래 코드를 보면 맨 위에 id가 추가되었다.

interface CheckboxProps {
  id: string;
  checked: boolean;
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
  label?: string | null;
}

이렇게 리뷰에서 요청되었던 부분을 모두 해결하였다!

후기

작은 컴포넌트의 개발이지만 많은 것을 배울 수 있었던 개발이었다. 각 태그, 타입의 목적에 맞게 개발하는 것의 중요성을 더 체감하게 되었다.

++ 많은 시간을 고민했지만 모를 때는, 꼭 선배 개발자님께 여쭤보자..!

0개의 댓글