190 lines
6.8 KiB
TypeScript
190 lines
6.8 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="bg-background text-foreground absolute z-50 mt-1 max-h-72 w-full overflow-y-auto rounded border p-1 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={`hover:bg-accent block w-full rounded p-2 text-left ${index === active ? 'bg-accent' : ''}`}
|
|
onMouseDown={(event) => event.preventDefault()}
|
|
onClick={() => choose(option)}
|
|
>
|
|
{option.name}
|
|
{option.source_code
|
|
? ` (${option.source_code})`
|
|
: ''}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{children}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|