100 lines
3.1 KiB
TypeScript
Executable File
100 lines
3.1 KiB
TypeScript
Executable File
import { useState, useRef, useEffect } from 'react';
|
|
|
|
interface ComboBoxOption {
|
|
value: string;
|
|
label: string;
|
|
}
|
|
|
|
interface ComboBoxProps {
|
|
value: string | number;
|
|
onChange: (value: string) => void;
|
|
options: ComboBoxOption[];
|
|
placeholder?: string;
|
|
className?: string;
|
|
}
|
|
|
|
export function ComboBox({ value, onChange, options, placeholder, className }: ComboBoxProps) {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
const selectedOption = options.find((opt) => opt.value === String(value));
|
|
const displayText = selectedOption?.label || placeholder || '선택';
|
|
|
|
return (
|
|
<div ref={containerRef} className="relative">
|
|
<div
|
|
className={`cursor-pointer flex items-center justify-between pr-2 ${className ?? ''}`}
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
>
|
|
<span>{displayText}</span>
|
|
<span
|
|
className="text-caption text-fg-disabled"
|
|
style={{
|
|
transition: 'transform 0.2s',
|
|
transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)',
|
|
}}
|
|
>
|
|
▼
|
|
</span>
|
|
</div>
|
|
|
|
{isOpen && (
|
|
<div
|
|
className="absolute left-0 right-0 bg-bg-base border border-stroke overflow-y-auto z-[1000]"
|
|
style={{
|
|
top: 'calc(100% + 2px)',
|
|
borderRadius: 'var(--radius-sm)',
|
|
maxHeight: '200px',
|
|
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
|
animation: 'fadeSlideDown 0.15s ease-out',
|
|
}}
|
|
>
|
|
{options.map((option) => (
|
|
<div
|
|
key={option.value}
|
|
onClick={() => {
|
|
onChange(option.value);
|
|
setIsOpen(false);
|
|
}}
|
|
className="text-label-2 cursor-pointer"
|
|
style={{
|
|
padding: '8px 10px',
|
|
color: option.value === String(value) ? 'var(--color-accent)' : 'var(--fg-sub)',
|
|
background: option.value === String(value) ? 'rgba(6,182,212,0.1)' : 'transparent',
|
|
transition: '0.1s',
|
|
borderLeft:
|
|
option.value === String(value)
|
|
? '2px solid var(--color-accent)'
|
|
: '2px solid transparent',
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (option.value !== String(value)) {
|
|
e.currentTarget.style.background = 'rgba(255,255,255,0.03)';
|
|
}
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
if (option.value !== String(value)) {
|
|
e.currentTarget.style.background = 'transparent';
|
|
}
|
|
}}
|
|
>
|
|
{option.label}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|