13 KiB
Combobox
현재 접근성 보완(2026-10-10): 흰 Trigger의 빈 값 안내는 기존
--hm-sem-fg-secondary를 사용한다. 기본 Theme에서 #6e6759/흰색이며 공통 placeholder Token과 Disabled opacity는 보존한다. 아래 과거 검증의 #999 실측은 당시 기록이다.
2026-10-10 추가 수정 요청 기준: 현재 구현의 연결형 Popup을 기준으로 문서를 통일. 팝업 dialog 이름은 aria-label → aria-labelledby → 고유 trigger ID 순으로 연결. Command 검색·listbox 이름과 인스턴스 ID는 독립. 과거 검증 기록은 당시 결과이며 현재 검증은 추가 수정 보고서를 따릅니다.
현재 HM 구현의 기준 문서. 공통 원칙은 docs/guidelines/프로젝트-지침.md(§6, §7), Token 구조 원칙은 테마-토큰-가이드.md를 따른다. 이 문서는 Combobox 고유의 API·Token·상태·결정만 기록한다.
1. 상태와 성격
| 항목 | 내용 |
|---|---|
| 성격 | Reusable component, Popover + Command 조합 (typed options wrapper) |
| 구현 상태 | 구현 완료 (single-select, 현재 HM 규격) |
| 기준일 | 2026-10-02 |
| 기반 | Popover(public API) + Command(public API) 조합. shadcn 공식 Combobox는 예시 조합이다 |
| 대표 Story | Components/Search & Date/Combobox → All (이름 "Combobox") |
| 보류 | 아래 §11 |
2. 적용 근거
- 참고 근거: 외부 ui_viz Reference (ZIP 미포함)의 기존 테스트 규격에는 Combobox가 없다(Select와 Search Field는 다른 컴포넌트). 참고할 수치가 없었다.
- Component로 만든 이유: 검색 가능한 단일 선택은 서비스에서 반복되고(trigger 텍스트, 검색, 선택 표시, 닫힘, focus 복귀가 동일) 최소 API(
options)로 충분해서 typed wrapper를 제공한다. 더 복잡한 항목(그룹, 아이콘 등)은 소비자가Command를 직접 조합한다. - 구현 방식: Popover(Portal, collision, Escape, focus 복귀)와 Command(검색, 필터링, 키보드, listbox ARIA)를 그대로 조합한다. Select 구현을 복제하지 않고 Popover + Command를 유지하며 현재 연결형 윤곽을 사용한다.
- 보류: §11.
3. Select와의 구분
| Select | Combobox | |
|---|---|---|
| 용도 | 정해진 짧은 목록에서 선택, 검색 불필요 | 검색/필터가 필요한 선택 |
| 구현 | Radix Select | Popover + Command(cmdk) |
| 목록 | trigger에 붙은 연결형 윤곽, 선택 항목은 면으로 표시 | trigger 연결형 패널(검색 Input + 목록), 선택 항목은 Accent 45% 면 + check 아이콘 |
| 접근성 모델 | Radix Select(listbox) | role=combobox trigger + 패널 안 cmdk combobox/listbox |
4. 기본 규격 (현재 HM 규격)
| 항목 | 값 |
|---|---|
| Size | large(기본, 44px, 16 / 24), medium(34px, 14 / 20) — Select와 같은 두 크기 |
| Trigger | 폭 100%(부모 폭), padding-x 12px, 1px --hm-sem-stroke-input-default, radius 4px, 흰 면, 오른쪽 chevron 18px |
| 값 / placeholder | 선택된 option의 label, 없으면 placeholder("선택", --hm-sem-fg-secondary), 길면 말줄임 |
| Popup | padding 0, 폭 = trigger 폭(--radix-popover-trigger-width), sideOffset=-1로 1px 연결 경계 공유. Focus border, 안쪽 두 모서리 radius는 기존 Input token, shadow 0 2px 24px black 12% |
| 검색 | Command Input(placeholder 기본 "검색...") |
| 목록 | Command List(최대 280px), Item 높이 36px·padding-x 12px·radius 0·weight 400. Large 16/24, Medium 14/20. 선택 check 16px, 긴 label은 말줄임 |
| Empty | "결과가 없습니다."(emptyText) |
5. 상태 규격
| 상태 | 값 |
|---|---|
| Focus / Open | trigger 테두리 전체가 --hm-sem-stroke-input-focus (Input·Select와 같은 방식) |
| Highlighted option | --hm-sem-surface-highlight (Accent 45%) |
| Selected option | check 아이콘 + Accent 45% 면(data-checked). Command highlight가 다른 항목으로 옮겨져도 유지 |
| Disabled (combobox) | opacity 0.6(이 컴포넌트 값), cursor not-allowed, 열리지 않음 |
| Disabled option | Command의 disabled(opacity 0.6), 방향키 이동·선택 제외 |
| 선택 후 | 값 갱신, Popover 닫힘, focus가 trigger로 복귀. 다시 열면 검색어가 비어 있다(Command가 닫을 때 unmount됨) |
6. API
export: Combobox, types ComboboxOption({ value, label, disabled? }), ComboboxSize.
options(필수),value/defaultValue/onValueChange(value),placeholder,searchPlaceholder,emptyText,size,disabled, 그리고buttonprops(id,aria-label,className…).- 필터링: option의
value와label(keywords)에 cmdk 기본 fuzzy 필터를 쓴다. - 이미 선택된 option을 다시 선택해도 값은 유지된다(해제 UI는 없다).
- 접근성: Trigger는
role="combobox",aria-haspopup="dialog",aria-expanded이며, 열린 동안aria-controls가 Popover의role="dialog"를 가리킨다. 패널 안 Command Input은 별도role="combobox"로 cmdk listbox를 제어하고, 항목은role="option"이다. 소비자는 공개Label의htmlFor와 triggerid, 또는aria-label/aria-labelledby로 항목명을 연결한다. 버튼 trigger + dialog 내부 검색 combobox 구조이며, trigger를aria-haspopup="listbox"로 덮어쓰지 않는다. Enter/Space로 열기, 검색·방향키·Enter로 선택, Escape로 닫기와 trigger focus 복귀를 유지한다. - generic data framework, 다중 선택, 비동기 로딩은 만들지 않았다.
값 소유권과 외부 초기화
controlled?: boolean은 선택적 값 소유권 설정이다.true이면value가undefined여도 Controlled이며, 부모 값만 표시한다.undefined는 선택 없음이다. 사용자 선택은 기존(string) => void콜백으로 전달하며, 부모가 값을 반영하지 않으면 표시값은 유지된다.controlled={false}이면 내부 state가 소유권을 가진다.value는 사용하지 않고defaultValue를 최초 마운트 시에만 적용한다. 이후defaultValue변경은 초기화가 아니다. 사용자 선택은 내부 state를 갱신하고 콜백을 호출한다.- Legacy 호환:
controlled를 생략하면 기존value !== undefined판정을 유지한다. 문자열이 있으면 Controlled,undefined이면 Uncontrolled이다. 이 방식으로 외부 값을undefined로 바꾸면 과거 내부 값/defaultValue가 다시 표시될 수 있다. 기존 소비 코드의 동작은 변경하지 않았다. - 신규 Controlled 사용과 FilterBar 연동은
controlled={true}를 사용한다.controlled옵션과 값 소유권 모드는 마운트 중 변경하지 않는 사용 계약을 권장한다. Controlled에서defaultValue를 함께 전달해도 부모value가 우선한다. ""은 유효한 문자열 옵션 값이며undefined와 동일한 빈 값으로 정규화하지 않는다.value=""는 빈 문자열 옵션이 있으면 그 label을 표시하고, 없으면 기존처럼 placeholder를 표시한다. 빈 문자열을 가진 항목을 선택하면 콜백은""을 전달한다.
const [category, setCategory] = useState<string | undefined>()
<Combobox controlled={true} options={categories} value={category} onValueChange={setCategory} />
// 외부 항목 지정/갱신: setCategory("design")
// 외부 초기화: setCategory(undefined) — key remount 불필요
외부 값 갱신/초기화는 onValueChange를 호출하지 않는다. FilterBar의 부모는 항목 state와 조회 조건을 함께 초기화한다. 선택값 clear는 팝업 닫기나 검색어 초기화를 의미하지 않는다. 열린 팝업의 검색어를 강제로 지우는 새 계약이나 clear UI는 추가하지 않는다. controlled를 생략한 Legacy 호출의 remount 우회는 이 신규 계약과 구분한다.
7. Dependency
새 dependency 없음(Command의 cmdk, Popover의 Radix를 그대로 사용).
8. Token
Component Mapping 위치: packages/ui/src/theme/combobox.css (--hm-combobox-*). Trigger와 선택 check만 스타일하며 Popover와 Command의 스타일은 중복 정의하지 않는다.
height-md|lg, border-width, padding-x, gap, radius, font-size|line-height-md|lg, background, text, placeholder, border-color, focus-border-color, icon-size|color, check-size, disabled-opacity. Semantic/Theme palette를 참조하며 --hm-popover-*, --hm-command-*, --hm-select-*는 참조하지 않는다. 기본 모양은 @layer components(소비자의 className이 이김). Trigger에는 min-width: 0; max-width: 100%를 두어 긴 값이 grid/flex 부모를 넓히지 않고 말줄임된다.
Popover 결합 지점: Popup의 padding과 폭을 PopoverContent의 inline style로 --hm-popover-padding: 0px, --hm-popover-width: var(--radix-popover-trigger-width)로 지정해 Popover token 값을 인스턴스 단위로 덮는다(Popover 코드는 변경하지 않음).
9. 조합한 컴포넌트에서 유지한 항목
Popover: Portal, collision, Escape, 바깥 클릭 닫힘, trigger focus 복귀. Command: 필터링, 방향키/Home/End/Enter, disabled 건너뜀, Empty, listbox/option ARIA.
10. Storybook
- 대표 Story
All("Combobox"): Empty placeholder + search(Remix는 disabled option), Selected(controlled, 값 표시), Medium, Long option, Disabled를 한 페이지에 둔다. Label은 Story 조합이다. - verification:
Empty,Controlled,Long Option.
11. 사용자 확정 결정 / 보류
- 2026-10-02: ui-viz는 참고 자료이며 최종 기준은 현재 HM 구현과 이 문서다(작업 지시). 개별 수치에 대한 별도 결정은 없다. Combobox는 최소
optionswrapper로 제공한다(작업 지시 B안). - 보류: 수치(44/34px, check 아이콘 위치)는 현재 HM 규격이며 디자인 확정 시 재검토. clear 버튼, 다중 선택(chips), 그룹 option, 새 항목 생성, 비동기 검색, 편집 가능한 trigger(input형 combobox)는 만들지 않았다. 선택 option의 label 줄바꿈 표시(긴 옵션 전체 보기)는 말줄임으로 대체.
- Popup 방향/collision은 Popover 기본(
align="start"). - 2026-10-06 Visual QA:
combobox.css의[data-combobox-popup]scope로 Select의 시각 언어와 정합. Command/Popover 공용 CSS·동작은 유지. 대표 Story의 Select 비교 구역에서 두 size를 확인한다.
12. 관련 파일
packages/ui/src/components/ui/combobox.tsx
packages/ui/src/theme/combobox.css
packages/ui/src/theme.css (import)
packages/ui/src/index.ts (export)
src/components/combobox.stories.tsx
13. 검증 상태
-
2026-10-08 Controlled 초기화: 실제 소스를 직접 묶은 격리 Headless Edge 환경에서 수정 전 과거 값 재표시를 재현했다.
controlled={true}의 undefined → A → B → undefined, 최초 Controlled 값 clear, 부모 선택 거부, 외부 값 갱신, 콜백 횟수, Legacy/defaultValue/명시적 Uncontrolled, 빈 문자열 옵션, 열린 팝업 clear와 검색어/focus 유지, 키보드 선택·disabled 제외·focus 복귀를 확인했다. 소스 기준 영향 범위 타입 검사 통과. 공용 dist / Storybook 빌드 및 전체 시각 회귀는 수행하지 않았다. -
2026-10-06 Visual QA: 1280/1024/768/390px에서 popup 폭=trigger, gap 4px, 선택 면 지속, 검색/Empty/키보드 선택·focus 복귀·disabled 건너뜀·긴 label 말줄임 통과. Select 비교에서 Item 높이는 36/42px를 유지하고 Medium 14/20·Large 16/24 typography를 일치시켰다. Command/Popover 공용 변경 없음.
typecheck,build,build-storybook통과. -
npm run typecheck,npm run build: 통과(2026-10-02). -
실제 interaction(Browser pane): trigger에서 Enter로 열림(
role=combobox,aria-expandedtrue, Popup 폭 = trigger 폭 320px), 검색 Input으로 focus 이동, 첫 항목 highlight, 검색 "nu" → Nuxt만 남아 highlight, Enter로 선택하면 trigger가 "Nuxt"로 바뀌고 Popover가 닫히며 focus가 trigger로 복귀. ArrowDown 이동에서 disabledRemix건너뜀(Nuxt → Astro), Escape로 닫힘과 trigger focus 복귀(값 유지), 결과 없음 "zzz"는 Empty 문구, 다시 열면 검색어가 비어 있고 전체 5개 표시. -
선택 표시: 선택된 option에만 check 아이콘(opacity 1)과
data-checked. 클릭으로 선택하면 trigger와 controlled 값이 갱신됨. -
Popover 스타일 확인: border
rgb(222,219,210), radius 4px, 그림자, padding 0, 흰 면(처음에data-slot덮어쓰기로 이 스타일이 빠진 문제를 수정함). -
Responsive(390×844): trigger 320px, popup(304px)이 viewport 안에 들어감, 긴 option의 값과 항목이 말줄임, 페이지 가로 overflow 없음(처음에는 trigger의
min-width:auto때문에 페이지가 833px로 밀리는 문제가 있어min-width:0으로 수정). -
미확인: 클릭 선택 직후 닫힘 animation 시각, 포인터 hover 시각, 화면 하단에서 popup이 위로 flip 되는 경우, 스크린리더 실제 읽기.