Combobox

Interactive UI Explorer

아래에서 각 컴포넌트의 다양한 Variants States를 문서화된 형태로 테스트해볼 수 있습니다.

Live Preview

Combobox

검색 입력 + 제안 드롭다운(autocomplete)입니다. Select와 달리 자유 입력으로 필터하며, 키보드 ↑↓·Enter·Esc·Home/End와 ARIA combobox를 지원합니다.

기본 — 검색 + 선택

입력에 맞춰 label·description을 필터합니다. debounceMs로 입력을 늦춰 API 호출에 연결할 수 있습니다.

Source Code
const [value, setValue] = useState<string>();

<Combobox
  options={options}      // { value, label, description? }
  value={value}
  onChange={setValue}
  placeholder="컴포넌트 검색…"
/>

선택값:

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>
  );
}