Components
Multi Select
검색과 다중 선택을 지원하는 콤보박스 컴포넌트. Popover + Command(cmdk) 조합으로 구현한다.
옵션이 많거나 여러 항목을 동시에 선택해야 할 때 사용한다. 트리거가 선택된 항목 수를 반영하고, Popover 안에서 검색과 선택/해제가 가능하다.
Mobile
모바일 폴더 선택은 tiro-app의 실제 RecordingBottomSheet 플로우를 따른다. 트리거 chip을 누르면 별도 TrueSheet가 열리고, FolderSelectionContent가 검색 없는 폴더 트리 선택 UI를 렌더한다.
- Source 기준:
components/RecordingBottomSheet/index.tsx→RecordingFolderTab→components/FolderSelector/FolderSelectionContent.tsx - 컨테이너:
TrueSheet,detents={[0.4, 1]},cornerRadius={20},grabber,scrollable - 헤더:
BottomSheetTitleHeader, titlenote.folder.addToFolder(폴더에 추가하기), rightTextcommon.confirm(확인) - Confirm: draft selection에 변경이 있을 때만 활성화. 저장 중에는 right action loading 표시
- 본문:
ScrollView className="px-5 pt-5 pb-6 gap-[10px]" - 섹션:
My,Team섹션을 분리하고 각 섹션은#FAFAFA배경의 rounded card 안에 폴더 트리를 표시 - 항목: 폴더 tree row, expand chevron, folder/team icon, title, 우측 check/lock/loading indicator로 구성
Preview
preview-mobile
preview-mobile
RecordingFolderTab — TrueSheet folder selection
Anatomy
Web (Desktop)
MultiSelect (Popover.Root)
├─ MultiSelectTrigger — 현재 선택값 표시 버튼
└─ MultiSelectContent — Popover.Content + Command 컨테이너
├─ MultiSelectSearch — 상단 검색 input (cmdk CommandInput)
├─ MultiSelectList — 스크롤 가능한 옵션 리스트 (max-h-64)
│ ├─ MultiSelectEmpty — 검색 결과 없을 때 표시
│ └─ MultiSelectItem ×N — 아이콘 + 라벨 + 선택 indicator
└─ MultiSelectFooter — 하단 고정 액션 영역 (선택사항)MultiSelectContent내부에 cmdkCommand가 자동 래핑되므로MultiSelectSearch의 입력이MultiSelectList항목을 자동으로 필터링한다.MultiSelectItem의valueprop이 필터링 키로 사용된다.
Mobile (React Native)
모바일에서는 트리거 주변 Popover나 검색형 Sheet를 사용하지 않는다. tiro-app 기준으로 녹음 바텀시트의 폴더 chip이 별도 TrueSheet를 열고, FolderSelectionContent가 My/Team 폴더 트리를 렌더한다.
FolderSelectionChip — RecordingBottomSheet header 안의 폴더 chip
└─ opens TrueSheet — folderSheetRef.present()
RecordingFolderSheet
├─ TrueSheet — detents={[0.4, 1]}, cornerRadius={20}, grabber, scrollable
├─ BottomSheetTitleHeader — title + right confirm action
└─ RecordingFolderTab
└─ FolderSelectionContent
├─ ScrollView — px-5 pt-5 pb-6 gap-[10px]
├─ Section: My — label + rounded card
│ └─ FolderTree
│ └─ FolderTreeItem — expander + icon + title + check/lock/loading
└─ Section: Team — 팀 폴더가 있을 때만 표시
└─ FolderTree| Slot | Web/Desktop | Mobile (tiro-app) |
|---|---|---|
| Trigger | Popover anchor. 선택값 첫 번째 +N 표시 | FolderSelectionChip: px-[6px] py-[4px] rounded-[6px] bg-[#F5F5F4], folder icon + 첫 폴더명 + + N |
| Container | Popover.Content + cmdk Command | TrueSheet detents={[0.4, 1]}, cornerRadius={20}, grabber, scrollable |
| Header | Popover 내부 search row | BottomSheetTitleHeader: 중앙 title, 우측 확인 action |
| Search | CommandInput, popover 상단 고정 | 없음 |
| Section | 단일 option list | My, Team 섹션. label은 14/24 SemiBold, #3F3F46 |
| List | CommandList, max-h-64 | FolderTree inside ScrollView |
| Item | h-8, hover/highlight 상태 | row rounded-[14px], selected row bg-white; main touch area py-[10px] pr-[10px] |
| Expansion | 없음 | children 있으면 좌측 ChevronDown/ChevronRight size=20, #A1A1AA |
| Folder icon | 좌측 icon container | Folder size=20 color={thread.color} 또는 TeamFolderIcon size=20 |
| Text | label2Medium text-stone-700 | 16/24, tracking -0.7px; selected #18181B SemiBold, default #2D2B2B Regular |
| Selected indicator | 우측 Check size={14} | 우측 Check size={20}; selected #18181B, unselected #E4E4E7 |
| Disabled | opacity-50 pointer-events-none | 선택 불가 항목은 LockKeyhole size=16 color="#A1A1AA" |
| Loading | 없음 | pending item은 우측 ActivityIndicator size="small" |
| Footer | MultiSelectFooter 하단 액션 | 없음. 저장/확정은 header right action |
모바일 구현에서는 TouchableOpacity activeOpacity={0.7}을 사용한다. 선택 변경은 sheet 내부 draft state에만 반영되고, 확인을 누를 때 offlineRecordStore.currentThreadInfo가 업데이트된다.
States
| State | 처리 |
|---|---|
| unselected | 우측 indicator 없음 |
| selected (web) | selected prop → 우측 Check 아이콘 (stone-500, size={14}) |
| selected (mobile folder) | draft selectedThreadIds에 포함 → row bg-white, title SemiBold #18181B, 우측 Check size={20} color="#18181B" |
| unselected (mobile folder) | 우측 Check size={20} color="#E4E4E7" |
| disabled (mobile folder) | 선택 불가 → 우측 LockKeyhole size={16} color="#A1A1AA" |
| pending (mobile folder) | 저장/대기 중 → 우측 ActivityIndicator size="small" color="#A1A1AA" |
| highlighted (keyboard) | data-[selected=true]:bg-stone-50 |
| disabled | data-[disabled=true]:opacity-50 pointer-events-none |
| empty | MultiSelectEmpty 렌더 |
Token Mapping
| Token | Value (web) | Value (mobile) |
|---|---|---|
| content width | w-72 (288px) | 100vw (TrueSheet) |
| sheet detents | — | [0.4, 1] |
| content radius | rounded-xl (12px) | cornerRadius={20} |
| content border | border-tds-stone-100 | 없음 (시트 자체가 컨테이너) |
| content shadow | shadow-md | sheet elevation은 native 처리 |
| header padding | — | headerStyle={{ paddingTop: 18, paddingBottom: 10 }} |
| header title | — | 16px / 24px, Pretendard SemiBold, warmGray-900 |
| header right action | — | 15px / 24px, tracking -0.5px; disabled warmGray-400 Regular |
| search height | h-10 (40px) | 없음 |
| search border | border-b border-tds-stone-100 | — |
| search typography | typography-body1NormalRegular text-tds-brown-800 | — |
| search placeholder | placeholder:text-tds-stone-200 | — |
| list max-height | max-h-64 (256px) | ScrollView className="px-5 pt-5 pb-6 gap-[10px]" |
| list padding | p-1 | — |
| section label | — | 14px / 24px, SemiBold, tracking -0.5px, #3F3F46 |
| section card | — | bg-[#FAFAFA] p-[10px] gap-1; My rounded-2xl, Team rounded-[20px] |
| item height | h-7 (28px) | wrapper rounded-[14px]; selected row bg-white |
| item touch area | h-7 px-2.5 | flex-1 flex-row items-center justify-between py-[10px] pr-[10px] |
| item typography | typography-label3Medium text-tds-stone-700 | 16px / 24px, tracking -0.7px; selected SemiBold #18181B, default Regular #2D2B2B |
| item icon container | h-5 w-5 rounded-md bg-tds-stone-100 text-tds-stone-500 | — |
| item hover | data-[selected=true]:bg-tds-stone-50 | — |
| folder icon | 12px, strokeWidth={1.75} | Folder size={20} color={thread.color} / TeamFolderIcon size={20} |
| expander | 없음 | ChevronDown/ChevronRight size={20}, #A1A1AA, touch target 24px |
| check icon | text-tds-stone-500, size={14}, strokeWidth={2.25} | Check size={20}; selected #18181B, unselected #E4E4E7 |
| disabled indicator | opacity-50 pointer-events-none | LockKeyhole size={16} color="#A1A1AA" |
| footer border | border-t border-tds-stone-100 | 없음 |
| footer action text | text-tds-brown-600 | — |
| sideOffset | 4px | — (BottomSheet는 화면 하단 고정) |
Props
MultiSelectItem
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | cmdk 필터링 키. 검색어와 매칭되는 값 |
selected | boolean | false | true이면 우측에 Check 아이콘 표시 |
icon | ReactNode | — | 좌측 아이콘. h-5 w-5 컨테이너 안에 렌더됨 |
onSelect | () => void | — | 항목 클릭/엔터 시 호출 |
disabled | boolean | false | 비활성화 |
MultiSelectContent
@radix-ui/react-popover PopoverContent의 모든 props를 그대로 전달한다. 기본값: align="start", sideOffset={4}.
Usage
import {
MultiSelect,
MultiSelectContent,
MultiSelectEmpty,
MultiSelectFooter,
MultiSelectItem,
MultiSelectList,
MultiSelectSearch,
MultiSelectTrigger,
} from "@/components/ui/multi-select";
function FolderSelector({ folders, selected, onToggle, onCreate }) {
const [open, setOpen] = useState(false);
const first = folders.find((f) => f.id === selected[0]);
return (
<MultiSelect open={open} onOpenChange={setOpen}>
<MultiSelectTrigger asChild>
<button className="inline-flex h-7 items-center gap-1.5 rounded-full border border-stone-200 bg-white px-3 label3Medium text-stone-700 hover:bg-stone-50">
<Folder size={12} strokeWidth={1.75} className="text-stone-400" />
{selected.length === 0 ? (
<span className="text-stone-400">폴더 추가</span>
) : (
<>
<span>{first?.label}</span>
{selected.length > 1 && (
<span className="text-stone-400">+{selected.length - 1}</span>
)}
</>
)}
<ChevronDown size={12} strokeWidth={2} className="text-stone-400" />
</button>
</MultiSelectTrigger>
<MultiSelectContent>
<MultiSelectSearch placeholder="Search" />
<MultiSelectList>
<MultiSelectEmpty>결과가 없습니다.</MultiSelectEmpty>
{folders.map((folder) => (
<MultiSelectItem
key={folder.id}
value={folder.label}
selected={selected.includes(folder.id)}
icon={<Folder size={12} strokeWidth={1.75} />}
onSelect={() => onToggle(folder.id)}
>
{folder.label}
</MultiSelectItem>
))}
</MultiSelectList>
<MultiSelectFooter>
<button
className="flex h-7 w-full items-center gap-1.5 rounded-md px-2 typography-label3Medium text-tds-brown-600 transition-colors hover:bg-tds-stone-50"
onClick={onCreate}
>
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-tds-stone-100 text-tds-stone-500">
<Plus size={12} strokeWidth={2.25} />
</div>
New folder
</button>
</MultiSelectFooter>
</MultiSelectContent>
</MultiSelect>
);
}Trigger 선택값 표시 패턴
| 선택 수 | 표시 |
|---|---|
| 0 | placeholder 텍스트 (stone-400) |
| 1 | 첫 번째 항목 라벨 |
| 2+ | 첫 번째 라벨 +N |
A11y
- cmdk
Command가role="combobox"+role="listbox"+role="option"자동 처리 MultiSelectSearch는 Popover가 열릴 때autoFocus적용 — 바로 타이핑 가능- 키보드:
↑↓항목 이동,Enter선택/해제,Esc닫기
Do / Don't
- Do:
MultiSelectItem의value는 검색어와 매칭되는 문자열로 설정 - Do: Web 선택 indicator는
text-tds-stone-500, mobile folder 선택 indicator는#18181B/ unselected는#E4E4E7 - Do: Trigger 선택값 표시: 1개는 라벨 직접, 2개 이상은
첫 번째 +N패턴 - Don't: 단일 선택에는
MultiSelect대신Select사용 - Don't:
MultiSelectFooter액션에 TDS 외 accent 색상 사용 금지 - Don't:
MultiSelectContent내부에 별도Command를 중첩하지 말 것 — 이미 내부에 포함되어 있음
