Combobox
Interactive UI Explorer
아래에서 각 컴포넌트의 다양한 Variants와 States를 문서화된 형태로 테스트해볼 수 있습니다.
Live Preview
Combobox
검색 입력 + 제안 드롭다운(autocomplete)입니다. Select와 달리 자유 입력으로 필터하며, 키보드 ↑↓·Enter·Esc·Home/End와 ARIA combobox를 지원합니다.
기본 — 검색 + 선택
입력에 맞춰 label·description을 필터합니다. debounceMs로 입력을 늦춰 API 호출에 연결할 수 있습니다.
Source Code
선택값: —
Live Preview
Implementation
제작 코드
이 컴포넌트가 실제로 어떻게 구현되어 있는지 — 본체 .tsx 파일을 그대로 보여줍니다. variant 매핑·접근성 처리·forwardRef 패턴 등 디테일을 그대로 확인할 수 있어요.
Comboboxtypescript
'use client';
/**
* Combobox 컴포넌트
*
* 검색 입력 + 제안 드롭다운(autocomplete). Select와 달리 자유 입력으로 필터합니다.
* - 입력을 debounceMs만큼 늦춰 필터(실무에선 이 지점에서 API 호출).
* - 키보드: ↑↓ 하이라이트 이동(wrap), Enter 선택, Esc 닫기, Home/End 양끝.
* - 마우스: hover 하이라이트 sync, 클릭(onMouseDown)으로 blur보다 먼저 선택.
* - ARIA combobox: role=combobox + aria-controls/expanded/activedescendant + role=option.
*/
import {
useEffect,
useId,
useMemo,
useRef,
useState,
type KeyboardEvent,
type ReactNode,
} from 'react';
import { cn } from '@/lib/cn';
export interface ComboboxOption {
value: string;
label: string;
description?: string;
}
export interface ComboboxProps {
options: ComboboxOption[];
value?: string;
onChange?: (value: string | undefined, option?: ComboboxOption) => void;
placeholder?: string;
emptyText?: ReactNode;
maxResults?: number;
/** 입력 디바운스(ms). 기본 0 */
debounceMs?: number;
label?: string;
disabled?: boolean;
className?: string;
}
function useDebouncedValue<T>(val: T, ms: number): T {
const [debounced, setDebounced] = useState(val);
useEffect(() => {
const t = setTimeout(() => setDebounced(val), ms);
return () => clearTimeout(t);
}, [val, ms]);
return ms > 0 ? debounced : val;
}
export function Combobox({
options,
value,
onChange,
placeholder = '검색…',
emptyText = '결과가 없어요.',
maxResults = 8,
debounceMs = 0,
label,
disabled = false,
className,
}: ComboboxProps) {
const listboxId = useId();
const optionIdPrefix = useId();
const initialLabel = useMemo(
() => options.find((o) => o.value === value)?.label ?? '',
// 최초 1회만 — 이후 입력은 사용자 주도
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const [query, setQuery] = useState(initialLabel);
const debounced = useDebouncedValue(query, debounceMs);
const debouncing = query !== debounced;
const [open, setOpen] = useState(false);
const [highlight, setHighlight] = useState(0);
const results = useMemo(() => {
const q = debounced.trim().toLowerCase();
const base = q === ''
? options
: options.filter(
(o) => o.label.toLowerCase().includes(q) || (o.description?.toLowerCase().includes(q) ?? false),
);
return base.slice(0, maxResults);
}, [debounced, options, maxResults]);
const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', onDown);
return () => document.removeEventListener('mousedown', onDown);
}, [open]);
useEffect(() => {
if (!open) return;
const el = listRef.current?.querySelector<HTMLLIElement>(`[data-index="${highlight}"]`);
el?.scrollIntoView({ block: 'nearest' });
}, [highlight, open]);
const commit = (option: ComboboxOption) => {
onChange?.(option.value, option);
setQuery(option.label);
setOpen(false);
inputRef.current?.blur();
};
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
if (!open) return setOpen(true);
if (results.length) setHighlight((h) => (h + 1) % results.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (!open) return setOpen(true);
if (results.length) setHighlight((h) => (h - 1 + results.length) % results.length);
} else if (e.key === 'Home') {
if (open) {
e.preventDefault();
setHighlight(0);
}
} else if (e.key === 'End') {
if (open) {
e.preventDefault();
setHighlight(Math.max(0, results.length - 1));
}
} else if (e.key === 'Enter') {
if (open && results[highlight]) {
e.preventDefault();
commit(results[highlight]);
}
} else if (e.key === 'Escape') {
if (open) {
e.preventDefault();
setOpen(false);
} else if (query !== '') {
setQuery('');
onChange?.(undefined);
}
}
};
const activeId = open && results[highlight] ? `${optionIdPrefix}-${highlight}` : undefined;
return (
<div className={cn('space-y-1.5', className)} ref={rootRef}>
{label && <div className="text-xs font-semibold text-slate-700">{label}</div>}
<div className="relative">
<svg
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
aria-hidden
>
<circle cx={11} cy={11} r={7} />
<path d="m20 20-3.5-3.5" strokeLinecap="round" />
</svg>
<input
ref={inputRef}
role="combobox"
aria-expanded={open}
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={activeId}
value={query}
disabled={disabled}
placeholder={placeholder}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
setHighlight(0);
}}
onFocus={() => setOpen(true)}
onKeyDown={onKeyDown}
className="w-full rounded-xl border border-slate-300 bg-white py-2.5 pl-10 pr-10 text-sm font-medium text-slate-900 shadow-sm outline-none transition-all placeholder:text-slate-400 hover:border-slate-400 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20 disabled:cursor-not-allowed disabled:opacity-60"
/>
{query && !disabled && (
<button
type="button"
onClick={() => {
setQuery('');
onChange?.(undefined);
setOpen(true);
inputRef.current?.focus();
}}
aria-label="입력 지우기"
className="absolute right-2.5 top-1/2 grid h-7 w-7 -translate-y-1/2 place-items-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} aria-hidden>
<path strokeLinecap="round" d="M6 6l12 12M18 6l-12 12" />
</svg>
</button>
)}
{open && (
<div className="absolute left-0 right-0 top-[calc(100%+6px)] z-50 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl shadow-slate-900/10">
{results.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-slate-400">{emptyText}</div>
) : (
<ul ref={listRef} id={listboxId} role="listbox" className="max-h-[300px] overflow-auto p-1.5">
{results.map((item, i) => {
const active = i === highlight;
return (
<li
key={item.value}
id={`${optionIdPrefix}-${i}`}
data-index={i}
role="option"
aria-selected={active}
onMouseEnter={() => setHighlight(i)}
onMouseDown={(e) => {
e.preventDefault();
commit(item);
}}
className={cn(
'flex cursor-pointer flex-col items-start rounded-lg px-3 py-2 transition-colors',
active ? 'bg-violet-50' : 'bg-white hover:bg-slate-50',
)}
>
<span className={cn('text-sm', active ? 'font-semibold text-violet-700' : 'text-slate-800')}>
{item.label}
</span>
{item.description && (
<span className="text-xs text-slate-500">{item.description}</span>
)}
</li>
);
})}
</ul>
)}
{debouncing && (
<div className="border-t border-slate-100 bg-slate-50/60 px-3 py-1.5 text-right text-[10px] font-semibold text-amber-600">
검색 중…
</div>
)}
</div>
)}
</div>
</div>
);
}