diff --git a/frontend/src/components/map/map-page/useMobileDrawer.ts b/frontend/src/components/map/map-page/useMobileDrawer.ts
new file mode 100644
index 0000000..6c018f7
--- /dev/null
+++ b/frontend/src/components/map/map-page/useMobileDrawer.ts
@@ -0,0 +1,131 @@
+import { useCallback, useRef, useState } from 'react';
+import type { MutableRefObject } from 'react';
+
+import type { MapFlyToOptions } from '../../../types';
+import type { MapFlyTo } from './types';
+
+export interface PendingFlyTo {
+ lat: number;
+ lng: number;
+ zoom: number;
+}
+
+/**
+ * Mobile drawer / bottom sheet state plus the fly-to plumbing that keeps a
+ * selected target visible above them. Fly-tos requested while the drawer panel
+ * hasn't measured itself yet are parked in refs and consumed once the panel
+ * rect arrives, so the camera lands in the area the drawer leaves uncovered.
+ */
+export function useMobileDrawer(isMobile: boolean, flyToRef: MutableRefObject) {
+ const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
+ const [mobileBottomSheetHeight, setMobileBottomSheetHeight] = useState(0);
+ const mobileDrawerPanelRectRef = useRef(null);
+ const pendingCurrentLocationFlyToRef = useRef<{ lat: number; lng: number } | null>(null);
+ const pendingLocationSearchFlyToRef = useRef(null);
+
+ const consumePendingLocationSearchFlyTo = useCallback(
+ (rect?: DOMRectReadOnly | null) => {
+ const pending = pendingLocationSearchFlyToRef.current;
+ const panelRect = rect ?? mobileDrawerPanelRectRef.current;
+ if (!pending || !panelRect) return;
+
+ const bottomInset = Math.max(0, window.innerHeight - panelRect.top);
+ const flyTo = flyToRef.current;
+ if (!flyTo) return;
+ flyTo(pending.lat, pending.lng, pending.zoom, {
+ visibleViewportArea: { bottom: bottomInset },
+ });
+ pendingLocationSearchFlyToRef.current = null;
+ },
+ [flyToRef]
+ );
+
+ const consumePendingCurrentLocationFlyTo = useCallback(
+ (rect?: DOMRectReadOnly | null) => {
+ const pending = pendingCurrentLocationFlyToRef.current;
+ const panelRect = rect ?? mobileDrawerPanelRectRef.current;
+ if (!pending || !panelRect) return;
+
+ const bottomInset = Math.max(0, window.innerHeight - panelRect.top);
+ const flyTo = flyToRef.current;
+ if (!flyTo) return;
+ flyTo(pending.lat, pending.lng, 17, {
+ visibleViewportArea: { bottom: bottomInset },
+ });
+ pendingCurrentLocationFlyToRef.current = null;
+ },
+ [flyToRef]
+ );
+
+ const openMobileDrawer = useCallback(() => {
+ setMobileDrawerOpen(true);
+ }, []);
+
+ /** Open the drawer and fly to the searched location once the panel rect is known. */
+ const openMobileDrawerForLocationSearch = useCallback(
+ (target: PendingFlyTo) => {
+ pendingLocationSearchFlyToRef.current = target;
+ setMobileDrawerOpen(true);
+ consumePendingLocationSearchFlyTo();
+ },
+ [consumePendingLocationSearchFlyTo]
+ );
+
+ const clearPendingLocationSearchFlyTo = useCallback(() => {
+ pendingLocationSearchFlyToRef.current = null;
+ }, []);
+
+ /** Park a current-location fly-to until the drawer panel has measured itself. */
+ const queueCurrentLocationFlyTo = useCallback(
+ (lat: number, lng: number) => {
+ pendingCurrentLocationFlyToRef.current = { lat, lng };
+ consumePendingCurrentLocationFlyTo();
+ },
+ [consumePendingCurrentLocationFlyTo]
+ );
+
+ const handleMobileDrawerPanelRectChange = useCallback(
+ (rect: DOMRectReadOnly) => {
+ mobileDrawerPanelRectRef.current = rect;
+ consumePendingCurrentLocationFlyTo(rect);
+ consumePendingLocationSearchFlyTo(rect);
+ },
+ [consumePendingCurrentLocationFlyTo, consumePendingLocationSearchFlyTo]
+ );
+
+ const handleMobileDrawerClose = useCallback(() => {
+ pendingCurrentLocationFlyToRef.current = null;
+ pendingLocationSearchFlyToRef.current = null;
+ mobileDrawerPanelRectRef.current = null;
+ setMobileDrawerOpen(false);
+ }, []);
+
+ const getMobileMapFlyToOptions = useCallback((): MapFlyToOptions | undefined => {
+ if (!isMobile) return undefined;
+
+ const panelRect = mobileDrawerPanelRectRef.current;
+ if (mobileDrawerOpen && panelRect) {
+ const bottomInset = Math.max(0, window.innerHeight - panelRect.top);
+ if (bottomInset > 0) {
+ return { visibleViewportArea: { bottom: bottomInset } };
+ }
+ }
+
+ return mobileBottomSheetHeight > 0
+ ? { visibleArea: { bottom: mobileBottomSheetHeight } }
+ : undefined;
+ }, [isMobile, mobileBottomSheetHeight, mobileDrawerOpen]);
+
+ return {
+ mobileDrawerOpen,
+ mobileBottomSheetHeight,
+ setMobileBottomSheetHeight,
+ openMobileDrawer,
+ openMobileDrawerForLocationSearch,
+ clearPendingLocationSearchFlyTo,
+ queueCurrentLocationFlyTo,
+ handleMobileDrawerPanelRectChange,
+ handleMobileDrawerClose,
+ getMobileMapFlyToOptions,
+ };
+}
diff --git a/frontend/src/components/ui/PlaceSearchInput.tsx b/frontend/src/components/ui/PlaceSearchInput.tsx
index 9fa5081..0e55935 100644
--- a/frontend/src/components/ui/PlaceSearchInput.tsx
+++ b/frontend/src/components/ui/PlaceSearchInput.tsx
@@ -23,7 +23,9 @@ interface SearchHook {
/** Addresses arrive in raw ALL-CAPS Land Registry casing; title-case for display. */
function titleCaseAddress(address: string): string {
- return address.toLowerCase().replace(/(^|[\s\-/(])([a-z])/g, (_, sep, c) => sep + c.toUpperCase());
+ return address
+ .toLowerCase()
+ .replace(/(^|[\s\-/(])([a-z])/g, (_, sep, c) => sep + c.toUpperCase());
}
interface PlaceSearchInputProps {
diff --git a/frontend/src/components/ui/SubNav.tsx b/frontend/src/components/ui/SubNav.tsx
index 6ed8515..2bac9ef 100644
--- a/frontend/src/components/ui/SubNav.tsx
+++ b/frontend/src/components/ui/SubNav.tsx
@@ -7,11 +7,11 @@ interface SubNavProps {
export function SubNav({ tabs, activeTab, onTabChange }: SubNavProps) {
return (
-
+
{tabs.map((tab) => (