SkateSpots/src/lib/mapSearchSuggestions.ts
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

83 lines
2.5 KiB
TypeScript

/** Minimal shape the map passes in (the unified spot/shop list). */
export interface MapSearchItem {
id: string;
title: string;
location?: string;
isShop?: boolean;
}
/** One row in the suggestion panel. */
export interface SearchSuggestion {
kind: 'spot' | 'place';
label: string;
/** Spot: its location. Place: the number of matching spots (as a string). */
sublabel?: string;
/** Set on `spot` rows — the unified list item id. */
itemId?: string;
isShop?: boolean;
/** Set on `place` rows — the text to put in the search box. */
value?: string;
}
/**
* Build autocomplete suggestions for the map search from the items that already
* matched the current query/filters.
*
* - `spot` — named spots/shops whose TITLE matches, so you can jump straight
* to one.
* - `place` — the distinct city/region/country tokens found in the matching
* items' `location` strings (e.g. "Barcelona, Spain" yields city
* "Barcelona" and country "Spain"), so a country query still
* suggests a concrete place to narrow down to.
*
* Deterministic and side-effect free, so MapPage can own keyboard navigation.
*/
export function buildSearchSuggestions(
items: MapSearchItem[],
query: string,
maxSpots = 6,
maxPlaces = 5,
): SearchSuggestion[] {
const q = query.trim().toLowerCase();
if (!q) return [];
const out: SearchSuggestion[] = [];
const titleMatches = items
.filter((it) => it.title.toLowerCase().includes(q))
.sort((a, b) => a.title.toLowerCase().indexOf(q) - b.title.toLowerCase().indexOf(q))
.slice(0, maxSpots);
for (const it of titleMatches) {
out.push({
kind: 'spot',
label: it.title,
sublabel: it.location,
itemId: it.id,
isShop: !!it.isShop,
});
}
const counts = new Map<string, { label: string; count: number }>();
for (const it of items) {
if (!it.location) continue;
for (const raw of it.location.split(',')) {
const part = raw.trim();
if (!part || !part.toLowerCase().includes(q)) continue;
const key = part.toLowerCase();
const existing = counts.get(key);
if (existing) existing.count += 1;
else counts.set(key, { label: part, count: 1 });
}
}
const places = [...counts.values()]
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label))
.slice(0, maxPlaces);
for (const p of places) {
out.push({ kind: 'place', label: p.label, sublabel: String(p.count), value: p.label });
}
return out;
}