(오픈소스 분석) Radix Checkbox

·

4 min read

@radix-ui/react-checkbox 라이브러리 코드를 살펴본다. 추가적으로 WAI-ARIA에서 소개하는 체크박스 패턴에 대해 알아본다.

Checkbox Pattern (WAI-ARIA)

여기에서 자세한 내용을 확인할 수 있다.

WAI-ARIA의 Checkbox Pattern에선 Accessible한 UI를 만들기 위한 어트리뷰트 및 키보드 인터랙션 가이드와 체크박스의 타입을 소개한다. 여기서 알아볼 것은 체크박스의 타입이다.

두 가지 타입의 체크박스를 소개하는데, 하나는 Dual State고 하나는 Tri State이다. Tri State는 "부분 체크"상태를 나타낼 수 있다.

실제로 input 엘리먼트는 indeterminate이라는 프로퍼티를 갖고 있는데, 이 값이 true일 때 보여줄 스타일이 브라우저에 구현되어있다.

// html
<input type="checkbox" id="checkbox" />
<label for="checkbox">Indeterminate</label>

// js
const input = document.querySelector('input');
input.indeterminate = true;

Tri State Checkbox에 대해 알아본 이유는, Radix의 Checkbox가 Tri State를 지원하기 때문이다.

Radix Checkbox

사용 예시

<Checkbox.Root
  checked={}
  onCheckedChange={}
  defaultChecked={}
>
  <Checkbox.Indicator asChild>
    <CheckedIcon />
  </Checkbox.Indiactor>
</Checkbox.Root>

controlled와 uncontrolled로 사용 가능하고, Indicator는 Checkbox의 checked상태가 true인 경우에만 렌더링된다. 이 두 기능은 이전에 살펴본 useControllableState과, Presence 컴포넌트로 구현되어있다. 따라서 이 두 부분을 제외한 나머지를 살펴본다.

isFormControl, BubbleInput

const Checkbox = forwardRef((props, ref) => {
  const [button, setButton] = useState(null);
  const composedRefs = useComposedRefs(ref, (node) => setButton(node));
  const isFormControl = button ? Boolean(button.closest('form')) : true;

  // 생략

  return (
    <CheckboxProvider>
      <Primitive.button 
        ref={composedRefs}
        ...
      />
      {isFormControl && (
        <BubbleInput ... />
      )}
    </CheckboxProvider>
  )
})

isFormControlCheckbox컴포넌트가 form엘리먼트 내부에서 사용되었는지 여부를 나타내는 boolean값이다. Radix Checkbox는 button엘리먼트로 구현되어있는데, 이 경우에는 form의 기본 동작을 지원하지 못한다.

예를들어 native 체크박스인 <input type="checkbox" />는 form 내부에서 사용될 때 form의 필드로 등록되고, 체크상태를 변경할 때 마다 form에 등록한 onChange핸들러가 호출된다. 만약 button으로 체크박스를 구현하면 이런 기본 동작이 발생하지 않는다.

그래서 form 내부에서 Checkbox가 사용된 경우에 보이지 않는 input엘리먼트를 추가하여, 버튼을 클릭할 때 마다 input에 대한 클릭 이벤트를 함께 dispatch해주기 위해 BubbleInput컴포넌트를 사용한 것이다.

isFormControl의 기본값은 true인데, 이건 서버 렌더링 환경에서 클라이언트에 전달된 HTML이 하이드레이션 되기 전에도 기본 동작을 지원하게끔 하려는 목적이다.

BubbleInput의 구현 일부분을 간단하게 살펴보자. 참고로 아래의 BubbleInput내부 useEffect는 실제 코드를 간략화하기 위해 조금 변형시켜서 작성한 것이다. 실제 코드는 Property Descriptor를 사용하여, input.checkedsetter를 사용하여 checked상태를 업데이트한다. (참고)

const Checkbox = () => {
  const [checked, setChecked] = useControllableState({...})
  const isFormControl = ...;
  const hasConsumerStoppedPropagationRef = useRef(false);
  // 일부 생략

  return (
    <CheckboxProvider>
      <Primitive.button
        onClick={composeEventHandlers((props.onClick, (event) => {
          setChecked(...);
        if (isFormControl) {
            hasConsumerStoppedPropagationRef = event.isPropagationStopped();

            if (!hasConsumerStoppedPropagationRef.current) {
            event.stopPropagation();
            }
          }
        })}
      />      
      {isFormControl && (
        <ButtonInput 
          bubbles={hasConsumerStoppedPropagationRef.current}
        />
      )}
    </CheckboxProvider>
  )
}

const BubbleInput = (props) => {
  const {
    checked, // true | false | 'indeterminate'
    bubbles,
  } = props;
  const ref = useRef()
  // 일부 생략

  useEffect(() => {
    const input = ref.current;
    if (is checked changed) {
      // bubbles는 Checkbox의 hasConsumerStoppedPropagationRef.current이다.
      const event = new Event('click', { bubbles });
      input.indeterminate = checked === 'indeterminate';
      input.checked = checked === 'indeterminate' ? false : checked;
      input.dispatchEvent(event);
    }
  }, [checked])

  return (
    <input 
      aria-hidden
      ...
      ref={ref}
      style={visuallyHiddenStyle}
    />
  )
}

Primitive.buttononClick핸들러를 살펴보자.

if (isFormControl) {
  hasConsumerStoppedPropagationRef = event.isPropagationStopped();

  if (!hasConsumerStoppedPropagationRef.current) {
    event.stopPropagation();
  }
}

isFormControltrue인 경우 form의 네이티브한 동작을 지원하기 위해 보이지 않는 input엘리먼트가 함께 렌더링된다. 그리고 BubbleInput이라는 컴포넌트에서 Checkbox버튼이 클릭될 때 마다 보이지 않는 input에 클릭 이벤트를 dispatch하여, input에서 발생한 이벤트를 버블링시킨다. 이로써 form의 기본 동작을 지원할 수 있게 된다.

그런데 이 상황에, button에 대한 클릭 이벤트와 input에 대한 클릭 이벤트가 모두 버블링되어서, parent element에 등록한 클릭 이벤트 핸들러가 두번 호출될 수 있다. 이를 막기 위해, isFormControltrue인 경우엔 button엘리먼트에 발생한 클릭 이벤트에 stopPropagation을 호출해서 이벤트 전파를 막는다.

다음으로, Checkbox에서 hasConsumerStoppedPropagationRef라는 ref를 BubbleInputbubbles prop으로 전달하고 있는데, ref의 변경사항이 BubbleInput에 올바르게 동기화되는지를 확인해봐야 한다.

<ButtonInput 
  bubbles={hasConsumerStoppedPropagationRef.current}
/>

hasConsumerStoppedPropagationRef가 사용되는 위치는 BubbleInputuseEffect내부와, CheckboxonClick핸들러이다. Checkbox가 클릭되면 onClick핸들러가 호출되고, event.isPropagationStopped()hasConsumerStoppedPropagationRef.current가 업데이트 되며 checked상태도 이 때 업데이트된다.

BubbleInputuseEffect를 살펴보면, deps에 checked상태가 포함되어있기 때문에, checked상태가 업데이트 된 이후 useEffect의 콜백함수가 호출된다. 그러므로 이 콜백함수에서 접근하는 bubbles값은 이미 업데이트가 반영된 이후이다. 따라서 useEffect내부에서 항상 올바른 bubbles값을 참조할 수 있다.

indeterminate

Radix Checkbox의 checked상태는 boolean이거나 indeterminate(부분 체크)인데, checked상태가 indeterminate이라면 클릭시 true로, boolean이라면 반대 boolean값으로 토글된다.

initialChecked

Checkbox는 초깃값을 기억하고 있다가, reset이벤트가 발생할 때 setChecked를 호출한다. 이것 또한 form의 기본 동작이다.

const initialCheckedStateRef = React.useRef(checked);
useEffect(() => {
  const form = button?.form;
  if (form) {
    const reset = () => setChecked(initialCheckedStateRef.current);
    form.addEventListener('reset', reset);
    return () => form.removeEventListener('reset', reset);
  }
}, [button, setChecked]);