import { useEffect, useRef, useState } from "react";
type Company = { name: string; domain: string; logo_url: string };
// logo_url already carries a token, so set parameters instead of concatenating.
// Parameters: https://www.logo.dev/docs/logo-images/introduction#parameters
function logoSrc(logoUrl: string) {
const url = new URL(logoUrl);
url.searchParams.set("size", "56");
url.searchParams.set("retina", "true");
url.searchParams.set("format", "webp");
return url.toString();
}
export function CompanyAutocomplete({
onSelect,
}: {
onSelect?: (company: Company) => void;
}) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<Company[]>([]);
const [open, setOpen] = useState(false);
const [active, setActive] = useState(0);
const [selected, setSelected] = useState<Company | null>(null);
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
const abortRef = useRef<AbortController | null>(null);
// Debounce input by 200ms, and abort the request in flight when the query
// changes, so an old response never overwrites a newer one.
useEffect(() => {
const q = query.trim();
if (!q || selected) {
setResults([]);
setError(false);
setLoading(false);
return;
}
setLoading(true);
const timer = setTimeout(async () => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
try {
const res = await fetch(
`/api/company-search?q=${encodeURIComponent(q)}`,
{ signal: controller.signal }
);
if (!res.ok) {
throw new Error("Search request failed");
}
setResults((await res.json()) as Company[]);
setActive(0);
setError(false);
setLoading(false);
} catch (err) {
if ((err as Error).name !== "AbortError") {
setResults([]);
setError(true);
setLoading(false);
}
}
}, 200);
return () => {
clearTimeout(timer);
abortRef.current?.abort();
};
}, [query, selected]);
const select = (company: Company) => {
setSelected(company);
setQuery(company.name);
setOpen(false);
onSelect?.(company);
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (!(open && results.length)) {
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
setActive((i) => (i + 1) % results.length);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((i) => (i - 1 + results.length) % results.length);
} else if (e.key === "Enter") {
e.preventDefault();
select(results[active]);
} else if (e.key === "Escape") {
setOpen(false);
}
};
return (
<div className="relative max-w-md">
<input
aria-activedescendant={
open && results.length > 0 ? `company-option-${active}` : undefined
}
aria-autocomplete="list"
aria-controls="company-listbox"
aria-expanded={open && results.length > 0}
autoComplete="off"
className="w-full rounded-xl border border-zinc-950/10 bg-white py-3 px-4 text-base focus:outline-none focus:ring-2 focus:ring-zinc-950/20 dark:border-white/20 dark:bg-zinc-900 dark:text-white"
onBlur={() => setOpen(false)}
onChange={(e) => {
setQuery(e.target.value);
setSelected(null);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onKeyDown={onKeyDown}
placeholder="Type a company name…"
role="combobox"
type="text"
value={query}
/>
{open && results.length > 0 && (
<ul
className="absolute z-10 mt-2 w-full overflow-hidden rounded-xl border border-zinc-950/10 bg-white py-1 shadow-lg dark:border-white/10 dark:bg-zinc-900"
id="company-listbox"
role="listbox"
>
{results.map((company, i) => (
<li
aria-selected={i === active}
className={`flex cursor-pointer items-center gap-3 px-3 py-2.5 ${
i === active ? "bg-zinc-100 dark:bg-zinc-800" : ""
}`}
id={`company-option-${i}`}
key={company.domain}
onMouseDown={(e) => {
e.preventDefault();
select(company);
}}
onMouseEnter={() => setActive(i)}
role="option"
>
<img
alt=""
className="h-7 w-7 rounded-md object-contain"
height={28}
loading="lazy"
src={logoSrc(company.logo_url)}
width={28}
/>
<span className="text-sm font-medium">{company.name}</span>
<span className="ml-auto text-xs text-zinc-400">
{company.domain}
</span>
</li>
))}
</ul>
)}
{error && (
<p className="mt-2 text-sm text-red-600 dark:text-red-400" role="alert">
Search unavailable. Try again.
</p>
)}
{open && !error && !loading && query.trim() && results.length === 0 && (
<div
className="absolute z-10 mt-2 w-full rounded-xl border border-zinc-950/10 bg-white px-3 py-2.5 text-sm text-zinc-400 shadow-lg dark:border-white/10 dark:bg-zinc-900"
role="status"
>
No companies found
</div>
)}
</div>
);
}