Files
HMDesign/docs/components/combobox.md
T
shkim4 4c29894e56 refactor(storybook): simplify component and preview examples
Drop page / section descriptions, state notes and repeated captions from
the representative Stories of 44 components, the Patterns, Center
Graphic and OrgChart pages. Value readouts, spec lists and captions hide
only on the representative page; hidden verification Stories keep them.
Tabs Default API and the Filter Bar draft / applied counts move to the
hidden DefaultApi and DraftApplied Stories; Center Overview is hidden
with its direct URL kept. Story IDs are unchanged.

Align the component, pattern and OrgChart docs with the pages, point the
quality reference check at the DraftApplied Story, and make the P8
styles check expect no section header on the preview-only Expanded page.
2026-10-23 00:28:05 +09:00

13 KiB
Raw Blame History

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, 그리고 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를 표시한다. 빈 문자열을 가진 항목을 선택하면 콜백은 ""을 전달한다.
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"): 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 되는 경우, 스크린리더 실제 읽기.