75f4ca8127
- sales_request_part DDL 추출(운영 11133)→RPS(11134) 마이그레이션 - 백엔드 6 엔드포인트: 프로젝트 자동채움/M-BOM 품목/저장/품의서생성/SSO · 품의서 결재상신 Amaranth SSO (target_type=PROPOSAL, formId=1163) - 프론트 다이얼로그 2개 (구매요청서작성 / 품의서생성 확인) · 프로젝트 선택→주문유형·제품구분·국내외·고객사·유무상 자동 채움 · 행추가 시 M-BOM 품번 셀렉트→품명/공급업체/단가 자동 셋팅 - 공용 SmartSelect: ↑↓·Enter·Esc·Home·End·PageUp·Down 키보드 네비 - 그리드 delivery_request_date . → - 형식 정규화 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
291 lines
9.7 KiB
TypeScript
291 lines
9.7 KiB
TypeScript
"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<HTMLDivElement | null>(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<HTMLInputElement>) => {
|
|
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 = (
|
|
<button
|
|
type="button"
|
|
tabIndex={-1}
|
|
aria-label="선택 해제"
|
|
onPointerDown={blockTrigger}
|
|
onMouseDown={blockTrigger}
|
|
onClick={stopAndClear}
|
|
className="absolute right-8 top-1/2 -translate-y-1/2 z-10 inline-flex h-5 w-5 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
);
|
|
|
|
if (safeOptions.length < SEARCH_THRESHOLD) {
|
|
return (
|
|
<div className={cn("relative", className)}>
|
|
{/* key: 빈값↔값 전환 시 Radix Select remount — controlled value=undefined 시 selection 미해제 우회 */}
|
|
<Select key={value ? "filled" : "empty"} value={value || undefined} onValueChange={onValueChange} disabled={disabled}>
|
|
<SelectTrigger className={cn("h-9", showClear && "pr-12")}>
|
|
<SelectValue placeholder={placeholder} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{safeOptions.map((o, idx) => (
|
|
<SelectItem key={`${o.code}-${idx}`} value={o.code}>
|
|
{o.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{showClear && ClearBtn}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="relative">
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={open}
|
|
disabled={disabled}
|
|
className={cn("h-9 w-full justify-between font-normal", showClear && "pr-12", className)}
|
|
>
|
|
<span className="truncate">
|
|
{selectedLabel || <span className="text-muted-foreground">{placeholder}</span>}
|
|
</span>
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="p-0"
|
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
|
align="start"
|
|
>
|
|
<div className="flex items-center border-b px-2">
|
|
<SearchIcon className="h-4 w-4 text-muted-foreground mr-1 shrink-0" />
|
|
<Input
|
|
autoFocus
|
|
placeholder="검색..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
onKeyDown={onSearchKeyDown}
|
|
className="h-9 border-0 px-1 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
|
/>
|
|
</div>
|
|
{filtered.length === 0 ? (
|
|
<div className="py-6 text-center text-sm text-muted-foreground">검색 결과가 없습니다.</div>
|
|
) : (
|
|
<div
|
|
ref={scrollRef}
|
|
className="overflow-auto py-1"
|
|
style={{ height: LIST_HEIGHT }}
|
|
>
|
|
<div
|
|
style={{
|
|
height: `${virtualizer.getTotalSize()}px`,
|
|
width: "100%",
|
|
position: "relative",
|
|
}}
|
|
>
|
|
{virtualizer.getVirtualItems().map((vItem) => {
|
|
const o = filtered[vItem.index];
|
|
const isSelected = value === o.code;
|
|
const isActive = activeIndex === vItem.index;
|
|
return (
|
|
<button
|
|
key={`${o.code}-${vItem.index}`}
|
|
type="button"
|
|
role="option"
|
|
aria-selected={isSelected}
|
|
onMouseEnter={() => setActiveIndex(vItem.index)}
|
|
onClick={() => {
|
|
onValueChange(o.code);
|
|
setOpen(false);
|
|
}}
|
|
className={cn(
|
|
"absolute left-0 top-0 w-full flex items-center px-2 text-sm text-left",
|
|
isActive ? "bg-accent" : "hover:bg-accent/40",
|
|
isSelected && !isActive && "bg-accent/60",
|
|
)}
|
|
style={{
|
|
height: `${vItem.size}px`,
|
|
transform: `translateY(${vItem.start}px)`,
|
|
}}
|
|
>
|
|
<Check className={cn("mr-2 h-4 w-4 shrink-0", isSelected ? "opacity-100" : "opacity-0")} />
|
|
<span className="truncate">{o.label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</PopoverContent>
|
|
</Popover>
|
|
{showClear && ClearBtn}
|
|
</div>
|
|
);
|
|
}
|