April 23, 2022
어떤 디자인 시스템에서 Select 컴포넌트를 제공한다면, 어떤 기능들을 기대하시나요?
이 글에서는 Select컴포넌트에게 바라는 기능을 중심으로 구현 방법과 예제 코드를 설명합니다.
⚠️ 이 글에서 소개되는 코드는 모두 수도코드(pseudo code)입니다. 동작여부보다는 소개하고자하는 맥락을 중심으로 작성되었습니다.
“디자인 시스템의 Select컴포넌트를 사용한다”라는 문장에서 기대하는 바는 위 사진과 같이 유려한 ui를 가진 Select일것이다. 이 글에서 Select의 구현방법에 대해 자세히 다루는 이유는 기본 <select>요소를 위와 같이 유려한 ui와 함께 좋은 ux를 지원하는것이 다른 요소에 비해 간단하지 않기 때문이다.
예를 들어, Button컴포넌트는 대부분 <button>요소에 스타일(css)을 적용하는 방식으로 구현한다. 하지만, select는 지원하는 CSS 스타일이 한정적이기 때문에 ‘스타일을 적용’하기 어렵다.
<select>의 기능을 가지면서 ui는 커스텀 가능해야하며 더불어 접근성도 지키기 위해서는 어떤 방법으로 구현해야 하는지 알아보자.
본격적인 구현에 앞서, 인터페이스를 먼저 정해보자. <select> 요소는 다음과 같은 인터페이스를 가지고 있다.
<label for="pet-select">Choose a pet:</label>
<select name="pets" id="pet-select">
<option value="">--Please choose an option--</option>
<option value="dog">Dog</option>
<option value="cat">Cat</option>
<option value="hamster">Hamster</option>
</select>Select컴포넌트에서 요구사항을 구현하기 어렵다는 이유로 value를 배열로 받고 option을 내부적으로 렌더링하는 선택을 한다면, 확장이 어려워 변경에 취약해진다. 따라서 우리가 만들어나갈 컴포넌트는 <select>와 같은 인터페이스, 같은 기능을 가지도록 한다.
<select> 요소의 기본 기능을 중심으로 정리해본 요구사항들은 다음과 같다.
앞서 정의한 요구사항은 어떤 방법으로, 어떤 데이터(상태)로 구성할 수 있을까? 요구사항을 구현 관점에서 다시 한번 정리해보자.
키보드, 마우스 액션으로 옵션 리스트 영역을 열고 닫을 수 있다.
open 상태 필요리스트가 열려있는 상태일 때 선택 된 옵션에 포커스 되어있어야 하고 키보드로 옵션 탐색이 가능해야한다.
Select컴포넌트의 value prop에 따라 각 옵션의 ‘선택 되었는 지’ 여부를 판단한다.옵션 검색이 가능해야한다.
키보드 액션을 통해 선택가능해야 한다.
요구사항을 구체화와 함께, 앞서 정의한 컴포넌트 인터페이스도 구체화가 필요하다. 복잡한 요구사항의 책임을 적절히 분담하기 위해 Select-Option컴포넌트가 협력하는 방향이 필요하고, 이 방향을 위해 Compound Component 방식을 사용할 것이다.
“Compound Component”란 요구되는 기능을 수행하기 위해 두 개 이상의 컴포넌트가 협력하는 형태를 말한다. 보통 부모 - 자식 컴포넌트로 구성되며, 컴포넌트 간에 외부로 드러나지 않는 상태공유가 존재한다.
<select>
<option value="value1">key1</option>
<option value="value2">key2</option>
<option value="value3">key3</option>
</select><select>-<option>요소의 관계를 생각해보자. 두 요소는 독립적으로 존재하지만 단독으로는 사용할 수 없다.
하지만 위와 같은 표현은 인터페이스 측면에서는 가장 좋은 표현이다. 예를 들어, select-option요소를 ‘독립적으로 존재할 수 없다’ 에만 초점을 맞추면 다음과 같이 표현될것이다.
<select options="key1:value1;key2:value2;key3:value3"></select>value만 표현한다면 간단해보일 수 있어도, disabled 등 다른 속성도 필요하다는 것을 고려하면, 이는 좋은 API가 아니다.
따라서 Select컴포넌트는 아래와 같은 인터페이스를 가지면서 open, value를 <Select> 컴포넌트와 <Select.Option> 컴포넌트에서 암묵적으로 공유하는 형태가 될것이다.
<Select open={open} defaultValue="value">
<Select.Trigger>open select</Select.Trigger>
<Select.OptionList>
<Select.Option value="value1">value1</Select.Option>
<Select.Option value="value2">value2</Select.Option>
<Select.Option value="value3">value3</Select.Option>
</Select.OptionList>
</Select><select>요소와의 차이점으로 Trigger , OptionList 컴포넌트가 추가되었다.
Trigger컴포넌트는 <select>요소에서는 없던 ‘옵션을 여는 동작’에 대한 제어를 위한 것이고 OptionList 는 사용처에서는 스타일 커스텀 및 list컴포넌트가 가질 수 있는 prop을 정의할 수 있고, Select컴포넌트 내부에서는 value control 등을 다루게 된다. (이는 밑에서 자세히 설명한다.)
OptionList 컴포넌트에서는 Select 에 전달된 open상태에 따라 children렌더링 여부를 결정해야 하는 등,Select와 하위 자식컴포넌트간에는 상태 공유가 필요하다.
이 처럼 독립적으로 사용하는 컴포넌트에서 상태를 공유하기 위한 수단으로 Context API 가 있다. 최상위 컴포넌트에 Provider를 선언하고 OptionList컴포넌트에서는 context의 open 값으로 렌더링 여부를 결정한다.
function Select() {
const [open = false, setOpen] = useControllableState({
prop: openProp,
defaultProp: defaultOpen,
onChange: onOpenChange,
});
return (
<SelectContext.Provider
value={{ open, onOpenChange: setOpen }}
>
{children}
</SelectContext.Provider>
)
}
function OptionList() {
const context = useSelectContext()
return (
<Ul role="listbox" onInteractOutside={context.onOpenChange}>
{context.open ? children : null}
</Ul>
)
}Option컴포넌트에서 ‘선택되었는 지’에 대한 판단도 비슷한 방식으로 구현할 수 있다.
// 위와 중복된 코드 생략
function Select() {
// ✅ '현재 선택된 값'을 관리하는 hooks선언
const [value = '', setValue] = useControllableState({
prop: valueProp,
defaultProp: defaultValue,
onChange: onValueChange,
});
return (
<SelectContext.Provider
value={{ value, onValueChange: setValue }}
>
{children}
</SelectContext.Provider>
)
}
function OptionList() {
const context = useSelectContext()
return (
<Ul role="listbox" onItemSelect={context.onValueChange}>
{context.open ? children : null}
</Ul>
)
}
function Options() {
const context = useSelectContext()
return (
<li role="option">
{children}
{/* ✅ context의 'value'와 prop의 value가 같다면 선택되었다고 판단 */}
{context.value === props.value ? 'selected' : ''}
</li>
}기본적인 구현과 설계방향이 정리되었다. 이 글에서는 자세히 다루지 않지만 아이템 선택, 키보드 액션 등도 마찬가지로 Context를 활용하여 구현할 수 있다.
완성할 Component의 DOM구조는 대략 아래와 같은 모습이다.
<button type="button">
<span>Devon Webb</span>
</button>
<ul>
<li>Wade Cooper</li>
<li>Arlene Mccoy</li>
<li>Tom Cook</li>
<li>Tanya Fox</li>
</ul>버튼을 클릭하면 리스트가 열리고, 키보드 액션에 의해 리스트를 탐색하고 값을 선택하는 동작을 할 수 있지만 위 마크업만으로는 해당 기능을 스크린 리더에게 설명할 수 없다.
<button
type="button"
id="select-box-1"
aria-haspopup="true"
aria-expanded="true"
aria-controls="select-list"
>
<span>Devon Webb</span>
</button>
<ul aria-labelledby="select-box-1" id="select-list" role="listbox">
<li>Wade Cooper</li>
<li>Arlene Mccoy</li>
<li>Tom Cook</li>
<li>Tanya Fox</li>
</ul>id, aria-controls ,aria-labelledby 속성을 통해 요소간의 관계를 명시했고 role , aria-haspopup, aria-expanded 속성을 통해 역할과 상태를 명시했다.
특정 요소가 다른 요소를 제어하는 경우 제어 대상 요소를 나타내기 위해 명시한다. Select컴포넌트에서는 button이 ul요소를 제어하기 때문에 ul의 id를 button의 aria-controls속성으로 명시해야 한다.
<button aria-controls="custom-select-1">버튼</button>
<ul id="custom-select-1">...</ul>하위 요소의 표시여부나 aria-controls 에 해당하는 요소가 확장/축소 되었는지를 나타내기 위해 사용한다.

<select>요소에서 지원되는 중요한 기능 중 하나는 키보드로 옵션 탐색이 가능하다는 점이다. 같은 UX를 제공하기 위해 Select에서도 키보드 탐색을 위한 처리가 필요하다.
“탐색”을 하기 위해서는 탐색 대상이 무엇인지 알아야 한다. onKeyDown이벤트가 발생할때 전체 옵션(value) 목록과 각 옵션의 ref 정보를 알아야 현재 포커스 된 요소 기준으로 다음 포커스 요소를 결정할 수 있다.
앞서 정의한 컴포넌트 인터페이스를 다시 살펴보자.
<Select open={open} defaultValue="value1">
<Select.Trigger>open select</Select.Trigger>
<Select.OptionList>
<Select.Option value="value1">value1</Select.Option>
<Select.Option value="value2">value2</Select.Option>
<Select.Option value="value3">value3</Select.Option>
</Select.OptionList>
</Select>Select컴포넌트에서 전체 옵션이 무엇인지 알기 위해서는 React Children API를 사용하거나 직접 명시하는 방법이 있다. 하지만 두 방법 모두 불편한 점이 있다.
<Select options={[’value1’, ‘value2’ ]}> 와 같이 직접 명시하는 방법이다. 여기서 문제점은 “Select의 Option이 무엇인지”에 관한 표현이 두 곳에 존재한다는 것이다. 표현이 두 곳에 존재한다는 것은 불필요한 관리포인트가 존재한다는것을 의미하며, 사용하는 관점에서 생각해보면 의도를 알 수 없는 API이다.기존 인터페이스는 수정하지 않으면서 최상위 컴포넌트에서 전체 옵션 목록을 알 수 있는 방법을 고민해보자.
이미 답이 있다. 정보가 이미 존재하기 때문에 사용하면 된다. Select.Option의 prop으로 value를 선언했으니 이 값이 무엇인지 최상위 컴포넌트에서 알아내면 되는것이다.
이에 대한 구현체로 Collection이라는 Context관리 컴포넌트를 생성한다. (Collection에 대한 아이디어와 구현은 radix-ui/collection 을 참고했다.)
function Select() {
return (
<SelectContext.Provider
value={{ open, onOpenChange: setOpen }}
>
<Collection.Provider>
{children}
</Collection.Provider>
</SelectContext.Provider>
)
}
function SelectOption() {
return (
<Collection.Item value={value}>
<li>{children}</li>
</Collection.Item>
)
}Collection Context는 itemMap자료구조를 가지며, 옵션의 value와 ref를 가지고 있다.
type ContextValue = {
itemMap: Map<RefObject<ItemElement>, { ref: React.RefObject<ItemElement> } & ItemData>
}
function CollectionProvider() {
const itemMap = React.useRef<ContextValue['itemMap']>(new Map()).current;
return (
<Collection.Provider value={{ itemMap }}>
{children}
</Collection.Provider>
)
}
const ITEM_DATA_ATTR = 'data-radix-collection-item';
function CollectionItem(props) {
const context = useCollectionContext();
useEffect(() => {
context.itemMap.set(ref, { ref, value: props.value })
}, [])
return <Slot {...{ [ITEM_DATA_ATTR]: '' }} ref={ref}>{children}</Slot>
}
function useCollection() {
const context = useCollectionContext();
const getItems = useCallback(() => {
const collectionNode = context.collectionRef.current;
const orderedNodes = Array.from(collectionNode.querySelectorAll(`[${ITEM_DATA_ATTR}]`));
return orderedItems;
}, [])
return getItems;
}
Collection.Provider = CollectionProvider;
Collection.Item = CollectionItem;CollectionProvider 컴포넌트에서 context를 생성하고, CollectionItem 컴포넌트에서는 context에 element의 ref, value를 추가한다.
useCollection hook에서는 context값을 반환하는 함수인 getItems를 반환하여 사용처에 전체 옵션(value) 목록과 각 옵션의 ref 정보를 제공한다.
“<select>요소의 키보드 탐색”은 useCollection 훅을 사용하여 키보드 액션이 발생할 때 다음 아이템을 찾아 focus 처리로 구현할 수 있게 되었다.
onKeyDown핸들러에서 방향키 이동 액션이 일어날 때, getItems 함수를 사용해서 전체 옵션 목록을 조회하고 방향키 종류에 따라 이전/다음 요소를 찾아 활성화 시킨다.
function OptionList() {
const context = useSelectContext()
const getItems = useCollection();
return (
<Ul
role="listbox"
onKeyDown={event => {
const items = getItems().filter((item) => !item.disabled);
const candidateNodes = items.map((item) => item.ref.current!);
위_방향키_이동인가 ? focusPrev(candidateNodes) : focusNext(candidateNodes);
}}>
{context.open ? children : null}
</Ul>
)
}위 코드는 개념을 설명하기 위한 간단한 수도코드이다. 실제 동작하는 flow는 radix-ui의 menu/onKeyDown 를 참고하는 것을 추천한다.
Combobox예시처럼 “검색과 키보드 이동이 동시에 가능해야 한다.”라는 요구사항을 생각해보자.
우리는 이미 ‘그렇게 구현되어 있는’ Select에 익숙해져 있지만, 구현 관점에서 생각 해본다면 focus 함수를 사용하는 방식으로는 구현할 수 없다는 점에서 이질적인 기능이다.
“focus를 두 개 가질수 없다.”에 대한 예시는 코드샌드박스에서 확인할 수 있다.
Combobox컴포넌트는 실제로 focus를 동시에 두 개 가지는 것이 아니라, 키보드 탐색 시 focus 효과(스타일)를 적용하는 방식으로 구현해야 한다.
‘focus 효과(스타일)’로 기능을 구현한다는 것은 곧 스크린 리더기에게 어떻게 이 기능을 알려줄지도 생각해봐야 한다는 뜻이다. 실제로는 focus된 요소가 변화하지 않으니, 방향키 키보드 액션이 일어나도 스크린 리더기는 탐색 에 대한 아무정보도 얻을 수 없다.
스크린 리더기에게 필요한 정보 제공
적절한 aria요소 사용으로 문제를 해결할 수 있다.
자동완성 Select input의 역할을 지정해준다.
사용자의 실제 focus는 input(혹은 focus가능한 다른 요소)에 위치하고 있지만 다른 요소에도 focus되어 있는 것과 같은 ui를 구현할 때, 해당 요소의 id를 가진다.
예를 들어 위 사진의 예시에서는 aria-activedescendant=”Wade Cooper요소의 id” 가 된다.
요구사항이 좀더 구체화 되었다. 요구사항은 “자동완성 Select에서 input이 focus를 가지고 있을 때 방향키 액션 발생 시 필요한 요소에 focus스타일을 적용”하는 것이다.
이 글에서는 수도코드만을 다루니 동작하는 코드가 필요한 경우 floating-ui/useListNavigation을 사용하는 것을 추천한다.
function useListNavigation() {
const setFocusStyle = useCallback(() => {
const presentListItems = getPresentListItems(listRef);
// ✅ focus스타일 적용
presentListItems[nextIndex].current?.classList.add(focusClassName);
// ✅ 이전에 focus스타일 적용된 아이템에서 스타일 제거
if (이전_스타일_제거해야하는가) {
presentListItems[currentIndex].current?.classList.remove(focusClassName);
}
}, [focusClassName]);
const handleActiveIndex = useCallback((event: KeyboardEvent) => {
const elementListRef = getListRef().map(item => item.ref);
const minIndex = getMinIndex(elementListRef);
const maxIndex = getMaxIndex(elementListRef);
if (아래방향_입력인가) {
/**
* nextIndex처리
*/
}
/*...*/
setFocusStyle(elementListRef, { currentIndex, nextIndex });
setActivedescendantId(elementListRef[nextIndex].current?.id);
}, []);
return [activeIndex, { onKeyDown, 'aria-activedescendant': activedescendantId];
}방향키 탐색 시 다음 아이템에 focusClassName 을 부여하는 것으로 focus효과를 부여하고, 이 로직을 가진 hook은 아래 값들을 반환한다.
이 hook을 재료로 “Enter시 아이템 선택”등의 추가 요구사항도 구현할 수 있다.
여러 오픈소스 디자인 시스템의 MultiSelect 인터페이스는 선택된 값을 string[] 으로 받고, 전체 목록을 data(label / string 배열) 로 받는 형태가 많다.
// https://mantine.dev/core/multi-select/
<MultiSelect
data={countriesData}
valueComponent={Value}
defaultValue={['US', 'FI']}
/>선택된 값을 렌더링하는 컴포넌트로 valueComponent prop을 받고 있긴 하지만, 값을 렌더링하는 위치가 제한적이라는 점에서 MultiSelect UI에 종속적이다.
이는 앞서 구현한 Context 와 Collection API를 사용하면 아래와 같은 인터페이스로 구성하여 해결할 수 있다.
<>
<div>selected: {value.join()}</div>
<MultiSelect defaultValue={values} onValueChange={setValue}>
<MutliSelect.Trigger>trigger</MutliSelect.Trigger>
<MutliSelect.Content>
<MutliSelect.Option>Korea</MutliSelect.Option>
<MutliSelect.Option>France</MutliSelect.Option>
<MutliSelect.Option>United states</MutliSelect.Option>
</MutliSelect.Content>
</MultiSelect>
</>구체적인 구현체에서 앞서 구현한 Select와 다른 점은 value의 type이 string[]형태로 변경되었다는 것이다.
MultiSelect 컴포넌트의 역할은 어떤 옵션들이 있는지 파악하는것, 값을 선택하는 것, 선택된 값들이 어떤 것인지 관리하는 것이며 구체적인 UI에는 관여하지 않음으로서 구현에 제약받지 않는 MultiSelect를 사용할 수 있다.
로그인 정보를 autofill했을 때 select의 값이 Student에서 Developer로 autoFill되고 있다. (1password의 섹션 > 라벨을 select의 name과 값을 저장한 후 코드샌드박스 링크에서 직접 테스트 해볼수 있다.)
이러한 autoFill을 커스텀 UI로 구현된 Select에서도 지원하려면 화면에서는 보여지지 않더라도 기본 select element요소를 가지고 있어야 한다.
function Select() {
return (
<SelectContext.Provider>
{/* 중간 코드 생략 */}
<VisuallyHidden>
<select name='job'>
<option value="student">Student</option>
<option value="developer">Developer</option>
<option value="designer">Designer</option>
</select>
</VisuallyHidden>
</SelectContext.Provider>
)
}화면에서 보여지지 않도록 VisuallyHidden컴포넌트를 사용했다. 이 컴포넌트의 스타일을 정의한다면 가장 먼저 떠오르는 방법은 아마도 display속성을 none으로 설정하는 방법일것이다.
const VisuallyHidden = styled('div', { display: 'none' })시각적으로는 목적한 바를 달성할 수 있지만 스크린 리더를 사용자들에게는 정보를 전달할 수 없고, 앞서 언급한 autoFill기능도 동작하지 않는다.
시각적으로 display: none과 동일한 효과를 가지면서 접근성을 준수하는 목적으로 “clip pattern”을 사용할 수 있다.
const VisullayHidden = styled('div', {
clip: 'rect(0 0 0 0)',
clipPath: 'inset(50%)',
height: '1px',
overflow: 'hidden',
position: 'absolute',
whiteSpace: 'nowrap',
width: '1px',
})Select는 많은 웹앱에서 필요성이 있는 컴포넌트라고 생각합니다.
native select element의 스타일을 오버라이드 하는 방식으로 구현하는 것이 어렵다보니 custom element를 생성하고 동작을 붙이는 방식으로 구현되는데, 이 과정에서 기존 select element가 가지고 있는 기능들을 포함하며 확장성 있는 컴포넌트로 고려하는 것은 결코 간단하다고 생각되지 않았습니다.
이 글에서 소개한 개념과 구현체는 다수의 headless ui library들을 기반으로 합니다. 동작하는 코드와 더 자세한 구현원리가 궁금하신 분들은 Reference로 추가한 radix-ui, headlessui 의 코드를 참고해보세요.