"use client"; /** * SmartSelect * * 옵션 개수에 따라 자동으로 검색 기능을 제공하는 셀렉트 컴포넌트. * - 옵션 5개 미만: 기본 Select * - 옵션 5개 이상: 검색 + 가상 스크롤 Combobox (대용량 옵션도 빠르게 처리) */ import React, { useState, useMemo, useEffect, useRef } from "react"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Check, ChevronsUpDown, Search as SearchIcon, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { useVirtualizer } from "@tanstack/react-virtual"; const SEARCH_THRESHOLD = 5; const ITEM_HEIGHT = 36; const LIST_HEIGHT = 280; export interface SmartSelectOption { code: string; label: string; } interface SmartSelectProps { options: SmartSelectOption[]; value: string; onValueChange: (value: string) => void; placeholder?: string; disabled?: boolean; className?: string; /** 값이 있을 때 ✕(선택 해제) 버튼 노출 (기본 true). 필수 필드는 false로 둘 것. */ clearable?: boolean; } export function SmartSelect({ options, value, onValueChange, placeholder = "선택", disabled = false, className, clearable = true, }: SmartSelectProps) { const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const [activeIndex, setActiveIndex] = useState(0); const scrollRef = useRef(null); // code가 비어있는 옵션은 자동 제외 (Radix Select value 제약 + key 중복 방지) const safeOptions = useMemo( () => options.filter((o) => o.code !== null && o.code !== undefined && o.code !== ""), [options], ); const selectedLabel = useMemo( () => safeOptions.find((o) => o.code === value)?.label, [safeOptions, value], ); // 팝오버 닫힐 때 검색어 리셋 useEffect(() => { if (!open) setSearch(""); }, [open]); // 검색어로 옵션 필터 (대소문자 무시) const filtered = useMemo(() => { const q = search.trim().toLowerCase(); if (!q) return safeOptions; return safeOptions.filter((o) => o.label.toLowerCase().includes(q)); }, [safeOptions, search]); const virtualizer = useVirtualizer({ count: filtered.length, getScrollElement: () => scrollRef.current, estimateSize: () => ITEM_HEIGHT, overscan: 10, }); // 팝오버 열릴 때 측정 강제 (Portal 렌더 타이밍 대응) useEffect(() => { if (!open) return; const id = requestAnimationFrame(() => virtualizer.measure()); return () => cancelAnimationFrame(id); }, [open, virtualizer, filtered.length]); // 팝오버 열릴 때 현재 선택값 위치로 활성 인덱스 초기화 (없으면 0) useEffect(() => { if (!open) return; const idx = filtered.findIndex((o) => o.code === value); setActiveIndex(idx >= 0 ? idx : 0); // 의도적으로 filtered.length 변화 시에도 재계산 안 함 (검색 입력 중 0번 유지) // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); // 검색어가 바뀌면 첫 항목으로 리셋 useEffect(() => { if (open) setActiveIndex(0); }, [search, open]); // 활성 인덱스가 바뀌면 가시 영역으로 스크롤 useEffect(() => { if (!open) return; if (activeIndex < 0 || activeIndex >= filtered.length) return; virtualizer.scrollToIndex(activeIndex, { align: "auto" }); }, [activeIndex, open, virtualizer, filtered.length]); const onSearchKeyDown = (e: React.KeyboardEvent) => { if (filtered.length === 0) { if (e.key === "Escape") { e.preventDefault(); setOpen(false); } return; } switch (e.key) { case "ArrowDown": e.preventDefault(); setActiveIndex((i) => Math.min(filtered.length - 1, (i < 0 ? -1 : i) + 1)); break; case "ArrowUp": e.preventDefault(); setActiveIndex((i) => Math.max(0, (i < 0 ? 0 : i) - 1)); break; case "Home": e.preventDefault(); setActiveIndex(0); break; case "End": e.preventDefault(); setActiveIndex(filtered.length - 1); break; case "PageDown": e.preventDefault(); setActiveIndex((i) => Math.min(filtered.length - 1, (i < 0 ? 0 : i) + 8)); break; case "PageUp": e.preventDefault(); setActiveIndex((i) => Math.max(0, (i < 0 ? 0 : i) - 8)); break; case "Enter": { e.preventDefault(); const hit = filtered[activeIndex]; if (hit) { onValueChange(hit.code); setOpen(false); } break; } case "Escape": e.preventDefault(); setOpen(false); break; } }; const showClear = clearable && !disabled && !!value; // Radix Select/Popover trigger는 onPointerDown으로 열린다 → 같은 단계에서 차단해야 X 클릭이 trigger를 안 깨움 const stopAndClear = (e: React.PointerEvent | React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); onValueChange(""); }; const blockTrigger = (e: React.PointerEvent | React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); }; const ClearBtn = ( ); if (safeOptions.length < SEARCH_THRESHOLD) { return (
{/* key: 빈값↔값 전환 시 Radix Select remount — controlled value=undefined 시 selection 미해제 우회 */} {showClear && ClearBtn}
); } return (
setSearch(e.target.value)} onKeyDown={onSearchKeyDown} className="h-9 border-0 px-1 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0" />
{filtered.length === 0 ? (
검색 결과가 없습니다.
) : (
{virtualizer.getVirtualItems().map((vItem) => { const o = filtered[vItem.index]; const isSelected = value === o.code; const isActive = activeIndex === vItem.index; return ( ); })}
)}
{showClear && ClearBtn}
); }