Map Search Nav
The top-of-page search on /map lets the user either explore accessible places by filters or jump straight to a specific place by name. Both flows share one entry point — the filter pill in the nav.
Entry Point — SearchNav
File: src/components/search/map-search/search-nav.tsx
Mounted in src/components/layout/nav.tsx only when pathname.startsWith('/map'). Wrapped in LocalExploreSearchProvider so the explore form has its own scratch state separate from the URL-bound SearchProvider.
SearchNav renders three things at different breakpoints:
| Breakpoint | Trigger | What opens |
|---|---|---|
| Mobile | "Search" button | Full-screen overlay with chooser as the first screen |
| Desktop | Filter pill (categories | access | location + dark icon) | Floating top panel; opens directly into chooser |
There is one trigger on each breakpoint. The pill body and the dark green icon are wrapped in a single <button> so that clicking anywhere on the pill opens the chooser.
The Chooser-First Flow
When the panel opens, the user first sees a chooser with two cards:
- Explore — switches the panel to
<ExploreSearch />(filter form for category, access, location). - Specific place — switches the panel to
<NameSearch />(Google Places autocomplete).
A back arrow lets the user return to the chooser without dismissing the panel. The state machine is local to SearchNav:
type SearchType = 'chooser' | 'explore' | 'name';
const [desktopOpen, setDesktopOpen] = useState(false);
const [desktopType, setDesktopType] = useState<SearchType>('chooser');
const [mobileOpen, setMobileOpen] = useState(false);
const [mobileType, setMobileType] = useState<SearchType>('chooser');
Both desktop and mobile share the same chooser → search transition, but with different layouts:
- Desktop: the chooser cards sit side-by-side in a
flex w-fullrow (no max-width cap so they fill the panel). The chosen search renders inside the samemotion.divoverlay. - Mobile: the chooser cards stack vertically. The chosen search fills the full-screen overlay below the header.
useClickOutside (from src/lib/hooks/use-click-outside.ts) closes the desktop panel when clicked outside. The hook defers its listener with requestAnimationFrame so the click that opened the panel doesn't immediately close it.
Specific-Place Search — NameSearch
File: src/components/search/name-search.tsx
A controlled input that drives Google Places autocomplete via useAutocompletePlaces, then resolves the selected place to a DB row via ensurePlaceExists (creating a places row if Google returned a place we haven't seen) and routes the user to /map/place/[dbPlaceId].
Auto-focus
The chooser passes autoFocus so the input gets focus as soon as the user picks Specific place:
<NameSearch onClose={closeDesktop} autoFocus />
Internally:
const [open, setOpen] = useState(autoFocus);
When open is true, the input mounts with React's native autoFocus attribute. The component is also used by the landing page's searchWrapper.tsx without autoFocus, so the prop defaults to false.
Keyboard Navigation
The input handles three keys:
| Key | Behavior |
|---|---|
↓ | Move highlight down, clamped to last result |
↑ | Move highlight up, clamped to first result |
↵ | Select the highlighted result; falls back to first result if missing |
State:
const [highlightedIndex, setHighlightedIndex] = useState(0);
useEffect(() => {
setHighlightedIndex(0);
}, [getAutocomplete.predictionResults]);
The reset effect keeps the highlight at index 0 whenever a fresh set of results arrives (typing a new query).
Double-Highlight Fix
NameSearch uses cmdk's <Command> for the dropdown. cmdk has its own internal "selected" state driven by hover, which it surfaces via data-[selected=true]:bg-accent on <CommandItem> (defined in src/components/ui/command.tsx).
If we tracked our own arrow-key highlight separately, hover and arrow-keys could disagree — two items would visually highlight at once. To prevent this, <Command> is controlled:
<Command
value={results[highlightedIndex]?.place_id ?? ''}
onValueChange={(v) => {
const i = results.findIndex((r) => r.place_id === v);
if (i >= 0) setHighlightedIndex(i);
}}
shouldFilter={false}
>
cmdk's selection now mirrors our highlightedIndex exactly. Hover updates value via onValueChange (so the arrow-key state follows the mouse); arrow keys update highlightedIndex (so cmdk's selection follows the keyboard). Only one item is ever highlighted.
shouldFilter={false} is set because filtering happens server-side (Google Places autocomplete) — cmdk should display whatever rows we hand it, in order.
Adjacent Operations
- Filter persistence —
ExploreSearchwrites filter selections throughSearchProvider(src/components/providers/search-provider.tsx), which syncs to URL search params. Reloading/map?categories= …&acsAttrs=…&location=…restores the same filter state. - Place creation on selection —
NameSearch.handlePlaceSelect()callsensurePlaceExists(placeId)(src/components/search/place-operations.tsx), which upserts aplacesrow from Google Place data so the app has a stable internal id to navigate to. - Map recentre — when a place is selected,
map.setCenter(...)recentres the Google Map, andmapPlacesCtx.setReloadPlaces(true)triggers a refetch of the places-in-view query. - Loading overlay —
useLoadingOverlay()shows a full-screen "Going to{name}…" overlay whileensurePlaceExistsis in flight, hiding it on success or error.