160 lines
5.7 KiB
TypeScript
160 lines
5.7 KiB
TypeScript
import React, { useState, useRef, useEffect, useMemo } from "react";
|
|
import { ChevronDown, Search, X } from "lucide-react";
|
|
|
|
export interface SearchableSelectOption {
|
|
value: string;
|
|
label: string;
|
|
disabled?: boolean;
|
|
className?: string; // For custom styling like red text
|
|
style?: React.CSSProperties; // For custom inline styles
|
|
}
|
|
|
|
interface SearchableSelectProps {
|
|
label?: string;
|
|
placeholder?: string;
|
|
options: SearchableSelectOption[];
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
disabled?: boolean;
|
|
className?: string;
|
|
error?: string;
|
|
required?: boolean;
|
|
}
|
|
|
|
export const SearchableSelect: React.FC<SearchableSelectProps> = ({
|
|
label,
|
|
placeholder = "Selecione...",
|
|
options,
|
|
value,
|
|
onChange,
|
|
disabled = false,
|
|
className = "",
|
|
error,
|
|
required = false,
|
|
}) => {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Close when clicking outside
|
|
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);
|
|
}, []);
|
|
|
|
// Focus search input when opening
|
|
useEffect(() => {
|
|
if (isOpen && searchInputRef.current) {
|
|
setTimeout(() => {
|
|
searchInputRef.current?.focus();
|
|
}, 50);
|
|
}
|
|
}, [isOpen]);
|
|
|
|
// Handle Selection
|
|
const handleSelect = (optionValue: string) => {
|
|
onChange(optionValue);
|
|
setIsOpen(false);
|
|
setSearchTerm("");
|
|
};
|
|
|
|
const selectedOption = options.find((opt) => opt.value === value);
|
|
|
|
// Filter options
|
|
const filteredOptions = useMemo(() => {
|
|
if (!searchTerm) return options;
|
|
const lowerTerm = searchTerm.toLowerCase();
|
|
return options.filter((opt) => opt.label.toLowerCase().includes(lowerTerm));
|
|
}, [options, searchTerm]);
|
|
|
|
return (
|
|
<div className={`w-full ${className}`} ref={containerRef}>
|
|
{label && (
|
|
<label className="block text-sm font-medium text-gray-700 mb-1 tracking-wide uppercase text-xs">
|
|
{label} {required && "*"}
|
|
</label>
|
|
)}
|
|
|
|
<div className="relative">
|
|
{/* Trigger Button */}
|
|
<button
|
|
type="button"
|
|
onClick={() => !disabled && setIsOpen(!isOpen)}
|
|
disabled={disabled}
|
|
className={`w-full px-3 py-2 text-left border rounded-sm flex items-center justify-between transition-colors bg-white
|
|
${error ? "border-red-500" : "border-gray-300"}
|
|
${disabled ? "bg-gray-100 cursor-not-allowed text-gray-400" : "hover:border-gray-400 focus:ring-1 focus:ring-brand-gold focus:border-brand-gold"}
|
|
`}
|
|
>
|
|
<span className={`block truncate ${!selectedOption ? "text-gray-500" : "text-gray-900"}`}>
|
|
{selectedOption ? selectedOption.label : placeholder}
|
|
</span>
|
|
<ChevronDown size={16} className={`text-gray-500 transition-transform ${isOpen ? "transform rotate-180" : ""}`} />
|
|
</button>
|
|
|
|
{/* Dropdown Menu */}
|
|
{isOpen && (
|
|
<div className="absolute z-50 w-full mb-1 bottom-full bg-white border border-gray-200 rounded-sm shadow-lg max-h-60 flex flex-col">
|
|
{/* Search Input */}
|
|
<div className="p-2 border-b border-gray-100 sticky top-0 bg-white z-10">
|
|
<div className="relative">
|
|
<Search size={14} className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400" />
|
|
<input
|
|
ref={searchInputRef}
|
|
type="text"
|
|
className="w-full pl-8 pr-8 py-1.5 text-xs sm:text-sm border border-gray-200 rounded-sm focus:outline-none focus:border-brand-gold"
|
|
placeholder="Buscar..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
/>
|
|
{searchTerm && (
|
|
<button
|
|
onClick={() => setSearchTerm("")}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Options List */}
|
|
<div className="overflow-y-auto flex-1">
|
|
{filteredOptions.length > 0 ? (
|
|
filteredOptions.map((opt) => (
|
|
<button
|
|
key={opt.value}
|
|
type="button"
|
|
disabled={opt.disabled}
|
|
onClick={() => handleSelect(opt.value)}
|
|
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-50 flex items-center justify-between
|
|
${opt.value === value ? "bg-blue-50 text-brand-gold font-medium" : "text-gray-700"}
|
|
${opt.disabled ? "opacity-50 cursor-not-allowed bg-gray-50/50" : "cursor-pointer"}
|
|
${opt.className || ""}
|
|
`}
|
|
style={opt.style}
|
|
>
|
|
<span className="truncate">{opt.label}</span>
|
|
{opt.value === value && <span className="text-brand-gold text-xs ml-2">✓</span>}
|
|
</button>
|
|
))
|
|
) : (
|
|
<div className="px-4 py-3 text-center text-sm text-gray-500">
|
|
Nenhum resultado encontrado
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{error && <span className="text-xs text-red-500 mt-1">{error}</span>}
|
|
</div>
|
|
);
|
|
};
|