212 lines
8.9 KiB
TypeScript
212 lines
8.9 KiB
TypeScript
"use client";
|
|
|
|
import { SelectHTMLAttributes, forwardRef, useState, useRef, useEffect } from "react";
|
|
|
|
interface SelectOption {
|
|
value: string;
|
|
label: string;
|
|
}
|
|
|
|
interface SearchableSelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, 'onChange'> {
|
|
label?: string;
|
|
error?: string;
|
|
helperText?: string;
|
|
leftIcon?: string;
|
|
options: SelectOption[];
|
|
placeholder?: string;
|
|
onChange?: (value: string) => void;
|
|
value?: string;
|
|
}
|
|
|
|
const SearchableSelect = forwardRef<HTMLSelectElement, SearchableSelectProps>(
|
|
(
|
|
{
|
|
label,
|
|
error,
|
|
helperText,
|
|
leftIcon,
|
|
options,
|
|
placeholder,
|
|
className = "",
|
|
onChange,
|
|
value,
|
|
required,
|
|
...props
|
|
},
|
|
ref
|
|
) => {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [selectedOption, setSelectedOption] = useState<SelectOption | null>(
|
|
options.find(opt => opt.value === value) || null
|
|
);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const filteredOptions = options.filter(option =>
|
|
option.label.toLowerCase().includes(searchTerm.toLowerCase())
|
|
);
|
|
|
|
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);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (isOpen && searchInputRef.current) {
|
|
searchInputRef.current.focus();
|
|
}
|
|
}, [isOpen]);
|
|
|
|
useEffect(() => {
|
|
if (value) {
|
|
const option = options.find(opt => opt.value === value);
|
|
if (option) {
|
|
setSelectedOption(option);
|
|
}
|
|
}
|
|
}, [value, options]);
|
|
|
|
const handleSelect = (option: SelectOption) => {
|
|
setSelectedOption(option);
|
|
setIsOpen(false);
|
|
setSearchTerm("");
|
|
if (onChange) {
|
|
onChange(option.value);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="w-full">
|
|
{/* Hidden select for form compatibility */}
|
|
<select
|
|
ref={ref}
|
|
value={selectedOption?.value || ""}
|
|
onChange={(e) => {
|
|
const option = options.find(opt => opt.value === e.target.value);
|
|
if (option) handleSelect(option);
|
|
}}
|
|
className="hidden"
|
|
required={required}
|
|
{...props}
|
|
>
|
|
<option value="" disabled>
|
|
{placeholder || "Selecione uma opção"}
|
|
</option>
|
|
{options.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
{label && (
|
|
<label className="block text-[13px] font-semibold text-zinc-900 dark:text-white mb-2">
|
|
{label}
|
|
{required && <span className="text-[#FF3A05] ml-1">*</span>}
|
|
</label>
|
|
)}
|
|
|
|
<div ref={containerRef} className="relative">
|
|
{leftIcon && (
|
|
<i
|
|
className={`${leftIcon} absolute left-3.5 top-1/2 -translate-y-1/2 text-zinc-500 dark:text-zinc-400 text-[20px] pointer-events-none z-10`}
|
|
/>
|
|
)}
|
|
|
|
{/* Custom trigger */}
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
className={`
|
|
w-full px-3.5 py-3 text-[14px] font-normal
|
|
border rounded-md bg-white dark:bg-zinc-800
|
|
text-zinc-900 dark:text-white text-left
|
|
transition-all
|
|
cursor-pointer
|
|
${leftIcon ? "pl-11" : ""}
|
|
pr-11
|
|
${error
|
|
? "border-[#FF3A05]"
|
|
: "border-zinc-200 dark:border-zinc-700 focus:border-[#FF3A05]"
|
|
}
|
|
outline-none ring-0 focus:ring-0 shadow-none focus:shadow-none
|
|
${className}
|
|
`}
|
|
>
|
|
{selectedOption ? selectedOption.label : (
|
|
<span className="text-zinc-500 dark:text-zinc-400">{placeholder || "Selecione uma opção"}</span>
|
|
)}
|
|
</button>
|
|
|
|
<i className={`ri-arrow-${isOpen ? 'up' : 'down'}-s-line absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-500 dark:text-zinc-400 text-[20px] pointer-events-none transition-transform`} />
|
|
|
|
{/* Dropdown */}
|
|
{isOpen && (
|
|
<div className="absolute z-50 w-full mt-2 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-md shadow-lg max-h-[300px] overflow-hidden">
|
|
{/* Search input */}
|
|
<div className="p-2 border-b border-zinc-200 dark:border-zinc-700">
|
|
<div className="relative">
|
|
<i className="ri-search-line absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500 dark:text-zinc-400 text-[16px]" />
|
|
<input
|
|
ref={searchInputRef}
|
|
type="text"
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
placeholder="Buscar..."
|
|
className="w-full pl-9 pr-3 py-2 text-[14px] border border-zinc-200 dark:border-zinc-700 rounded-md outline-none focus:border-[#FF3A05] shadow-none bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder:text-zinc-500 dark:placeholder:text-zinc-400"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Options list */}
|
|
<div className="overflow-y-auto max-h-60">
|
|
{filteredOptions.length > 0 ? (
|
|
filteredOptions.map((option) => (
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
onClick={() => handleSelect(option)}
|
|
className={`
|
|
w-full px-4 py-2.5 text-left text-[14px] transition-colors
|
|
hover:bg-zinc-100 dark:hover:bg-zinc-700 cursor-pointer
|
|
${selectedOption?.value === option.value ? 'bg-[#FF3A05]/10 text-[#FF3A05] font-medium' : 'text-zinc-900 dark:text-white'}
|
|
`}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
))
|
|
) : (
|
|
<div className="px-4 py-8 text-center text-zinc-500 dark:text-zinc-400 text-[14px]">
|
|
Nenhum resultado encontrado
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{helperText && !error && (
|
|
<p className="mt-1.5 text-[12px] text-zinc-600 dark:text-zinc-400">{helperText}</p>
|
|
)}
|
|
{error && (
|
|
<p className="mt-1 text-[13px] text-[#FF3A05] flex items-center gap-1">
|
|
<i className="ri-error-warning-line" />
|
|
{error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
);
|
|
|
|
SearchableSelect.displayName = "SearchableSelect";
|
|
|
|
export default SearchableSelect;
|