Files
Investidor-V2/resources/js/components/territory-search-select.tsx
T

67 lines
4.4 KiB
TypeScript

import { useEffect, useRef, useState, type ReactNode } from 'react';
type Choice = { id: string; name: string; source_code?: string | null };
/** One search field owns the list and commits only an explicitly chosen option. */
export default function TerritorySearchSelect<T extends Choice>({
id, label, search, onSearch, options, selected, onChange, disabled = false,
loading = false, error, required = false, className, placeholder, children,
}: {
id: string; label: string; search: string; onSearch: (query: string) => void;
options: T[]; selected?: T; onChange: (option?: T) => void;
disabled?: boolean; loading?: boolean; error?: string; required?: boolean;
className?: string; placeholder?: string; children?: ReactNode;
}) {
const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1);
const activeRef = useRef<HTMLButtonElement | null>(null);
const expanded = open && !disabled;
const listId = `${id}-options`;
useEffect(() => { if (expanded) activeRef.current?.scrollIntoView({ block: 'nearest' }); }, [active, expanded]);
const close = () => { setOpen(false); setActive(-1); onSearch(''); };
const choose = (option?: T) => { onChange(option); close(); };
return <div className="relative" onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) close();
}}>
<input id={id} className={className} role="combobox" aria-label={`Pesquisar ${label}`}
aria-autocomplete="list" aria-expanded={expanded} aria-controls={expanded ? listId : undefined}
aria-activedescendant={expanded && !loading && options[active] ? `${listId}-${active}` : undefined}
aria-invalid={Boolean(error)} aria-describedby={error ? `${id}-error` : undefined}
aria-required={required || undefined} required={required} autoComplete="off" maxLength={100}
disabled={disabled} value={expanded ? search : selected?.name ?? ''}
placeholder={disabled ? 'Seleccione o nível anterior' : placeholder ?? `Pesquisar ${label.toLowerCase()}`}
onFocus={() => { setOpen(true); setActive(-1); onSearch(''); }}
onClick={() => setOpen(true)}
onChange={(event) => { setOpen(true); setActive(-1); onSearch(event.target.value); }}
onKeyDown={(event) => {
if (event.key === 'Escape') { event.preventDefault(); close(); }
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault(); setOpen(true);
setActive((current) => !options.length ? -1 : event.key === 'ArrowDown'
? Math.min(current + 1, options.length - 1) : current < 0 ? options.length - 1 : Math.max(0, current - 1));
}
if (event.key === 'Enter' && expanded) {
event.preventDefault();
if (!loading && options[active < 0 ? 0 : active]) choose(options[active < 0 ? 0 : active]);
}
}} />
{selected && !disabled && <button type="button" className="mt-1 text-sm underline"
aria-label={`Limpar ${label}`} onClick={() => choose()}>Limpar</button>}
{error && <p id={`${id}-error`} role="alert">{error}</p>}
{expanded && <div className="absolute z-50 mt-1 max-h-72 w-full overflow-y-auto rounded border bg-background p-1 text-foreground shadow-lg">
<div id={listId} role="listbox" aria-label={label} aria-busy={loading}>
{loading && <p role="status" className="p-2">A carregar…</p>}
{!loading && options.length === 0 && <p className="p-2">{error ? 'Não foi possível carregar as opções.' : 'Sem resultados para esta pesquisa.'}</p>}
{options.map((option, index) => <button key={option.id} id={`${listId}-${index}`} type="button"
ref={index === active ? activeRef : undefined} role="option" tabIndex={-1}
aria-selected={option.id === selected?.id} disabled={loading}
className={`block w-full rounded p-2 text-left hover:bg-accent ${index === active ? 'bg-accent' : ''}`}
onMouseDown={(event) => event.preventDefault()} onClick={() => choose(option)}>
{option.name}{option.source_code ? ` (${option.source_code})` : ''}
</button>)}
</div>
{children}
</div>}
</div>;
}