+ ,
+ );
+ const region = screen.getByRole("region", { name: "sheet.routeAndStations" });
+ const handle = screen.getByRole("button", { name: "sheet.resize" });
+ return { ...utils, region, handle, onSnapChange };
+}
+
+describe("BottomSheet", () => {
+ beforeEach(() => {
+ Object.defineProperty(window, "innerHeight", { value: VH, configurable: true, writable: true });
+ });
+ afterEach(() => vi.restoreAllMocks());
+
+ it("translates to the correct Y for each snap point", () => {
+ for (const s of ["full", "half", "peek"] as const) {
+ const { region, unmount } = renderSheet(s);
+ expect(region.style.transform).toBe(`translateY(${T[s]}px)`);
+ unmount();
+ }
+ });
+
+ it("tap (no movement) cycles peek → half → full → peek", () => {
+ // peek → half
+ let r = renderSheet("peek");
+ fireEvent.pointerDown(r.handle, { clientY: 700, pointerId: 1 });
+ fireEvent.pointerUp(r.handle, { clientY: 700, pointerId: 1 });
+ expect(r.onSnapChange).toHaveBeenCalledWith("half");
+ r.unmount();
+ // half → full
+ r = renderSheet("half");
+ fireEvent.pointerDown(r.handle, { clientY: 400, pointerId: 1 });
+ fireEvent.pointerUp(r.handle, { clientY: 400, pointerId: 1 });
+ expect(r.onSnapChange).toHaveBeenCalledWith("full");
+ r.unmount();
+ // full → peek (wrap)
+ r = renderSheet("full");
+ fireEvent.pointerDown(r.handle, { clientY: 100, pointerId: 1 });
+ fireEvent.pointerUp(r.handle, { clientY: 100, pointerId: 1 });
+ expect(r.onSnapChange).toHaveBeenCalledWith("peek");
+ });
+
+ it("dragging down from full snaps to the nearest lower point", () => {
+ const { handle, onSnapChange } = renderSheet("full");
+ // Drag down ~340px: lands near half (320), far from peek (604) and full (0).
+ fireEvent.pointerDown(handle, { clientY: 100, pointerId: 1 });
+ fireEvent.pointerMove(handle, { clientY: 440, pointerId: 1 }); // delta +340
+ fireEvent.pointerUp(handle, { clientY: 440, pointerId: 1 });
+ expect(onSnapChange).toHaveBeenCalledWith("half");
+ });
+
+ it("dragging up from peek snaps toward full", () => {
+ const { handle, onSnapChange } = renderSheet("peek");
+ // From peek translate 604, drag up 600px → ~4, nearest is full (0).
+ fireEvent.pointerDown(handle, { clientY: 700, pointerId: 1 });
+ fireEvent.pointerMove(handle, { clientY: 100, pointerId: 1 }); // delta -600
+ fireEvent.pointerUp(handle, { clientY: 100, pointerId: 1 });
+ expect(onSnapChange).toHaveBeenCalledWith("full");
+ });
+
+ it("a sub-slop move (<=4px) is treated as a tap, not a drag", () => {
+ const { handle, onSnapChange } = renderSheet("half");
+ fireEvent.pointerDown(handle, { clientY: 400, pointerId: 1 });
+ fireEvent.pointerMove(handle, { clientY: 403, pointerId: 1 }); // 3px → tap
+ fireEvent.pointerUp(handle, { clientY: 403, pointerId: 1 });
+ expect(onSnapChange).toHaveBeenCalledWith("full"); // half → full cycle
+ });
+
+ it("pointercancel restores without cycling the snap", () => {
+ const { handle, onSnapChange } = renderSheet("half");
+ fireEvent.pointerDown(handle, { clientY: 400, pointerId: 1 });
+ fireEvent.pointerCancel(handle, { clientY: 400, pointerId: 1 });
+ expect(onSnapChange).not.toHaveBeenCalled();
+ });
+
+ it("keyboard ArrowUp/ArrowDown/Enter resize the sheet", () => {
+ const { handle, onSnapChange } = renderSheet("peek");
+ fireEvent.keyDown(handle, { key: "ArrowUp" });
+ expect(onSnapChange).toHaveBeenCalledWith("half"); // peek → half
+ onSnapChange.mockClear();
+ fireEvent.keyDown(handle, { key: "Enter" });
+ expect(onSnapChange).toHaveBeenCalledWith("half"); // cycle peek → half
+ });
+
+ it("exposes aria-expanded reflecting the snap state", () => {
+ const peek = renderSheet("peek");
+ expect(peek.handle).toHaveAttribute("aria-expanded", "false");
+ peek.unmount();
+ const half = renderSheet("half");
+ expect(half.handle).toHaveAttribute("aria-expanded", "true");
+ });
+});
diff --git a/src/components/search/bottom-sheet.tsx b/src/components/search/bottom-sheet.tsx
new file mode 100644
index 0000000..e8f0c7a
--- /dev/null
+++ b/src/components/search/bottom-sheet.tsx
@@ -0,0 +1,194 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useI18n } from "@/lib/i18n";
+
+export type SheetSnap = "peek" | "half" | "full";
+
+interface BottomSheetProps {
+ /** Sheet content. The header (handle + optional summary) is rendered by the sheet. */
+ children: React.ReactNode;
+ /** Always-visible summary shown next to the drag handle (e.g. "6.4 km · 8 min"). */
+ summary?: React.ReactNode;
+ /** Controlled snap point. */
+ snap: SheetSnap;
+ onSnapChange: (snap: SheetSnap) => void;
+}
+
+// Snap positions as a fraction of the viewport height that the sheet OCCUPIES
+// (so larger = taller sheet). "peek" reveals only PEEK_HEIGHT px (the header).
+const HALF_FRACTION = 0.5;
+const FULL_FRACTION = 0.9;
+// Visible peek strip = drag handle + the one/two-line summary. Must track the
+// rendered header height; kept as a constant since the header is fixed-size.
+const PEEK_HEIGHT = 116;
+// A pointer move under this many px counts as a tap (cycle), not a drag (snap).
+const TAP_SLOP = 4;
+
+/**
+ * Mobile bottom sheet with drag-to-snap (peek / half / full). The sheet is
+ * fixed to the bottom of the viewport over a full-height map; dragging the
+ * handle (or anywhere in the header) snaps between the three heights. Tapping
+ * the handle cycles peek → half → full → peek, and the handle is a real button
+ * so keyboard users can resize it (↑/↓/Enter/Space).
+ *
+ * Rendered ONLY on mobile (the parent gates on a media query); on desktop the
+ * panel keeps its original side-card layout, so this component never mounts
+ * there and the desktop tests are unaffected.
+ */
+export function BottomSheet({ children, summary, snap, onSnapChange }: BottomSheetProps) {
+ const { t } = useI18n();
+ // Measure the viewport lazily so the first client paint already uses a real
+ // height (avoids a full-open flash and a dead drag while vh is still 0).
+ const [vh, setVh] = useState(() => (typeof window !== "undefined" ? window.innerHeight : 0));
+ // Live drag state lives in a ref (read synchronously at pointerup, immune to
+ // React's async state batching) plus a mirror in state to drive the render.
+ const dragRef = useRef<{
+ startY: number;
+ startTranslate: number;
+ pointerId: number;
+ moved: boolean;
+ maxDown: number; // clamp ceiling snapshotted at pointerdown — stable mid-gesture
+ translate: number; // latest live translate, read in settle()
+ } | null>(null);
+ const [dragTranslate, setDragTranslate] = useState(null);
+
+ useEffect(() => {
+ const measure = () => {
+ // Ignore resizes mid-drag so the gesture keeps one consistent basis.
+ if (dragRef.current) return;
+ setVh(window.innerHeight);
+ };
+ measure();
+ window.addEventListener("resize", measure);
+ return () => window.removeEventListener("resize", measure);
+ }, []);
+
+ // translateY (px from fully-open at top) for a given snap point. The sheet's
+ // own height is FULL_FRACTION of vh; we slide it down to reveal less.
+ const sheetHeight = vh * FULL_FRACTION;
+ const translateForSnap = useCallback(
+ (s: SheetSnap): number => {
+ if (vh === 0) return 0;
+ if (s === "full") return 0;
+ if (s === "half") return sheetHeight - vh * HALF_FRACTION;
+ // peek: only PEEK_HEIGHT px visible at the bottom
+ return sheetHeight - PEEK_HEIGHT;
+ },
+ [vh, sheetHeight],
+ );
+
+ const restingTranslate = translateForSnap(snap);
+ const currentTranslate = dragTranslate ?? restingTranslate;
+
+ const cycleSnap = useCallback(
+ () => onSnapChange(snap === "peek" ? "half" : snap === "half" ? "full" : "peek"),
+ [snap, onSnapChange],
+ );
+
+ const onPointerDown = useCallback(
+ (e: React.PointerEvent) => {
+ // Don't start a drag before the viewport is measured (vh===0 would pin
+ // the sheet open). The tap fallback / keyboard still work.
+ if (vh === 0) return;
+ dragRef.current = {
+ startY: e.clientY,
+ startTranslate: restingTranslate,
+ pointerId: e.pointerId,
+ moved: false,
+ maxDown: sheetHeight - PEEK_HEIGHT,
+ translate: restingTranslate,
+ };
+ (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
+ },
+ [vh, restingTranslate, sheetHeight],
+ );
+
+ const onPointerMove = useCallback((e: React.PointerEvent) => {
+ const d = dragRef.current;
+ if (!d) return;
+ const delta = e.clientY - d.startY;
+ if (Math.abs(delta) > TAP_SLOP) d.moved = true;
+ // Clamp between fully-open (0) and peek, using the basis snapshotted at down.
+ const next = Math.max(0, Math.min(d.maxDown, d.startTranslate + delta));
+ d.translate = next;
+ setDragTranslate(next);
+ }, []);
+
+ const settle = useCallback(
+ (e: React.PointerEvent) => {
+ const d = dragRef.current;
+ dragRef.current = null;
+ if (!d) return;
+ (e.currentTarget as HTMLElement).releasePointerCapture?.(d.pointerId);
+ setDragTranslate(null);
+
+ // A browser-stolen gesture (pointercancel) should restore, never cycle.
+ if (e.type === "pointercancel") return;
+
+ // Tap (no real movement) → cycle to the next snap point.
+ if (!d.moved) {
+ cycleSnap();
+ return;
+ }
+
+ // Snap to whichever resting position is nearest the released translate
+ // (read from the ref, so a fast flick's final position isn't lost).
+ const released = d.translate;
+ const candidates: SheetSnap[] = ["full", "half", "peek"];
+ let best: SheetSnap = "peek";
+ let bestDist = Infinity;
+ for (const c of candidates) {
+ const dist = Math.abs(translateForSnap(c) - released);
+ if (dist < bestDist) { bestDist = dist; best = c; }
+ }
+ onSnapChange(best);
+ },
+ [translateForSnap, onSnapChange, cycleSnap],
+ );
+
+ const onKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === "ArrowUp") { e.preventDefault(); onSnapChange(snap === "peek" ? "half" : "full"); }
+ else if (e.key === "ArrowDown") { e.preventDefault(); onSnapChange(snap === "full" ? "half" : "peek"); }
+ else if (e.key === "Enter" || e.key === " ") { e.preventDefault(); cycleSnap(); }
+ },
+ [snap, onSnapChange, cycleSnap],
+ );
+
+ return (
+
+ {/* Drag handle — a real button so it's keyboard/AT operable. `touch-none`
+ lives ONLY here (not the container) so the body below can still scroll
+ on touch. Pointer events drive drag/tap; keys drive snap for keyboards. */}
+
+ {/* Scrollable body — the station list scrolls here when the sheet is tall.
+ pan-y lets vertical touch-scroll work even with the dragging ancestor. */}
+
+ {children}
+
+
+ );
+}
diff --git a/src/components/search/search-panel.test.tsx b/src/components/search/search-panel.test.tsx
index de73ab4..9078ebc 100644
--- a/src/components/search/search-panel.test.tsx
+++ b/src/components/search/search-panel.test.tsx
@@ -17,7 +17,15 @@ const MADRID: PhotonResult = {
name: "Madrid", city: "Madrid", state: "Comunidad de Madrid", coordinates: [-3.7, 40.4],
} as PhotonResult;
-function renderPanel() {
+// A resolved route so deep-link prefill / route-phase render paths engage.
+const ROUTE_FOR_SEED = {
+ geometry: { type: "LineString" as const, coordinates: [[-1.0, 38.0], [-0.37, 39.47]] },
+ distance: 120000,
+ duration: 4800,
+ bbox: [-1.0, 38.0, -0.37, 39.47] as [number, number, number, number],
+};
+
+function renderPanel(props: Partial> = {}) {
return render(
,
);
}
@@ -37,12 +46,14 @@ describe("SearchPanel — autocomplete dropdown closes on selection", () => {
});
afterEach(() => vi.restoreAllMocks());
- it("closes the origin suggestion list after picking a result", async () => {
+ // Destination-first flow: the single box on open is the DESTINATION
+ // (placeholder "search.whereTo"). Picking a suggestion must close the list.
+ it("closes the destination suggestion list after picking a result", async () => {
const user = userEvent.setup();
renderPanel();
- const origin = screen.getByPlaceholderText("search.placeholder");
- await user.type(origin, "Mad");
+ const dest = screen.getByPlaceholderText("search.whereTo");
+ await user.type(dest, "Mad");
const option = await screen.findByText("Madrid", {}, { timeout: 2000 });
await user.click(option);
@@ -54,3 +65,159 @@ describe("SearchPanel — autocomplete dropdown closes on selection", () => {
});
});
});
+
+describe("SearchPanel — destination-first flow", () => {
+ beforeEach(() => {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => [MADRID] }));
+ });
+ afterEach(() => vi.restoreAllMocks());
+
+ it("shows a single destination box on open (origin section collapsed)", () => {
+ renderPanel();
+ // Primary box uses the "where to?" placeholder (the destination).
+ expect(screen.getByPlaceholderText("search.whereTo")).toBeInTheDocument();
+ // The origin input exists in the DOM (kept mounted for the slide animation)
+ // but its wrapper is collapsed (max-h-0 opacity-0) — not visible on open.
+ const originInput = screen.getByPlaceholderText("search.origin");
+ const slideWrapper = originInput.closest("div.transition-all");
+ expect(slideWrapper).not.toBeNull();
+ expect(slideWrapper!.className).toContain("max-h-0");
+ expect(slideWrapper!.className).toContain("opacity-0");
+ });
+
+ it("routes straight from userLocation when a destination is picked", async () => {
+ const user = userEvent.setup();
+ const onRoute = vi.fn();
+ // Location already shared → origin auto-seeds; picking a destination routes.
+ renderPanel({ onRoute, userLocation: [-3.70, 40.42] });
+
+ const dest = screen.getByPlaceholderText("search.whereTo");
+ await user.type(dest, "Mad");
+ const option = await screen.findByText("Madrid", {}, { timeout: 2000 });
+ await user.click(option);
+
+ await waitFor(() => expect(onRoute).toHaveBeenCalledTimes(1));
+ // origin = userLocation, destination = Madrid coords.
+ expect(onRoute).toHaveBeenCalledWith([-3.70, 40.42], [-3.7, 40.4], undefined, undefined);
+ });
+
+ it("reveals the origin box (no route yet) when location is unavailable", async () => {
+ const user = userEvent.setup();
+ const onRoute = vi.fn();
+ // No userLocation → after picking a destination we must prompt for origin.
+ renderPanel({ onRoute });
+
+ const dest = screen.getByPlaceholderText("search.whereTo");
+ await user.type(dest, "Mad");
+ const option = await screen.findByText("Madrid", {}, { timeout: 2000 });
+ await user.click(option);
+
+ // The origin input is always mounted (for the slide animation), so assert
+ // the REVEAL: its slide wrapper must flip from collapsed (max-h-0) to
+ // expanded (max-h-[500px]/opacity-100) after the destination is picked.
+ const originInput = screen.getByPlaceholderText("search.origin");
+ const slideWrapper = originInput.closest("div.transition-all");
+ expect(slideWrapper).not.toBeNull();
+ await waitFor(() => {
+ expect(slideWrapper!.className).toContain("max-h-[500px]");
+ expect(slideWrapper!.className).toContain("opacity-100");
+ });
+ expect(slideWrapper!.className).not.toContain("max-h-0");
+ // NO route was computed (origin still unknown).
+ expect(onRoute).not.toHaveBeenCalled();
+ });
+});
+
+describe("SearchPanel — mobile bottom sheet", () => {
+ const ROUTE = {
+ geometry: { type: "LineString" as const, coordinates: [[-3.7, 40.4], [-0.37, 39.47]] },
+ distance: 6400,
+ duration: 480,
+ bbox: [-3.7, 39.47, -0.37, 40.4] as [number, number, number, number],
+ };
+
+ // Stub matchMedia so the (max-width: 639px) query reports a match → isMobile.
+ function stubViewport(isMobile: boolean) {
+ vi.stubGlobal(
+ "matchMedia",
+ vi.fn().mockImplementation((q: string) => ({
+ matches: q.includes("max-width: 639px") ? isMobile : false,
+ media: q,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ );
+ }
+
+ beforeEach(() => {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => [] }));
+ });
+ afterEach(() => vi.restoreAllMocks());
+
+ it("renders the route in a bottom sheet (role=region) on a mobile viewport", async () => {
+ stubViewport(true);
+ renderPanel({ routes: [ROUTE], initialRoute: { from: [-3.7, 40.4], to: [-0.37, 39.47], via: [] } });
+ // The sheet is a labelled region landmark; it appears once a route is active.
+ await waitFor(() =>
+ expect(screen.getByRole("region", { name: "sheet.routeAndStations" })).toBeInTheDocument(),
+ );
+ });
+
+ it("does NOT render a bottom sheet on desktop (side-card layout)", async () => {
+ stubViewport(false);
+ renderPanel({ routes: [ROUTE], initialRoute: { from: [-3.7, 40.4], to: [-0.37, 39.47], via: [] } });
+ // Give effects a tick; the sheet region must never appear on desktop.
+ await waitFor(() => expect(screen.getByText("share.shareRoute")).toBeInTheDocument());
+ expect(screen.queryByRole("region", { name: "sheet.routeAndStations" })).not.toBeInTheDocument();
+ });
+});
+
+describe("SearchPanel — geolocation & auto-seed", () => {
+ afterEach(() => vi.restoreAllMocks());
+
+ function stubGeo(impl: Parameters[0]) {
+ vi.stubGlobal("navigator", {
+ ...navigator,
+ geolocation: { getCurrentPosition: vi.fn(impl), watchPosition: vi.fn(), clearWatch: vi.fn() },
+ });
+ }
+
+ it("shows a transient error and does not strand the user when 'My location' is denied", async () => {
+ const user = userEvent.setup();
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => [MADRID] }));
+ // getCurrentPosition invokes the ERROR callback (2nd arg).
+ stubGeo((_ok: PositionCallback, err: PositionErrorCallback) =>
+ err({ code: 1, message: "denied" } as GeolocationPositionError),
+ );
+ const onRoute = vi.fn();
+ renderPanel({ onRoute });
+
+ // Pick a destination → no userLocation → origin box reveals with "My location".
+ const dest = screen.getByPlaceholderText("search.whereTo");
+ await user.type(dest, "Mad");
+ await user.click(await screen.findByText("Madrid", {}, { timeout: 2000 }));
+ const myLocation = await screen.findByText("geo.myLocation", {}, { timeout: 2000 });
+ await user.click(myLocation);
+
+ // Failure surfaces feedback (geo.denied toast) and never computes a route.
+ await waitFor(() => expect(screen.getByText("geo.denied")).toBeInTheDocument());
+ expect(onRoute).not.toHaveBeenCalled();
+ });
+
+ it("auto-seeds origin from userLocation but does NOT clobber a deep-link origin", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => [] }));
+ // Deep-link route present AND userLocation present → deep-link owns the origin.
+ renderPanel({
+ userLocation: [-3.70, 40.42],
+ initialRoute: { from: [-1.0, 38.0], to: [-0.37, 39.47], via: [] },
+ routes: [ROUTE_FOR_SEED],
+ });
+ // Origin label is the deep-link "share.point", not "geo.myLocation".
+ const origin = screen.getByPlaceholderText("search.origin") as HTMLInputElement;
+ await waitFor(() => expect(origin.value).toBe("share.point"));
+ expect(origin.value).not.toBe("geo.myLocation");
+ });
+});
diff --git a/src/components/search/search-panel.tsx b/src/components/search/search-panel.tsx
index 5b0c9a3..a725695 100644
--- a/src/components/search/search-panel.tsx
+++ b/src/components/search/search-panel.tsx
@@ -7,13 +7,21 @@ import type { StationsGeoJSONCollection } from "@/types/station";
import { AutocompleteInput, type AutocompleteRef } from "./autocomplete-input";
import { RouteAlternatives } from "./route-alternatives";
import { StationResults } from "./station-results";
+import { BottomSheet, type SheetSnap } from "./bottom-sheet";
+import { useMediaQuery } from "@/lib/use-media-query";
import { useI18n } from "@/lib/i18n";
import { projectOntoRoute } from "@/lib/route-geometry";
import { formatDistance, formatDuration } from "@/lib/format";
import { shareOrCopy, copyToClipboard } from "@/lib/share";
import { buildRouteQuery } from "@/lib/share-url";
-type Phase = "search" | "destination" | "route";
+// Destination-first flow:
+// "dest" — single box on open; whatever you type is the DESTINATION.
+// Origin is hidden (auto-fills from your location when available).
+// "planning" — both origin + destination visible, no route yet (origin was
+// revealed because location resolved, or you need to type a start).
+// "route" — a route is active.
+type Phase = "dest" | "planning" | "route";
const MAX_WAYPOINTS = 5;
@@ -49,6 +57,12 @@ interface SearchPanelProps {
initialRoute?: { from: [number, number]; to: [number, number]; via: [number, number][] } | null;
/** Selected fuel code — written into the shared route URL (`fuel=` param). */
selectedFuel?: string;
+ /**
+ * The user's current location (lng,lat) once shared, else null. Used to
+ * auto-fill the ORIGIN in the destination-first flow: when present, picking a
+ * destination routes straight from "My location" — no manual origin entry.
+ */
+ userLocation?: [number, number] | null;
}
interface Location {
@@ -91,10 +105,17 @@ export function SearchPanel({
onCorridorKmChange,
initialRoute,
selectedFuel,
+ userLocation,
}: SearchPanelProps) {
const { t } = useI18n();
- const [phase, setPhase] = useState("search");
+ const isMobile = useMediaQuery("(max-width: 639px)");
+ const [phase, setPhase] = useState("dest");
const [collapsed, setCollapsed] = useState(false);
+ // Mobile bottom-sheet snap point. Starts at "half" so the first stations are
+ // visible the moment a route resolves, without covering the whole map.
+ const [sheetSnap, setSheetSnap] = useState("half");
+ // Transient toast for a "My location" geolocation failure (auto-clears).
+ const [geoErrorMsg, setGeoErrorMsg] = useState(null);
const [sortBy, setSortBy] = useState<"price" | "detour" | "km">("price");
const [originText, setOriginText] = useState("");
const [destText, setDestText] = useState("");
@@ -105,11 +126,16 @@ export function SearchPanel({
const destRef = useRef(null);
const waypointRefs = useRef