@nexus-cross/design-system 1.1.0 → 2.0.0-beta.1

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 (72) hide show
  1. package/claude-rules/nexus/CLAUDE.md +34 -6
  2. package/cursor-rules/CLAUDE.md +38 -9
  3. package/cursor-rules/nexus-project-setup.mdc +30 -16
  4. package/cursor-rules/nexus-ui-api.mdc +127 -41
  5. package/cursor-rules/nexus-ui-decisions.mdc +6 -2
  6. package/dist/chunks/{chunk-G3RLK2HS.js → chunk-3SCSND6S.js} +1 -1
  7. package/dist/chunks/{chunk-56ZOOQFE.mjs → chunk-AG2UJPFX.mjs} +124 -17
  8. package/dist/chunks/{chunk-EILXBLEV.mjs → chunk-QWK4CLS2.mjs} +1 -1
  9. package/dist/chunks/{chunk-5ASTWFJW.js → chunk-RC2Y4UH7.js} +127 -17
  10. package/dist/combobox.js +15 -3
  11. package/dist/combobox.mjs +1 -1
  12. package/dist/components/Combobox.d.ts +53 -8
  13. package/dist/components/Combobox.d.ts.map +1 -1
  14. package/dist/index.d.ts +2 -2
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +35 -24
  17. package/dist/index.mjs +4 -5
  18. package/dist/modal/index.js +11 -11
  19. package/dist/modal/index.mjs +2 -2
  20. package/dist/schemas/_all.json +62 -17
  21. package/dist/schemas/combobox.d.ts +33 -10
  22. package/dist/schemas/combobox.d.ts.map +1 -1
  23. package/dist/schemas/combobox.json +6 -6
  24. package/dist/schemas/comboboxOption.json +16 -11
  25. package/dist/schemas/comboboxOptionDescription.json +20 -0
  26. package/dist/schemas/comboboxOptionMeta.json +20 -0
  27. package/dist/schemas/index.d.ts +1 -1
  28. package/dist/schemas/index.d.ts.map +1 -1
  29. package/dist/schemas.js +74 -13
  30. package/dist/schemas.mjs +72 -13
  31. package/dist/styles/.generated/built.d.ts +1 -1
  32. package/dist/styles/.generated/built.d.ts.map +1 -1
  33. package/dist/styles/layer.js +2 -2
  34. package/dist/styles/layer.mjs +1 -1
  35. package/dist/styles.css +13 -0
  36. package/dist/styles.js +2 -2
  37. package/dist/styles.layered.css +13 -0
  38. package/dist/styles.mjs +1 -1
  39. package/dist/tailwind-v4.css +19 -0
  40. package/dist/tokens/TOKENS.md +426 -0
  41. package/dist/tokens/company.css +410 -0
  42. package/dist/tokens/css.css +405 -0
  43. package/dist/tokens/data/borderWidth.json +38 -0
  44. package/dist/tokens/data/breakpoint.json +23 -0
  45. package/dist/tokens/data/color.json +973 -0
  46. package/dist/tokens/data/index.ts +63 -0
  47. package/dist/tokens/data/motion.json +64 -0
  48. package/dist/tokens/data/opacity.json +65 -0
  49. package/dist/tokens/data/radius.json +25 -0
  50. package/dist/tokens/data/shadow.json +76 -0
  51. package/dist/tokens/data/size.json +46 -0
  52. package/dist/tokens/data/space.json +86 -0
  53. package/dist/tokens/data/typography.json +626 -0
  54. package/dist/tokens/data/zIndex.json +22 -0
  55. package/dist/tokens/index.d.ts +11 -0
  56. package/dist/tokens/index.d.ts.map +1 -0
  57. package/dist/tokens/index.js +12 -0
  58. package/dist/tokens/index.mjs +1 -0
  59. package/dist/tokens/tailwind.js +260 -0
  60. package/dist/tokens-domains/data/index.ts +16 -0
  61. package/dist/tokens-domains/data/prediction/domain.json +324 -0
  62. package/dist/tokens-domains/index.d.ts +12 -0
  63. package/dist/tokens-domains/index.d.ts.map +1 -0
  64. package/dist/tokens-domains/index.js +12 -0
  65. package/dist/tokens-domains/index.mjs +1 -0
  66. package/dist/tokens-domains/prediction-vars.css +154 -0
  67. package/dist/tokens-domains/prediction.css +153 -0
  68. package/dist/tokens-domains/prediction.md +70 -0
  69. package/dist/tokens-domains/tailwind.js +59 -0
  70. package/package.json +27 -6
  71. package/dist/chunks/{chunk-5ZVPTIL3.mjs → chunk-3VFBPFZF.mjs} +1 -1
  72. package/dist/chunks/{chunk-7F4SOLAC.js → chunk-U53UA76K.js} +1 -1
@@ -34,7 +34,13 @@
34
34
 
35
35
  ---
36
36
 
37
- ## Quick Component Import
37
+ ## Quick Install (단일 패키지)
38
+
39
+ ```bash
40
+ npm install @nexus-cross/design-system # 토큰/도메인 토큰까지 자동 transitive install
41
+ ```
42
+
43
+ ## Quick Component / Token Import
38
44
 
39
45
  ```tsx
40
46
  import {
@@ -47,25 +53,47 @@ import {
47
53
  } from '@nexus-cross/design-system';
48
54
 
49
55
  import { modal, useModal, ModalTemplate, ModalContainer } from '@nexus-cross/design-system/modal';
56
+
57
+ // 토큰 JS API (sub-path)
58
+ import { getTheme } from '@nexus-cross/design-system/tokens';
59
+ import { getPredictionTheme } from '@nexus-cross/design-system/tokens-domains';
50
60
  ```
51
61
 
52
62
  ---
53
63
 
54
64
  ## CSS Setup (한 번만)
55
65
 
56
- ### Tailwind v4
66
+ ### Tailwind v4 (Next + Turbopack / Vite 등 모든 환경) — **2줄 셋업**
67
+
68
+ **design-system + 토큰을 함께 쓸 때 (대부분의 경우):**
57
69
  ```css
70
+ /* globals.css */
58
71
  @import 'tailwindcss';
59
- @import '@nexus-cross/tokens/company.css';
60
- /* Next.js Turbopack only: */
72
+ @import '@nexus-cross/design-system/tailwind-v4.css';
73
+ /* 도메인 토큰 사용 시 한 줄 추가 */
74
+ /* @import '@nexus-cross/design-system/tokens-domains/prediction.css'; */
75
+ ```
76
+
77
+ `tailwind-v4.css` 안에서 회사 공통 토큰이 sub-path를 통해 자동 로드되므로 **토큰 import를 따로 쓸 필요 없습니다.** 컴포넌트 CSS는 Tailwind의 `components` 레이어에 주입되어 `className="bg-red-500"` 오버라이드가 항상 utility에 우선합니다.
78
+
79
+ **디자인 토큰만 쓸 때 (design-system 없이):** `@nexus-cross/tokens`를 별도 설치하고 `@import '@nexus-cross/tokens/company.css';` 사용.
80
+
81
+ **`@layer base, nexus, components, utilities;` statement를 직접 쓰지 마세요.** Tailwind v4 프로세서가 자동 정리/제거하기 때문에 의도와 다르게 동작합니다.
82
+
83
+ ### Tailwind v3 / 순수 CSS
84
+ ```css
85
+ /* globals.css */
86
+ @import '@nexus-cross/design-system/tokens/css';
61
87
  @import '@nexus-cross/design-system/styles.css';
88
+ @tailwind base; @tailwind components; @tailwind utilities;
62
89
  ```
90
+ 또는 entry에서 `import '@nexus-cross/design-system/styles'`.
63
91
 
64
- ### Next.js Turbopack
92
+ ### Next.js (모든 버전)
65
93
  ```js
66
94
  // next.config.mjs
67
95
  const nextConfig = {
68
- transpilePackages: ['@nexus-cross/design-system', '@nexus-cross/tokens'],
96
+ transpilePackages: ['@nexus-cross/design-system'],
69
97
  }
70
98
  ```
71
99
 
@@ -25,33 +25,62 @@
25
25
 
26
26
  4. **`className` 오버라이드 시 `!important` 금지.** `cn()` 유틸이 프리픽스 충돌 자동 해소.
27
27
 
28
- ## Component Import
28
+ ## 설치 (단일 패키지)
29
+
30
+ v2.0부터 `@nexus-cross/design-system` 1개 install로 토큰까지 모두 사용 가능합니다.
31
+
32
+ ```bash
33
+ npm install @nexus-cross/design-system
34
+ ```
35
+
36
+ ## Component / Token Import
29
37
 
30
38
  ```tsx
39
+ // UI 컴포넌트
31
40
  import { Button, TextInput, TextArea, Select, Switch, Chip, Spinner, Divider } from '@nexus-cross/design-system';
32
41
  import { Tooltip, Popover, Drawer, Accordion } from '@nexus-cross/design-system';
33
42
  import { toast, Toaster } from '@nexus-cross/design-system';
34
43
  import { modal, useModal, ModalTemplate, ModalContainer } from '@nexus-cross/design-system/modal';
35
- import { NumberInput, numberInputBind } from '@nexus-cross/design-system';
36
- import { Avatar, Tab, ToggleGroup } from '@nexus-cross/design-system';
37
- import { cn } from '@nexus-cross/design-system';
44
+ import { NumberInput, numberInputBind, Avatar, Tab, ToggleGroup, cn } from '@nexus-cross/design-system';
45
+
46
+ // 토큰 JS API (sub-path)
47
+ import { getTheme } from '@nexus-cross/design-system/tokens';
48
+ import { getPredictionTheme } from '@nexus-cross/design-system/tokens-domains';
38
49
  ```
39
50
 
40
51
  ## CSS Setup
41
52
 
42
- ### Tailwind v4
53
+ ### Tailwind v4 (Next + Turbopack / Vite 등 모든 환경) — **2줄 셋업**
54
+
55
+ **design-system + 토큰을 함께 쓸 때 (대부분의 경우):**
43
56
  ```css
57
+ /* globals.css */
44
58
  @import 'tailwindcss';
45
- @import '@nexus-cross/tokens/company.css';
46
- /* Next.js Turbopack only: */
59
+ @import '@nexus-cross/design-system/tailwind-v4.css';
60
+ /* 도메인 토큰 사용 시 한 줄 추가 */
61
+ /* @import '@nexus-cross/design-system/tokens-domains/prediction.css'; */
62
+ ```
63
+
64
+ `tailwind-v4.css` 안에서 회사 공통 토큰이 자동 로드되므로 **토큰 import를 따로 쓸 필요 없습니다.** 컴포넌트 CSS는 Tailwind의 `components` 레이어에 주입되어 `className="bg-red-500"` 오버라이드가 항상 utility에 우선합니다.
65
+
66
+ **디자인 토큰만 쓸 때 (design-system 없이):** `@nexus-cross/tokens`를 별도 설치하고 `@import '@nexus-cross/tokens/company.css';` 사용.
67
+
68
+ **절대 직접 `@layer base, nexus, components, utilities;` 같은 statement를 globals.css에 쓰지 말 것.** Tailwind v4 프로세서가 자동 정리해버려 의도와 다르게 동작합니다.
69
+
70
+ ### Tailwind v3 / 순수 CSS
71
+ ```css
72
+ /* globals.css */
73
+ @import '@nexus-cross/design-system/tokens/css';
47
74
  @import '@nexus-cross/design-system/styles.css';
75
+ @tailwind base; @tailwind components; @tailwind utilities;
48
76
  ```
77
+ 또는 entry에서 `import '@nexus-cross/design-system/styles'`.
49
78
 
50
- ### Next.js Turbopack
79
+ ### Next.js (모든 버전)
51
80
  ```js
52
81
  // next.config.mjs
53
82
  const nextConfig = {
54
- transpilePackages: ['@nexus-cross/design-system', '@nexus-cross/tokens'],
83
+ transpilePackages: ['@nexus-cross/design-system'],
55
84
  }
56
85
  ```
57
86
 
@@ -13,28 +13,41 @@ This project uses NEXUS Design System. All generated code MUST follow the rules
13
13
  - **UI Components**: `@nexus-cross/design-system` (React, CVA + Plain CSS)
14
14
  - **Styling**: Tailwind CSS v4 + NEXUS semantic tokens
15
15
 
16
- ## Design System CSS Setup
16
+ ## Install (단일 패키지)
17
17
 
18
- Choose one of two CSS entry points depending on the project environment:
18
+ v2.0부터 design-system 1개 install로 토큰까지 모두 가능합니다.
19
19
 
20
- | Environment | import | Description |
21
- |---|---|---|
22
- | Tailwind v3 / Plain CSS / CSS Modules | `@nexus-cross/design-system/styles` | unlayered CSS |
23
- | Tailwind v4 | `@nexus-cross/design-system/styles/layer` | wrapped in `@layer nexus` |
20
+ ```bash
21
+ npm install @nexus-cross/design-system # @nexus-cross/tokens, tokens-domains 자동 transitive install
22
+ ```
24
23
 
25
- ```tsx
26
- // Tailwind v3, Plain CSS, CSS Modules
27
- import '@nexus-cross/design-system/styles'
24
+ 토큰만 단독으로 쓰는 경우엔 `npm install @nexus-cross/tokens` (또는 `tokens-domains`) 가능.
28
25
 
29
- // Tailwind v4
30
- import '@nexus-cross/design-system/styles/layer'
31
- ```
26
+ ## Design System CSS Setup
32
27
 
33
- For Tailwind v4 projects, also declare layer order in `globals.css`:
28
+ | Environment | Setup |
29
+ |---|---|
30
+ | **Tailwind v4** (Next + Turbopack / Vite 등) | `globals.css`에서 2줄: `@import 'tailwindcss';` + `@import '@nexus-cross/design-system/tailwind-v4.css';` |
31
+ | Tailwind v3 | `globals.css`에 `@import '@nexus-cross/design-system/tokens/css';` + `@import '@nexus-cross/design-system/styles.css';` |
32
+ | Plain CSS / CSS Modules | `@import '@nexus-cross/design-system/tokens/css';` + entry에서 `import '@nexus-cross/design-system/styles'` |
33
+
34
+ ### Tailwind v4 — **2줄 셋업** (권장)
35
+
36
+ **design-system + 토큰을 함께 쓸 때 (대부분의 경우):**
34
37
  ```css
35
- @layer base, nexus, components, utilities;
38
+ /* globals.css */
39
+ @import 'tailwindcss';
40
+ @import '@nexus-cross/design-system/tailwind-v4.css';
41
+ /* 도메인 토큰 사용 시 */
42
+ /* @import '@nexus-cross/design-system/tokens-domains/prediction.css'; */
36
43
  ```
37
44
 
45
+ `tailwind-v4.css` 안에서 회사 공통 토큰이 sub-path를 통해 자동 로드되므로 **토큰 import를 따로 쓸 필요 없습니다.** 컴포넌트 CSS는 Tailwind의 `components` 레이어에 주입되어 `className="bg-red-500"` 오버라이드가 항상 utility에 우선합니다.
46
+
47
+ **디자인 토큰만 쓸 때 (design-system 없이):** `@nexus-cross/tokens`를 별도 설치하고 `@import '@nexus-cross/tokens/company.css';` 사용.
48
+
49
+ **`@layer base, nexus, components, utilities;` 같은 statement를 직접 쓰지 마세요.** Tailwind v4 프로세서가 자동으로 정리/제거하기 때문에 의도와 다르게 동작합니다.
50
+
38
51
  ## Absolute Rules
39
52
 
40
53
  1. **Always use NEXUS tokens for colors.** Hardcoding is prohibited.
@@ -225,8 +238,9 @@ import { toast, Toaster } from '@nexus-cross/design-system';
225
238
  // Modal system (separate subpath)
226
239
  import { modal, useModal, ModalTemplate, ModalContainer } from '@nexus-cross/design-system/modal';
227
240
 
228
- // Tokens (only when JS API is needed)
229
- import { getTheme } from '@nexus-cross/tokens';
241
+ // Tokens (sub-path design-system 1개 install로 사용 가능)
242
+ import { getTheme } from '@nexus-cross/design-system/tokens';
243
+ import { getPredictionTheme } from '@nexus-cross/design-system/tokens-domains';
230
244
  ```
231
245
 
232
246
  ## Modal Writing Rules
@@ -241,7 +241,7 @@ Individual option within Select.
241
241
 
242
242
  ## Combobox
243
243
 
244
- Searchable select. Text input + popover listbox. Single/multi-select. Sync (auto-filter) or async (onSearch + loading) modes.
244
+ Searchable select with compound option API. Text input + popover listbox. Single/multi-select. Sync (auto-filter) or async (onSearch + loading) modes.
245
245
 
246
246
  WHEN TO USE:
247
247
  • Options ≥ 7, OR labels are long, OR search/filter is needed → Combobox (not Select)
@@ -249,8 +249,29 @@ WHEN TO USE:
249
249
  • Async data from server → set onSearch + loading
250
250
  For ≤7 simple options use Select. For free-text tags use TagInput.
251
251
 
252
+ COMPOUND API:
253
+ <Combobox value={v} onValueChange={setV} placeholder="…">
254
+ <Combobox.Option value="kr">한국</Combobox.Option>
255
+ <Combobox.Option value="jp" disabled>
256
+ 일본
257
+ <Combobox.OptionDescription>품절</Combobox.OptionDescription>
258
+ <Combobox.OptionMeta>JP</Combobox.OptionMeta>
259
+ </Combobox.Option>
260
+ </Combobox>
261
+
262
+ • <Combobox.Option> requires unique `value` (dev mode warns on duplicates and drops the duplicate)
263
+ • <Combobox.OptionDescription> = secondary line under label
264
+ • <Combobox.OptionMeta> = right-aligned slot (price, shortcut, badge)
265
+ • Both slots are excluded from textValue-based search
266
+
252
267
  ASYNC PATTERN:
253
- <Combobox options={results} loading={isFetching} onSearch={(q) => mutate(q)} />
268
+ <Combobox loading={isFetching} onSearch={(q) => mutate(q)}>
269
+ {results.map((u) => (
270
+ <Combobox.Option key={u.id} value={u.id}>{u.name}
271
+ <Combobox.OptionDescription>{u.email}</Combobox.OptionDescription>
272
+ </Combobox.Option>
273
+ ))}
274
+ </Combobox>
254
275
  — onSearch fires after searchDebounce (default 250ms). Do NOT clear input on result update; component preserves user's typing.
255
276
 
256
277
  IME (Korean/Japanese/Chinese): Enter during composition is ignored automatically — do not add custom keydown handlers.
@@ -258,19 +279,22 @@ IME (Korean/Japanese/Chinese): Enter during composition is ignored automatically
258
279
  ANTI-PATTERNS:
259
280
  ✗ <Select> with 20 options → <Combobox>
260
281
  ✗ Manual <input> + dropdown div + filter logic → <Combobox>
282
+ ✗ Passing options through a prop array (legacy API removed) → use <Combobox.Option> children
283
+ ✗ Wrapping options in extra elements (<div><Combobox.Option/></div>) → keep them as direct children
284
+ ✗ Same `value` on two <Combobox.Option> — duplicates are warned + dropped in dev mode
261
285
  ✗ Setting value externally to clear input mid-typing → use onValueChange instead
262
286
 
263
287
  | Prop | Type | Default | Description |
264
288
  |---|---|---|---|
265
- | `options` | `ReactNode` | - | Available options array (ComboboxOption[], required) |
289
+ | `children` | `ReactNode` | - | <Combobox.Option> elements (required). Other children are ignored with a dev-mode warning. Async-search consumers swap this list as `onSearch` results arrive. |
266
290
  | `value` | `ReactNode` | - | Selected value. string for single, string[] for multiple |
267
291
  | `defaultValue` | `ReactNode` | - | Initial value (uncontrolled) |
268
292
  | `onValueChange` | `ReactNode` | - | Value change callback. (value: string | string[]) => void |
269
293
  | `multiple` | `boolean` | `false` | Multi-select mode. Selected values shown as chips inside input |
270
- | `onSearch` | `ReactNode` | - | Async search callback. (query: string) => void. Triggers external data fetching with debounce |
294
+ | `onSearch` | `ReactNode` | - | Async search callback. (query: string) => void. Triggers external data fetching with debounce. When set, the built-in client filter is disabled — render whatever <Combobox.Option> children match the latest results. |
271
295
  | `searchDebounce` | `number` | `250` | Debounce delay (ms) before onSearch fires |
272
- | `loading` | `boolean` | `false` | Externally-controlled loading state. Shows spinner in input suffix |
273
- | `filter` | `ReactNode` | - | Custom client-side filter. (option, query) => boolean. Default: case-insensitive label includes match |
296
+ | `loading` | `boolean` | `false` | Externally-controlled loading state. Shows spinner in input suffix and a status row inside the popover. |
297
+ | `filter` | `ReactNode` | - | Custom client-side filter. (option: { value, textValue, disabled }, query: string) => boolean. Default: case-insensitive textValue includes match. Ignored when onSearch is set. |
274
298
  | `placeholder` | `string` | - | Input placeholder |
275
299
  | `emptyMessage` | `ReactNode` | - | Message when no options match (string | ReactNode). Default: "검색 결과 없음" |
276
300
  | `loadingMessage` | `ReactNode` | - | Message during loading state inside popover (string | ReactNode). Default: "검색 중…" |
@@ -284,83 +308,140 @@ ANTI-PATTERNS:
284
308
  | `className` | `string` | - | Wrapper className |
285
309
  | `popoverClassName` | `string` | - | Popover content className |
286
310
 
287
- ### ComboboxOption
311
+ ### Combobox.Option
312
+
313
+ Single Combobox option. Direct child of <Combobox> only.
314
+
315
+ WHEN TO USE:
316
+ • One per selectable item; `value` MUST be unique within the Combobox
317
+ • Wrap rich content (icons, badges) directly as children — no escape hatch needed
318
+ • Use textValue when label is non-text (e.g. <Combobox.Option value="apple" textValue="사과 apple">🍎</Combobox.Option>)
319
+
320
+ ANTI-PATTERNS:
321
+ ✗ <Combobox><div><Combobox.Option/></div></Combobox> — Option must be a direct child
322
+ ✗ Same value on two options → dev warning + silent drop; pick unique values
323
+ ✗ Putting label text inside <Combobox.OptionDescription> — that slot is the secondary line below the label
324
+
325
+ | Prop | Type | Default | Description |
326
+ |---|---|---|---|
327
+ | `value` | `string` | - | Unique value (string, required). Duplicate values within one Combobox produce a dev-mode console.error and the duplicate option is dropped. |
328
+ | `disabled` | `boolean` | - | Disable selection. Skipped by keyboard navigation (Arrow Up/Down, Home/End). |
329
+ | `textValue` | `string` | - | Text used for client-side filtering and the input display when this option is selected. If omitted, derived from `children` (string nodes only; OptionDescription / OptionMeta are excluded). Set this when label contains icons or non-text nodes you still want searchable (e.g. textValue="apple 사과 fruit"). |
330
+ | `className` | `string` | - | Class merged onto the rendered <div role="option">. |
331
+ | `children` | `ReactNode` | - | Label content + optional <Combobox.OptionDescription> / <Combobox.OptionMeta> slots. |
332
+
333
+ ### Combobox.OptionDescription
334
+
335
+ Secondary text shown below an option label. Use for hints like "Republic of Korea" beneath "한국". Excluded from textValue-based search.
336
+
337
+ | Prop | Type | Default | Description |
338
+ |---|---|---|---|
339
+ | `children` | `ReactNode` | - | Secondary text below the label (ReactNode). |
340
+ | `className` | `string` | - | Class for the description node. |
341
+
342
+ ### Combobox.OptionMeta
288
343
 
289
- Single Combobox option.
344
+ Right-aligned slot inside an option. Use for prices, keyboard shortcuts, version tags, status badges. Excluded from textValue-based search.
290
345
 
291
346
  | Prop | Type | Default | Description |
292
347
  |---|---|---|---|
293
- | `value` | `string` | - | Option value (unique key) |
294
- | `label` | `ReactNode` | - | Display label (string | ReactNode) |
295
- | `description` | `ReactNode` | - | Secondary text below label (ReactNode) |
296
- | `disabled` | `boolean` | - | Disabled option |
348
+ | `children` | `ReactNode` | - | Right-aligned meta content (price, badge, shortcut, etc.). |
349
+ | `className` | `string` | - | Class for the meta slot. |
297
350
 
298
- Searchable select with popover listbox. Supports single/multi-select and sync (auto-filter) / async (onSearch + loading) modes. **WAI-ARIA Combobox pattern** (Radix Popover under the hood — NOT Radix Select).
351
+ Searchable select with popover listbox. **Compound API** — options are declared as <Combobox.Option> children. Supports single/multi-select and sync (auto-filter) / async (onSearch + loading) modes. **WAI-ARIA Combobox pattern** (Radix Popover under the hood — NOT Radix Select).
299
352
 
300
- - **Sync mode** (no `onSearch` prop): client-side filters `options` by typed query (case-insensitive label includes match by default; override via `filter`).
301
- - **Async mode** (provide `onSearch`): debounced (`searchDebounce`, default 250ms) callback fires for external data fetch. Set `loading` to show spinner in the input suffix.
302
- - **Multi-select** (`multiple`): selected values render as removable chips inside the input. Backspace on empty input removes the last chip.
353
+ - **Subcomponents**: `Combobox.Option` (required `value`), `Combobox.OptionDescription` (secondary line), `Combobox.OptionMeta` (right-aligned slot).
354
+ - **Duplicate `value`**: dev mode logs `console.error` and silently drops the duplicate.
355
+ - **textValue**: derived from option children (text nodes only; OptionDescription/OptionMeta excluded). Override per-option when label is non-text.
356
+ - **Sync mode** (no `onSearch`): client-side filters by `textValue` (case-insensitive includes; override with `filter`).
357
+ - **Async mode** (provide `onSearch`): debounced (`searchDebounce`, default 250ms) callback fires for external data fetch. Set `loading` for spinner.
358
+ - **Multi-select** (`multiple`): chips inside input; Backspace on empty input removes the last chip.
303
359
  - Keyboard: Arrow Up/Down, Home/End, Enter to select, Escape to close.
304
360
 
305
361
  ```tsx
306
- // 1) Sync — auto-filter local options
307
- const COUNTRIES = [
308
- { value: 'kr', label: '한국' },
309
- { value: 'jp', label: '일본' },
310
- { value: 'us', label: '미국' },
311
- ];
312
-
313
- <Combobox
314
- options={COUNTRIES}
315
- value={value}
316
- onValueChange={setValue}
317
- placeholder="국가 선택"
318
- />
362
+ // 1) Sync — declarative options
363
+ <Combobox value={value} onValueChange={setValue} placeholder="국가 선택">
364
+ <Combobox.Option value="kr">한국</Combobox.Option>
365
+ <Combobox.Option value="jp">일본</Combobox.Option>
366
+ <Combobox.Option value="us" disabled>
367
+ 미국
368
+ <Combobox.OptionDescription>일시 품절</Combobox.OptionDescription>
369
+ </Combobox.Option>
370
+ </Combobox>
319
371
 
320
372
  // 2) Async — external search w/ loading spinner
321
- const [results, setResults] = useState<ComboboxOption[]>([]);
373
+ const [results, setResults] = useState<{ id: string; name: string; email: string }[]>([]);
322
374
  const [loading, setLoading] = useState(false);
323
375
 
324
376
  const handleSearch = async (q: string) => {
325
- if (!q) return setResults([]);
326
377
  setLoading(true);
327
- const users = await fetchUsers(q);
328
- setResults(users.map((u) => ({ value: u.id, label: u.name, description: u.email })));
378
+ setResults(await fetchUsers(q));
329
379
  setLoading(false);
330
380
  };
331
381
 
332
382
  <Combobox
333
- options={results}
334
383
  loading={loading}
335
384
  onSearch={handleSearch}
336
385
  value={selectedId}
337
386
  onValueChange={setSelectedId}
338
387
  placeholder="유저 검색…"
339
388
  emptyMessage="검색 결과 없음"
340
- />
389
+ >
390
+ {results.map((u) => (
391
+ <Combobox.Option key={u.id} value={u.id}>
392
+ {u.name}
393
+ <Combobox.OptionDescription>{u.email}</Combobox.OptionDescription>
394
+ </Combobox.Option>
395
+ ))}
396
+ </Combobox>
341
397
 
342
398
  // 3) Multi-select with chips
343
399
  <Combobox
344
400
  multiple
345
- options={results}
346
401
  value={selectedIds}
347
402
  onValueChange={setSelectedIds}
348
403
  loading={loading}
349
404
  onSearch={handleSearch}
350
- />
351
-
352
- // 4) With label / description / error
405
+ >
406
+ {results.map((u) => (
407
+ <Combobox.Option key={u.id} value={u.id}>{u.name}</Combobox.Option>
408
+ ))}
409
+ </Combobox>
410
+
411
+ // 4) Rich option — meta slot for prices / shortcuts / badges
412
+ <Combobox placeholder="플랜 선택">
413
+ <Combobox.Option value="free">
414
+ Free
415
+ <Combobox.OptionDescription>개인용</Combobox.OptionDescription>
416
+ <Combobox.OptionMeta>$0/mo</Combobox.OptionMeta>
417
+ </Combobox.Option>
418
+ <Combobox.Option value="pro">
419
+ Pro
420
+ <Combobox.OptionDescription>소규모 팀</Combobox.OptionDescription>
421
+ <Combobox.OptionMeta>$12/mo</Combobox.OptionMeta>
422
+ </Combobox.Option>
423
+ </Combobox>
424
+
425
+ // 5) Non-text label — explicit textValue for searching
426
+ <Combobox.Option value="apple" textValue="apple 사과 fruit">
427
+ 🍎 사과
428
+ </Combobox.Option>
429
+
430
+ // 6) With label / description / error
353
431
  <Combobox
354
432
  label="담당자"
355
433
  description="검색 후 선택하세요"
356
- options={results}
357
434
  loading={loading}
358
435
  onSearch={handleSearch}
359
436
  value={value}
360
437
  onValueChange={setValue}
361
438
  error={!value}
362
439
  size="lg"
363
- />
440
+ >
441
+ {results.map((u) => (
442
+ <Combobox.Option key={u.id} value={u.id}>{u.name}</Combobox.Option>
443
+ ))}
444
+ </Combobox>
364
445
  ```
365
446
 
366
447
  **When to use `Select` vs `Combobox`**:
@@ -368,6 +449,11 @@ const handleSearch = async (q: string) => {
368
449
  - Long list / async search / typed input → `Combobox`
369
450
  - Free-form tag input (any string) → `TagInput`
370
451
 
452
+ **ANTI-PATTERNS**:
453
+ - ✗ `<Combobox options={[...]}>` — legacy prop API removed; use `<Combobox.Option>` children
454
+ - ✗ Wrapping options: `<Combobox><div><Combobox.Option/></div></Combobox>` — Option must be a direct child
455
+ - ✗ Duplicate `value` across options — dev warning + duplicate dropped
456
+
371
457
  ---
372
458
 
373
459
  ## CheckBox
@@ -40,8 +40,12 @@ UI 작업 시 어떤 컴포넌트를 골라야 할지 결정하는 가이드. **
40
40
  // ❌ 옵션 20개에 Select
41
41
  <Select><SelectItem>...</SelectItem>...</Select>
42
42
 
43
- // ✅ 옵션 20개에 Combobox
44
- <Combobox options={options} placeholder="검색하여 선택" />
43
+ // ✅ 옵션 20개에 Combobox (compound API)
44
+ <Combobox placeholder="검색하여 선택">
45
+ {options.map((o) => (
46
+ <Combobox.Option key={o.value} value={o.value}>{o.label}</Combobox.Option>
47
+ ))}
48
+ </Combobox>
45
49
 
46
50
  // ❌ 옵션 3개에 Select
47
51
  <Select><SelectItem>S</SelectItem><SelectItem>M</SelectItem><SelectItem>L</SelectItem></Select>