Select
커스텀 리스트박스 — APG select-only combobox 패턴. 포커스는 트리거에 머물고 aria-activedescendant가 활성 옵션을 가리킵니다. 제어(value)·비제어(defaultValue) 모두 지원, 옵션 그룹 헤더 지원.
마지막 업데이트 2026-06-11
한눈에#
커스텀 리스트박스 — APG select-only combobox. 고정 옵션에서 하나를 고르는 폼 값에 씁니다.
- 단일 선택
- 옵션 그룹
- 타이프어헤드
- APG combobox
기본 — 클릭 또는 ↓로 열고, 타이핑으로 점프
사용 시점#
고정 옵션 단일 선택이면 Select — 그 외에는 목적에 맞는 컴포넌트를 가리킵니다.
고정 옵션에서 하나만 고르는 폼 값(환경·요금제·국가)
열린 상태#
포털로 렌더되는 팝업은 정적 문서에 inline으로 못 띄우므로, faux-viewport에 열린 리스트박스를 재현합니다.
스테이징▾
- 리스트박스 팝업
- 활성 옵션 강조
- 그룹 헤더
- 타이프어헤드
해부#
닫힌 트리거 위에 토큰 실측을 핀으로 얹습니다 — Select는 Input의 component 토큰(input.*)을 공유해 같은 줄에서 자동 정렬됩니다.
- height
- 44px
input.height - padding-inline
- 12px
space-3 - gap
- 8px
space-2 - radius
- 10px
input.radius - chevron
- 20px
icon.size-md - border
- 1px
input.border - font
- 16px
body-1
플레이그라운드#
컨트롤로 props를 조작하면 미리보기와 코드가 실시간 갱신됩니다.
import { Select } from '@wds/ui-web';
<Select
aria-label="우선순위"
options={[
{ value: 'low', label: '낮음' },
{ value: 'medium', label: '보통' },
{ value: 'high', label: '높음' },
]}
/>변형#
팝업은 트리거 아래에 뜨고, 뷰포트 하단 공간이 부족하면 위로 플립됩니다.
옵션 그룹#
옵션에 group을 주면 같은 group을 가진 연속 옵션 앞에 비대화 헤더가
한 번 끼워집니다. 헤더는 role="presentation" + aria-hidden이라 listbox
의미론에서 제외되고, 키보드 탐색·aria-activedescendant는 헤더를 건너뜁니다
(M3 ExposedDropdownMenu / Apple Menu 섹션 참조). group이 없는 옵션은 헤더
없이 평면으로 렌더됩니다.
제어 · 비제어#
value를 넘기지 않으면 비제어 — defaultValue로 시작해 내부 상태가
선택을 소유합니다. value를 넘기면 제어 — 그 값이 진실의 원천이 되고
선택은 내부 상태를 쓰지 않은 채 onChange(next)만 통지하므로, 부모가
value를 갱신해야 표시가 바뀝니다(같은 값 재선택은 통지하지 않음).
타이프어헤드·listbox·모바일 시트 동작은 두 모드에서 동일합니다.
function EnvPicker() {
const [env, setEnv] = useState('dev');
return (
<Select
aria-label="배포 환경"
value={env}
onChange={setEnv}
options={[
{ value: 'dev', label: '개발' },
{ value: 'staging', label: '스테이징' },
{ value: 'prod', label: '프로덕션' },
]}
/>
);
}
크기#
트리거 높이는 input.height(44px) 단일 + 최소 폭 14rem — Input과 같은 폼
컨트롤 줄맞춤입니다. 리스트는 최대 16rem 높이에서 스크롤됩니다.
상태#
- Focus — 트리거
:focus-visible에input.border-focus보더 + 18% 링 - 활성 옵션 —
color.surface-selected배경(마우스 호버도 활성을 따라감) - 선택 옵션 —
aria-selected+color.primary-text잉크 - Disabled —
input.bg-disabled배경 + 커서 차단
모바일#
DK 모바일 재활용 표준입니다 — mobile을 켜면 트리거 클릭 시 데스크톱 팝업
대신 BottomSheet가 열리고, 옵션 리스트는
role="listbox" 의미론을 유지한 채 시트 안에 렌더됩니다. 단일 선택이므로
항목을 누르는 즉시 확정되고 시트가 닫힙니다(임시 상태 없음). 시트 제목은
mobileTitle, 없으면 aria-label → placeholder 순으로 채워집니다.
라이브 데모는 데스크톱 뷰포트라 코드로 안내합니다.
<Select
mobile
mobileTitle="배포 환경 선택"
aria-label="배포 환경"
placeholder="환경 선택"
options={[
{ value: 'dev', label: '개발' },
{ value: 'staging', label: '스테이징' },
{ value: 'prod', label: '프로덕션' },
]}
onChange={(value) => applyEnvironment(value)}
/>
닫힘 경로(배경 클릭·Escape·닫기 버튼·핸들 드래그 다운)와 포커스 복원은 BottomSheet 계약을 그대로 따릅니다 — 값 변경 없이 닫힙니다.
Props#
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
options | ReadonlyArray<SelectOption> | — | { value, label, disabled?, group? } 배열 — group이 같은 연속 옵션 앞에 헤더 |
value | string | — | 제어 선택 value — 제공하면 제어 모드(내부 상태 무시, 선택은 onChange만 통지) |
defaultValue | string | — | 초기 선택 value — 이후 상태는 내부 소유(비제어, value 미제공 시) |
onChange | (value: string) => void | — | 선택 변경 콜백 — 같은 값 재선택은 발화하지 않음 |
placeholder | string | '선택' | 선택 전 트리거에 표시되는 문구 |
mobile | boolean | false | true면 팝업 대신 BottomSheet로 옵션 리스트를 엽니다 (DK 모바일 표준) |
mobileTitle | string | — | 시트 제목 — 기본 aria-label → placeholder |
…rest | Omit<ButtonHTMLAttributes, 'onChange' | 'value' | 'defaultValue'> | — | aria-label · disabled 등은 트리거 button으로 전달 |
접근성#
트리거가 role="combobox" + aria-haspopup="listbox", 팝업이
role="listbox"입니다. 포커스는 항상 트리거에 머물고
aria-activedescendant가 활성 옵션 id를 가리킵니다.
| 키 | 동작 |
|---|---|
↓ / ↑ / Enter / Space (닫힘) | 열기 — 선택(없으면 첫 활성) 옵션 활성 |
↓ / ↑ (열림) | 활성 옵션 이동 — 비활성 옵션 건너뜀 |
Home / End | 첫/마지막 활성 옵션으로 점프 |
Enter / Space | 활성 옵션 선택 + 닫기 + 트리거 포커스 유지 |
Escape | 값 변경 없이 닫기 |
Tab | 닫고 기본 포커스 이동 |
| 문자 입력 | 타이프어헤드 — label 전방 일치 옵션으로 점프(0.5s 버퍼) |
외부 클릭으로도 닫히며, 비활성 옵션은 aria-disabled로 보고되고 클릭·선택이
차단됩니다. 옵션 그룹 헤더는 role="presentation" + aria-hidden이라 보조기술의
옵션 카운트와 키보드 탐색에서 모두 제외됩니다(↓/↑가 헤더를 건너뜀).
토큰#
component 토큰 없이 Input의 component 토큰(input.*)과 semantic을 직접
소비합니다(신설 기준 §4 미충족).
| 속성 | 토큰 |
|---|---|
| 트리거 | input.bg/fg/border/height/radius · placeholder input.placeholder |
| 포커스 | input.border-focus + color.primary 18% 링 |
| 팝업 | color.surface-raised · radius.md · shadow.lg · z.dropdown |
| 옵션 활성/선택 | color.surface-selected · 선택 잉크 color.primary-text · 눌림 color.surface-pressed |
| 옵션 높이 | control.height-sm |
| 그룹 헤더 | color.text-subtle · font.size-caption · font.weight-medium |
| 비활성 | input.bg-disabled · color.text-placeholder |
| 모션 | duration.fast + ease.out/standard |