(오픈소스 분석) Radix Checkbox
@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>
)
})
isFormControl
은 Checkbox
컴포넌트가 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.checked
의 setter
를 사용하여 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.button
의 onClick
핸들러를 살펴보자.
if (isFormControl) {
hasConsumerStoppedPropagationRef = event.isPropagationStopped();
if (!hasConsumerStoppedPropagationRef.current) {
event.stopPropagation();
}
}
isFormControl
이 true
인 경우 form
의 네이티브한 동작을 지원하기 위해 보이지 않는 input
엘리먼트가 함께 렌더링된다. 그리고 BubbleInput
이라는 컴포넌트에서 Checkbox버튼이 클릭될 때 마다 보이지 않는 input
에 클릭 이벤트를 dispatch하여, input
에서 발생한 이벤트를 버블링시킨다. 이로써 form의 기본 동작을 지원할 수 있게 된다.
그런데 이 상황에, button
에 대한 클릭 이벤트와 input
에 대한 클릭 이벤트가 모두 버블링되어서, parent element에 등록한 클릭 이벤트 핸들러가 두번 호출될 수 있다. 이를 막기 위해, isFormControl
가 true
인 경우엔 button
엘리먼트에 발생한 클릭 이벤트에 stopPropagation
을 호출해서 이벤트 전파를 막는다.
다음으로, Checkbox
에서 hasConsumerStoppedPropagationRef
라는 ref를 BubbleInput
의 bubbles
prop으로 전달하고 있는데, ref의 변경사항이 BubbleInput
에 올바르게 동기화되는지를 확인해봐야 한다.
<ButtonInput
bubbles={hasConsumerStoppedPropagationRef.current}
/>
hasConsumerStoppedPropagationRef
가 사용되는 위치는 BubbleInput
의 useEffect
내부와, Checkbox
의 onClick
핸들러이다. Checkbox
가 클릭되면 onClick
핸들러가 호출되고, event.isPropagationStopped()
로 hasConsumerStoppedPropagationRef.current
가 업데이트 되며 checked
상태도 이 때 업데이트된다.
BubbleInput
의 useEffect
를 살펴보면, 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]);