271 lines
10 KiB
TypeScript
271 lines
10 KiB
TypeScript
"use client";
|
||
|
||
import { type RefObject, useEffect, useId, useRef, useState } from "react";
|
||
import { namePlace, searchPlaces } from "@/lib/client-api";
|
||
import { formatCoords } from "@/lib/display";
|
||
import { outlineButton } from "@/lib/styles";
|
||
import type { Place } from "@/lib/weather/types";
|
||
|
||
/** City search with debounced suggestions, arrow-key navigation, and "use my location". */
|
||
export function PlaceSearch({
|
||
onSelect,
|
||
inputRef,
|
||
geocoderUsesOpenWeather = false,
|
||
}: {
|
||
onSelect: (place: Place) => void;
|
||
inputRef?: RefObject<HTMLInputElement | null>;
|
||
geocoderUsesOpenWeather?: boolean;
|
||
}) {
|
||
const [query, setQuery] = useState("");
|
||
const [results, setResults] = useState<Place[]>([]);
|
||
const [activeIndex, setActiveIndex] = useState(-1);
|
||
const [searching, setSearching] = useState(false);
|
||
const [open, setOpen] = useState(false);
|
||
const [message, setMessage] = useState<string | null>(null);
|
||
const [locating, setLocating] = useState(false);
|
||
const listId = useId();
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
|
||
// Close the dropdown when clicking outside the search box.
|
||
useEffect(() => {
|
||
function handleOutsidePointer(event: PointerEvent) {
|
||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||
setOpen(false);
|
||
}
|
||
}
|
||
document.addEventListener("pointerdown", handleOutsidePointer);
|
||
return () => document.removeEventListener("pointerdown", handleOutsidePointer);
|
||
}, []);
|
||
|
||
// Wait until typing pauses for 350 ms, then search. Cancel stale requests.
|
||
useEffect(() => {
|
||
const trimmed = query.trim();
|
||
if (trimmed.length < 2) {
|
||
setResults([]);
|
||
setSearching(false);
|
||
setActiveIndex(-1);
|
||
return;
|
||
}
|
||
|
||
setSearching(true);
|
||
const controller = new AbortController();
|
||
const timer = setTimeout(async () => {
|
||
try {
|
||
const places = await searchPlaces(trimmed, controller.signal);
|
||
setResults(places);
|
||
setActiveIndex(places.length > 0 ? 0 : -1);
|
||
setOpen(true);
|
||
setMessage(places.length ? null : `No places match “${trimmed}”. Try adding a state or country.`);
|
||
} catch (error) {
|
||
if (!controller.signal.aborted) setMessage((error as Error).message);
|
||
} finally {
|
||
if (!controller.signal.aborted) setSearching(false);
|
||
}
|
||
}, 350);
|
||
|
||
return () => {
|
||
clearTimeout(timer);
|
||
controller.abort();
|
||
};
|
||
}, [query]);
|
||
|
||
function choose(place: Place) {
|
||
onSelect(place);
|
||
setQuery("");
|
||
setResults([]);
|
||
setActiveIndex(-1);
|
||
setOpen(false);
|
||
setMessage(null);
|
||
}
|
||
|
||
function useMyLocation() {
|
||
if (!navigator.geolocation) {
|
||
setMessage("This browser can't share its location. Search for a city instead.");
|
||
return;
|
||
}
|
||
setLocating(true);
|
||
setMessage(null);
|
||
navigator.geolocation.getCurrentPosition(
|
||
async ({ coords }) => {
|
||
try {
|
||
choose(await namePlace(coords.latitude, coords.longitude));
|
||
} catch (error) {
|
||
setMessage((error as Error).message);
|
||
} finally {
|
||
setLocating(false);
|
||
}
|
||
},
|
||
() => {
|
||
setLocating(false);
|
||
setMessage("Location access was blocked. Allow it in your browser, or search for a city.");
|
||
},
|
||
{ timeout: 10_000, maximumAge: 300_000 },
|
||
);
|
||
}
|
||
|
||
const showDropdown = open && results.length > 0;
|
||
|
||
return (
|
||
<div ref={containerRef} className="relative">
|
||
<div className="flex gap-2">
|
||
<label className="sr-only" htmlFor={`${listId}-input`}>
|
||
Search for a place
|
||
</label>
|
||
|
||
<div className="relative flex min-w-0 flex-1 items-center">
|
||
{/* Magnifying glass glyph */}
|
||
<svg
|
||
width={16}
|
||
height={16}
|
||
viewBox="0 0 16 16"
|
||
fill="none"
|
||
aria-hidden="true"
|
||
className="pointer-events-none absolute left-3.5 text-gold"
|
||
>
|
||
<circle cx="7" cy="7" r="4.75" stroke="currentColor" strokeWidth="1.5" />
|
||
<path d="M10.7 10.7L14 14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="square" />
|
||
</svg>
|
||
|
||
<input
|
||
ref={inputRef}
|
||
id={`${listId}-input`}
|
||
type="search"
|
||
role="combobox"
|
||
aria-expanded={showDropdown}
|
||
aria-autocomplete="list"
|
||
aria-controls={listId}
|
||
aria-activedescendant={showDropdown && activeIndex >= 0 ? `${listId}-opt-${activeIndex}` : undefined}
|
||
placeholder="Search a town, city, or region…"
|
||
autoComplete="off"
|
||
// text-base (16px) stops iOS from zooming in on focus
|
||
className="min-h-[46px] w-full min-w-0 rounded-none border border-gold-dim bg-panel/80 pr-20 pl-10 text-base text-ink transition-colors placeholder:text-stone hover:border-gold/70 focus-visible:border-teal focus-visible:outline-offset-0"
|
||
value={query}
|
||
onFocus={() => {
|
||
if (results.length > 0) setOpen(true);
|
||
}}
|
||
onChange={(e) => {
|
||
setQuery(e.target.value);
|
||
if (!e.target.value.trim()) setMessage(null);
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Escape") {
|
||
setOpen(false);
|
||
return;
|
||
}
|
||
if (e.key === "ArrowDown" && results.length > 0) {
|
||
e.preventDefault();
|
||
setOpen(true);
|
||
setActiveIndex((prev) => (prev + 1) % results.length);
|
||
} else if (e.key === "ArrowUp" && results.length > 0) {
|
||
e.preventDefault();
|
||
setOpen(true);
|
||
setActiveIndex((prev) => (prev <= 0 ? results.length - 1 : prev - 1));
|
||
} else if (e.key === "Enter" && results.length > 0) {
|
||
e.preventDefault();
|
||
choose(results[activeIndex >= 0 ? activeIndex : 0]);
|
||
}
|
||
}}
|
||
/>
|
||
|
||
<div className="absolute right-2 flex items-center gap-1.5">
|
||
{searching && <span className="font-pixel text-[0.65rem] text-gold">…</span>}
|
||
{query.length > 0 && (
|
||
<button
|
||
type="button"
|
||
aria-label="Clear search"
|
||
onClick={() => {
|
||
setQuery("");
|
||
setResults([]);
|
||
setOpen(false);
|
||
setMessage(null);
|
||
inputRef?.current?.focus();
|
||
}}
|
||
className="grid size-7 cursor-pointer place-items-center font-pixel text-xs text-stone hover:text-gold-bright"
|
||
>
|
||
×
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
className={`${outlineButton} inline-flex items-center gap-2`}
|
||
type="button"
|
||
onClick={useMyLocation}
|
||
disabled={locating}
|
||
>
|
||
<svg width={14} height={14} viewBox="0 0 16 16" fill="none" aria-hidden="true" className="flex-none">
|
||
<circle cx="8" cy="8" r="4.5" stroke="currentColor" strokeWidth="1.5" />
|
||
<circle cx="8" cy="8" r="1.5" fill="currentColor" />
|
||
<path d="M8 1V3M8 13V15M1 8H3M13 8H15" stroke="currentColor" strokeWidth="1.5" />
|
||
</svg>
|
||
<span className="hidden sm:inline">{locating ? "Finding you…" : "Use my location"}</span>
|
||
<span className="sm:hidden">{locating ? "Finding…" : "Locate"}</span>
|
||
</button>
|
||
</div>
|
||
|
||
{showDropdown && (
|
||
<div className="absolute inset-x-0 top-[calc(100%+4px)] z-20 border border-gold bg-panel shadow-[0_18px_40px_rgba(0,0,0,0.85)]">
|
||
<ul id={listId} role="listbox" className="py-1">
|
||
{results.map((place, index) => {
|
||
const isActive = index === activeIndex;
|
||
return (
|
||
<li key={`${place.lat},${place.lon}`} id={`${listId}-opt-${index}`} role="option" aria-selected={isActive}>
|
||
<button
|
||
type="button"
|
||
onPointerEnter={() => setActiveIndex(index)}
|
||
onClick={() => choose(place)}
|
||
className={`flex min-h-11 w-full cursor-pointer items-center justify-between gap-3 px-3.5 py-2 text-left transition-colors focus-visible:outline-none ${
|
||
isActive ? "bg-gold/15 text-gold-bright" : "text-ink hover:bg-gold/10"
|
||
}`}
|
||
>
|
||
<span className="min-w-0 truncate">
|
||
<span className="font-medium">{place.name}</span>
|
||
{(place.region || place.country) && (
|
||
<span className="text-stone">
|
||
{", "}
|
||
{[place.region, place.country].filter(Boolean).join(", ")}
|
||
</span>
|
||
)}
|
||
</span>
|
||
<span className="flex-none font-pixel text-[0.65rem] text-stone tabular-nums">
|
||
{formatCoords(place.lat, place.lon, 1)}
|
||
</span>
|
||
</button>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
<p className="border-t border-gold-dim/40 bg-black/60 px-3.5 py-1.5 text-[11px] text-stone">
|
||
{geocoderUsesOpenWeather ? (
|
||
<>
|
||
Location search by{" "}
|
||
<a href="https://openweathermap.org/" target="_blank" rel="noopener" className="text-gold hover:underline">
|
||
OpenWeather
|
||
</a>
|
||
</>
|
||
) : (
|
||
<>
|
||
Location data by{" "}
|
||
<a href="https://open-meteo.com/" target="_blank" rel="noopener" className="text-gold hover:underline">
|
||
Open-Meteo.com
|
||
</a>{" "}
|
||
&{" "}
|
||
<a href="https://www.geonames.org/" target="_blank" rel="noopener" className="text-gold hover:underline">
|
||
GeoNames
|
||
</a>{" "}
|
||
(
|
||
<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener" className="hover:underline">
|
||
CC BY 4.0
|
||
</a>
|
||
)
|
||
</>
|
||
)}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{message && <p className="mt-2 text-sm text-stone">{message}</p>}
|
||
</div>
|
||
);
|
||
}
|