From a485e67e31baab2c860bc146914638ef8079f005 Mon Sep 17 00:00:00 2001 From: Mikhail Chandran Date: Thu, 29 Oct 2026 23:25:15 +0530 Subject: [PATCH] Cap the map results list at one page and mount a single MapPanel --- src/components/map/places-map.test.tsx | 31 ++++++ src/components/map/places-map.tsx | 9 +- src/components/map/results-list.test.tsx | 119 ++++++++++++++++++++++- src/components/map/results-list.tsx | 40 +++++++- src/lib/use-media-query.ts | 32 ++++++ 5 files changed, 227 insertions(+), 4 deletions(-) create mode 100644 src/lib/use-media-query.ts diff --git a/src/components/map/places-map.test.tsx b/src/components/map/places-map.test.tsx index 5ef7575..bea3ac0 100644 --- a/src/components/map/places-map.test.tsx +++ b/src/components/map/places-map.test.tsx @@ -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); @@ -85,6 +95,7 @@ afterEach(() => { cleanup(); vi.useRealTimers(); vi.restoreAllMocks(); + vi.unstubAllGlobals(); if (clipboardDescriptor) { Object.defineProperty(navigator, "clipboard", clipboardDescriptor); } else { @@ -208,3 +219,23 @@ describe("PlacesMap search URLs", () => { } }); }); + +describe("PlacesMap panel mounting", () => { + it("mounts the sidebar panel at desktop width", () => { + render(); + + // 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(); + + // 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(); + }); +}); diff --git a/src/components/map/places-map.tsx b/src/components/map/places-map.tsx index b646a26..96e0ee2 100644 --- a/src/components/map/places-map.tsx +++ b/src/components/map/places-map.tsx @@ -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"; @@ -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(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); @@ -380,7 +385,7 @@ export function PlacesMap({ places }: PlacesMapProps) {
{/* Desktop sidebar */} {/* Map + mobile overlays */} @@ -462,7 +467,7 @@ export function PlacesMap({ places }: PlacesMapProps) { )} {/* Full panel: visible when sheet above peek */} - {snap !== SHEET_SNAP_POINTS[0] && ( + {snap !== SHEET_SNAP_POINTS[0] && !isDesktop && ( )} diff --git a/src/components/map/results-list.test.tsx b/src/components/map/results-list.test.tsx index 105eb0e..50dff45 100644 --- a/src/components/map/results-list.test.tsx +++ b/src/components/map/results-list.test.tsx @@ -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. @@ -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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + fireEvent.click(screen.getByRole("button", { name: /^Show 100 more/ })); + expect(screen.getByText("Place 150")).toBeTruthy(); + + rerender( + , + ); + + expect(screen.queryByText("Place 150")).toBeNull(); + expect(screen.getByText("Place 99")).toBeTruthy(); + }); +}); diff --git a/src/components/map/results-list.tsx b/src/components/map/results-list.tsx index e8510c6..d5a0ca8 100644 --- a/src/components/map/results-list.tsx +++ b/src/components/map/results-list.tsx @@ -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"; @@ -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, @@ -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 (
@@ -90,7 +115,7 @@ export function ResultsList({ /> ) : (
    - {rows.map(({ place, distanceKm }) => ( + {shownRows.map(({ place, distanceKm }) => (
  • ))} + {rows.length > limit && ( +
  • + +
  • + )}
)}
diff --git a/src/lib/use-media-query.ts b/src/lib/use-media-query.ts new file mode 100644 index 0000000..4a1920e --- /dev/null +++ b/src/lib/use-media-query.ts @@ -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); +}