@xonic0506/sonic-ui 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/AI-RULES.md +1 -1
  2. package/DESIGN.md +505 -16
  3. package/README.md +87 -38
  4. package/dist/components/accordion.d.ts +30 -0
  5. package/dist/components/accordion.js +44 -0
  6. package/dist/components/alert-dialog.d.ts +16 -0
  7. package/dist/components/alert-dialog.js +46 -0
  8. package/dist/components/badge.d.ts +8 -0
  9. package/dist/components/badge.js +77 -0
  10. package/dist/components/button.d.ts +11 -2
  11. package/dist/components/button.js +24 -14
  12. package/dist/components/checkbox.d.ts +30 -0
  13. package/dist/components/checkbox.js +48 -0
  14. package/dist/components/dialog.d.ts +11 -0
  15. package/dist/components/dialog.js +17 -14
  16. package/dist/components/empty-state.d.ts +11 -0
  17. package/dist/components/empty-state.js +17 -0
  18. package/dist/components/error-state.d.ts +12 -0
  19. package/dist/components/error-state.js +24 -0
  20. package/dist/components/field.d.ts +13 -0
  21. package/dist/components/field.js +33 -0
  22. package/dist/components/file-upload.d.ts +57 -0
  23. package/dist/components/file-upload.js +238 -0
  24. package/dist/components/input.d.ts +1 -0
  25. package/dist/components/input.js +5 -5
  26. package/dist/components/link.d.ts +9 -0
  27. package/dist/components/link.js +30 -0
  28. package/dist/components/menu.d.ts +33 -0
  29. package/dist/components/menu.js +44 -0
  30. package/dist/components/popover.d.ts +26 -0
  31. package/dist/components/popover.js +50 -0
  32. package/dist/components/popup-styles.d.ts +3 -0
  33. package/dist/components/popup-styles.js +5 -0
  34. package/dist/components/progress.d.ts +16 -0
  35. package/dist/components/progress.js +27 -0
  36. package/dist/components/radio-group.d.ts +20 -0
  37. package/dist/components/radio-group.js +40 -0
  38. package/dist/components/select.d.ts +30 -0
  39. package/dist/components/select.js +66 -0
  40. package/dist/components/separator.d.ts +7 -0
  41. package/dist/components/separator.js +14 -0
  42. package/dist/components/skeleton.d.ts +8 -0
  43. package/dist/components/skeleton.js +22 -0
  44. package/dist/components/state-layout.d.ts +11 -0
  45. package/dist/components/state-layout.js +31 -0
  46. package/dist/components/switch.d.ts +12 -0
  47. package/dist/components/switch.js +24 -0
  48. package/dist/components/table.d.ts +24 -0
  49. package/dist/components/table.js +46 -0
  50. package/dist/components/tabs.d.ts +31 -0
  51. package/dist/components/tabs.js +41 -0
  52. package/dist/components/textarea.d.ts +6 -0
  53. package/dist/components/textarea.js +20 -0
  54. package/dist/components/toast.d.ts +24 -0
  55. package/dist/components/toast.js +62 -0
  56. package/dist/components/toggle-group.d.ts +36 -0
  57. package/dist/components/toggle-group.js +44 -0
  58. package/dist/components/tooltip.d.ts +11 -0
  59. package/dist/components/tooltip.js +19 -0
  60. package/dist/index.d.ts +23 -0
  61. package/dist/index.js +28 -5
  62. package/dist/styles.css +2 -2
  63. package/dist/tailwind.css +15 -0
  64. package/package.json +9 -2
package/AI-RULES.md CHANGED
@@ -10,7 +10,7 @@
10
10
  ## 규칙
11
11
 
12
12
  1. **DESIGN.md가 기준이에요.** 색, 간격, radius, 글자, 모션은 DESIGN.md의 토큰만 써요. 임의의 값(`p-[7px]`, `#3355ff`, `text-[15px]`)과 Tailwind 기본 팔레트(`bg-blue-500`)는 쓰지 않아요.
13
- 2. **UI는 sonic-ui 컴포넌트로 조립해요.** Button, Input, Text, Card, Avatar, Dialog가 있는 역할은 직접 만들지 않아요. 없는 역할이 필요하면 먼저 사용자에게 알려요(라이브러리에 추가할지 판단이 필요해요).
13
+ 2. **UI는 sonic-ui 컴포넌트로 조립해요.** DESIGN.md §7 Core 목록(Button, Input, Select, Dialog, Menu, Toast, Badge 등)에 있는 역할은 직접 만들지 않아요. 없는 역할이 필요하면 먼저 사용자에게 알려요(라이브러리에 추가할지 판단이 필요해요).
14
14
  3. **frontend-design은 제한해서 써요.** 레이아웃과 문구 판단에만 참고하고, 새 색·새 폰트·새 토큰 제안은 따르지 않아요. 컴포넌트를 구현할 때는 쓰지 않아요.
15
15
  4. **데이터 화면은 4가지 상태를 모두 만들어요.** 정상, 로딩, 빈 상태, 에러. Figma에 4가지가 없으면 디자인부터 요청해요(DESIGN.md §8).
16
16
  5. **Domain 컴포넌트는 프로젝트에 둬요.** 도메인 타입, API 호출, 비즈니스 규칙을 아는 컴포넌트는 sonic-ui에 넣지 않아요. 같은 형태를 두 번째 프로젝트에서 복사하게 되면 Pattern 승격을 제안해요(DESIGN.md §7).
package/DESIGN.md CHANGED
@@ -10,7 +10,7 @@ Sonic(`sonic-ui`)의 디자인 규칙이에요. Figma에서 디자인할 때,
10
10
  1. **토큰 밖의 값은 없다.** 색, 간격, radius, 글자 크기는 모두 아래 표에서 고릅니다. 임의의 값(`#3355ff`, `13px`, `p-[7px]`)은 쓰지 않아요.
11
11
  2. **강조는 한 곳에만.** 한 화면에서 `primary` 버튼은 하나만 둬요. 나머지는 `secondary`나 `ghost`를 씁니다.
12
12
  3. **숫자는 고정폭.** 수치, ID, 키 값은 `mono` 계열 스타일로 써서 자리를 맞춰요.
13
- 4. **그림자 대신 선.** 높낮이는 1px 테두리(`color/border/default`)와 배경 단계(canvas → surface → subtle)로 구분해요. 그림자 토큰은 없어요.
13
+ 4. **그림자 대신 선.** 높낮이는 1px 테두리(`color/border/default`)와 배경 단계(canvas → surface → subtle)로 구분해요. Menu, Popover, Select, Toast처럼 떠 있는 요소도 `bg/surface` + 1px 테두리로 구분하고, 그림자 토큰은 없어요.
14
14
  5. **움직임은 사용자 행동에 대한 응답만.** 열기, 닫기, hover처럼 사용자가 일으킨 변화에만 모션을 넣고, `prefers-reduced-motion`을 지켜요.
15
15
 
16
16
  ## 2. 이름 규칙
@@ -57,6 +57,21 @@ Figma Variable 이름이 그대로 CSS 변수가 돼요. `/`는 `-`로 바꾸고
57
57
  | `color/border/strong` | gray/400 | gray/600 | hover 테두리 |
58
58
  | `color/border/focus` | cobalt/600 | cobalt/400 | 포커스 링 |
59
59
  | `color/border/danger` | red/600 | red/400 | 에러 상태 Input |
60
+ | `color/bg/inverse` | gray/950 | gray/100 | Tooltip, neutral Badge(solid) |
61
+ | `color/text/inverse` | gray/0 | gray/950 | inverse 바탕 위 글자 |
62
+ | `color/bg/success` | green/600 `#16804F` | green/400 `#3DB882` | success Badge(solid) |
63
+ | `color/text/on-success` | gray/0 | gray/950 | success 바탕 위 글자 |
64
+ | `color/bg/success-subtle` | green/50 `#EAF7F0` | green/950 `#0B2218` | success Badge(subtle) |
65
+ | `color/text/success` | green/700 `#0F6640` | green/300 `#6BD1A0` | 성공 문구, 아이콘 |
66
+ | `color/border/success` | green/600 | green/400 | 성공 강조 테두리 |
67
+ | `color/bg/warning` | amber/400 `#E3A43A` | amber/400 | warning Badge(solid) |
68
+ | `color/text/on-warning` | gray/950 | gray/950 | warning 바탕 위 글자 (amber 위 흰 글자는 대비 부족) |
69
+ | `color/bg/warning-subtle` | amber/50 `#FCF5E6` | amber/950 `#24190A` | warning Badge(subtle) |
70
+ | `color/text/warning` | amber/700 `#85520A` | amber/300 `#EEC46A` | 주의 문구, 아이콘 |
71
+ | `color/border/warning` | amber/600 | amber/400 | 주의 강조 테두리 |
72
+ | `color/bg/danger-subtle` | red/50 `#FCEDEC` | red/950 `#2B1311` | danger Badge(subtle) |
73
+
74
+ 글자와 바탕 조합은 모두 WCAG 4.5:1 이상이에요. 토큰을 바꾸면 `npm run check:contrast`로 다시 확인해요(배포 전 검사에도 들어 있어요).
60
75
 
61
76
  ### 3.2 간격 (4px 단위)
62
77
 
@@ -102,6 +117,30 @@ Figma Variable 이름이 그대로 CSS 변수가 돼요. `/`는 `-`로 바꾸고
102
117
  | `duration/fast` | 120ms (hover, 색 전환) |
103
118
  | `duration/normal` | 200ms (Dialog 열기/닫기) |
104
119
  | `easing/standard` | `cubic-bezier(0.2, 0, 0, 1)` |
120
+ | `duration/loop` | 1000ms (반복 애니메이션: Skeleton 깜빡임, 로딩 스피너 회전) |
121
+
122
+ 반복 애니메이션은 `prefers-reduced-motion`이면 멈춰요.
123
+
124
+ ### 3.6 아이콘 (Lucide)
125
+
126
+ 아이콘은 [Lucide](https://lucide.dev)만 써요. 쓰는 쪽 프로젝트에서 `lucide-react`를 설치해요. Figma에는 Sonic이 쓰는 아이콘만 `Icon / <이름>` 컴포넌트로 있어요(Foundations › 04 Icon).
127
+
128
+ | 항목 | 규칙 |
129
+ |---|---|
130
+ | 이름 | Lucide 이름 그대로. Figma `Icon / chevron-down` = 코드 `<ChevronDown />` |
131
+ | 크기 | `space/4` 16 (버튼 sm·md·icon-sm·icon-md, 입력란 안, 본문 옆) · `space/5` 20 (버튼 lg·icon-lg) · `space/6` 24 (빈 상태처럼 아이콘이 주인공일 때) |
132
+ | 선 두께 | Lucide 기본 2 (24 기준). 크기에 비례해 줄어요. `absoluteStrokeWidth`는 쓰지 않아요. Figma에서는 Scale(K)로 줄여요 |
133
+ | 색 | `currentColor`. 주변 글자색 토큰을 따라가요. 아이콘에만 다른 색을 줄 때도 text 토큰만 써요 |
134
+ | 접근성 | 글자와 함께 쓰면 장식이라 숨겨요(lucide-react 기본 `aria-hidden`). 아이콘만 있는 버튼은 `aria-label`을 꼭 달아요 |
135
+ | 추가 | 새 아이콘이 필요하면 Lucide에서 가져와 Figma `Icon /` 컴포넌트로 추가하고, 이 표 규칙을 따라요. 다른 아이콘 세트는 섞지 않아요 |
136
+
137
+ Button 안에 넣은 아이콘은 크기가 자동으로 맞춰져요(sm·md 16, lg 20). 아이콘과 글자 사이 간격은 `space/2`예요. 아이콘은 글자 앞(left), 뒤(right), 양쪽(both)에 둘 수 있고, 뒤쪽 아이콘은 펼침(chevron)이나 외부 링크처럼 결과를 알려 줄 때만 써요. 아이콘만 있는 버튼은 `size="icon-*"`를 써요(§10 Button).
138
+
139
+ ```tsx
140
+ import { Plus } from 'lucide-react';
141
+ <Button><Plus />API 키 만들기</Button>
142
+ <Button size="icon-md" variant="ghost" aria-label="더 보기"><Ellipsis /></Button>
143
+ ```
105
144
 
106
145
  ## 4. Auto layout 규칙 (Figma)
107
146
 
@@ -117,6 +156,7 @@ Figma Variable 이름이 그대로 CSS 변수가 돼요. `/`는 `-`로 바꾸고
117
156
  | 위치 | 값 |
118
157
  |---|---|
119
158
  | 페이지 콘텐츠 | 최대 너비 960, 가운데 정렬. 좌우 padding `space/4`(960 안쪽), 위아래 padding `space/10` |
159
+ | 사이드 내비가 있는 화면 | 헤더와 사이드 내비(너비 240)는 화면 전체를 쓰고, 960 규칙은 본문 영역에만 적용 |
120
160
  | 섹션 사이 | `space/6` |
121
161
  | Card 안쪽 padding / Header·Body·Footer 사이 | `space/5` / `space/4` |
122
162
  | 폼 필드 사이 | `space/4` |
@@ -126,7 +166,7 @@ Figma Variable 이름이 그대로 CSS 변수가 돼요. `/`는 `-`로 바꾸고
126
166
  | 제목 ↔ 바로 아래 설명 | `space/1` |
127
167
 
128
168
  - 버튼 묶음은 오른쪽 정렬이고, 보조 버튼이 왼쪽, primary가 맨 오른쪽이에요. Dialog Footer도 같아요.
129
- - 에러 상태: 입력란 테두리를 `color/border/danger`로 바꾸고, 입력란 바로 아래에 `caption` + `color/text/danger`로 고치는 방법을 씁니다. 라벨 색은 바꾸지 않아요.
169
+ - 에러 상태: 입력란 테두리를 `color/border/danger`로 바꾸고, 입력란 바로 아래(설명 자리)에 `caption` + `color/text/danger`로 고치는 방법을 씁니다. 설명은 숨겨요. 라벨 색은 바꾸지 않아요. 이 묶음은 **Field 컴포넌트**가 처리해요(§10).
130
170
  - 문구는 한국어 해요체예요. 취소 버튼은 "취소"로 충분해요. 무엇을 취소하는지는 바로 옆 primary 버튼 문구가 알려 줘요.
131
171
  - Avatar 이니셜은 이름에서 1~2글자를 써요(한글은 이름 첫 글자, 영문은 이름·성 첫 글자).
132
172
 
@@ -164,17 +204,29 @@ Figma에서도 레이어 이름을 이 이름으로 맞춰요. 예: `Dialog / Ba
164
204
  | `disabled=true` | `[data-disabled]` / `disabled` prop |
165
205
  | `highlighted=true` | `[data-highlighted]` (키보드나 hover로 선택된 항목) |
166
206
  | `invalid=true` | `[data-invalid]` (Base UI Field) |
207
+ | `selected=true` | `[data-selected]` (Select 항목) |
208
+ | `checked=indeterminate` | `indeterminate` prop, `[data-indeterminate]` (Checkbox) |
209
+
210
+ hover, focus는 **컴포넌트 variant로 만들지 않아요.** React prop이 아니라 브라우저가 정하는 상태라서예요.
211
+
212
+ | 상태 | Figma | 코드 |
213
+ |---|---|---|
214
+ | hover | Core 문서 "Interaction states" 매트릭스에 인스턴스 오버라이드로 그림 | `:hover` (disabled일 때는 적용 안 함) |
215
+ | focus | 같은 매트릭스. 2px `border/focus` 링, 간격 2px. invalid Input은 `border/danger` 링 | `:focus-visible` → `outline 2px`, `outline-offset 2px` (Tab, ToggleGroup 항목만 안쪽 −2px). 포커스 링을 그리는 요소에는 `outline-none`을 같이 쓰지 않아요(Tailwind v4에서 링이 사라져요) |
216
+ | press | 그리지 않음 | 구현 안 함 (v0.1) |
167
217
 
168
- hover, focus는 Figma에서 `state=hover`, `state=focus`로 그리고, 코드에서는 CSS의 `:hover`, `:focus-visible`로 처리해요.
218
+ 상태별 토큰은 §10 컴포넌트 스펙 표가 기준이에요. 화면 디자인에서는 hover, focus를 따로 그리지 않아요. 컴포넌트가 자동으로 처리해요.
169
219
 
170
220
  ## 7. 3계층 판단 기준
171
221
 
172
222
  | 계층 | 위치 | 기준 | 예 |
173
223
  |---|---|---|---|
174
- | **Core** | sonic-ui `src/components/` | 더 나눌 수 없고, 도메인을 전혀 모름 | Button, Input, Text, Card, Avatar, Dialog |
224
+ | **Core** | sonic-ui `src/components/` | 더 나눌 수 없고, 도메인을 전혀 모름 | Button, Input, Textarea, Checkbox, RadioGroup, Switch, Select, Text, Card, Avatar, Dialog, AlertDialog, Menu, Popover, Tooltip, Tabs, Separator, Toast, Badge, Progress, Link, Table, ToggleGroup, Accordion, FileDropzone, FileList, Field, Skeleton, EmptyState, ErrorState |
175
225
  | **Pattern** | sonic-ui `src/patterns/` | Core 조합이고 도메인을 모르지만, **두 프로젝트 이상에서 같은 형태로 복사됨** | (아직 없음) |
176
226
  | **Domain** | 각 프로젝트 | 도메인 타입, API, 비즈니스 로직 중 하나라도 앎 | `UserProfileCard`, `ApiKeyList` |
177
227
 
228
+ 예외: EmptyState와 ErrorState는 Core 조합이라 원래는 Pattern 후보지만, §8 규칙이 모든 데이터 화면에 요구하는 **시스템 컴포넌트**라서 Core에 둬요.
229
+
178
230
  판단 순서:
179
231
  1. props에 도메인 타입(`User`, `Order`)이 들어가나요? → **Domain**
180
232
  2. 데이터를 직접 불러오거나 비즈니스 규칙이 있나요? → **Domain**
@@ -183,16 +235,16 @@ hover, focus는 Figma에서 `state=hover`, `state=focus`로 그리고, 코드에
183
235
 
184
236
  ## 8. 데이터 화면의 4가지 상태
185
237
 
186
- 데이터를 보여 주는 화면은 Figma에서 **4가지 상태를 모두 그려야** 개발로 넘어가요.
238
+ 데이터를 보여 주는 화면은 Figma에서 **4가지 상태를 모두 그려야** 개발로 넘어가요. 각 상태는 아래 컴포넌트로 그려요(예: Figma Playground › `Sample / API keys — 4 states`).
187
239
 
188
- | 상태 | 그릴 것 |
189
- |---|---|
190
- | 정상 | 실제와 비슷한 양의 데이터 |
191
- | 로딩 | 정상 화면과 같은 배치에 자리만 표시(`color/bg/subtle` 블록) |
192
- | 빈 상태 | 무엇이 없는지, 다음에 할 행동 하나(버튼) |
193
- | 에러 | 무엇이 실패했는지, 다시 시도할 방법 |
240
+ | 상태 | 그릴 것 | 컴포넌트 |
241
+ |---|---|---|
242
+ | 정상 | 실제와 비슷한 양의 데이터 | 화면 구성 그대로 |
243
+ | 로딩 | 정상 화면과 **같은 배치**에 자리만 표시 | `Skeleton` (text · rect · circle) |
244
+ | 빈 상태 | 무엇이 없는지, 다음에 할 행동 하나 | `EmptyState` |
245
+ | 에러 | 무엇이 실패했는지, 다시 시도할 방법 | `ErrorState` |
194
246
 
195
- Skeleton, EmptyState, ErrorState 컴포넌트는 첫 실제 프로젝트에서 필요할 때 Core에 추가해요. 그 전에는 Core 조합으로 그립니다.
247
+ 저장처럼 사용자가 일으킨 동작이 진행 중일 때는 화면 전체가 아니라 그 버튼에 `loading`을 켜요.
196
248
 
197
249
  ## 9. frontend-design 사용 규칙
198
250
 
@@ -213,15 +265,24 @@ Figma Core 페이지의 컴포넌트 속성 이름 = React props 이름이에요
213
265
  | prop | 값 | 기본값 |
214
266
  |---|---|---|
215
267
  | `variant` | `primary` · `secondary` · `ghost` · `danger` | `primary` |
216
- | `size` | `sm` · `md` · `lg` | `md` |
268
+ | `size` | `sm` · `md` · `lg` · `icon-sm` · `icon-md` · `icon-lg` | `md` |
217
269
  | `disabled` | boolean | `false` |
218
- | `children` | 버튼 문구 | — |
270
+ | `children` | 버튼 문구. 아이콘은 문구 앞뒤에 lucide 컴포넌트로 넣음 | — |
271
+ | `loading` | boolean. 스피너 + 클릭 막기 + `aria-busy` | `false` |
272
+ | `aria-label` | icon 크기일 때 **필수** (타입으로 강제) | — |
219
273
 
220
274
  | | sm | md | lg |
221
275
  |---|---|---|---|
222
276
  | 높이 | `space/7` (28) | `space/8` (32) | `space/10` (40) |
223
277
  | 좌우 padding | `space/2` | `space/3` | `space/4` |
224
278
  | 글자 | `label` | `body-strong` | `body-strong` |
279
+ | 아이콘 | 16 | 16 | 20 |
280
+
281
+ | | icon-sm | icon-md | icon-lg |
282
+ |---|---|---|---|
283
+ | 크기 (정사각형) | `space/7` (28) | `space/8` (32) | `space/10` (40) |
284
+ | padding | 0 | 0 | 0 |
285
+ | 아이콘 | 16 | 16 | 20 |
225
286
 
226
287
  | variant | 바탕 | 글자 | 테두리 | hover |
227
288
  |---|---|---|---|---|
@@ -244,6 +305,8 @@ Figma Core 페이지의 컴포넌트 속성 이름 = React props 이름이에요
244
305
 
245
306
  높이 `space/8`, 좌우 padding `space/3`, radius `radius/md`, 바탕 `bg/surface`, 테두리 `border/default`(hover `border/strong`, invalid `border/danger`), 입력값 `body` + `text/default`, placeholder `text/muted`, 너비는 부모를 채움.
246
307
 
308
+ focus 링은 2px, 간격 2px이고, 색은 기본 `border/focus`, invalid일 때 `border/danger`예요. 에러 상태에서 파란 링이 뜨면 상태 신호가 섞이기 때문이에요.
309
+
247
310
  ### Text
248
311
 
249
312
  `<Text variant="caption" tone="muted" as="p">…</Text>`
@@ -297,6 +360,407 @@ radius `radius/full`, 바탕 `bg/subtle`, fallback 글자 `text/muted`. 이미
297
360
  - 열기/닫기: `duration/normal` + `easing/standard`. `[data-starting-style]`과 `[data-ending-style]`에서 opacity 0, scale 0.98. Backdrop은 opacity만 바꿔요.
298
361
  - `open`, `onOpenChange`로 제어할 수 있어요. Esc와 바깥 클릭으로 닫혀요.
299
362
 
363
+ #### 트리거가 밖에 있을 때 (Dialog, AlertDialog 공통)
364
+
365
+ `DialogTrigger`를 쓸 수 없으면 **제어 모드**로 열어요. Trigger 없이 `open`과 `onOpenChange`만 넘겨요.
366
+
367
+ ```tsx
368
+ // 1) 다른 곳의 버튼, Menu 항목에서 열기
369
+ const [confirm, setConfirm] = useState(false);
370
+ <Menu>
371
+ <MenuTrigger render={<Button size="icon-md" variant="ghost" aria-label="더 보기" />}><Ellipsis /></MenuTrigger>
372
+ <MenuPopup><MenuItem tone="danger" onClick={() => setConfirm(true)}><Trash2 />삭제</MenuItem></MenuPopup>
373
+ </Menu>
374
+ <AlertDialog open={confirm} onOpenChange={setConfirm}>
375
+ <AlertDialogPopup>…</AlertDialogPopup>
376
+ </AlertDialog>
377
+
378
+ // 2) 코드로 열기 (예: API 에러 뒤). 닫힌 뒤 포커스가 갈 곳을 finalFocus로 정해요
379
+ const retryRef = useRef<HTMLButtonElement>(null);
380
+ <Dialog open={hasError} onOpenChange={setHasError}>
381
+ <DialogPopup finalFocus={retryRef}>…</DialogPopup>
382
+ </Dialog>
383
+ ```
384
+
385
+ - 닫히면 포커스는 **열기 전에 포커스가 있던 요소**로 돌아가요(바깥 버튼, Menu 트리거). 사용자 동작 없이 코드로 열었다면 돌아갈 곳이 없으니 `finalFocus`를 꼭 지정해요.
386
+ - 열릴 때 포커스는 팝업 안 첫 포커스 요소예요. 다른 곳이 맞으면 `initialFocus`로 정해요(예: AlertDialog에서 위험한 확인 버튼 대신 취소 버튼).
387
+ - 확인 동작이 끝나면 `onOpenChange(false)` 대신 상태를 직접 `false`로 바꿔 닫아요.
388
+
389
+ ### Button loading
390
+
391
+ `<Button loading>저장</Button>`
392
+
393
+ - 왼쪽에 `loader-circle`이 `duration/loop`로 회전하고, 클릭이 막혀요. 포커스는 유지되고 `aria-busy="true"`, `data-loading`이 붙어요.
394
+ - 로딩 중에는 왼쪽 아이콘을 넣지 않아요(스피너가 그 자리를 써요). icon 버튼은 아이콘 자리에 스피너만 보여요.
395
+
396
+ ### Field
397
+
398
+ ```tsx
399
+ <Field label="이메일" description="로그인할 때 써요." error="https://로 시작하는 주소를 입력하세요." invalid>
400
+ <Input />
401
+ </Field>
402
+ ```
403
+
404
+ | prop | 값 |
405
+ |---|---|
406
+ | `label` | 라벨 문구 (필수) |
407
+ | `description` | 설명 문구 (선택) |
408
+ | `error` | 에러 문구. `invalid`일 때만 보이고, 그때는 **설명 자리를 대신해요** |
409
+ | `invalid`, `disabled` | boolean. 안의 Input에 자동으로 전달돼요 (Base UI Field) |
410
+ | `children` | 입력 컨트롤 (`Input`, `Textarea`, `Select`, `FileDropzone`) |
411
+
412
+ 간격: 라벨 ↔ 입력 ↔ 설명·에러 `space/2`. 라벨 `label` + `text/default`(invalid여도 그대로), 설명 `caption` + `text/muted`, 에러 `caption` + `text/danger`. **Field 안의 Input에는 `invalid`를 따로 주지 않아요.** 입력 컨트롤과 설명 또는 에러는 `aria-describedby`로 연결돼요.
413
+
414
+ 에러는 설명과 함께 보이지 않고 **설명을 대신해요**(Material, Carbon, Spectrum과 같은 방식). 아래 줄이 늘어나지 않아 레이아웃이 밀리지 않고, 화면 낭독기도 에러 하나만 읽어요. 설명에 있던 형식 안내는 에러 문구가 대신 전해야 하니, 에러 문구에는 고치는 방법을 꼭 써요(§5).
415
+
416
+ ### Skeleton
417
+
418
+ `<Skeleton shape="text" className="w-40" />`
419
+
420
+ | shape | 기본 크기 | radius |
421
+ |---|---|---|
422
+ | `text` | 높이 `space/3` (12, body 한 줄 자리), 너비 100% | `radius/sm` |
423
+ | `rect` | 높이 `space/16` (64), 너비 100% | `radius/md` |
424
+ | `circle` | `space/8` (32) | `radius/full` |
425
+
426
+ 바탕 `bg/subtle`, opacity 1 ↔ 0.5를 `duration/loop`로 반복해요. 크기는 쓰는 곳에서 `className`으로 정하고, 스크린 리더에는 숨겨요(`aria-hidden`). 로딩 영역 전체에는 `aria-busy`를 둬요.
427
+
428
+ ### EmptyState / ErrorState
429
+
430
+ ```tsx
431
+ <EmptyState icon={<Inbox />} title="API 키가 없어요" description="키를 만들면 여기에서 사용량을 볼 수 있어요." action={<Button>API 키 만들기</Button>} />
432
+ <ErrorState title="API 키를 불러오지 못했어요" description="네트워크 연결을 확인하고 다시 시도해 주세요." onRetry={refetch} />
433
+ ```
434
+
435
+ | | EmptyState | ErrorState |
436
+ |---|---|---|
437
+ | 아이콘 | `icon` prop (24, `text/muted`, `bg/subtle` 원 `space/12`) | 기본 `triangle-alert` (`text/danger`), `icon`으로 바꿀 수 있음 |
438
+ | 제목 / 설명 | `body-strong` / `body` + `text/muted`, 가운데 정렬 | 같음 |
439
+ | 행동 | `action` (보통 primary Button 하나) | `onRetry`가 있으면 secondary Button + `refresh-cw`, 문구 `retryLabel`(기본 "다시 시도") |
440
+ | 간격 | 요소 사이 `space/4`, 위아래 `space/12`, 좌우 `space/6` | 같음 |
441
+
442
+ 에러 문구는 사과하지 않고, 무엇이 실패했고 어떻게 하면 되는지 써요.
443
+
444
+ ### Textarea
445
+
446
+ `<Textarea rows={3} invalid disabled />` — Input과 같은 테두리·hover·focus·invalid 규칙이에요. 최소 높이 80(약 3줄), padding `space/3`·`space/2`, 세로로만 크기를 바꿀 수 있어요. Field 안에서 라벨·에러와 함께 써요.
447
+
448
+ ### Checkbox / CheckboxGroup
449
+
450
+ ```tsx
451
+ <Checkbox checked={on} onCheckedChange={setOn}>알림 받기</Checkbox>
452
+ <CheckboxGroup label="알림 채널" value={channels} onValueChange={setChannels}>
453
+ <Checkbox value="email">이메일</Checkbox>
454
+ <Checkbox value="push">푸시</Checkbox>
455
+ </CheckboxGroup>
456
+ ```
457
+
458
+ - 여러 개를 고를 수 있을 때 써요. 일부만 선택된 상위 항목은 `indeterminate`예요(화면 낭독기에는 "mixed").
459
+ - 상자 16(`space/4`), `radius/sm`. 해제: `bg/surface` + `border/strong`, 선택: `bg/primary` + check 12(`text/on-primary`). 라벨 `body`, 상자와 간격 `space/2`.
460
+ - 그룹은 `fieldset` + `legend`(`label` 스타일)로 묶고, 항목 사이 `space/2`, legend와 `space/3`이에요.
461
+
462
+ ### RadioGroup
463
+
464
+ ```tsx
465
+ <RadioGroup label="결제 주기" value={cycle} onValueChange={setCycle}>
466
+ <Radio value="month">월간 결제</Radio>
467
+ <Radio value="year">연간 결제</Radio>
468
+ </RadioGroup>
469
+ ```
470
+
471
+ 하나만 고를 때 써요. 화살표 키로 이동하면 바로 선택돼요. 원 16(`space/4`), 선택 `bg/primary` + 점 8(`space/2`). 항목이 5개를 넘으면 Select를 써요.
472
+
473
+ ### Switch
474
+
475
+ `<Switch checked={on} onCheckedChange={setOn}>이메일 알림</Switch>` — 누르는 즉시 적용되는 설정에만 써요. 저장 버튼을 눌러야 반영되는 폼에서는 Checkbox를 써요. 트랙 28×16(`space/7`·`space/4`), 손잡이 12(`space/3`), 해제 `bg/subtle` + `border/strong`, 켜짐 `bg/primary`. 손잡이는 `duration/fast`로 움직여요.
476
+
477
+ ### Select
478
+
479
+ ```tsx
480
+ <Select options={plans} value={plan} onValueChange={setPlan} placeholder="요금제 선택" /> // 단일: string | null
481
+ <Select multiple options={tags} value={selected} onValueChange={setSelected} placeholder="태그 선택" /> // 다중: string[]
482
+ ```
483
+
484
+ | prop | 값 |
485
+ |---|---|
486
+ | `options` | `{ value, label, disabled? }[]` |
487
+ | `multiple` | 다중 선택. 켜면 `value`·`onValueChange`가 배열이에요(타입으로 강제) |
488
+ | `placeholder`, `invalid`, `disabled`, `name`, `aria-label` | Field 안에서는 라벨과 invalid가 자동으로 연결돼요 |
489
+
490
+ | | 단일 | 다중 |
491
+ |---|---|---|
492
+ | 항목 | 선택 시 오른쪽 `check` | 왼쪽 체크박스 상자 |
493
+ | 고른 뒤 | 팝업이 닫혀요 | 열린 채로 여러 개를 골라요 |
494
+ | 트리거 | 고른 항목 이름 | 쉼표로 이어 쓰고, 넘치면 말줄임(…) |
495
+
496
+ - 트리거는 Input과 같은 크기·테두리예요(높이 32). 오른쪽 `chevron-down` 16(`text/muted`), 열리면 180° 돌고 테두리가 `border/focus`가 돼요.
497
+ - 팝업은 트리거 아래 4px, 트리거 너비 이상이에요. 떠 있는 요소 공통 스타일: `bg/surface` + 1px `border/default` + `radius/lg`, 안쪽 `space/1`, 그림자 없음.
498
+ - 항목은 높이 32, padding `space/2`, 키보드나 마우스로 가리키면(`highlighted`) `bg/subtle`이에요.
499
+ - 고를 항목이 5개 이상이거나 화면이 좁을 때 써요. 그보다 적으면 RadioGroup이 한눈에 보여요.
500
+
501
+ ### AlertDialog
502
+
503
+ ```tsx
504
+ <AlertDialog>
505
+ <AlertDialogTrigger render={<Button variant="danger" />}>삭제</AlertDialogTrigger>
506
+ <AlertDialogPopup>
507
+ <AlertDialogTitle>API 키를 삭제할까요?</AlertDialogTitle>
508
+ <AlertDialogDescription>이 키로 보내는 요청이 바로 실패해요. 삭제하면 되돌릴 수 없어요.</AlertDialogDescription>
509
+ <AlertDialogFooter>
510
+ <AlertDialogClose render={<Button variant="secondary" />}>취소</AlertDialogClose>
511
+ <Button variant="danger" onClick={remove}>삭제</Button>
512
+ </AlertDialogFooter>
513
+ </AlertDialogPopup>
514
+ </AlertDialog>
515
+ ```
516
+
517
+ 되돌릴 수 없는 동작 전에만 써요. Dialog와 같은 모양(Backdrop, Popup 440, padding `space/6`)이고, 바깥을 눌러도 닫히지 않아요(Esc는 닫혀요). 제목은 질문, 설명은 결과, 확인 버튼은 동작 이름 그대로(`danger`)예요.
518
+
519
+ ### Menu
520
+
521
+ ```tsx
522
+ <Menu>
523
+ <MenuTrigger render={<Button size="icon-md" variant="ghost" aria-label="더 보기" />}><Ellipsis /></MenuTrigger>
524
+ <MenuPopup>
525
+ <MenuItem onClick={rename}><Pencil />이름 바꾸기</MenuItem>
526
+ <MenuSeparator />
527
+ <MenuItem tone="danger" onClick={confirmDelete}><Trash2 />삭제</MenuItem>
528
+ </MenuPopup>
529
+ </Menu>
530
+ ```
531
+
532
+ 행이나 카드에 딸린 동작을 묶어요. 팝업은 떠 있는 요소 공통 스타일(§10 Select와 같음), 트리거 아래 4px·시작 정렬, 최소 너비 192. 항목 높이 32, 아이콘 16, `highlighted`면 `bg/subtle`. 위험한 동작은 맨 아래 구분선 뒤에 `tone="danger"`(`text/danger`)로 두고, 실행 전에 AlertDialog로 확인해요. 화살표로 이동, Enter로 실행하면 닫혀요.
533
+
534
+ ### Popover
535
+
536
+ `<Popover><PopoverTrigger render={<Button variant="secondary" />}>공유</PopoverTrigger><PopoverPopup><PopoverTitle>…</PopoverTitle><PopoverDescription>…</PopoverDescription>…</PopoverPopup></Popover>`
537
+
538
+ 대상 가까이에 짧은 내용이나 간단한 입력을 띄울 때 써요. 너비 320, padding `space/4`, 영역 사이 `space/3`, 제목 `body-strong`, 설명 `caption` + `text/muted`, 대상에서 8px. 확인이 필요한 결정은 Dialog, 목록형 동작은 Menu를 써요.
539
+
540
+ ### Tooltip
541
+
542
+ `<Tooltip content="설정"><Button size="icon-md" variant="ghost" aria-label="설정"><Settings /></Button></Tooltip>`
543
+
544
+ 아이콘만 있는 버튼의 이름을 보여 줘요. `bg/inverse` + `text/inverse`, `caption`, padding `space/2`·`space/1`, `radius/md`, 대상에서 4px. 마우스는 600ms 뒤, 키보드 포커스는 바로 보여요. 중요한 정보나 클릭할 요소는 넣지 않아요(Tooltip이 있어도 `aria-label`은 따로 달아요).
545
+
546
+ ### Tabs
547
+
548
+ ```tsx
549
+ <Tabs defaultValue="overview">
550
+ <TabsList><Tab value="overview">개요</Tab><Tab value="usage">사용량</Tab></TabsList>
551
+ <TabsPanel value="overview">…</TabsPanel>
552
+ <TabsPanel value="usage">…</TabsPanel>
553
+ </Tabs>
554
+ ```
555
+
556
+ 같은 대상의 다른 보기를 오갈 때만 써요(페이지 이동 링크에는 쓰지 않아요). 탭 높이 32, padding `space/3`, `body-strong`. 선택 `text/default`, 나머지 `text/muted`(hover 시 `text/default`). 목록 아래 1px `border/default`, 선택 표시 막대 2px `bg/primary`가 `duration/normal`로 움직여요. 목록과 패널 사이 `space/4`예요.
557
+
558
+ 키보드: 기본은 화살표 키로 이동하면 **바로 탭이 바뀌어요**(`activateOnFocus`, 기본 `true`). 탭을 바꿀 때 무거운 내용을 불러온다면 `<TabsList activateOnFocus={false}>`로 화살표는 이동만, Enter·Space로 전환하게 해요. 끝에서 처음으로 돌아가는 `loopFocus`도 기본 `true`예요. 키보드 조작 자체를 끄는 옵션은 없어요. 포커스 링은 탭끼리 겹치지 않도록 탭 **안쪽**(offset −2px)에 그려요. 패널(`TabsPanel`)은 Tab 키로 들어갈 수 있어서(WAI-ARIA Tabs 패턴), 키보드 포커스일 때 패널 **바깥**에 같은 2px 링(offset 2px)을 그려요. 마우스로 누를 때는 보이지 않아요(Figma › 10 Navigation › Tabs › Focus).
559
+
560
+ ### Separator
561
+
562
+ `<Separator orientation="horizontal" />` — 1px `border/default`. 관련 없는 묶음 사이를 나눌 때만 쓰고, 카드 안 영역이나 목록 행은 간격으로 먼저 구분해요.
563
+
564
+ ### Toast
565
+
566
+ ```tsx
567
+ // 앱 최상단에 한 번 (Next.js는 layout.tsx). 알림 상자를 화면 오른쪽 아래에 그려요
568
+ <ToastProvider>{children}</ToastProvider>
569
+
570
+ // 클라이언트 컴포넌트 어디서든
571
+ const toast = useToast();
572
+ toast.add({ title: 'API 키를 만들었어요', description: '키는 지금 한 번만 보여 줘요.', tone: 'success' });
573
+ ```
574
+
575
+ | `add` 옵션 | 값 | 기본값 |
576
+ |---|---|---|
577
+ | `title` | 결과 한 줄 (필수) | — |
578
+ | `description` | 덧붙일 설명 | — |
579
+ | `tone` | `neutral` · `success` · `warning` · `danger` | `neutral` |
580
+ | `timeout` | 사라지기까지 ms. `0`이면 직접 닫을 때까지 남아요 | 5000 |
581
+
582
+ - 사용자 동작의 결과를 잠깐 알려 줄 때 써요. 되돌릴 수 없는 동작의 확인은 AlertDialog, 화면을 채우는 실패는 ErrorState를 써요.
583
+ - 너비 360(좁은 화면은 좌우 `space/4`를 뺀 너비), 화면 가장자리에서 `space/4`, 알림 사이 `space/2`. 최대 3개까지 보이고 오래된 것부터 가려져요.
584
+ - 상자: 떠 있는 요소 공통 스타일(`bg/surface` + 1px `border/default` + `radius/lg`, 그림자 없음), padding·간격 `space/3`.
585
+ - 아이콘 16: neutral `info`(`text/muted`) · success `circle-check`(`text/success`) · warning `triangle-alert`(`text/warning`) · danger `circle-alert`(`text/danger`). 제목 `body-strong`, 설명 `caption` + `text/muted`, 오른쪽에 닫기(ghost `icon-sm`, `x`, "알림 닫기").
586
+ - 마우스를 올리거나 포커스하면 시간이 멈춰요. 들어오고 나갈 때 opacity와 위치(`space/2`)가 `duration/normal`로 바뀌어요.
587
+ - 화면 낭독기: 보통은 차례가 오면 읽고, `danger`는 바로 읽어요. 알림은 F6으로 이동할 수 있어요.
588
+ - `useToast`는 클라이언트 컴포넌트에서만 써요(`ToastProvider` 안).
589
+
590
+ ### Badge
591
+
592
+ `<Badge tone="success" variant="subtle">활성</Badge>`
593
+
594
+ | prop | 값 | 기본값 |
595
+ |---|---|---|
596
+ | `tone` | `neutral` · `success` · `warning` · `danger` | `neutral` |
597
+ | `variant` | `subtle` · `solid` | `subtle` |
598
+
599
+ | | subtle | solid |
600
+ |---|---|---|
601
+ | neutral | `bg/subtle` + `text/default` | `bg/inverse` + `text/inverse` |
602
+ | success | `bg/success-subtle` + `text/success` | `bg/success` + `text/on-success` |
603
+ | warning | `bg/warning-subtle` + `text/warning` | `bg/warning` + `text/on-warning` |
604
+ | danger | `bg/danger-subtle` + `text/danger` | `bg/danger` + `text/on-danger` |
605
+
606
+ 높이 20(`space/5`), 좌우 `space/2`, `label`, `radius/sm`. 상태를 짧은 낱말로 표시해요. 목록과 표에서는 subtle을, 꼭 눈에 띄어야 하는 한 곳에만 solid를 써요. 누를 수 없는 표시라 버튼처럼 쓰지 않아요.
607
+
608
+ ### Progress
609
+
610
+ `<Progress value={45} label="내보내는 중" />`
611
+
612
+ | prop | 값 |
613
+ |---|---|
614
+ | `value` | 현재 값 (필수) |
615
+ | `max` | 최댓값 (기본 100) |
616
+ | `label` | 라벨 문구. 없으면 `aria-label`이 필수예요 (타입으로 강제) |
617
+
618
+ 라벨 `label` + `text/default`, 오른쪽에 백분율 `mono-caption` + `text/muted`, 막대와 사이 `space/2`. 트랙 높이 4(`space/1`) `bg/subtle` `radius/full`, 채움 `bg/primary`이 `duration/normal`로 늘어나요. 끝이 정해진 작업(업로드, 내보내기)에만 써요. 끝을 알 수 없는 기다림은 Button `loading`이나 Skeleton을 써요.
619
+
620
+ ### Link
621
+
622
+ ```tsx
623
+ <Link href="/docs">DESIGN.md 보기</Link>
624
+ <Link href="https://base-ui.com" external>Base UI 문서</Link>
625
+ <Link render={<NextLink href="/docs" />}>문서</Link> // Next.js Link와 함께
626
+ ```
627
+
628
+ | prop | 값 |
629
+ |---|---|
630
+ | `href` | 이동할 주소 |
631
+ | `external` | 새 탭으로 열고(`rel="noopener noreferrer"`) 오른쪽에 `external-link` 아이콘을 붙여요 |
632
+ | `render` | 다른 링크 컴포넌트로 렌더링 (예: Next.js `Link`) |
633
+
634
+ 다른 페이지나 문서로 **이동**할 때 써요. 동작을 실행하면 Button을 써요. 글자 크기는 부모를 따르고, 색은 `text/default` + 밑줄(offset 2px)이에요. 강조는 한 곳에만 쓰는 원칙(§1)에 따라 링크에 코발트를 쓰지 않아요. hover는 `text/muted`, 포커스 링은 공통 규칙(2px, offset 2px)이에요. 외부 링크 아이콘은 16, 글자와 간격 `space/1`이에요.
635
+
636
+ ### Table
637
+
638
+ ```tsx
639
+ <Table>
640
+ <TableHeader>
641
+ <TableRow><TableHead>이름</TableHead><TableHead align="end">요청 수</TableHead></TableRow>
642
+ </TableHeader>
643
+ <TableBody>
644
+ <TableRow><TableCell>production</TableCell><TableCell align="end">12,480</TableCell></TableRow>
645
+ </TableBody>
646
+ </Table>
647
+ ```
648
+
649
+ | prop | 값 |
650
+ |---|---|
651
+ | `TableHead`, `TableCell`의 `align` | `start` · `end` (숫자 열은 `end`) |
652
+
653
+ 여러 항목의 같은 속성을 비교할 때 써요. 행 높이 40(`space/10`), 좌우 padding `space/3`, 행 아래 1px `border/default`. 머리글 `label` + `text/muted`, 본문 `body` + `text/default`, `align="end"`인 본문 칸은 숫자라서 `mono`예요. 줄무늬나 hover 바탕은 쓰지 않아요. 표가 부모보다 넓으면 가로로 스크롤해요.
654
+
655
+ ### ToggleGroup
656
+
657
+ ```tsx
658
+ <ToggleGroup value={view} onValueChange={setView} aria-label="보기 방식"> // 단일: string
659
+ <ToggleGroupItem value="list">목록</ToggleGroupItem>
660
+ <ToggleGroupItem value="card">카드</ToggleGroupItem>
661
+ </ToggleGroup>
662
+ <ToggleGroup multiple value={filters} onValueChange={setFilters}>…</ToggleGroup> // 다중: string[]
663
+ <ToggleGroupItem value="dark" iconOnly aria-label="다크 테마"><Moon /></ToggleGroupItem> // 아이콘만이면 iconOnly + aria-label 필수
664
+ ```
665
+
666
+ | prop | 값 |
667
+ |---|---|
668
+ | `multiple` | 여러 개를 함께 누를 수 있음. 켜면 `value`·`onValueChange`가 배열이에요(타입으로 강제) |
669
+ | `value`, `defaultValue`, `onValueChange` | 단일은 `string`, 다중은 `string[]`. 단일은 항상 하나가 눌려 있어요(눌린 항목을 다시 눌러도 꺼지지 않음) |
670
+ | `disabled` | 그룹 전체 또는 항목 하나 |
671
+ | `ToggleGroupItem`의 `iconOnly` | 아이콘만 있는 24×24 항목. `aria-label`이 필수예요(타입으로 강제) |
672
+
673
+ 같은 대상을 **보는 방식**을 바꿀 때 써요(목록/카드, 라이트/다크). 누르면 바로 적용돼요. 폼에서 값을 고를 때는 RadioGroup이나 Select를 써요. 바탕 `bg/subtle` + 안쪽 `space/1` + `radius/md`(전체 높이 32). 항목 높이 24(`space/6`), 좌우 `space/2`, `label`, `radius/sm`. 눌린 항목 `bg/surface` + 1px `border/default` + `text/default`, 나머지 `text/muted`(hover `text/default`). 아이콘만 있는 항목은 24×24, 아이콘 16이에요. 화살표 키로 항목 사이를 이동해요. 항목끼리 붙어 있어서 포커스 링은 Tab처럼 **안쪽**(offset −2px)에 그려요.
674
+
675
+ ### Accordion
676
+
677
+ ```tsx
678
+ <Accordion defaultValue={['limit']}>
679
+ <AccordionItem value="limit">
680
+ <AccordionTrigger>키는 몇 개까지 만들 수 있나요?</AccordionTrigger>
681
+ <AccordionPanel>프로젝트마다 10개까지 만들 수 있어요.</AccordionPanel>
682
+ </AccordionItem>
683
+ </Accordion>
684
+ ```
685
+
686
+ | prop | 값 |
687
+ |---|---|
688
+ | `multiple` | 여러 항목을 함께 열 수 있음 (기본 `false`) |
689
+ | `value`, `defaultValue`, `onValueChange` | 열린 항목들의 `value` 배열 |
690
+ | `AccordionItem`의 `disabled` | 항목 하나를 비활성화 |
691
+
692
+ 긴 내용을 제목만 먼저 보여 주고 필요할 때 펼칠 때 써요(자주 묻는 질문, 문서 사이드바 그룹). 꼭 읽어야 하는 내용은 접어 두지 않아요. 항목 아래 1px `border/default`. 트리거는 위아래 `space/3` + `body-strong`, 오른쪽 `chevron-down` 16(`text/muted`)이 열리면 180° 돌아요. 패널은 `body` + `text/muted`, 아래 `space/4`이고, 높이가 `duration/normal`로 변해요. 닫힌 내용도 브라우저 페이지 찾기로 찾아 열 수 있어요.
693
+
694
+ ### FileDropzone / FileList
695
+
696
+ ```tsx
697
+ const [items, setItems] = useState<FileItemData[]>(serverFiles.map(toItem)); // 서버 파일도 같은 모양으로
698
+
699
+ <Field label="첨부 파일" description="PDF, 10MB 이하, 5개까지" error="파일을 1개 이상 올려 주세요." invalid={submitted && !items.length}>
700
+ <FileDropzone
701
+ multiple accept=".pdf" maxSize={10_000_000} maxFiles={5 - items.length}
702
+ onFilesChange={(accepted, rejected) => { addItems(accepted); upload(accepted); }} // 업로드는 프로젝트가 해요
703
+ />
704
+ </Field>
705
+ <FileList items={items} onRemove={remove} onRetry={retry} />
706
+ <FileList layout="grid" items={images} onRemove={remove} />
707
+ ```
708
+
709
+ 컴포넌트는 **고르기·검사·표시만** 해요. 실제 업로드(어느 API로, 어떤 인증으로), 진행률 계산, 재시도는 API를 아는 프로젝트가 하고, 그 결과를 `items`로 넘겨요(§7). 두 번째 프로젝트에서 같은 업로드 코드를 복사하게 되면 `useFileUpload` Pattern으로 올려요.
710
+
711
+ **FileDropzone**
712
+
713
+ | prop | 값 |
714
+ |---|---|
715
+ | `onFilesChange` | `(accepted: File[], rejected: FileRejection[]) => void` (필수) |
716
+ | `accept` | `.pdf`, `image/*`, `image/png` 같은 목록 (쉼표로 구분) |
717
+ | `multiple`, `maxFiles` | 여러 개 고르기와 최대 개수. `multiple`이 없으면 한 개만 받아요 |
718
+ | `maxSize` | 파일 하나의 최대 크기(바이트) |
719
+ | `variant` | `file` · `image` (아이콘과 기본 문구) |
720
+ | `title`, `description` | 영역 안 문구. 기본은 "파일을 끌어다 놓거나 눌러서 고르세요". Field 안에서는 조건을 Field `description`에 쓰고 영역 안 `description`은 비워요 |
721
+ | `invalid`, `disabled` | boolean |
722
+
723
+ - **다른 폼 항목처럼 Field로 감싸요.** Field의 라벨이 버튼 이름이 되고, 설명·에러·`invalid`·`disabled`가 자동으로 연결돼요(Base UI `Field.Control`). Field 없이 쓰면 영역 안 제목이 버튼 이름이 돼요.
724
+ - 에러는 두 종류예요. 고르는 순간 조건에 맞지 않은 파일은 **영역 안**에 바로 알려 주고, "1개 이상 올려 주세요"처럼 제출할 때 확인하는 규칙은 Field `error`로 **아래**에 보여 줘요.
725
+ - 영역 전체가 버튼이라 눌러도, Enter·Space를 눌러도 파일 선택 창이 열려요. 파일을 끌어다 놓아도 돼요.
726
+ - 조건(형식 `type`, 크기 `size`, 개수 `count`)에 맞지 않는 파일은 `rejected`로 넘기고, 영역 안에 고치는 방법을 기본 문구로 보여 줘요(예: "계약서.pdf: 10MB보다 큰 파일은 올릴 수 없어요"). 문구는 `rejected[].message`로 바꿀 수 있어요.
727
+ - 점선 1px `border/strong` + `bg/surface` + `radius/lg`, 위아래 `space/6`, 좌우 `space/4`, 요소 사이 `space/2`. 아이콘 24(`upload` · `image`, `text/muted`), 제목 `body-strong`, 조건 `caption` + `text/muted`. hover `bg/subtle`, 끌어다 놓는 중 `border/focus` + `bg/subtle`, 거부되면 `border/danger` + 문구 `text/danger`.
728
+
729
+ **FileList**
730
+
731
+ | prop | 값 |
732
+ |---|---|
733
+ | `items` | `FileItemData[]` (아래) |
734
+ | `layout` | `list` (일반 파일) · `grid` (이미지 썸네일) |
735
+ | `onRemove`, `onRetry` | `(id) => void`. 넘기면 삭제·다시 시도 버튼이 생겨요 |
736
+
737
+ ```ts
738
+ interface FileItemData {
739
+ id: string;
740
+ name: string;
741
+ size?: number; // 바이트, "2.4 MB"로 표시
742
+ type?: string; // MIME, 이미지 여부 판단
743
+ status: 'uploading' | 'done' | 'error';
744
+ progress?: number; // 0~100, uploading일 때 Progress
745
+ error?: string; // error일 때 문구 (고치는 방법 포함)
746
+ url?: string; // done 파일을 열 주소 → 이름이 Link
747
+ previewUrl?: string; // 썸네일 주소
748
+ file?: File; // 방금 고른 로컬 파일
749
+ }
750
+ ```
751
+
752
+ 미리보기는 `previewUrl` 문자열 하나로 받아요.
753
+
754
+ | 출처 | `previewUrl` | 비고 |
755
+ |---|---|---|
756
+ | 서버 이미지 | `https://…/thumb.jpg` | 서버가 썸네일 주소를 따로 주는 게 가장 가벼워요 |
757
+ | 서버가 base64로 줌 | `data:image/png;base64,…` | 그대로 표시돼요. 응답이 커지니 썸네일 크기만 권장 |
758
+ | 방금 고른 파일 | 넣지 않고 `file`만 넘김 | 컴포넌트가 blob URL을 만들고, 목록에서 빠지면 해제해요 |
759
+ | 인증이 필요한 파일 | 프로젝트가 받아 만든 blob URL | `<img>`는 인증 헤더를 붙일 수 없어요 |
760
+
761
+ - `list`: `bg/surface` + 1px `border/default` + `radius/md`, padding·간격 `space/3`, 항목 사이 `space/2`. 아이콘 칸 32(`bg/subtle`, `file` 16), 이름 `body`(완료이고 `url`이 있으면 Link), 크기·진행률 `mono-caption` + `text/muted`. 업로드 중에는 라벨 없는 Progress, 에러면 `border/danger` + `caption` + `text/danger`. 버튼은 ghost `icon-sm`(`refresh-cw` 다시 시도, `x` 삭제).
762
+ - `grid`: 썸네일 96×96(`bg/subtle`, 1px `border/default`, `radius/md`, 안쪽 `space/1`), 항목 사이 `space/3`. 오른쪽 위 secondary `icon-sm` 버튼, 아래 Progress, 아래 이름 `caption` 한 줄 말줄임. 에러면 `border/danger` + `circle-alert`.
763
+
300
764
  ## 11. Figma ↔ 코드 매핑 (Code Connect 대체)
301
765
 
302
766
  Sonic 팀은 Figma Pro 요금제라 Code Connect(Organization 이상)를 쓸 수 없어요. 아래 표로 대신합니다. Figma 컴포넌트를 코드로 옮길 때는 이 표의 컴포넌트를 그대로 써요.
@@ -304,10 +768,35 @@ Sonic 팀은 Figma Pro 요금제라 Code Connect(Organization 이상)를 쓸 수
304
768
  | Figma 컴포넌트 (Core 페이지) | node id | 코드 | 파일 |
305
769
  |---|---|---|---|
306
770
  | Button | `5:52` | `<Button variant size disabled>` | `src/components/button.tsx` |
771
+ | Button Icon | `23:2294` | `<Button size="icon-*" variant disabled aria-label>` + 아이콘 자식 | `src/components/button.tsx` |
307
772
  | Input | `5:61` | `<Input invalid disabled placeholder>` | `src/components/input.tsx` |
308
773
  | Text | `5:110` | `<Text variant tone as>` | `src/components/text.tsx` |
309
774
  | Avatar | `5:117` | `<Avatar size src alt fallback>` | `src/components/avatar.tsx` |
310
775
  | Card | `6:2` | `Card`, `CardHeader`, `CardTitle`, `CardDescription`, `CardBody`, `CardFooter` | `src/components/card.tsx` |
311
776
  | Dialog | `6:13` | `Dialog`, `DialogTrigger`, `DialogPopup`, `DialogTitle`, `DialogDescription`, `DialogClose`, `DialogFooter` | `src/components/dialog.tsx` |
312
-
313
- Figma 속성 → props 변환 규칙: variant 속성은 같은 이름의 prop으로, 텍스트 속성 `children`은 자식으로, boolean `header`/`footer`는 해당 하위 컴포넌트를 넣을지 말지로 옮겨요.
777
+ | Field | `31:1543` | `<Field label description error invalid disabled>` + `<Input />` | `src/components/field.tsx` |
778
+ | Skeleton | `31:1547` | `<Skeleton shape>` | `src/components/skeleton.tsx` |
779
+ | EmptyState | `31:1548` | `<EmptyState icon title description action>` | `src/components/empty-state.tsx` |
780
+ | ErrorState | `31:1561` | `<ErrorState icon title description onRetry retryLabel>` | `src/components/error-state.tsx` |
781
+ | Textarea | `44:2239` | `<Textarea invalid disabled placeholder>` | `src/components/textarea.tsx` |
782
+ | Checkbox | `44:2266` | `<Checkbox checked indeterminate disabled>` (`checked=indeterminate` → `indeterminate`) | `src/components/checkbox.tsx` |
783
+ | Radio | `44:2281` | `<RadioGroup label>` + `<Radio value disabled>` | `src/components/radio-group.tsx` |
784
+ | Switch | `44:2298` | `<Switch checked disabled>` | `src/components/switch.tsx` |
785
+ | Select Trigger · Item · Popup | `44:2315` · `44:2372` · `44:2373` | `<Select options multiple placeholder invalid disabled>` (Trigger `state=open`은 열린 상태, Item은 `options`에서 자동 생성) | `src/components/select.tsx` |
786
+ | AlertDialog | `49:4264` | `AlertDialog`, `AlertDialogTrigger`, `AlertDialogPopup`, `…Title`, `…Description`, `…Close`, `…Footer` | `src/components/alert-dialog.tsx` |
787
+ | Menu Item · Menu Popup | `49:3092` · `49:3093` | `<MenuItem tone disabled>` (`showIcon`/`icon` → 자식 아이콘), `MenuPopup`, `MenuSeparator` | `src/components/menu.tsx` |
788
+ | Popover | `49:4247` | `Popover`, `PopoverTrigger`, `PopoverPopup`, `PopoverTitle`, `PopoverDescription`, `PopoverClose` | `src/components/popover.tsx` |
789
+ | Tooltip | `49:3040` | `<Tooltip content>` | `src/components/tooltip.tsx` |
790
+ | Tab · Tabs List | `49:3050` · `49:3051` | `Tabs`, `TabsList`, `<Tab value disabled>`, `TabsPanel` (`selected` → 현재 값) | `src/components/tabs.tsx` |
791
+ | Separator | `49:3039` | `<Separator orientation>` | `src/components/separator.tsx` |
792
+ | Toast | `57:3549` | `useToast().add({ tone, title, description })` (`showDescription` → `description`을 넘길지), 앱에는 `<ToastProvider>` | `src/components/toast.tsx` |
793
+ | Badge | `57:3498` | `<Badge tone variant>` (`children` → 자식 문구) | `src/components/badge.tsx` |
794
+ | Progress | `58:5093` | `<Progress value max label>` (`value` variant는 채움 미리보기, `value text`는 코드가 자동 표시) | `src/components/progress.tsx` |
795
+ | Link | `65:3733` | `<Link href external>` (`children` → 자식 문구) | `src/components/link.tsx` |
796
+ | Table Cell · Table | `65:3742` · `65:3743` | `Table`, `TableHeader`, `TableBody`, `TableRow`, `TableHead`, `TableCell` (`type=header` → `TableHead`, `align` → 같은 prop) | `src/components/table.tsx` |
797
+ | Toggle Item · Toggle Group | `66:3760` · `66:3761` | `<ToggleGroup multiple>` + `<ToggleGroupItem value disabled>` (`pressed` → 현재 값, `content=icon`이면 `aria-label`) | `src/components/toggle-group.tsx` |
798
+ | Accordion Item · Accordion | `66:3792` · `66:3793` | `Accordion`, `AccordionItem`, `AccordionTrigger`, `AccordionPanel` (`open` → 현재 값, `title`/`content` → 자식) | `src/components/accordion.tsx` |
799
+ | File Dropzone · File Item | `71:4191` · `72:4251` | `<FileDropzone variant accept multiple maxSize maxFiles onFilesChange>` (`state` → 끌어다 놓기·거부·`disabled`), `<FileList layout items onRemove onRetry>` (`status` → `items[].status`) | `src/components/file-upload.tsx` |
800
+ | Icon (35개) | Foundations `Source icons` | `lucide-react`의 같은 이름 컴포넌트 (`Icon / chevron-down` → `<ChevronDown />`) | 쓰는 쪽에서 설치 |
801
+
802
+ Figma 속성 → props 변환 규칙: variant 속성은 같은 이름의 prop으로, 텍스트 속성 `children`은 자식으로, boolean `header`/`footer`는 해당 하위 컴포넌트를 넣을지 말지로 옮겨요. Button의 `iconLeft`/`iconRight`(+ `leftIcon`/`rightIcon` 교체)와 Button Icon의 `icon`은 prop이 아니라 **자식 순서**예요: `<Button><Plus />추가하기<ChevronDown /></Button>`.