본문으로 건너뛰기

Select 컴포넌트 css 커스텀을 위한 여정

· 약 24분

native select는 자체적으로 강력한 태그라고 생각합니다. 그래서 그만큼 컴포넌트를 만들며 가장 어려움을 겪었습니다. 이 여정을 풀어볼까합니다.

들어가기에 앞서

  • compound pattern을 알고, 사용할 수 있다는 전제가 깔려있습니다.
  • 매끄러운 내용 진행을 위해 선택 전용 single select를 기본으로 글을 작성하였습니다.
  • 매끄러운 내용 진행을 위해 코드에 생략이 있습니다.
  • <select> 의 경우 native select element를 의미합니다.

배경

디자인 시스템을 구축해야 할 일이 있어 여러 기본 컴포넌트들을 만들고 있었습니다. 이 때 가장 구현이 어려웠다고 느낀 컴포넌트가 바로 Select 였습니다. 생각보다 native한 <select>의 기능을 따르면서 css 까지 원하는대로 변경하는 것은 쉽지 않았습니다.

왜냐하면 다른 태그들과는 다르게 <select>는 css를 한정적으로만 바꿀 수 있고, <select>와 같이쓰이는 <optgroup>, <option> 태그도 존재하기 때문입니다.

그렇다면 원하는 디자인으로 <select>를 사용하기 위해서는 어떻게 해야할지 확인해봅시다.

다른 태그로 접근하기

위에서 언급했다시피 native select element는 css를 한정적으로만 적용할 수 있습니다. 그래서 원하는 디자인대로 커스텀을 위해서는 native select 대신 이와 비슷한 기능과 구조를 가질 수 있는 태그로 대체해야 합니다.

기본적인 select에서 원하는 동작을 나열해 봅시다.

  • 선택한 특정 option을 보여주는 상자가 있다.
  • 이 상자를 클릭하면 옵션들을 담은 상자가 나온다.
  • 특정 옵션을 클릭하면 옵션들을 담은 상자가 닫힌다.
  • 이외에 Esc나 외부 클릭 등으로 옵션들을 담은 상자가 닫힌다.

이 기본 동작을 컴포넌트로 표현한다면 다음과 같이 표현할 수 있을 것 같습니다.

function Select({ name, children, defaultValue = '', placeholder = '' }: SelectProps) {
const { value: isOpen, setFalse: close, toggle } = useBooleanState(false);
const [currentValue, setCurrentValue] = useState(defaultValue);

const handleOptionClick = (e) => {
close();
setCurrentValue(e.target.value);
};

...

return (
<div>
<button onClick={toggle}>{선택된 옵션 이름 || placeholder}</button>
// css로 isOpen이 false 인경우 안보이게 처리
<ul $isOpen={isOpen} onKeyDown={...}>
<li onClick={handleOptionClick} value="1">
option 1
</li>
<li onClick={handleOptionClick} value="2">
option 2
</li>
<li onClick={handleOptionClick} value="3">
option 3
</li>
</ul>
</div>
);
}

이 구조를 베이스로 native에 근접한 Select를 구현해봅시다.

native select와 구조 맞추기

select를 구현하는 방식은 크게 두 가지로 확인할 수 있었습니다.

  • props로 { value, name } 꼴의 <option>에 담기는 정보 배열을 전달하기.

    • function SelectExample() {
      return (
      <Select
      options={[
      { value: 1, name: 'option 1' },
      { value: 2, name: 'option 2' },
      { value: 3, name: 'option 3' },
      ]}
      />
      );
      }
  • compound pattern 등을 사용해 컴포넌트 자식으로 option을 사용할 수 있도록 하기.

    • function SelectExample() {
      return (
      <Select>
      <Select.Option value="1">option 1</Select.Option>
      <Select.Option value="2">option 2</Select.Option>
      <Select.Option value="3">option 3</Select.Option>
      </Select>
      );
      }

첫 번째 방법으로 구현을 하게 되면 구현 난이도 및 코드량 자체는 낮아집니다. 하지만 <option>에서 다루는 값을 props으로 다룬다는 점과 native select의 형태와는 사용할 때 거리가 멀다는 점이 마음에 들지 않았습니다.

두 번째 방법은 어떨까요? 첫 번째 방법에 비해 구현 난이도와 코드량 자체는 높아질테지만 첫 번째 방법으로 구현했을 때 나타나는 단점들을 무마할 수 있습니다.

Select 안에 들어갈 요소 정하기

Select 구현 방식을 정했으니 Select 자식에 쓰일 컴포넌트로 무엇이 올 수 있을지 정의해 봅시다.

<select> 내부에 사용하는 전용 태그는 <option>, <optgroup>두 가지입니다. 그리고 특정 <option>, <optgroup> 사이를 구분하기 위해 <hr>를 사용하기도 합니다.

이 세 가지 태그를 내부에서 정의해 사용할 수 있도록 하면 될 것 같네요! 저는 작업하는 디자인 시스템에 맞게 Slot, SlotGroup, Divider 라는 이름으로 만들어주었습니다.

└── 📁Select
└── 📁Slot
└── 📁SlotGroup
└── 📁Divider

Compound pattern을 사용해 Select에서 Slot, SlotGroup, Divider를 사용할 수 있도록합니다. Divider는 로직과 관련 없는 디자인적 요소이므로 생략하겠습니다.

Select.tsx
... // compound pattern을 위한 context 세팅

function Select({ children, defaultValue = '', placeholder = '' }) {
const { value: isOpen, setFalse: close, toggle } = useBooleanState(false);
const [currentValue, setCurrentValue] = useState(defaultValue);

const contextValue = useMemo(
() => ({ close, currentValue, setCurrentValue }),
[close, currentValue, setCurrentValue],
);

return (
<SelectContext.Provider value={contextValue}>
<S.SelectContainer>
<S.SelectButton onClick={toggle}>{선택된 옵션 이름 || placeholder}</S.SelectButton>

<S.OptionList $isOpen={isOpen}>{children}</S.OptionList>
</S.SelectContainer>
</SelectContext.Provider>
);
}
Slot.tsx
function Slot({ value, children, disabled = false }) {
const { currentValue, setCurrentValue, close } = useSelectContext();

const handleClick = () => {
if (disabled) return;

setCurrentValue(value);
close();
};

return (
<S.Li
value={value}
onClick={handleClick}
$disabled={disabled}
$selected={currentValue === value}
>
{children}
</S.Li>
);
}
SlotGroup.tsx
function SlotGroup({ children, label }) {
return (
<S.Container>
<S.Label htmlFor={label}>{label}</S.Label>
<ul id={label}>{children}</ul>
</S.Container>
);
}

아래와 같은 구조로 이제 사용할 수 있도록 준비를 마쳤습니다!

function SelectExample() {
return (
<Select name="example" placeholder="text">
<Select.Slot value="1">Option 1</Select.Slot>
<Select.Slot value="11">Option 11</Select.Slot>
<Select.SlotGroup label="group">
<Select.Slot value="www">Option www</Select.Slot>
<Select.Slot value="xxx">Option xxx</Select.Slot>
<Select.Slot value="zzz">Option zzz</Select.Slot>
</Select.SlotGroup>
<Select.Divider />
<Select.Slot value="2">Option 2</Select.Slot>
<Select.Slot value="22">Option 22</Select.Slot>
</Select>
);
}

Focus 구현

<select>에서 focus는 다분하게 바뀔 수 있습니다. <select>가 아닌 자체 커스텀 Select를 사용한다면 <select>에서 일어나는 focus 처리를 해 주어야 합니다.

또한 Focus를 구현하기 위해 사용하는 방법으로 다른 <select>의 동작도 충분히 구현할 수 있습니다. 기본적으로 <select>에서 focus가 일어나는 상황을 정리해보겠습니다.

  1. <select> 상자 클릭 시 현재 select된 옵션이 focus
  2. 각각의 <option>에 마우스 hover 시 해당 옵션이 focus
  3. 화살표 위, 아래 키를 누르면 현재 focus된 옵션의 위, 아래 옵션이 focus
  4. <option> 클릭 시 옵션 상자를 벗어나서 <select> 상자에 focus
  5. space, 엔터, Esc 키를 누르거나 옵션 상자 이외의 영역을 클릭해 옵션 상자를 벗어나면 <select> 상자에 focus

추가적으로 더 존재하지만 위의 항목들을 중점으로 다뤄보겠습니다.


직접 확인해보기


react에서는 focus를 위해 특정 요소의 ref를 참조해야합니다. ref를 사용해 위의 focus 상황을 하나씩 해결해 봅시다.

Slot

Slot 컴포넌트에서는 2번, 4번 상황을 해결할 수 있습니다.

  • 2번 - 각각의 <option>에 마우스 hover 시 해당 옵션이 focus
  • 4번 - <option> 클릭 시 옵션 상자를 벗어나서 <select> 상자에 focus

2번의 경우 단순하게 onMouseEnter시 disabled가 아니라면 focus를 주고 onMouseLeave 시 blur를 줍니다.

4번의 경우 옵션 클릭 시 Select 컴포넌트 button의 ref를 가져와 focus를 줍니다.

Slot.tsx
function Slot({ value, children, disabled = false }) {
const slotRef = useRef<HTMLLIElement | null>(null);
const { currentValue, setCurrentValue, close, selectButtonRef } = useSelectContext();

const handleClick = () => {
if (disabled) return;

setCurrentValue(value);
close();
selectButtonRef.current?.focus();
};

const handleMouseEnter = () => {
if (disabled) return;

slotRef.current?.focus();
};

const handleMouseLeave = () => {
if (disabled) return;

slotRef.current?.blur();
};

return (
<S.Li
ref={slotRef}
value={value}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
$disabled={disabled}
$selected={currentValue === value}
>
{children}
</S.Li>
);
}

Select

Select 컴포넌트에서는 5번 상황을 해결할 수 있습니다.

  • 5번 - space, 엔터, Esc 키를 누르거나 옵션 상자 이외의 영역을 클릭해 옵션 상자를 벗어나면 <select> 상자에 focus

공통적으로 <button>에 focus를 주고 옵션 상자를 닫는 일을 하고 있습니다. 이 일을 하기 위한 이벤트가 optionList의 keydown, 옵션 상자 외부 클릭이며 이 이벤트가 발생할 때 focus를 줄 수 있도록 합니다.

Select.tsx
... // compound pattern을 위한 context 세팅

function Select({ children, defaultValue = '', placeholder = '' }) {
const selectRef = useRef<HTMLDivElement | null>(null);
const selectButtonRef = useRef<HTMLButtonElement | null>(null);

...

const closeOptionList = () => {
close();
selectButtonRef.current?.focus();
}

const handleOptionListKeyDown: React.KeyboardEventHandler<HTMLUListElement> = (e) => {
// 이미 이벤트가 실행되는 중이라면 아무 동작도 하지 않습니다.
if (e.defaultPrevented) return;

switch (e.key) {
...
case ' ':
case 'Enter':
closeOptionList();
break;
case 'Esc':
case 'Escape':
closeOptionList();
break;
default:
return;
}

e.preventDefault();
}

useOnClickOutside(selectRef, closeOptionList);

return (
<SelectContext.Provider value={contextValue}>
<S.SelectContainer ref={selectRef}>
<S.SelectButton ref={selectButtonRef} onClick={toggle}>{currentValue || placeholder}</S.SelectButton>

<S.OptionList $isOpen={isOpen} onKeyDown={handleOptionListKeyDown}>{children}</S.OptionList>
</S.SelectContainer>
</SelectContext.Provider>
);
}

option ref 모으기

조금 어려운 부분은 1, 3번 상황입니다.

  • 1번 - <select> 상자 클릭 시 현재 select된 옵션이 focus
  • 3번 - 화살표 위, 아래 키를 누르면 현재 focus된 옵션의 위, 아래 옵션이 focus

이 두 가지 상황의 경우 특정 요소를 눌렀는데 다른 요소가 focus되어야 하거나 나열된 같은 컴포넌트로 이루어진 요소의 ref를 모두 가지고 있어야 하기 때문입니다.

공통적으로 두 가지 상황 모두 옵션들의 ref가 있어야 focus를 할 수 있다는 점입니다. 그렇다면 모든 옵션들의 ref를 가져와봅시다.

모든 옵션들의 ref를 가져오기 위해 Select에서 compound pattern을 위해 사용하는 context를 활용합니다. Select에 모든 옵션들의 ref를 저장할 ref를 만들고, 이 ref를 Slot에서 가져가 사용할 수 있도록 할것입니다. 여기에서 생각해 보아야 할 점은 모든 옵션들이 가지는 각기 다른 고유의 값은 무엇일까? 입니다. 바로 생각나는 것은 옵션의 value 혹은 index, ref 그 자체가 될 수 있겠네요.

옵션의 value의 경우 중복되는 경우가 존재하지 않는 다는 전제가 있습니다. 중복된다고 하면 1번 상황에 대해 특정 option을 가려내기 어려울 수 있습니다. 하지만 중복이 불가능하지는 않다는 점이 살짝 걸렸습니다.

index 의 경우는 select의 옵션이 동적으로 추가되는 경우에 문제가 됩니다. 물론 이러한 경우가 거의 없을지라도 불가능하지는 않죠.

ref 자체는 고유의 값은 맞지만 이걸 활용하는 것은 살짝 낯설었습니다. value 와 고민을 많이 했지만 ref를 고유의 값으로 활용하기로 확정지었습니다. ref 값은 우리가 직접 설정해 주는것이 아닌 react 자체에서 내보내는 값이기 때문에 휴먼 에러 측면에서도 괜찮아 보였습니다. 이제 ref를 고유 키로 갖는 객체를 ref로 선언하면 되겠네요! 객체는 Map 을 사용해 보겠습니다.

Select.tsx
interface SlotMapValue {
ref: React.RefObject<HTMLLIElement>;
value: string;
option: string;
}

type SlotMap = Map<React.RefObject<HTMLLIElement>, SlotMapValue>;

function Select({ children, defaultValue = '', placeholder = '' }) {
const slotMapRef = useRef<SlotMap>(new Map());

...

// slotMapRef를 context에 추가
const contextValue = useMemo(
() => ({ close, currentValue, setCurrentValue, slotMapRef, selectButtonRef }),
[close, currentValue, setCurrentValue, slotMapRef, selectButtonRef]
);

...
}

이제 Slot에서 slotMapRef에게 ref를 전달해줍시다.

Slot.tsx
function Slot({ value, children, disabled = false }) {
const slotRef = useRef<HTMLLIElement | null>(null);
const { ..., slotMapRef } = useSelectContext();

...

useEffect(() => {
const slotElementMap = slotMapRef.current;

if (slotElementMap instanceof Map && disabled === false) {
slotElementMap.set(slotRef, { ref: slotRef, value, option: children });
}
}, [disabled]);

...
}

이제 Select에서 slotMapRef를 활용해서 focus를 컨트롤 할 수 있습니다.

1번 상황의 경우 현재 select 된 옵션을 알기 위해 slotRefArray 에서 특정 value를 갖는 slotRef를 찾아내면 됩니다. 다만, 옵션 상자가 열린 후 포커스를 주어야 하기 때문에 useEffect를 사용하겠습니다.

slotMapRef를 통해 slotRef 배열을 만들 수 있습니다. 이 배열을 활용해보겠습니다.

const slotRefArray = Array.from(slotMapRef.current.values());
useEffect(() => {
if (isOpen) {
const slotRefArray = Array.from(slotMapRef.current.values());
const selectedSlot = slotRefArray.find(({ value }) => value === currentValue);

if (selectedSlot) {
selectedSlot.ref.current?.focus();
} else {
// select 된 옵션이 없는 경우 첫번째 옵션을 포커스
slotRefArray[0].ref.current?.focus();
}
}
}, [isOpen]);

3번 상황의 경우 document에 포커스되어있는 요소를 찾고 해당 요소가 몇번째 slotRef 에 해당하는지 찾습니다. 이후에는 slotRef 배열에서 index를 조절하는 식으로 포커스를 변경할 수 있습니다.

const moveFocus = (count = 1) => {
const slotRefArray = Array.from(slotMapRef.current.values());
const currentIndex = slotRefArray.findIndex(({ ref }) => ref.current === document.activeElement);

slotRefArray[currentIndex + count]?.ref.current?.focus();
};

접근성 챙기기

<select>가 아닌 다른 요소를 이용해서 "select 처럼" 동작하게 했기 때문에 기본적인 스크린 리더 정보를 챙겨줄 필요가 있습니다.

role을 select role을 사용할 수 있다면 좋겠지만 abstract role로 직접 명시하는 방식으로 사용하는 것을 권장하지 않습니다.(best practices에도 do not use 라고 적혀있죠.) 따라서 role을 따로 주지 않거나, 이와 유사한 combobox를 줍시다.

Select.tsx

Select에서 button은 옵션 상자를 트리거 하는 역할을 합니다. 그리고 ul은 옵션 리스트를 보여줍니다. 따라서 이 두 요소간의 관계를 작성할 필요가 있습니다.

추가적으로 tabIndex를 활용해 focus 여부를 기재합니다. 다음과 같은 속성을 사용합니다.참고

  • tabIndex
  • role
  • aria-controls
  • aria-haspopup
  • aria-expanded
  • aria-labelledby
Select.tsx
function Select({ children, defaultValue = '', placeholder = '' }) {

...

return (
<SelectContext.Provider value={contextValue}>
<S.SelectContainer ref={selectRef}>
<S.SelectButton
id="select-button"
ref={selectButtonRef}
role="combobox"
tabIndex={0}
aria-controls="select"
aria-haspopup="true"
aria-expanded={isOpen}
onClick={toggle}
$isOpen={isOpen}
>

<S.OptionList role="listbox" aria-labelledby="select-button" $isOpen={isOpen}>{children}</S.OptionList>
</S.SelectContainer>
</SelectContext.Provider>
);
}

Slot.tsx

Slot에서는 option에 해당하는 접근성을 챙겨줍니다. selected, disabled 상태 표현을 위한 ARIA와 role, tabIndex를 추가합니다.

Slot.tsx
function Slot({ value, children, disabled = false }) {

...

return (
<S.Li
ref={slotRef}
value={value}
role="option"
tabIndex={-1}
aria-selected={currentValue === value}
aria-disabled={disabled}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
$disabled={disabled}
$selected={currentValue === value}
>
{children}
</S.Li>
);
}

SlotGroup.tsx

SlotGroup에서는 <optgroup>에 해당하는 접근성을 챙기기 위해 해당 <ul>role="group"을 추가합니다.

SlotGroup.tsx
function SlotGroup({ children, label }) {
return (
<S.Container>
<S.Label htmlFor={label}>{label}</S.Label>
<ul id={label} role="group">
{children}
</ul>
</S.Container>
);
}

native select element 활용하기

원하는 접근성과 포커스 동작도 챙겨주었지만 아직 챙기지 못한것이 있습니다.

<input>/<select>/<textarea><form>과 많이 사용하는데, 이 때 문제가 발생합니다.

<form> 접근.

<select>의 경우 <form>을 통해 name attribute에 접근해 해당하는 value를 가져올 수 있습니다. 커스텀한 Select 컴포넌트의 경우 이 동작을 구현할 수 없죠.

// 이와같이 사용할 수 없습니다.

<form
onSubmit={(e) => {
e.preventDefault();
console.log(e.currentTarget.example.value);
}}
>
<select name="example">
<option value="www">Option www</option>
<option value="xxx">Option xxx</option>
<option value="zzz">Option zzz</option>
</select>
<button type="submit">콘솔로 현재 선택된 값 확인</button>
</form>

autocomplete 특성

<select>의 경우 HTML autocomplete 특성을 가지고 있습니다. 하지만 커스텀한 Select는 autocomplete 특성을 가질 수 없습니다.

따라서

위의 두 가지 문제를 해결하기 위해 <select>의 경우도 커스텀 Select를 이용할 때 가지고 있는 것이 좋습니다. 대신 <select>를 숨길 필요가 있습니다. <select> 를 숨기기 위해 이 글에서 사용한 css를 적용했습니다.

Select.tsx
function Select({ children, defaultValue = '', placeholder = '' }) {

...

return (
<SelectContext.Provider value={contextValue}>
<S.SelectContainer ref={selectRef}>
...
</S.SelectContainer>

<S.HiddenSelect>
<select
name={name}
tabIndex={-1}
value={currentValue}
onChange={(e) => setCurrentValue(e.target.value)}
>
...
</select>
</S.HiddenSelect>
</SelectContext.Provider>
);
}

optgroup 고려하기

이제 select안에 option들을 채워 넣으면 됩니다. 이 때 SlotGroup을 사용한 경우를 고려해야합니다. 이 경우 <option><optgroup>으로 묶어주어야 하기 때문이죠.

Q&A

Q: SlotGroup 안에 SlotGroup이 중첩될 수 있나요?

A: 아니요. <select><optgroup>이 중첩 불가하기 때문에 이를 최대한 따라가기 위해 중첩은 허용하지 않았습니다.

permitted content를 확인해보면 <optgroup><option>만을 허용합니다.

특정 Slot이 SlotGroup안의 요소인지 판별하기 위해서는 어떻게 해야 할까요? SlotGroup 안에 들어가는 children의 경우 추가적인 props을 전달하는 방법을 생각해 보았습니다.

React.children.mapReact.cloneElement를 활용해서 children을 순회하며 groupLabel을 prop에 추가합니다.

SlotGroup.tsx
function SlotGroup({ children, label }) {
return (
<S.Container role="group">
<S.Label htmlFor={label}>{label}</S.Label>
<ul tabIndex={-1} id={label} role="listbox">
{React.Children.map(children, (child) => React.cloneElement(child, { groupLabel: label }))}
</ul>
</S.Container>
);
}

이제 Select의 slotMapRef에 저장되는 ref의 값에는 groupLabel이 optional 하게 존재합니다.

interface SlotMapValue {
ref: React.RefObject<HTMLLIElement>;
value: string;
option: string;
groupLabel?: string;
}

<select>의 내부 요소를 구성하기 위한 단계를 나열해 봅시다.

  • slotMapRef.current의 값을 모아 SlotMapValue 타입의 배열로 바꾸어 줍니다.
  • groupLabel이 있는 경우 같은 groupLabel을 가진 Slot끼리 한 객체로 묶어줍니다.
        const example = { groupLabel: 그룹라벨이름, options: [ option1 , option2 , ...] };
  • 위 과정을 통해 얻은 객체 배열을 바탕으로 객체 안에 group이 true인 경우 options들을 <optgroup>의 자식으로 넣습니다.

대략적으로 표현하자면 아래와 같습니다.

<select
name={name}
tabIndex={-1}
value={currentValue}
onChange={(e) => setCurrentValue(e.target.value)}
>
{groupSlotByLabel(Array.from(slotMapRef.current.values())).map((item) => {
if (그룹인 객체면) {
return (
<optgroup label={item.label} key={item.label}>
{item.element.map(({ value, option }) => (
<option value={value} key={value}>
{option}
</option>
))}
</optgroup>
);
}

return (
<option value={item.value} key={item.value}>
{item.option}
</option>
);
})}
</select>

이제 <select>에 맞게 <form>을 사용하고 autocomplete 특성도 활용할 수 있게 되었습니다!

마치며

select는 결코 디자인 하기 간단한 컴포넌트가 아니라는 것을 크게 깨닫게 되었습니다. 기존 컴포넌트에서 벗어나 이 것을 그대로 흉내낸다는 것은 100% 완벽할수도 없을 뿐더러 매우 수고스러움이 들죠. 하지만 그만큼 ui에 있어 디자인이 추구하는 가치가 크다는 것을 느낄 수 있었습니다.

글에서는 큰 부분들만을 다루어 중간중간 생략된 부분이 많습니다.

추가적인 keyboard interaction도 존재하고 Multi Select나 편집 가능한 Select, 옵션 키보드 탐색 기능 등이 있죠.

이 기능들까지 자세히 들여다보고 싶다면 reference를 참고해 주세요.

reference

접근성, 태그

React component library

블로그