Tiro
Tiro Design
Components

Multi Select

검색과 다중 선택을 지원하는 콤보박스 컴포넌트. Popover + Command(cmdk) 조합으로 구현한다.

옵션이 많거나 여러 항목을 동시에 선택해야 할 때 사용한다. 트리거가 선택된 항목 수를 반영하고, Popover 안에서 검색과 선택/해제가 가능하다.

Mobile

모바일 폴더 선택은 tiro-app의 실제 RecordingBottomSheet 플로우를 따른다. 트리거 chip을 누르면 별도 TrueSheet가 열리고, FolderSelectionContent검색 없는 폴더 트리 선택 UI를 렌더한다.

  • Source 기준: components/RecordingBottomSheet/index.tsxRecordingFolderTabcomponents/FolderSelector/FolderSelectionContent.tsx
  • 컨테이너: TrueSheet, detents={[0.4, 1]}, cornerRadius={20}, grabber, scrollable
  • 헤더: BottomSheetTitleHeader, title note.folder.addToFolder(폴더에 추가하기), rightText common.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 내부에 cmdk Command가 자동 래핑되므로 MultiSelectSearch의 입력이 MultiSelectList 항목을 자동으로 필터링한다.
  • MultiSelectItemvalue prop이 필터링 키로 사용된다.

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
SlotWeb/DesktopMobile (tiro-app)
TriggerPopover anchor. 선택값 첫 번째 +N 표시FolderSelectionChip: px-[6px] py-[4px] rounded-[6px] bg-[#F5F5F4], folder icon + 첫 폴더명 + + N
ContainerPopover.Content + cmdk CommandTrueSheet detents={[0.4, 1]}, cornerRadius={20}, grabber, scrollable
HeaderPopover 내부 search rowBottomSheetTitleHeader: 중앙 title, 우측 확인 action
SearchCommandInput, popover 상단 고정없음
Section단일 option listMy, Team 섹션. label은 14/24 SemiBold, #3F3F46
ListCommandList, max-h-64FolderTree inside ScrollView
Itemh-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 containerFolder size=20 color={thread.color} 또는 TeamFolderIcon size=20
Textlabel2Medium text-stone-70016/24, tracking -0.7px; selected #18181B SemiBold, default #2D2B2B Regular
Selected indicator우측 Check size={14}우측 Check size={20}; selected #18181B, unselected #E4E4E7
Disabledopacity-50 pointer-events-none선택 불가 항목은 LockKeyhole size=16 color="#A1A1AA"
Loading없음pending item은 우측 ActivityIndicator size="small"
FooterMultiSelectFooter 하단 액션없음. 저장/확정은 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
disableddata-[disabled=true]:opacity-50 pointer-events-none
emptyMultiSelectEmpty 렌더

Token Mapping

TokenValue (web)Value (mobile)
content widthw-72 (288px)100vw (TrueSheet)
sheet detents[0.4, 1]
content radiusrounded-xl (12px)cornerRadius={20}
content borderborder-tds-stone-100없음 (시트 자체가 컨테이너)
content shadowshadow-mdsheet elevation은 native 처리
header paddingheaderStyle={{ paddingTop: 18, paddingBottom: 10 }}
header title16px / 24px, Pretendard SemiBold, warmGray-900
header right action15px / 24px, tracking -0.5px; disabled warmGray-400 Regular
search heighth-10 (40px)없음
search borderborder-b border-tds-stone-100
search typographytypography-body1NormalRegular text-tds-brown-800
search placeholderplaceholder:text-tds-stone-200
list max-heightmax-h-64 (256px)ScrollView className="px-5 pt-5 pb-6 gap-[10px]"
list paddingp-1
section label14px / 24px, SemiBold, tracking -0.5px, #3F3F46
section cardbg-[#FAFAFA] p-[10px] gap-1; My rounded-2xl, Team rounded-[20px]
item heighth-7 (28px)wrapper rounded-[14px]; selected row bg-white
item touch areah-7 px-2.5flex-1 flex-row items-center justify-between py-[10px] pr-[10px]
item typographytypography-label3Medium text-tds-stone-70016px / 24px, tracking -0.7px; selected SemiBold #18181B, default Regular #2D2B2B
item icon containerh-5 w-5 rounded-md bg-tds-stone-100 text-tds-stone-500
item hoverdata-[selected=true]:bg-tds-stone-50
folder icon12px, strokeWidth={1.75}Folder size={20} color={thread.color} / TeamFolderIcon size={20}
expander없음ChevronDown/ChevronRight size={20}, #A1A1AA, touch target 24px
check icontext-tds-stone-500, size={14}, strokeWidth={2.25}Check size={20}; selected #18181B, unselected #E4E4E7
disabled indicatoropacity-50 pointer-events-noneLockKeyhole size={16} color="#A1A1AA"
footer borderborder-t border-tds-stone-100없음
footer action texttext-tds-brown-600
sideOffset4px— (BottomSheet는 화면 하단 고정)

Props

MultiSelectItem

PropTypeDefaultDescription
valuestringcmdk 필터링 키. 검색어와 매칭되는 값
selectedbooleanfalsetrue이면 우측에 Check 아이콘 표시
iconReactNode좌측 아이콘. h-5 w-5 컨테이너 안에 렌더됨
onSelect() => void항목 클릭/엔터 시 호출
disabledbooleanfalse비활성화

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 선택값 표시 패턴

선택 수표시
0placeholder 텍스트 (stone-400)
1첫 번째 항목 라벨
2+첫 번째 라벨 +N

A11y

  • cmdk Commandrole="combobox" + role="listbox" + role="option" 자동 처리
  • MultiSelectSearch는 Popover가 열릴 때 autoFocus 적용 — 바로 타이핑 가능
  • 키보드: ↑↓ 항목 이동, Enter 선택/해제, Esc 닫기

Do / Don't

  • Do: MultiSelectItemvalue는 검색어와 매칭되는 문자열로 설정
  • 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를 중첩하지 말 것 — 이미 내부에 포함되어 있음

On this page