Files
HMDesign/docs/components/combobox.md
T

12 KiB
Raw Blame History

Combobox

현재 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의 구현이나 연결형 윤곽을 복제하지 않았다.
  • 보류: §11.

3. Select와의 구분

Select Combobox
용도 정해진 짧은 목록에서 선택, 검색 불필요 검색/필터가 필요한 선택
구현 Radix Select Popover + Command(cmdk)
목록 trigger에 붙은 연결형 윤곽, 선택 항목은 면으로 표시 독립 floating 패널(검색 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-placeholder), 길면 말줄임
Popup padding 0, 폭 = trigger 폭(--radix-popover-trigger-width), 독립 4px gap. Focus Light 2 border, input radius 4px(네 모서리), 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="listbox", aria-expanded. 패널 안 Command Input이 role=combobox(aria-controls → role=listbox), option은 role=option. 이름은 <label htmlFor=id> 또는 aria-label을 소비자가 준다. 실제 ARIA combobox(편집 가능한 trigger input)가 아니라 버튼 trigger + 패널 내 검색 combobox 구조다(shadcn 공식 구조).
  • 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는 최소 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 되는 경우, 스크린리더 실제 읽기.