wayfarer-weather/components/PlaceSearch.tsx
Cayden Schwechten 6250d23297 init
2026-10-31 21:20:16 -05:00

271 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>{" "}
&amp;{" "}
<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>
);
}