# 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는 독립. 과거 검증 기록은 당시 결과이며 현재 검증은 [추가 수정 보고서](../guidelines/publisher-additional-review-2026-10-10.md)를 따릅니다. 현재 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`, 그리고 `button` props(`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`와 trigger `id`, 또는 `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를 표시한다. 빈 문자열을 가진 항목을 선택하면 콜백은 `""`을 전달한다. ```tsx const [category, setCategory] = useState() // 외부 항목 지정/갱신: 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"): Overview(Placeholder + search, Remix는 disabled option), Sizes(Medium, 같은 size의 Select / Combobox 비교), States(Selected(controlled), Disabled), Usage(Long option). Label은 Story 조합이다. 대표 화면은 선택값 표시(`value = …`)를 숨기며 숨김 `Controlled`는 유지한다. - verification: `Empty`, `Controlled`, `Long Option`. ## 11. 사용자 확정 결정 / 보류 - 2026-10-02: ui-viz는 참고 자료이며 최종 기준은 현재 HM 구현과 이 문서다(작업 지시). 개별 수치에 대한 별도 결정은 없다. Combobox는 최소 `options` wrapper로 제공한다(작업 지시 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-expanded` true, Popup 폭 = trigger 폭 320px), 검색 Input으로 focus 이동, 첫 항목 highlight, 검색 "nu" → Nuxt만 남아 highlight, Enter로 선택하면 trigger가 "Nuxt"로 바뀌고 Popover가 닫히며 focus가 trigger로 복귀. ArrowDown 이동에서 disabled `Remix` 건너뜀(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 되는 경우, 스크린리더 실제 읽기.