67 lines
4.4 KiB
TypeScript
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>;
|
|
}
|