@nexus-cross/design-system 1.1.0 → 1.1.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.
@@ -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>