Component Library 만들기 : Checkbox

jaejin·2024년 3월 9일

회사에서 컴포넌트 개발 및 유지보수를 담당하고 있는데 처음부터 다 만들시간이 없어서 기본적으로는 Antd를 커스텀해서 사용하고 있다. 하지만, Atnd를 베이스로 하다보니 커스텀에 제한도 많이 되고 제대로된 개발을 하지 않고 임시방편만을 만드는 것 같아서 직접 간단하게 디자인 시스템을 제작해보기로 했다.

일단 간단한 컴포넌트를 한두개 정도 만들고 피그마 디자인 시스템을 간단하게 만들어서 디자인적인 일관성까지 고려해서 만들어보려고 한다.

Checkbox

가장 먼저 비교적 간단한 컴포넌트인 checkbox를 만들기로 했다. 회사에서는 Antd를 사용하고 평소에 코드도 참고하지만 quasar가 구현한 방향이 조금 더 vuejs 기반 컴포넌트에 적절한 방식이라고 판단해서 quasar의 코드를 참고했다. 근데 참고하다보니 코드가 거의 같아지긴 했는데 일단 잘 만들어진 오픈소스를 이해하는 것도 의미가 있다고 생각해서 그냥 그렇게 진행했다.

svg

체크박스가 체크된 상태와 indeterminate 상태를 어떻게 보여줘야 하는지 몰랐는데 svg를 통해 그려서 해결할 수 있었다.

<div class="r-checkbox__bg">
  <svg class="r-checkbox__svg" viewBox="0 0 24 24">
    <path
       class="r-checkbox__truthy"
       fill="none"
       d="M4 12.6111L8.92308 17.5L20 6.5" />
    <path class="r-checkbox__indet" d="M4,14H20V10H4" />
  </svg>
</div>

class 네이밍은 BEM 방법론을 적용했다. r-checkbox__bg는 svg tag를 감싸서 위치를 잡아주고 truthy, indeterminate 두 가지 상태를 보여주기 위해 두 가지 path를 가진다.

path에 대해서도 이번에 처음 알게됐는데 path를 분석해주는 도구랑 함께 보면 이해가 좀 쉽다. 상위 div 클래스에 따라 path를 보여주기 위한 css를 적용하는데 전혀 몰랐던 css들이라 이 부분 이해하는데도 시간이 좀 걸렸다.

modelValue

export interface Props {
  modelValue: any;
  value?: any;

  trueValue?: any;
  falseValue?: any;
  indeterminateValue?: any;

  ...
}

vuejs의 공식문서를 보면 checkbox type의 input은 true-value, false-value를 커스텀하게 사용할 수 있다. 어떤 값이라도 가능하기 때문에 modelValue가 any 타입을 가져야 한다.

const modelIsArray = computed(() => Array.isArray(props.modelValue));

const index = computed(() => {
  const value = toRaw(props.value);

  return modelIsArray.value === true
    ? props.modelValue.findIndex((opt: any) => toRaw(opt) === value)
    : -1;
});

const isTrue = computed(() =>
  modelIsArray.value === true
    ? index.value > -1
    : toRaw(props.modelValue) === toRaw(props.trueValue),
);

const isFalse = computed(() =>
  modelIsArray.value === true
    ? index.value === -1
    : toRaw(props.modelValue) === toRaw(props.falseValue),
);
  • value props는 modelValue가 Array 타입일 때 사용되는 props로 이것도 vuejs에서의 사용성을 그대로 지원하기 위해 사용된다. modelValue는 크게 배열일 때와 그렇지 않을 때로 나뉘기 때문에 대부분의 로직에서 modelIsArray 값을 통해 조건문을 사용한다.
  • isTrue, isFalse property 방식도 좀 신기했는데 modelIsArray를 통해 index를 return하거나 trueValue, falseValue랑 값을 비교해서 boolean 값을 return한다.
  • indeterminate 상태는 isTrue === false && isFalse === false 조건을 통해 판별할 수 있다.
const getNextValue = () => {
  if (modelIsArray.value === true) {
    if (isTrue.value === true) {
      const copy = [...props.modelValue] as Array<any>;
      copy.splice(index.value, 1);
      return copy;
    }

    return props.modelValue.concat([props.value]);
  }

  if (isTrue.value === true) {
    return props.falseValue;
  } else if (isFalse.value === true) {
    return props.trueValue;
  }

  return getIndetNextValue();
};

const getIndetNextValue = () => {
  return props.trueValue;
};
  • modelValue의 값을 update할 때 getNextValue 함수를 통해 값을 판별한다.
  • modelValue가 배열이면 값을 배열에서 넣거나 빼주고 그렇지 않으면 각 상태별 value를 return한다.

Keyboard

const onKeyup = (e: KeyboardEvent) => {
  if (e.code === 'Enter' || e.code === 'Space') {
    onClick(e);
  }
};

const onKeyDown = (e: KeyboardEvent) => {
  if (e.code === 'Enter' || e.code === 'Space') {
    e.preventDefault();
    e.stopPropagation();
  }
};

const tabIndex = computed(() =>
  props.disabled === true ? -1 : props.tabIndex,
);
  • keyboard를 통한 인터랙션을 지원해야 되기 때문에 keyup, keydown 이벤트를 활용했다.
  • vuejs에서 제공하는 @keyup.enter와 같은 key modifiers를 사용하면 좀 더 쉽게 코드를 쓸 수 있기는 한데 사용할 키보드 지원이 늘어날수록 코드가 번잡해지는 것 같아 그냥 함수 내 조건문으로 처리했다.
  • disabled 상태에 따라 tabIndex를 지정했다.

웹 접근성

const attributes = computed(() => {
  const attrs: HTMLAttributes = {
    tabindex: tabIndex.value,
    role: 'checkbox',
    'aria-label': props.label,
    'aria-checked':
      isIndeterminate.value === true
        ? 'mixed'
        : isTrue.value === true
          ? 'true'
          : 'false',
    'aria-disabled': props.disabled === true ? 'true' : 'false',
  };

  return attrs;
});
  • 개인적으로 웹 접근성은 처음 고려해봐서 재미있었다.
  • role, aria-label, aria-checked, aria-disabled attributes를 설정해주고 실제 inner의 역할을 하는 div에는 aria-hidden을 적용해서 불필요하게 많은 정보가 전달되는 상황은 방지하고 필요한 정보만 전달되게끔 구현했다.

form attributes

const formAttrs = computed(() => {
  const attrs = {
    type: 'checkbox',
    // dom property
    '.checked': isTrue.value,
    // dom attribute
    '^checked': isTrue.value === true ? true : void 0,
    name: props.name,
    value: modelIsArray.value === true ? props.value : props.trueValue,
  };

  return attrs;
});
  • 이 부분은 아직 완전히 이해한 부분은 아닌데 input 구현하면서 정리할 예정이다.
  • 일단은 input type="checkbox"의 기존 로직을 최대한 적용하려고 했다.
  • . ^ prefixes를 사용해서 dom property, dom attribute 적용했다.

정리

금방 만들 줄 알았는데 생각보다 쉽지 않았다. qasar 코드를 참고하면서 느꼈지만 생각보다 고려할게 많고 코드의 가독성도 중요하기 때문에 최대한 의미있는 함수 단위로 묶으려고 했다.
일단 필요한 기능은 다 정의가 되었고 이후에 form, 디자인 시스템 적용할 때 다시 작업할 예정

전체 코드는 아래 링크에서 확인 가능하니다.
https://github.com/Jaejin-Song/Design-System

profile
jjlabsio

0개의 댓글