123 lines
3.5 KiB
TypeScript
123 lines
3.5 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* SmartSelect
|
||
|
|
*
|
||
|
|
* 옵션 개수에 따라 자동으로 검색 기능을 제공하는 셀렉트 컴포넌트.
|
||
|
|
* - 옵션 5개 미만: 기본 Select (드롭다운)
|
||
|
|
* - 옵션 5개 이상: Combobox (검색 + 드롭다운)
|
||
|
|
*/
|
||
|
|
|
||
|
|
import React, { useState, useMemo } from "react";
|
||
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
|
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { Check, ChevronsUpDown } from "lucide-react";
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
|
||
|
|
const SEARCH_THRESHOLD = 5;
|
||
|
|
|
||
|
|
export interface SmartSelectOption {
|
||
|
|
code: string;
|
||
|
|
label: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface SmartSelectProps {
|
||
|
|
options: SmartSelectOption[];
|
||
|
|
value: string;
|
||
|
|
onValueChange: (value: string) => void;
|
||
|
|
placeholder?: string;
|
||
|
|
disabled?: boolean;
|
||
|
|
className?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function SmartSelect({
|
||
|
|
options,
|
||
|
|
value,
|
||
|
|
onValueChange,
|
||
|
|
placeholder = "선택",
|
||
|
|
disabled = false,
|
||
|
|
className,
|
||
|
|
}: SmartSelectProps) {
|
||
|
|
const [open, setOpen] = useState(false);
|
||
|
|
|
||
|
|
const selectedLabel = useMemo(
|
||
|
|
() => options.find((o) => o.code === value)?.label,
|
||
|
|
[options, value],
|
||
|
|
);
|
||
|
|
|
||
|
|
if (options.length < SEARCH_THRESHOLD) {
|
||
|
|
return (
|
||
|
|
<Select value={value} onValueChange={onValueChange} disabled={disabled}>
|
||
|
|
<SelectTrigger className={cn("h-9", className)}>
|
||
|
|
<SelectValue placeholder={placeholder} />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{options.map((o) => (
|
||
|
|
<SelectItem key={o.code} value={o.code}>
|
||
|
|
{o.label}
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<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", 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"
|
||
|
|
>
|
||
|
|
<Command
|
||
|
|
filter={(val, search) => {
|
||
|
|
if (!search) return 1;
|
||
|
|
return val.toLowerCase().includes(search.toLowerCase()) ? 1 : 0;
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<CommandInput placeholder="검색..." className="h-9" />
|
||
|
|
<CommandList>
|
||
|
|
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
|
||
|
|
<CommandGroup>
|
||
|
|
{options.map((o) => (
|
||
|
|
<CommandItem
|
||
|
|
key={o.code}
|
||
|
|
value={o.label}
|
||
|
|
onSelect={() => {
|
||
|
|
onValueChange(o.code);
|
||
|
|
setOpen(false);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<Check
|
||
|
|
className={cn(
|
||
|
|
"mr-2 h-4 w-4",
|
||
|
|
value === o.code ? "opacity-100" : "opacity-0",
|
||
|
|
)}
|
||
|
|
/>
|
||
|
|
{o.label}
|
||
|
|
</CommandItem>
|
||
|
|
))}
|
||
|
|
</CommandGroup>
|
||
|
|
</CommandList>
|
||
|
|
</Command>
|
||
|
|
</PopoverContent>
|
||
|
|
</Popover>
|
||
|
|
);
|
||
|
|
}
|