Item
media, title, description, actions를 한 줄 문법으로 묶는 조합형 primitive.
Item은 Tiro의 설정, 멤버, 결제 요약, 네비게이션 row를 구성하는 조합형 primitive다. Dialog, Popover처럼 Radix가 상태와 포커스 관리까지 책임지는 종류는 아니고, 의미 있는 슬롯을 가진 layout primitive에 가깝다.
즉 구현은 대부분 div / li / button / a 조합으로 끝나는 것이 정상이다. 중요한 건 내부가 단순 div냐가 아니라, ItemMedia · ItemContent · ItemActions라는 안정된 API와 정렬 규칙을 가지느냐다.
상호작용 root가 필요하면 Item은 button이나 a로 렌더할 수 있어야 한다. 구현 시에는 asChild 또는 slot 패턴을 권장한다.
Mobile
Interactive Item은 모바일에서 행 전체가 터치 타겟이 되도록 구성한다.
- 최소 행 높이 ≥44pt (iOS HIG). dense list라도 44pt 미만으로 내리지 말 것
- 행 전체를
Pressable또는TouchableOpacity로 감싸 media·content·actions 어디를 탭해도 root 액션이 트리거되게 함 ItemActions내부의 별도 액션 버튼(예: trailing icon-only)은 행 전체 hit area에서 분리된 별도 hit area를 가져야 함 (e.g.stopPropagation). 각 액션 버튼도 ≥44pt 확보 — Button → Mobile icon-only 가이드 참조ItemMedia의 아바타/아이콘은 비인터랙티브하더라도 시각적으로 충돌하지 않게gap-3이상 확보- Swipe action(좌→우 삭제, 우→좌 아카이브)은 행 root와 독립된 gesture로 별도 라이브러리 사용 시 명시 (예:
react-native-gesture-handler의Swipeable)
Preview
각 Item은 standalone outline으로 보여준다. 슬롯 조합만 다를 뿐, 모두 동일한 media / content / actions 문법을 따른다. 여러 Item을 그룹으로 묶을 때는 List가 surface를 책임지고 개별 outline은 사라진다.
Anatomy
Item
├─ ItemMedia (optional)
├─ ItemContent (required)
│ ├─ ItemTitle (required)
│ └─ ItemDescription (optional)
└─ ItemActions (optional)필요하면 아래 두 슬롯을 추가할 수 있다.
ItemMeta // 우측 보조 텍스트
ItemFooter // row 아래 확장 영역Slots
| Slot | Required | Purpose | Allowed content |
|---|---|---|---|
ItemMedia | optional | 좌측 식별자 | icon, avatar, color swatch, thumbnail |
ItemContent | required | 주 정보 블록 | title + description |
ItemTitle | required | 행의 핵심 라벨 | plain text |
ItemDescription | optional | 보조 설명 | 1–2 lines text |
ItemMeta | optional | 날짜·시간·길이 등 스캔용 메타데이터 | text, small icon, compact inline group |
ItemActions | optional | 우측 액션/컨트롤 | button, select trigger, switch, chevron, badge, TextInput (outline sm) |
Slot rules
ItemContent는 항상flex-1ItemTitle은label1Medium또는body1Regular계열에서 시작ItemDescription은body2Regular text-stone-500- 날짜·시간·길이처럼 반복적으로 훑는
ItemMeta는label2Regular text-stone-300을 기본으로 한다. 아이콘도 같은 톤을 상속하거나text-stone-300에 맞춘다 ItemActions는 hugging width. 우측 끝선 정렬ItemMedia와ItemActions는 모두 shrink하지 않는다
Media Variants
| Variant | Size | Use |
|---|---|---|
icon | 40×40 | settings, actions, status rows — List/설정 화면 전용 |
avatar | 40×40 | member, assignee, participant — List/설정 화면 전용 |
swatch | 12×12 | color legend, calendar color, status dot |
thumbnail | 48×48 to 56×56 | file, image, template preview |
icon media는 기본적으로 stone-50 surface 위에 놓는다. 브랜드 brown이나 feedback 컬러는 아이콘 자체 의미가 있을 때만 제한적으로 사용한다.
**Overlay 컨텍스트 (Popover, Dialog, Command Palette, 검색 결과)**에서는
icon (40×40)variant를 사용하지 않는다. 컨테이너 없이 14–16px 아이콘을 직접 배치하거나, 최대h-5 w-5(20×20) 컨테이너를 사용한다.
Root Semantics
Item은 단일 HTML element로 닫히는 root를 가진다. 문맥에 따라 root element를 바꾼다.
| Case | Recommended root |
|---|---|
| 읽기 전용 summary row | div |
| 리스트 안의 구조적 항목 | li |
| row 전체 클릭 내비게이션 | button or a |
| menu-like action row | button |
구현 시 asChild 또는 slot 패턴을 권장한다. Item 자체가 복잡한 behavior primitive는 아니므로 Radix root가 필수는 아니다.
Variants
default— 배경 없음.List안의 기본 rowoutline— thin border + white surface. standalone itemmuted—stone-50기반 보조 row
Item이 standalone으로 쓰일 때만 outline을 직접 사용한다. 여러 개를 묶을 때는 보통 List가 surface를 책임진다.
Sizes
| Size | Min height (web) | Min height (mobile) | Use |
|---|---|---|---|
sm | 48px | 48px | dense utility row |
md | 56px | 56px | default |
lg | 64px | 64px | media 포함, 설명 2줄, 더 여유 있는 settings row |
모든 사이즈가 모바일 최소 터치 타겟(iOS HIG 44pt)을 충족. dense list라도 44pt 미만으로 내리지 말 것 — 시각적 압축이 필요하면 sm까지가 한계.
Tiro는 flat-first 인터페이스라 과도한 세로 여백을 쓰지 않는다. row는 조용하고 압축적이어야 한다.
Overlay 컨텍스트 사이즈 제한 (web only): Popover · Dialog · Command Palette · 검색 결과 목록 안에서 Item이 사용될 경우, 단일 라인 선택 row는 h-7 (28px)을 기본으로 한다. 아이콘 슬롯이나 보조 설명이 필요한 row도 h-8 (32px) ~ h-9 (36px) 을 상한으로 한다. md (56px) 이상은 오버레이 내부에서 사용하지 않는다. 모바일은 적용 불가 — overlay 자체가 BottomSheet로 변환되며 row는 ≥44pt 유지.
Token Mapping
| Token | Value |
|---|---|
| horizontal padding | px-5 |
| vertical padding | py-2.5 |
| content gap | gap-3 |
| title | label1Medium, text-stone-700 |
| description | body2Regular, text-stone-500 |
| metadata | label2Regular, text-stone-300 |
| media bg | stone-50 |
| outline border | stone-100 |
| outline radius | rounded-xl |
| hover bg | stone-50 |
Usage
<Item variant="outline">
<ItemMedia variant="icon">
<ShieldAlertIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Security Alert</ItemTitle>
<ItemDescription>New login detected from unknown device.</ItemDescription>
</ItemContent>
<ItemActions>
<Button size="sm" variant="outline">
Review
</Button>
</ItemActions>
</Item><Item asChild>
<button type="button">
<ItemContent>
<ItemTitle>Workspace name</ItemTitle>
<ItemDescription>기본 워크스페이스 이름을 수정합니다.</ItemDescription>
</ItemContent>
<ItemActions>
<ChevronRight />
</ItemActions>
</button>
</Item>TextField in ItemActions
ItemActions에 TextInput을 배치해 inline 폼 필드를 만들 수 있다. 이 패턴은 설정 화면의 "이름 편집", "직책 입력" 같이 제목과 입력이 한 row에 나란히 있어야 할 때 사용한다.
<Item>
<ItemContent>
<ItemTitle>닉네임</ItemTitle>
</ItemContent>
<ItemActions>
<TextInput size="sm" className="w-[200px]" value={nickname} onChange={...} />
</ItemActions>
</Item>size="sm"(h-8) 사용 — row 높이와 균형을 맞추기 위해- 고정 너비(
w-[160px]~w-[240px]) 지정 — actions 영역을 예측 가능하게 유지 variant="outline"(기본값) — border가 항상 보여야 field임을 식별할 수 있음
Interactive Row (Navigation / Selectable)
사이드바 폴더 항목, 노트 목록 행처럼 클릭·선택·드래그 상태가 있는 row에 적용하는 패턴이다. 설정 화면의 정적 summary row와 구분된다.
상태 정의
| State | bg | text | 비고 |
|---|---|---|---|
default | transparent | stone-700 | 배경 없음 |
hover | stone-50 | stone-700 | hover:bg-stone-50 |
selected | stone-50 | brown-700 | bg-stone-50 text-brown-700 |
dragging | transparent | — | row 자체 opacity-40, 드래그 고스트 별도 |
drop-target | stone-50 | — | bg-stone-50 (dragging과 동시에 적용 안 함) |
disabled | transparent | stone-300 | opacity-50 pointer-events-none |
focus-visible:ring-2 ring-offset-2 ring-brown-800— hover와 시각적으로 구분되어야 함selected와hover가 겹칠 때는selected스타일이 우선한다 (배경 동일, 텍스트 색상만 유지)
인라인 액션 (group-hover 노출)
row 오른쪽에 붙는 ⋯, 삭제, 잠금 같은 액션은 기본 opacity-0, hover 시 opacity-100 으로 제어한다.
// 부모 row에 group 클래스 추가
<div className="group flex items-center ...">
{/* 항상 보이는 content */}
<span>{label}</span>
{/* hover에서만 보이는 액션 버튼 */}
<button
className="opacity-0 group-hover:opacity-100 transition-opacity
p-1 rounded-lg hover:bg-stone-100"
>
<MoreHorizontal size={14} strokeWidth={1.75} />
</button>
</div>- 액션 버튼 크기:
xs(28×28) — row context에서는 한 단계 낮은 사이즈 규칙 (Button 문서 참조) - 액션 버튼 자체 hover:
hover:bg-stone-100(row hoverstone-50보다 한 단계 진하게) - Popover가 열려 있는 동안은
opacity-100을 강제로 유지해야 함 (isPopoverOpen && "opacity-100") - 아이콘은 Lucide 전용 (
MoreHorizontal,LockKeyhole,Users등) —react-icons사용 금지
계층 들여쓰기
트리형 계층 구조(폴더 트리)에서는 padding-left를 동적으로 계산한다.
// depth × 16px + base offset
style={{ paddingLeft: `${depth * 16 + 6}px` }}- 들여쓰기는
styleprop으로 계산 — Tailwind arbitrary value 사용 금지 - depth 0의 base left padding은
px-1.5(6px) 유지
Expand/Collapse 토글
자식이 있는 행에만 노출. 크기: 12×12 (w-3 h-3).
{
hasChildren ? (
<button
onClick={(e) => {
e.stopPropagation();
onToggleExpand();
}}
className="flex h-3 w-3 items-center justify-center rounded transition-colors
hover:bg-stone-100 flex-shrink-0"
>
{isExpanded ? (
<ChevronDown size={12} strokeWidth={1.75} className="text-stone-500" />
) : (
<ChevronRight size={12} strokeWidth={1.75} className="text-stone-500" />
)}
</button>
) : (
<div className="w-3 flex-shrink-0" /> // 자식 없을 때 동일 너비 placeholder
);
}맥락 메뉴 Popover
row의 ⋯ 버튼을 눌러 열리는 인라인 컨텍스트 메뉴.
<PopoverContent className="w-fit p-1 bg-white border border-stone-200 rounded-lg shadow-sm">
<button
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5
typography-caption1Regular text-stone-500 hover:bg-stone-100"
>
<Settings size={12} strokeWidth={1.75} />
편집
</button>
{/* destructive action은 rose 계열 */}
<button
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5
typography-caption1Regular text-rose-600 hover:bg-rose-50"
>
<Trash2 size={12} strokeWidth={1.75} />
삭제
</button>
</PopoverContent>- 메뉴 아이템 아이콘: 12px (
size={12}) - 일반 액션:
text-stone-500 hover:bg-stone-100 - destructive 액션:
text-rose-600 hover:bg-rose-50—hover:bg-stone-50사용 금지 (Button 문서 Destructive Ghost 규칙과 동일) - shadow:
shadow-sm(Elevation 2 — Subtle, popover/dropdown 전용)
전체 코드 예시
<div
className={cn(
"group flex items-center rounded-lg p-1.5 typography-label2Regular",
"transition-colors duration-150 select-none",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brown-800",
!isDragging && "hover:bg-stone-50",
isSelected && !isDragging && "bg-stone-50",
isDragging && "opacity-40",
isDropTarget && !isDragging && "bg-stone-50",
)}
style={{ paddingLeft: `${depth * 16 + 6}px` }}
>
{/* expand toggle */}
{/* icon + label (draggable area) */}
{/* group-hover actions */}
</div>Mobile selectable row — Recording folder sheet
tiro-app main에서 녹음 메인 시트 상단의 FolderSelectionChip을 누르면 열리는 폴더 변경 화면이다. RecordingBottomSheetHeader의 상단 chip이 folderSheetRef.current?.present()를 호출하고, TrueSheet 안에서 RecordingFolderTab이 FolderSelectionContent를 렌더한다.
Preview에서는 상위 폴더를 눌러 하위 폴더가 펼쳐진 상태를 함께 보여준다. row는 FolderSelectionContent.renderFolderTreeItem 기준이다.
| Part | Source value |
|---|---|
| trigger | FolderSelectionChip: px-[6px] py-[4px] rounded-[6px] bg-[#F5F5F4], Folder size={14}, 첫 폴더명 + + N |
| sheet | TrueSheet, backgroundColor="#ffffff", detents={[0.4, 1]}, cornerRadius={20}, grabber, scrollable |
| header | BottomSheetTitleHeader, title note.folder.addToFolder, rightText common.confirm, disabled when no changes |
| content | FolderSelectionContent, ScrollView className="px-5 pt-5 pb-6 gap-[10px]" |
| section label | text-[#3F3F46] text-[14px] tracking-[-0.5px] font-['Pretendard-SemiBold'] leading-6 |
| section card | My: rounded-2xl bg-[#FAFAFA] p-[10px] gap-1; Team: rounded-[20px] bg-[#FAFAFA] p-[10px] gap-1 |
| row wrapper | w-full flex-row items-center rounded-[14px]; selected wrapper bg-white |
| expand control | TouchableOpacity w-6 h-6 mr-1, ChevronDown/Right size={20} color="#A1A1AA", hitSlop={8} |
| row press target | flex-1 flex-row items-center justify-between py-[10px] pr-[10px], activeOpacity={0.7} |
| folder icon | personal Folder size={20} color={thread.color}; team TeamFolderIcon size={20} |
| title | text-[16px] leading-6 tracking-[-0.7px]; selected #18181B SemiBold, default #2D2B2B Regular |
| trailing | selected Check size={20} color="#18181B"; unselected Check size={20} color="#E4E4E7"; locked LockKeyhole size={16} color="#A1A1AA"; pending ActivityIndicator |
| State | Row | Title | Trailing |
|---|---|---|---|
default | transparent | #2D2B2B, Pretendard Regular | Check #E4E4E7 |
selected | wrapper bg-white | #18181B, Pretendard SemiBold | Check #18181B |
disabled | press target disabled | default or selected | LockKeyhole #A1A1AA when not selected |
pending | press target disabled | current title state | ActivityIndicator #A1A1AA |
Do / Don't
- Do: row의 정보 구조를
media / content / actions로 안정적으로 반복한다 - Do: 버튼, 셀렉트, 스위치 등은 각 컴포넌트 문서에서 정의한 크기와 타입을 그대로 가져다 쓴다
- Do: root element를 문맥에 맞게 바꾼다
- Do:
ItemActions에TextInput을 쓸 때는size="sm"과 고정 너비를 함께 지정한다 - Do: interactive row의 인라인 액션 아이콘은 Lucide만 사용한다
- Do: destructive 액션 hover는 반드시
hover:bg-rose-50를 사용한다 - Don't: row마다 임의의 padding과 radius를 새로 만들지 않는다
- Don't: raw browser button/input 스타일을 그대로 쓰지 않는다
- Don't: 한 item 안에 독립 인터랙션을 3개 이상 병렬 배치하지 않는다
- Don't: Popover · Dialog · 검색 결과 등 오버레이 컨텍스트에서
icon (40×40)미디어 variant를 사용하지 않는다 — 오버레이 내부에서는 아이콘 컨테이너 없이 14–16px 아이콘 직접 배치, 또는 최대h-5 w-5컨테이너 사용 - Don't: 날짜 · 시간처럼 짧은 메타데이터를 오버레이 컨텍스트에서
ItemDescription(두 번째 줄)로 내리지 않는다 —ItemMeta로 같은 행 우측에 인라인 배치 - Don't: bordered
TextInput을 2개 이상 같은 Item row 안에 나란히 배치하지 않는다. List surface 카드(border)가 이미 있는 상황에서 input border가 2개 이상 가로로 나란히 놓이면 "박스 안에 박스 여러 개" 노이즈가 생긴다. 입력 필드가 2개 이상인 추가 form은 List 외부에 별도 블록으로 분리한다. - Don't:
Textarea를ItemActions에 넣지 않는다 — 여러 줄 입력은 Item 슬롯 구조에 맞지 않으므로 List 안의 자유 블록(px-5 py-3wrapper)으로 배치한다 - Don't: interactive row에서
text-gray-*,bg-gray-*(cool gray 계열) 사용 금지 — 반드시stone-*계열 - Don't: 인라인 HEX 색상 사용 금지 — TDS 토큰 클래스 또는 Tailwind 피드백 스케일 사용
- Don't: 들여쓰기에 Tailwind arbitrary value (
pl-[22px]) 사용 금지 —styleprop으로 동적 계산
