init
This commit is contained in:
commit
6250d23297
53 changed files with 6381 additions and 0 deletions
271
components/PlaceSearch.tsx
Normal file
271
components/PlaceSearch.tsx
Normal 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>{" "}
|
||||
&{" "}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue