This commit is contained in:
Cayden Schwechten 2026-10-31 21:20:16 -05:00
commit 6250d23297
53 changed files with 6381 additions and 0 deletions

271
components/PlaceSearch.tsx Normal file
View file

@ -0,0 +1,271 @@
"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>
);
}