SkateSpots/src/components/MapSearchSuggestions.tsx
SkateSpots 486ffdb000 feat(map): add autocomplete suggestions to the search bar
Typing in the map search pill now opens a suggestions dropdown built
from the items that already match the active filters:
- Spots: title matches; selecting one flies to it and opens its sheet.
- Places: distinct city/region/country tokens split out of each item's
  location; selecting one sets the query to that token (e.g. spain ->
  Spain).

buildSearchSuggestions lives in lib (pure, testable) and
MapSearchSuggestions.tsx is presentational; MapPage owns the list and
keyboard nav (ArrowUp/Down, Enter, Escape). Adds mapPage.searchSuggestPlaces
(en/es only; other locales fall back to English).
2026-10-09 16:31:31 +07:00

95 lines
3.4 KiB
TypeScript

import { MapPin, Store } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { SearchSuggestion } from '@/lib/mapSearchSuggestions';
interface MapSearchSuggestionsProps {
suggestions: SearchSuggestion[];
activeIndex: number;
spotsLabel: string;
placesLabel: string;
emptyLabel: string;
onHover: (index: number) => void;
onSelect: (suggestion: SearchSuggestion) => void;
}
/**
* Dropdown panel for the map search bar. Purely presentational: the parent owns
* the suggestion list (see `buildSearchSuggestions` in
* `@/lib/mapSearchSuggestions`), the active index and selection.
*/
export function MapSearchSuggestions({
suggestions,
activeIndex,
spotsLabel,
placesLabel,
emptyLabel,
onHover,
onSelect,
}: MapSearchSuggestionsProps) {
return (
<div
role="listbox"
// Keep the input focused when a row is pressed, so `blur` cannot close the
// panel before the click lands.
onMouseDown={(e) => e.preventDefault()}
className="absolute left-0 right-0 top-full z-[1001] mt-2 max-h-80 overflow-y-auto rounded-2xl border border-white/15 bg-black/95 p-1.5 shadow-2xl backdrop-blur-md"
>
{suggestions.length === 0 ? (
<div className="px-3 py-2.5 text-sm text-gray-400">{emptyLabel}</div>
) : (
suggestions.map((s, index) => {
const header =
index === 0 || suggestions[index - 1].kind !== s.kind
? s.kind === 'spot'
? spotsLabel
: placesLabel
: null;
const active = index === activeIndex;
return (
<div key={`${s.kind}:${s.label}:${index}`}>
{header && (
<div className="px-3 pb-1 pt-2 text-[10px] font-bold uppercase tracking-wider text-gray-500">
{header}
</div>
)}
<button
type="button"
role="option"
aria-selected={active}
ref={(el) => {
if (active && el) el.scrollIntoView({ block: 'nearest' });
}}
onMouseEnter={() => onHover(index)}
onClick={() => onSelect(s)}
className={cn(
'flex min-h-0 w-full items-center gap-3 rounded-xl px-3 py-2 text-left transition-colors',
active ? 'bg-white/10 text-white' : 'text-gray-300 hover:bg-white/5',
)}
>
<span className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg bg-white/5">
{s.isShop ? (
<Store className="h-3.5 w-3.5" />
) : (
<MapPin className="h-3.5 w-3.5" />
)}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{s.label}</span>
{s.kind === 'spot' && s.sublabel && (
<span className="block truncate text-xs text-gray-500">{s.sublabel}</span>
)}
</span>
{s.kind === 'place' && s.sublabel && (
<span className="flex-shrink-0 rounded-full bg-white/5 px-2 py-0.5 text-[10px] text-gray-400">
{s.sublabel}
</span>
)}
</button>
</div>
);
})
)}
</div>
);
}