← back to Open Seo
src/client/components/SerpLocationCombobox.tsx
223 lines
import { useEffect, useRef, useState } from "react";
import { Loader2, Search } from "lucide-react";
import { searchSerpLocations } from "@/serverFunctions/serp-locations";
import { formatLocationLabel } from "@/shared/keyword-locations";
import type { SerpLocationResult } from "@/server/lib/dataforseo/serp-locations";
type Props = {
value: string | undefined;
onChange: (locationName: string | undefined) => void;
/** ISO 3166-1 alpha-2 country code, e.g. "us". */
countryCode: string;
placeholder?: string;
};
function useDebounce(value: string, delayMs: number): string {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return debounced;
}
export function SerpLocationCombobox({
value,
onChange,
countryCode,
placeholder = "Search cities...",
}: Props) {
const [inputValue, setInputValue] = useState(
value ? formatLocationLabel(value) : "",
);
const [results, setResults] = useState<SerpLocationResult[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLUListElement>(null);
// Selecting a result sets the input to its display label; that change must
// not itself trigger a search for the label text.
const skipNextFetchRef = useRef(false);
const debouncedQuery = useDebounce(inputValue, 350);
// Sync display when value prop changes externally (e.g. mode reset)
useEffect(() => {
if (!value) {
setInputValue("");
setResults([]);
setOpen(false);
}
}, [value]);
// Fetch results when debounced query changes
useEffect(() => {
const trimmed = debouncedQuery.trim();
if (!trimmed) {
setResults([]);
setOpen(false);
setIsLoading(false);
return;
}
if (skipNextFetchRef.current) {
skipNextFetchRef.current = false;
// Any fetch this change superseded was cancelled before its own
// finally could clear the spinner, so clear it here.
setIsLoading(false);
return;
}
let cancelled = false;
setIsLoading(true);
setIsError(false);
searchSerpLocations({ data: { query: trimmed, countryCode } })
.then((data) => {
if (cancelled) return;
setResults(data);
setOpen(true);
setActiveIndex(0);
})
.catch(() => {
if (cancelled) return;
setIsError(true);
setOpen(true);
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, [debouncedQuery, countryCode]);
// Close on outside click
useEffect(() => {
if (!open) return;
const handlePointerDown = (e: PointerEvent) => {
if (
e.target instanceof Node &&
!containerRef.current?.contains(e.target)
) {
setOpen(false);
}
};
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [open]);
// Scroll active item into view
useEffect(() => {
if (!open) return;
listRef.current?.children[activeIndex]?.scrollIntoView({
block: "nearest",
});
}, [activeIndex, open]);
const select = (loc: SerpLocationResult) => {
onChange(loc.locationName);
skipNextFetchRef.current = true;
setInputValue(loc.displayLabel);
setResults([]);
setOpen(false);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.value;
setInputValue(v);
if (!v.trim()) {
onChange(undefined);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!open) return;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, results.length - 1));
break;
case "ArrowUp":
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
break;
case "Enter": {
e.preventDefault();
const loc = results[activeIndex];
if (loc) select(loc);
break;
}
case "Escape":
e.preventDefault();
setOpen(false);
break;
}
};
return (
<div ref={containerRef} className="relative w-full">
<label className="flex items-center gap-2 input input-bordered w-full pr-3">
{isLoading ? (
<Loader2 className="size-4 shrink-0 text-base-content/50 animate-spin" />
) : (
<Search className="size-4 shrink-0 text-base-content/50" />
)}
<input
type="text"
className="grow min-w-0 bg-transparent outline-none placeholder:text-base-content/40"
placeholder={placeholder}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={() => {
if (results.length > 0) setOpen(true);
}}
autoComplete="off"
/>
</label>
{open && (
<div className="absolute z-30 mt-1 w-full rounded-box border border-base-300 bg-base-100 shadow-lg p-1">
{isError ? (
<p className="px-3 py-2 text-sm text-error">
Unable to load locations
</p>
) : results.length === 0 ? (
<p className="px-3 py-2 text-sm text-base-content/50">
No locations found for "{debouncedQuery.trim()}"
</p>
) : (
<ul
ref={listRef}
role="listbox"
className="menu max-h-56 w-full flex-nowrap overflow-y-auto p-0"
>
{results.map((loc, index) => (
<li
key={loc.locationCode}
role="option"
aria-selected={loc.locationName === value}
>
<button
type="button"
className={`w-full flex items-center justify-between gap-2 ${index === activeIndex ? "menu-focus" : ""}`}
onClick={() => select(loc)}
onMouseEnter={() => setActiveIndex(index)}
>
<span className="truncate text-left">
{loc.displayLabel}
</span>
<span className="badge badge-xs bg-base-300 border-0 text-base-content/60 shrink-0">
{loc.locationType}
</span>
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}