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
31 changes: 31 additions & 0 deletions src/components/map/places-map.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,18 @@ function expectExistingState() {
expect(params().get("zoom")).toBe("11");
}

function stubViewport(desktop: boolean) {
vi.stubGlobal("matchMedia", (query: string) => ({
matches: desktop && query === "(min-width: 1024px)",
media: query,
addEventListener: () => {},
removeEventListener: () => {},
}));
}

beforeEach(() => {
vi.useFakeTimers();
stubViewport(true);
window.history.replaceState(null, "", "/map");
renderMap.mockClear();
writeText.mockReset().mockResolvedValue(undefined);
Expand All @@ -85,6 +95,7 @@ afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
vi.unstubAllGlobals();
if (clipboardDescriptor) {
Object.defineProperty(navigator, "clipboard", clipboardDescriptor);
} else {
Expand Down Expand Up @@ -208,3 +219,23 @@ describe("PlacesMap search URLs", () => {
}
});
});

describe("PlacesMap panel mounting", () => {
it("mounts the sidebar panel at desktop width", () => {
render(<PlacesMap places={PLACES} />);

// Sidebar search + mobile top bar search.
expect(searchInputs()).toHaveLength(2);
expect(screen.getAllByRole("button", { name: "Suggest a place" })).toHaveLength(1);
});

it("does not build the sidebar panel on a phone", () => {
stubViewport(false);
render(<PlacesMap places={PLACES} />);

// Only the mobile top bar search; the sheet is closed at peek height.
expect(searchInputs()).toHaveLength(1);
expect(screen.queryByRole("button", { name: "Suggest a place" })).toBeNull();
expect(screen.queryByText("City Library")).toBeNull();
});
});
9 changes: 7 additions & 2 deletions src/components/map/places-map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { MapErrorBoundary } from "@/components/map/map-error-boundary";
import { MapPanel } from "@/components/map/map-panel";
import { MapSheet, SHEET_SNAP_POINTS } from "@/components/map/map-sheet";
import { NearMeFab } from "@/components/map/near-me-fab";
import { useMediaQuery } from "@/lib/use-media-query";
import { UserPlaceDialog } from "@/components/map/user-place-dialog";
import { UserHomeDialog } from "@/components/map/user-home-dialog";
import type { ResultRow } from "@/components/map/results-list";
Expand Down Expand Up @@ -86,6 +87,10 @@ export function PlacesMap({ places }: PlacesMapProps) {
const [closePopupTrigger, setClosePopupTrigger] = React.useState(0);
const [sortByDistance, setSortByDistance] = React.useState(false);
const [snap, setSnap] = React.useState<number | string | null>(SHEET_SNAP_POINTS[0]);
// Mount one MapPanel, not both: the sidebar from `lg` up (the same breakpoint as the
// `lg:` classes below), the sheet below it. Building the other one only to hide it with
// CSS doubled the list and its interactive elements.
const isDesktop = useMediaQuery("(min-width: 1024px)");
// Sheet always open on mobile at peek snap; "closing" resets to peek, not hidden.
const [sheetOpen, setSheetOpen] = React.useState(true);
const hydrated = React.useRef(false);
Expand Down Expand Up @@ -380,7 +385,7 @@ export function PlacesMap({ places }: PlacesMapProps) {
<div className="flex h-full w-full overflow-hidden">
{/* Desktop sidebar */}
<aside className="hidden w-[360px] shrink-0 flex-col overflow-hidden border-r border-border bg-card p-4 lg:flex">
<MapPanel {...panelProps} showSearch showNearMe scrollChips />
{isDesktop && <MapPanel {...panelProps} showSearch showNearMe scrollChips />}
</aside>

{/* Map + mobile overlays */}
Expand Down Expand Up @@ -462,7 +467,7 @@ export function PlacesMap({ places }: PlacesMapProps) {
</button>
)}
{/* Full panel: visible when sheet above peek */}
{snap !== SHEET_SNAP_POINTS[0] && (
{snap !== SHEET_SNAP_POINTS[0] && !isDesktop && (
<MapPanel {...panelProps} showSearch={false} showNearMe={false} scrollChips />
)}
</MapSheet>
Expand Down
119 changes: 118 additions & 1 deletion src/components/map/results-list.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { ResultsList } from "@/components/map/results-list";
import { ResultsList, type ResultRow } from "@/components/map/results-list";

beforeEach(() => {
// The verified badge expires with time, so these tests run on a fixed date.
Expand Down Expand Up @@ -81,3 +81,120 @@ describe("ResultsList verified badge", () => {
expect(within(row as HTMLElement).queryByText("Verified")).toBeNull();
});
});

describe("ResultsList paging", () => {
const rowsOf = (count: number): ResultRow[] =>
Array.from({ length: count }, (_, i) => ({
place: {
id: `place-${i}`,
name: `Place ${i}`,
type: "library" as const,
city: "mumbai",
lat: 19,
lng: 72,
gmaps_link: "https://maps.google.com/?q=19,72",
added_by: "test",
},
}));
const emptyState = { activeFilters: [], onReset: vi.fn(), onSuggest: vi.fn() };

it("renders one page of rows and a Show more button for the rest", () => {
render(
<ResultsList
header="All places"
rows={rowsOf(250)}
emptyState={emptyState}
onSelect={vi.fn()}
pageSize={100}
/>,
);

expect(screen.getByText("Place 99")).toBeTruthy();
expect(screen.queryByText("Place 100")).toBeNull();
expect(
screen.getByRole("button", { name: "Show 100 more (150 not shown)" }),
).toBeTruthy();
});

it("reveals the next page and then the remainder", () => {
render(
<ResultsList
header="All places"
rows={rowsOf(250)}
emptyState={emptyState}
onSelect={vi.fn()}
pageSize={100}
/>,
);

fireEvent.click(screen.getByRole("button", { name: /^Show 100 more/ }));
expect(screen.getByText("Place 199")).toBeTruthy();
expect(screen.queryByText("Place 200")).toBeNull();

fireEvent.click(screen.getByRole("button", { name: "Show 50 more (50 not shown)" }));
expect(screen.getByText("Place 249")).toBeTruthy();
expect(screen.queryByRole("button", { name: /Show .* more/ })).toBeNull();
});

it("shows no button when everything fits on one page", () => {
render(
<ResultsList
header="All places"
rows={rowsOf(100)}
emptyState={emptyState}
onSelect={vi.fn()}
pageSize={100}
/>,
);

expect(screen.getAllByRole("listitem")).toHaveLength(100);
expect(screen.queryByRole("button", { name: /Show .* more/ })).toBeNull();
});

it("keeps the focused row visible even when it is beyond the first page", () => {
render(
<ResultsList
header="All places"
rows={rowsOf(250)}
emptyState={emptyState}
onSelect={vi.fn()}
focusId="place-230"
pageSize={100}
/>,
);

const row = screen.getByText("Place 230").closest("li");
expect(row).not.toBeNull();
expect(
screen.getByRole("button", { name: "Show 100 more (149 not shown)" }),
).toBeTruthy();
});

it("starts again from the first page when the result set changes", () => {
const first = rowsOf(250);
const { rerender } = render(
<ResultsList
header="All places"
rows={first}
emptyState={emptyState}
onSelect={vi.fn()}
pageSize={100}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /^Show 100 more/ }));
expect(screen.getByText("Place 150")).toBeTruthy();

rerender(
<ResultsList
header="All places"
rows={rowsOf(250)}
emptyState={emptyState}
onSelect={vi.fn()}
pageSize={100}
/>,
);

expect(screen.queryByText("Place 150")).toBeNull();
expect(screen.getByText("Place 99")).toBeTruthy();
});
});
40 changes: 39 additions & 1 deletion src/components/map/results-list.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import * as React from "react";
import { ExternalLink, MapPinPlus, MapPinX, Navigation, RotateCcw } from "lucide-react";

import type { Place } from "@/lib/types";
Expand Down Expand Up @@ -28,8 +29,13 @@ interface ResultsListProps {
focusId?: string | null;
/** Optional expand/collapse control (e.g. "Show all" for the nearest list). */
toggle?: { label: string; onClick: () => void } | null;
/** Rows rendered at once; a "Show more" button reveals the next page. */
pageSize?: number;
}

/** Rows rendered before "Show more", so the unfiltered map does not build ~2,000 rows. */
export const RESULTS_PAGE_SIZE = 100;

/** A scrollable list of places. Clicking a row flies the map to that pin. */
export function ResultsList({
header,
Expand All @@ -38,7 +44,26 @@ export function ResultsList({
onSelect,
focusId,
toggle,
pageSize = RESULTS_PAGE_SIZE,
}: ResultsListProps) {
const [limit, setLimit] = React.useState(pageSize);

// A new result set (filter, search, nearest-first) starts again from the first page.
const [seenRows, setSeenRows] = React.useState(rows);
if (rows !== seenRows) {
setSeenRows(rows);
setLimit(pageSize);
}

const shownRows = rows.slice(0, limit);
// A focused pin beyond the page still needs its row, or the highlight would vanish.
if (focusId && rows.length > limit) {
const focused = rows.find((row) => row.place.id === focusId);
if (focused && !shownRows.includes(focused)) shownRows.push(focused);
}
const nextPage = Math.min(pageSize, rows.length - limit);
const hiddenCount = rows.length - shownRows.length;

return (
<div className="flex min-h-[120px] flex-1 flex-col">
<div className="flex items-center justify-between pb-1.5">
Expand Down Expand Up @@ -90,7 +115,7 @@ export function ResultsList({
/>
) : (
<ul className="min-h-0 space-y-1 overflow-y-auto">
{rows.map(({ place, distanceKm }) => (
{shownRows.map(({ place, distanceKm }) => (
<li key={place.id}>
<div
className={`group flex items-center gap-2.5 rounded-md px-2 py-2 ${
Expand Down Expand Up @@ -145,6 +170,19 @@ export function ResultsList({
</div>
</li>
))}
{rows.length > limit && (
<li className="pt-1">
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={() => setLimit((current) => current + pageSize)}
>
Show {nextPage} more ({hiddenCount} not shown)
</Button>
</li>
)}
</ul>
)}
</div>
Expand Down
32 changes: 32 additions & 0 deletions src/lib/use-media-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"use client";

import * as React from "react";

/**
* Whether `query` currently matches. Always `false` on the server and during hydration, then
* follows the browser. Used to mount only the layout that fits the viewport instead of
* building both and hiding one with CSS.
*/
export function useMediaQuery(query: string): boolean {
const subscribe = React.useCallback(
(notify: () => void) => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
return () => {};
}
const list = window.matchMedia(query);
list.addEventListener("change", notify);
return () => list.removeEventListener("change", notify);
},
[query],
);

const getSnapshot = React.useCallback(
() =>
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia(query).matches,
[query],
);

return React.useSyncExternalStore(subscribe, getSnapshot, () => false);
}
Loading