import { useEffect, useRef, useState, type ReactNode } from 'react'; import { Search, Check, Loader2, ChevronsUpDown } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Input } from './input'; import { Typography } from './typography'; // 서버검색 콤보박스 — 목록(options)은 부모가 쿼리에 맞춰 조회해 넘기고, 검색어 디바운스는 이 컴포넌트가 처리한다. // variant: 'field'(트리거+팝오버, 단일선택 폼용) / 'inline'(검색창+리스트 상시노출, 다중 체크리스트용). export type ComboOption = { id: string; label: string; // 검색결과에 없을 때 선택 표시용 텍스트 node?: ReactNode; // 커스텀 행(미지정 시 label 렌더) disabled?: boolean; }; type ComboboxProps = { options: ComboOption[]; onQueryChange: (q: string) => void; // 내부 디바운스 후 호출 loading?: boolean; placeholder?: string; searchPlaceholder?: string; emptyText?: string; debounceMs?: number; id?: string; className?: string; maxListHeight?: string; // tailwind, default max-h-56 variant?: 'field' | 'inline'; multiple?: boolean; // single value?: string; selectedLabel?: ReactNode; onSelect?: (opt: ComboOption) => void; // multi values?: string[]; onToggle?: (opt: ComboOption) => void; }; export function Combobox({ options, onQueryChange, loading, placeholder = '선택...', searchPlaceholder = '검색...', emptyText = '결과가 없습니다', debounceMs = 300, id, className, maxListHeight = 'max-h-56', variant = 'field', multiple = false, value, selectedLabel, onSelect, values = [], onToggle, }: ComboboxProps) { const [text, setText] = useState(''); const [open, setOpen] = useState(false); const rootRef = useRef(null); const popoverRef = useRef(null); // 검색어 디바운스 → onQueryChange. 콜백은 ref로 잡아 text 변화에만 반응. const qcRef = useRef(onQueryChange); qcRef.current = onQueryChange; useEffect(() => { const t = setTimeout(() => qcRef.current(text.trim()), debounceMs); return () => clearTimeout(t); }, [text, debounceMs]); // field 팝오버 바깥 클릭 시 닫기. useEffect(() => { if (variant === 'inline') return; const onDoc = (e: MouseEvent) => { if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false); }; document.addEventListener('mousedown', onDoc); return () => document.removeEventListener('mousedown', onDoc); }, [variant]); // field 팝오버가 열리면 스크롤 컨테이너(모달 등) 안에서 잘리지 않게 뷰로 끌어온다. // 로딩→목록 로 높이가 커질 때도 다시 당겨온다(이미 보이면 scrollIntoView 는 no-op). useEffect(() => { if (variant === 'inline' || !open) return; popoverRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); }, [open, variant, loading, options.length]); const isSelected = (oid: string) => (multiple ? values.includes(oid) : value === oid); const handlePick = (opt: ComboOption) => { if (opt.disabled) return; if (multiple) onToggle?.(opt); else { onSelect?.(opt); setOpen(false); } }; const searchInput = (
setText(e.target.value)} placeholder={searchPlaceholder} className="pl-7 text-xs" autoComplete="off" /> {loading && }
); const list = (
{loading && options.length === 0 ? (
불러오는 중…
) : options.length === 0 ? ( {emptyText} ) : ( options.map((opt) => { const sel = isSelected(opt.id); return ( ); }) )}
); if (variant === 'inline') { return (
{searchInput} {list}
); } // field: 트리거(선택 요약) + 팝오버(검색창 + 리스트) const hasSelection = multiple ? values.length > 0 : !!value; const summary = multiple ? (values.length ? `${values.length}개 선택됨` : placeholder) : (value ? selectedLabel ?? '선택됨' : placeholder); return (
{open && (
{searchInput} {list}
)}
); }