# Multi Select 정해진 목록에서 여러 개를 고르게 하는 선택 컴포넌트입니다. 고른 뒤에도 목록은 열린 채로 남습니다. > 원본: `03_ASSET_GUIDE/catalog/multi-select.js` · CSS: `03_ASSET_GUIDE/styles/elements/multi-select.css` > kind `component` · category `selection` · status `stable` · specSource `measured` > [!NOTE] > **이번 31개 목록 밖에서 새로 추가한 항목입니다** > 요청 목록에는 없었고, seed · Spectrum · Codeit 에도 대응 항목이 없습니다. 참고한 것은 > Ant Design 의 `Select mode="multiple"` 하나뿐이고, 나머지는 이 저장소의 > [Select](./select.md) 와 [Checkbox](./checkbox.md) 규약을 이어붙인 > 것입니다. 수치는 `styles/elements/multi-select.css` 에서 실측했습니다. --- ## 1. 구성 닫혀 있을 때 보이는 **Trigger** 와, 열렸을 때 나타나는 **Listbox** 로 나뉩니다. Select 와 달리 Trigger 가 여러 개의 값을 동시에 보여줘야 하고, Option 은 체크박스를 가집니다. ``` ┌──────────────────────────────────────┐ │ (항목 A ×) (항목 B ×) +3 ⌄ │ ← Trigger ① Chip+Remove ② Overflow ③ Caret ④ └──────────────────────────────────────┘ ┌──────────────────────────────────────┐ │ [■] 전체 선택 선택 해제 │ ← Toolbar ⑤ │ 그룹 A │ ← Group label ⑥ │ [✓] 항목 A │ ← Option + Checkbox ⑦ │ [ ] 항목 D │ │ 3개 선택됨 · 최대 8개 │ ← Footer ⑧ └──────────────────────────────────────┘ ``` | # | 요소 | 필수 | 클래스 | 설명 | |---|---|---|---|---| | — | Root | 필수 | `atom-multi-select` | 상태(`data-state` · `data-empty` · `data-invalid` · `data-disabled`)를 가집니다. | | ① | Trigger | 필수 | `atom-multi-select__trigger` · `__value` · `__placeholder` | 지금 무엇이 선택되어 있는지와 Caret 을 보여주는 버튼입니다. | | ② | Chip | 선택 | `atom-multi-select__chip` · `__chip-label` · `__chip-remove` | 선택된 항목 하나입니다. Remove 를 눌러 목록을 열지 않고도 해제합니다. | | ③ | Overflow counter | 선택 | `atom-multi-select__overflow` | Trigger 폭에 들어가지 못한 나머지 개수입니다. | | — | Count | `display="count"` 일 때 | `atom-multi-select__count` | `n개 선택됨`. | | ④ | Caret | 필수 | `atom-multi-select__caret` | 열리면 180° 회전해 상태를 알립니다. | | — | Listbox | 필수 | `atom-multi-select__listbox` | 열렸을 때 나타나는 목록. | | ⑤ | Toolbar | 선택 | `atom-multi-select__toolbar` · `__clear` | 전체 선택과 선택 해제입니다. 선택지가 많을 때만 둡니다. | | ⑥ | Group label | 선택 | `atom-multi-select__group-label` | 선택지가 많을 때 묶음의 이름입니다. | | ⑦ | Option | 필수 | `atom-multi-select__option` · `__option-box` · `__option-check` · `__option-indeterminate` · `__option-icon` · `__option-text` · `__option-label` · `__option-description` | 체크박스와 라벨은 필수, 아이콘과 설명은 선택입니다. | | — | Empty | 선택지 0개일 때 | `atom-multi-select__empty` | 빈 목록 문구. | | ⑧ | Footer | 선택 | `atom-multi-select__footer` | 현재 선택 수와 상한입니다. 상한이 있을 때만 둡니다. | --- ## 2. 종류 / 크기 ### Display 선택된 값을 Trigger 에 어떻게 요약할지 정합니다. `chip` 은 고른 항목을 그대로 보여주고 개별 해제가 되지만 폭을 많이 씁니다. `count` 는 개수만 보여주며 폭에 관계없이 표시가 일정합니다. | Display | Trigger 내용 | 개별 해제 | 쓰는 곳 | | --- | --- | --- | --- | | `chip` | 선택된 항목 칩 + 넘침 카운트 | 칩의 × 로 가능 | 선택 3~5개, 무엇을 골랐는지가 중요할 때 | | `count` | n개 선택됨 | 목록을 열어야 함 | 선택이 많거나 Trigger 폭이 좁을 때 | > [!NOTE] > **마크업은 하나입니다** > Trigger 안에 Chip 과 Count 를 모두 두고, 모디파이어가 어느 쪽을 보일지 고릅니다. > Display 는 모디파이어만 바꿔 전환합니다. ### 펼친 상태 선택해도 Listbox 는 닫히지 않습니다. 매번 닫히면 여러 개를 고를 때 Trigger 를 반복해서 눌러야 하기 때문입니다. Esc 나 바깥 클릭으로만 닫습니다. ### 종류 (Variant) `weak` 은 배경만으로 입력 영역을 표시하고, `outline` 은 테두리를 씁니다. 한 화면 안에서는 다른 입력 컴포넌트와 같은 Variant 로 맞춥니다. ### 크기 (Size) | Size | Trigger 높이 | 모서리 | 폰트 | Chip 폰트 | | --- | --- | --- | --- | --- | | `small` | 40px | 8px | 14px | 12px | | `medium` | 48px | 8px | 15px | 13px | | `large` | 56px | 12px | 16px | 14px | Chip 은 Trigger 를 넘기지 않도록 사이즈마다 함께 줄어듭니다. ### 빈 목록 검색이나 필터로 선택지가 하나도 남지 않으면 Listbox 를 비워두지 말고 문구를 둡니다. ### Field 와 함께 라벨과 상한 안내는 Multi Select 가 아니라 [Field](./field.md) 가 담당합니다. 상한이 있으면 고르기 전에 알립니다. ### 클래스 계약 `styles/elements/multi-select.css` 헤더와 원본 `registry.json` `css.blocks` 기준입니다. 클래스가 없는 값(—)은 기본 스타일 그대로입니다. | Prop | 값 | 클래스 | |---|---|---| | `size` | `small` · `medium` · `large` | `--size_small` · — · `--size_large` | | `variant` | `weak` · `outline` | — · `--variant_outline` | | `display` | `chip` · `count` | — · `--display_count` | 상태 선택자: `[data-state="closed|open"]` · `[data-empty]` · `[data-invalid]` · `[data-disabled]` · `__trigger:hover` · `__trigger:focus-visible` · `__option[aria-selected="true"]` · `__option[aria-checked="mixed"]` · `__option[aria-disabled="true"]` --- ## 3. 상태 값이 없을 때는 `data-empty` 로 Placeholder 를 보여주고, 값이 생기면 Chip 이나 Count 가 그 자리를 대신합니다. `data-invalid` 와 `data-disabled` 는 Select 와 같습니다. | State | 표현 | 선택자 | |---|---|---| | `closed` | Listbox 숨김 | `[data-state="closed"]` | | `open` | Listbox 표시, Caret 180° 회전 | `[data-state="open"]` | | empty | Placeholder 표시, Chip · Count · Overflow 숨김 | `[data-empty]` | | hover | `filter: brightness(0.96)` | `__trigger:hover` | | focus | `--atom-control-border-focus` | `__trigger:focus-visible` | | `invalid` | `--atom-control-border-invalid` | `[data-invalid]` | | `disabled` | `opacity: 0.4`, 입력 불가 | `[data-disabled]` | --- ## 4. Props | Prop | Type | Default | 필수 | 설명 | |---|---|---|---|---| | `value` | `string[]` | `[]` | | 선택된 항목들의 값입니다. | | `options` | `Array<{ value, label, description?, icon?, disabled? }>` | | ● | 선택지 목록입니다. | | `placeholder` | `string` | | | 선택이 없을 때 Trigger 에 보이는 문구입니다. | | `display` | `"chip" \| "count"` | `"chip"` | | 선택된 값을 Trigger 에 요약하는 방식입니다. | | `maxVisibleChips` | `number` | `3` | | `display="chip"` 에서 칩으로 보여줄 최대 개수입니다. 나머지는 넘침 카운트가 됩니다. | | `maxSelected` | `number` | | | 고를 수 있는 최대 개수입니다. 도달하면 나머지 Option 이 비활성이 됩니다. | | `selectAll` | `boolean` | `false` | | Listbox 위에 전체 선택 / 선택 해제 Toolbar 를 둡니다. | | `size` | `"small" \| "medium" \| "large"` | `"medium"` | | Trigger 높이와 폰트 크기를 결정합니다. | | `variant` | `"weak" \| "outline"` | `"weak"` | | Trigger 의 배경과 테두리 처리입니다. | | `invalid` | `boolean` | `false` | | Trigger 테두리를 오류 색으로 바꿉니다. | | `disabled` | `boolean` | `false` | | 열 수 없게 만듭니다. | | `emptyText` | `string` | `"결과가 없어요"` | | 선택지가 비었을 때 Listbox 에 보일 문구입니다. | | `onValueChange` | `(value: string[]) => void` | | | 선택이 바뀔 때마다 호출됩니다. 목록이 닫힐 때가 아닙니다. | ### Parts (compound) Multi Select 는 파트 없이 하나의 컴포넌트입니다. 내부 DOM 은 원본 Markup 을 따릅니다. | Part | 요소 | 설명 | |---|---|---| | `MultiSelect` | `
` | Root. 위 Props 를 받습니다. `ref` 는 Trigger(`role="combobox"`)를 가리킵니다. [Field](./field.md) 안에서는 `useFieldControl()` 로 id · aria-describedby · invalid · disabled · required · size 를 받습니다. | --- ## 5. Component tokens | 대상 | 속성 | Token | Value | |---|---|---|---| | Trigger | background | `{color.bg.neutral-weak}` | | | Trigger | radius | `{radius.r2}` | 8px | | Trigger (`large`) | radius | `{radius.r3}` | 12px | | Trigger | padding-inline | `{space.x3}` / `{space.x4}` | | | Chip | background | `{color.bg.layer}` | | | Chip | border | `{color.border.subtle}` | inset 1px | | Chip | radius | `{radius.full}` | | | Overflow counter | background | `{color.bg.neutral-weak}` | | | Listbox | background | `{color.bg.layer}` | | | Listbox | radius | `{radius.r3}` | 12px | | Listbox | shadow | `{shadow.s2}` | + inset 1px `{color.border.subtle}` | | Option box | border | `{color.border.default}` | inset 1.5px | | Option box (checked) | background | `{color.bg.neutral-solid}` | | | Option check | color | `{color.fg.on-solid}` | | | Caret | transition | `{duration.base}` · `{easing.out}` | | | Trigger | height | `{control.h}` | 48px | | Trigger (`small` · `large`) | height | `{control.h-small}` · `{control.h-large}` | 40 · 56px | | Trigger (`outline`) | border | `{control.border}` | `{color.border.default}` 1px inset | | Trigger · focus | ring | `{control.border-focus}` | `{color.primary.600}` 2px inset | | Invalid | border | `{control.border-invalid}` | `{color.fg.critical}` 2px inset | ```json { "multi-select": { "trigger": { "bg": { "$type": "color", "$value": "{color.bg.neutral-weak}" }, "height": { "$type": "dimension", "default": { "$value": "{control.h}" }, "small": { "$value": "{control.h-small}" }, "large": { "$value": "{control.h-large}" } }, "radius": { "$type": "dimension", "default": { "$value": "{radius.r2}" }, "large": { "$value": "{radius.r3}" } }, "padding-start": { "$type": "dimension", "$value": "{space.x3}" }, "padding-end": { "$type": "dimension", "$value": "{space.x4}" }, "border-outline": { "$type": "shadow", "$value": "{control.border}" }, "border-focus": { "$type": "shadow", "$value": "{control.border-focus}" }, "border-invalid": { "$type": "shadow", "$value": "{control.border-invalid}" } }, "chip": { "bg": { "$type": "color", "$value": "{color.bg.layer}" }, "border": { "$type": "color", "$value": "{color.border.subtle}" }, "radius": { "$type": "dimension", "$value": "{radius.full}" } }, "overflow": { "$type": "color", "$value": "{color.bg.neutral-weak}" }, "listbox": { "bg": { "$type": "color", "$value": "{color.bg.layer}" }, "radius": { "$type": "dimension", "$value": "{radius.r3}" }, "shadow": { "$type": "shadow", "$value": "{shadow.s2}" }, "border": { "$type": "color", "$value": "{color.border.subtle}" } }, "option-box": { "border": { "$type": "color", "$value": "{color.border.default}" }, "bg-checked": { "$type": "color", "$value": "{color.bg.neutral-solid}" }, "check": { "$type": "color", "$value": "{color.fg.on-solid}" } }, "caret": { "duration": { "$type": "duration", "$value": "{duration.base}" }, "easing": { "$type": "cubicBezier", "$value": "{easing.out}" } } } } ``` --- ## 6. 접근성 - **역할** — Trigger 는 `aria-haspopup="listbox"` 와 `aria-expanded`, Listbox 는 `role="listbox"` 에 `aria-multiselectable="true"` 를 함께 둡니다. 이것이 없으면 스크린리더가 단일 선택으로 읽습니다. - **상태** — 각 Option 은 `role="option"` 과 `aria-selected` 를 가집니다. 전체 선택처럼 일부만 켜진 항목은 `aria-checked="mixed"` 로 알립니다. - **키보드** — `↑` `↓` 로 항목 이동, `Space` 로 토글, `Esc` 로 닫기. 계속 고를 수 있도록 `Enter` 로는 닫지 않습니다. - **Chip** — Remove 는 각각 초점을 받을 수 있어야 하고, `aria-label` 로 `항목 A 삭제` 처럼 무엇을 지우는지 밝힙니다. - **알림** — 선택이 바뀌면 개수를 `aria-live="polite"` 로 알립니다. 목록이 닫히지 않아 다른 방법으로는 변화를 알기 어렵습니다. - **초점** — 닫힌 뒤 초점은 Trigger 로 되돌립니다. Specification 의 요약: - **역할** — `listbox` + `aria-multiselectable="true"`. 각 항목은 `option`. - **키보드** — `↑` `↓` 이동 · `Space` 토글 · `Esc` 닫기. `Enter` 는 닫지 않습니다. - **스크린리더** — 선택 수 변화를 `aria-live="polite"` 로 알립니다. - **대비** — 체크된 Option box 는 `--atom-bg-neutral-solid` 위에 `--atom-fg-on-solid`, UI 요소 3:1 을 충족합니다. --- ## 7. 사용 가이드 ### 칩은 몇 개까지만 칩이 Trigger 를 가득 채우면 Placeholder 도 Caret 도 읽히지 않고, 폭이 바뀔 때마다 잘리는 위치가 달라집니다. 보통 3개까지만 칩으로 두고 나머지는 넘침 카운트로 접거나, `count` 로 바꿉니다. - ✅ **Do** 선택이 많아지면 개수만 보여주고, 상세는 목록에서 확인하게 합니다. - ❌ **Don't** 칩을 Trigger 폭까지 밀어 넣지 않습니다. ### 상한은 미리, 도달하면 즉시 최대 개수가 있다면 Field 설명으로 미리 알리고, 상한에 닿으면 고를 수 없는 Option 을 바로 비활성으로 바꿉니다. - ✅ **Do** 상한에 닿으면 남은 Option 을 비활성으로 만들고 현재 개수를 함께 보여줍니다. - ❌ **Don't** 누를 수 있게 두었다가 선택 후에 막지 않습니다. > [!WARNING] > **전체 선택은 되돌릴 수 있어야 합니다** > Toolbar 의 전체 선택을 두었다면 선택 해제도 같은 자리에 둡니다. 선택 해제가 없으면 한 번에 > 켜진 수십 개를 하나씩 꺼야 합니다. ### Checkbox · Select 와 구분하기 | 상황 | 쓸 컴포넌트 | | --- | --- | | 여러 개를 고르는데 선택지가 2~5개, 전부 보여주고 싶다 | [Checkbox](./checkbox.md) | | 여러 개를 고르고 선택지가 6개 이상이다 | **Multi Select** | | 하나만 고른다, 선택지가 6개 이상이다 | [Select](./select.md) | | 하나만 고르고 선택지가 적다 | [Radio](./radio.md) | ### 관련 컴포넌트 - [Select](./select.md) — 하나만 고를 때. - [Checkbox](./checkbox.md) — 선택지가 적어 전부 펼쳐 보여줄 때. --- ## 8. 예시 ```tsx ``` ```html
+3 4개 선택됨
항목 A
``` > 위 원본 Markup 은 `__trigger`(`