Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions src/components/map/city-filter.test.tsx
Original file line number Diff line number Diff line change
@@ -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<City | null>(initial);
return (
<>
<CityFilter
cities={CITIES}
value={city}
onChange={(next) => {
setCity(next);
onChange(next);
}}
/>
<button onClick={() => setCity(null)}>external reset</button>
</>
);
}

describe("CityFilter", () => {
it("offers every city as a suggestion, labelled like the old dropdown", () => {
render(<Harness />);
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(<Harness onChange={onChange} />);

fireEvent.change(field(), { target: { value: "Istanbul" } });

expect(onChange).toHaveBeenCalledWith("istanbul");
});

it("matches regardless of case and surrounding spaces", () => {
const onChange = vi.fn();
render(<Harness onChange={onChange} />);

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(<Harness onChange={onChange} />);

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(<Harness initial="mumbai" onChange={onChange} />);
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(<Harness initial="nairobi" onChange={onChange} />);

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(<Harness initial="mumbai" />);

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(<Harness initial="istanbul" />);
expect(field().value).toBe("Istanbul");

fireEvent.click(screen.getByText("external reset"));

expect(field().value).toBe("");
});

it("gives each instance its own suggestion list", () => {
render(
<>
<CityFilter cities={CITIES} value={null} onChange={() => {}} />
<CityFilter cities={CITIES} value={null} onChange={() => {}} />
</>,
);
const lists = Array.from(document.querySelectorAll("datalist")).map((d) => d.id);
expect(new Set(lists).size).toBe(2);
});
});
90 changes: 90 additions & 0 deletions src/components/map/city-filter.tsx
Original file line number Diff line number Diff line change
@@ -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 `<datalist>` (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<string, City>();
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 (
<div className="relative min-w-0 flex-1">
<Input
type="text"
aria-label="Filter by city"
placeholder="All cities"
autoComplete="off"
list={listId}
value={text}
onChange={(e) => handleChange(e.target.value)}
onBlur={handleBlur}
className="h-8 pr-7 text-sm"
/>
{text !== "" && (
<button
type="button"
aria-label="Clear city filter"
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded p-0.5 text-muted-foreground hover:text-foreground"
// Keep focus in the field so the blur handler does not undo the clear.
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleChange("")}
>
<X className="size-3.5" />
</button>
)}
<datalist id={listId}>
{cities.map((city) => (
<option key={city} value={humanizeCity(city)} />
))}
</datalist>
</div>
);
}
34 changes: 7 additions & 27 deletions src/components/map/map-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -132,24 +125,11 @@ export function MapPanel({
/>

<div className="flex items-center gap-2">
<Select
value={filters.city ?? undefined}
onValueChange={(value) =>
onFiltersChange({ ...filters, city: value === "all" ? null : value })
}
>
<SelectTrigger className="h-8 flex-1 text-sm">
<SelectValue placeholder="All cities" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All cities</SelectItem>
{cities.map((city) => (
<SelectItem key={city} value={city}>
{humanizeCity(city)}
</SelectItem>
))}
</SelectContent>
</Select>
<CityFilter
cities={cities}
value={filters.city}
onChange={(city) => onFiltersChange({ ...filters, city })}
/>
{dirty && (
<Button
variant="ghost"
Expand Down
4 changes: 3 additions & 1 deletion src/components/map/places-map.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,9 @@ describe("PlacesMap search URLs", () => {
expect(
screen.getByRole("button", { name: /^Library\s*1$/ }).getAttribute("aria-pressed"),
).toBe("true");
expect(screen.getByRole("combobox").textContent).toContain("Mumbai");
expect((screen.getByLabelText("Filter by city") as HTMLInputElement).value).toBe(
"Mumbai",
);
expect(renderMap.mock.lastCall?.[0]).toMatchObject({
places: [PLACES[0]],
focusId: "city-library",
Expand Down
Loading