Files
HMDesign/docs/components/combobox.md
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

137 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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 되는 경우, 스크린리더 실제 읽기.