163 lines
6.3 KiB
JavaScript
163 lines
6.3 KiB
JavaScript
import React, { useState, useEffect, useRef } from 'react';
|
|
import { Search, Loader2, Plus, UserPlus, Check } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
/**
|
|
* AutoFillInput - Um input inteligente para consultas e autopreenchimento.
|
|
*
|
|
* @param {string} label - Label do campo.
|
|
* @param {string} placeholder - Marcador de posição.
|
|
* @param {Array} data - Dados locais (opcional).
|
|
* @param {string} apiRoute - Rota da API para consulta (se não houver 'data').
|
|
* @param {string} filterField - Campo do objeto usado para o filtro (ex: 'nome').
|
|
* @param {string} displayField - Campo que será exibido no input após seleção.
|
|
* @param {function} onSelect - Callback disparado ao selecionar um item. Retorna o objeto completo.
|
|
* @param {function} onAddNew - Callback para o botão "Adicionar Novo".
|
|
*/
|
|
export const AutoFillInput = ({
|
|
label,
|
|
placeholder = "Comece a digitar para pesquisar...",
|
|
data = [],
|
|
apiRoute,
|
|
filterField = "name",
|
|
displayField = "name",
|
|
onSelect,
|
|
onAddNew,
|
|
className,
|
|
icon: Icon = Search
|
|
}) => {
|
|
const [query, setQuery] = useState('');
|
|
const [suggestions, setSuggestions] = useState([]);
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
|
const containerRef = useRef(null);
|
|
|
|
// Fecha a lista ao clicar fora do componente
|
|
useEffect(() => {
|
|
const handleClickOutside = (event) => {
|
|
if (containerRef.current && !containerRef.current.contains(event.target)) {
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
// Lógica de Filtro / API (Mockada para o exemplo)
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => {
|
|
if (query.trim().length > 0) {
|
|
setIsLoading(true);
|
|
|
|
// Simulação de consulta (Pode ser substituído por axios.get(apiRoute))
|
|
const results = data.filter(item =>
|
|
String(item[filterField] || "").toLowerCase().includes(query.toLowerCase())
|
|
).slice(0, 10);
|
|
|
|
setSuggestions(results);
|
|
setIsOpen(true);
|
|
setIsLoading(false);
|
|
} else {
|
|
setSuggestions([]);
|
|
setIsOpen(false);
|
|
}
|
|
}, 300); // 300ms de debounce
|
|
|
|
return () => clearTimeout(timer);
|
|
}, [query, data, filterField]);
|
|
|
|
const handleSelect = (item) => {
|
|
setQuery(item[displayField]);
|
|
setIsOpen(false);
|
|
if (onSelect) onSelect(item);
|
|
};
|
|
|
|
const handleKeyDown = (e) => {
|
|
if (e.key === 'ArrowDown') {
|
|
setSelectedIndex(prev => (prev < suggestions.length - 1 ? prev + 1 : prev));
|
|
} else if (e.key === 'ArrowUp') {
|
|
setSelectedIndex(prev => (prev > -1 ? prev - 1 : prev));
|
|
} else if (e.key === 'Enter' && selectedIndex >= 0) {
|
|
handleSelect(suggestions[selectedIndex]);
|
|
} else if (e.key === 'Escape') {
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={cn("relative w-full space-y-1.5", className)} ref={containerRef}>
|
|
{label && (
|
|
<label className="text-xs font-bold uppercase tracking-widest text-slate-500 ml-1">
|
|
{label}
|
|
</label>
|
|
)}
|
|
|
|
<div className="relative group">
|
|
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-emerald-500 transition-colors">
|
|
{isLoading ? <Loader2 size={16} className="animate-spin" /> : <Icon size={16} />}
|
|
</div>
|
|
|
|
<input
|
|
type="text"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
onFocus={() => query && setIsOpen(true)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder={placeholder}
|
|
className={cn(
|
|
"w-full bg-white dark:bg-[#1a1a1a] border border-slate-200 dark:border-white/5 rounded-lg pl-10 pr-4 py-2.5 text-sm font-medium",
|
|
"text-slate-900 dark:text-slate-100", // Fix visibility
|
|
"focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500/50 transition-all",
|
|
"placeholder:text-slate-500"
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Lista de Sugestões (Painel) */}
|
|
{isOpen && (
|
|
<div className="absolute z-50 w-full mt-1 bg-white dark:bg-[#111] border border-slate-200 dark:border-white/10 rounded-xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200">
|
|
<div className="max-h-[300px] overflow-y-auto custom-scrollbar p-1">
|
|
{suggestions.length > 0 ? (
|
|
suggestions.map((item, index) => (
|
|
<button
|
|
key={index}
|
|
onClick={() => handleSelect(item)}
|
|
className={cn(
|
|
"w-full flex items-center justify-between px-3 py-2.5 rounded-lg text-sm text-left transition-all",
|
|
selectedIndex === index || query === item[displayField]
|
|
? "bg-emerald-500/10 text-emerald-500"
|
|
: "text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/5"
|
|
)}
|
|
>
|
|
<div className="flex flex-col">
|
|
<span className="font-bold">{item[displayField]}</span>
|
|
{item.subtext && <span className="text-[10px] opacity-60">{item.subtext}</span>}
|
|
</div>
|
|
{query === item[displayField] && <Check size={14} className="text-emerald-500" />}
|
|
</button>
|
|
))
|
|
) : (
|
|
<div className="px-4 py-6 text-center text-xs text-slate-500 italic">
|
|
Nenhum resultado encontrado para "{query}"
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Rodapé de Ação (Adicionar Novo) */}
|
|
<div className="border-t border-slate-200 dark:border-white/5 p-1 bg-slate-50/50 dark:bg-white/[0.02]">
|
|
<button
|
|
onClick={() => onAddNew && onAddNew(query)}
|
|
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs font-black uppercase tracking-tighter text-emerald-500 hover:bg-emerald-500/10 transition-colors"
|
|
>
|
|
<Plus size={14} strokeWidth={3} />
|
|
Criar novo registro
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|