photum/frontend/components/SearchableSelect.tsx

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>
);
};