diff --git a/src/components/map/city-filter.test.tsx b/src/components/map/city-filter.test.tsx new file mode 100644 index 0000000..490a64b --- /dev/null +++ b/src/components/map/city-filter.test.tsx @@ -0,0 +1,132 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import * as React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { CityFilter } from "@/components/map/city-filter"; +import type { City } from "@/lib/types"; + +afterEach(cleanup); + +const CITIES: City[] = ["mumbai", "navi_mumbai", "istanbul", "nairobi"]; + +function field() { + return screen.getByLabelText("Filter by city") as HTMLInputElement; +} + +function optionValues() { + return Array.from(document.querySelectorAll("datalist option")).map((o) => + o.getAttribute("value"), + ); +} + +// A parent that owns the value, like MapPanel does through the filters object. +function Harness({ + initial = null, + onChange = () => {}, +}: { + initial?: City | null; + onChange?: (city: City | null) => void; +}) { + const [city, setCity] = React.useState(initial); + return ( + <> + { + setCity(next); + onChange(next); + }} + /> + + + ); +} + +describe("CityFilter", () => { + it("offers every city as a suggestion, labelled like the old dropdown", () => { + render(); + expect(optionValues()).toEqual(["Mumbai", "Navi Mumbai", "Istanbul", "Nairobi"]); + expect(field().getAttribute("list")).toBeTruthy(); + expect(field().placeholder).toBe("All cities"); + }); + + it("selects the city when the typed or picked text matches a suggestion", () => { + const onChange = vi.fn(); + render(); + + fireEvent.change(field(), { target: { value: "Istanbul" } }); + + expect(onChange).toHaveBeenCalledWith("istanbul"); + }); + + it("matches regardless of case and surrounding spaces", () => { + const onChange = vi.fn(); + render(); + + fireEvent.change(field(), { target: { value: " navi mumbai " } }); + + expect(onChange).toHaveBeenCalledWith("navi_mumbai"); + }); + + it("does not change the filter while the text is only a partial match", () => { + const onChange = vi.fn(); + render(); + + fireEvent.change(field(), { target: { value: "ist" } }); + + expect(onChange).not.toHaveBeenCalled(); + expect(field().value).toBe("ist"); + }); + + it("returns to all cities when the field is cleared", () => { + const onChange = vi.fn(); + render(); + expect(field().value).toBe("Mumbai"); + + fireEvent.change(field(), { target: { value: "" } }); + + expect(onChange).toHaveBeenCalledWith(null); + }); + + it("clears with the clear button", () => { + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Clear city filter" })); + + expect(onChange).toHaveBeenCalledWith(null); + expect(field().value).toBe(""); + expect(screen.queryByRole("button", { name: "Clear city filter" })).toBeNull(); + }); + + it("restores the selected city's name when leaving a half-typed field", () => { + render(); + + fireEvent.change(field(), { target: { value: "xyz" } }); + expect(field().value).toBe("xyz"); + fireEvent.blur(field()); + + expect(field().value).toBe("Mumbai"); + }); + + it("follows the filter when it is reset from outside", () => { + render(); + expect(field().value).toBe("Istanbul"); + + fireEvent.click(screen.getByText("external reset")); + + expect(field().value).toBe(""); + }); + + it("gives each instance its own suggestion list", () => { + render( + <> + {}} /> + {}} /> + , + ); + const lists = Array.from(document.querySelectorAll("datalist")).map((d) => d.id); + expect(new Set(lists).size).toBe(2); + }); +}); diff --git a/src/components/map/city-filter.tsx b/src/components/map/city-filter.tsx new file mode 100644 index 0000000..adeddd3 --- /dev/null +++ b/src/components/map/city-filter.tsx @@ -0,0 +1,90 @@ +"use client"; + +import * as React from "react"; +import { X } from "lucide-react"; + +import type { City } from "@/lib/types"; +import { humanizeCity } from "@/lib/types"; +import { Input } from "@/components/ui/input"; + +interface CityFilterProps { + cities: City[]; + /** The selected city slug, or null for "All cities". */ + value: City | null; + onChange: (city: City | null) => void; +} + +/** + * Type-to-search city filter. With 500+ cities a plain dropdown is a long scroll, so this is a + * text input backed by a native `` (the same pattern as the suggest-place dialog): + * typing narrows the suggestions, picking one selects that city, and clearing the field + * returns to "All cities". Typing something that is not a city leaves the current filter alone + * and the field snaps back to the selected city when it loses focus. + */ +export function CityFilter({ cities, value, onChange }: CityFilterProps) { + const listId = React.useId(); + const [text, setText] = React.useState(value ? humanizeCity(value) : ""); + + // Keep the field in step when the filter changes from outside (Reset, a shared link). + const [seenValue, setSeenValue] = React.useState(value); + if (value !== seenValue) { + setSeenValue(value); + setText(value ? humanizeCity(value) : ""); + } + + const bySlugLabel = React.useMemo(() => { + const map = new Map(); + for (const city of cities) { + const key = humanizeCity(city).toLowerCase(); + if (!map.has(key)) map.set(key, city); + } + return map; + }, [cities]); + + function handleChange(next: string) { + setText(next); + if (next.trim() === "") { + if (value !== null) onChange(null); + return; + } + const match = bySlugLabel.get(next.trim().toLowerCase()); + if (match && match !== value) onChange(match); + } + + function handleBlur() { + setText(value ? humanizeCity(value) : ""); + } + + return ( +
+ handleChange(e.target.value)} + onBlur={handleBlur} + className="h-8 pr-7 text-sm" + /> + {text !== "" && ( + + )} + + {cities.map((city) => ( + +
+ ); +} diff --git a/src/components/map/map-panel.tsx b/src/components/map/map-panel.tsx index c6b98fb..1ca91e2 100644 --- a/src/components/map/map-panel.tsx +++ b/src/components/map/map-panel.tsx @@ -4,18 +4,11 @@ import * as React from "react"; import { Code2, MapPinPlus, Search, Share2, X } from "lucide-react"; import type { City, PlaceType, Place } from "@/lib/types"; -import { humanizeCity } from "@/lib/types"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Separator } from "@/components/ui/separator"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { CategoryChips } from "@/components/map/category-chips"; +import { CityFilter } from "@/components/map/city-filter"; import { NearMeButton } from "@/components/map/near-me-button"; import { MyPlacesPanel } from "@/components/map/my-places-panel"; import { SuggestPlaceDialog } from "@/components/map/suggest-place-dialog"; @@ -69,7 +62,7 @@ interface MapPanelProps { } /** - * The full map control stack: search, category chips, city select, actions, + * The full map control stack: search, category chips, city filter, actions, * and the results list. Shared by the desktop sidebar and the mobile sheet; * `showSearch` / `showNearMe` let the mobile layout hoist those into the * always-visible top bar and FAB instead. @@ -132,24 +125,11 @@ export function MapPanel({ />
- + onFiltersChange({ ...filters, city })} + /> {dirty && (