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