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

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>
);
}