ProvidersStep: mobile map-first layout with bottom drawer

On xs + viewMode=map, render a map-first layout: full-bleed map,
floating card-shaped control strip at the top (search + Filters +
Sort + compact List/Map toggle), and a bottom drawer that slides up
when a pin or cluster is tapped. The desktop list-map layout is
unchanged.

On xs + viewMode=list, the List/Map toggle now appears in the sticky
control bar (icon-only) so users can reach the map from the list view.
On desktop the toggle stays on the map panel as before.

Drawer content:
- Single pin → the existing ProviderCard molecule, entire card
  clickable (navigates to packages)
- Cluster → a list of image-free rows (verified icon slot + name +
  location + rating), tap a row to pan+zoom into the provider
- Close X on the drawer clears the active state

To support externalising popups, ProviderMap gains two opt-in props
(`externalisePopups`, `onActiveChange`) and an imperative handle
(`clearActive`, `drillIntoProvider`). Desktop behaviour unchanged
when these aren't used. The forwardRef now exposes the handle rather
than the DOM element; no existing callsite passed a DOM ref.

The filter-dialog children are now defined once as a shared JSX
fragment used by both desktop and mobile FilterPanel instances.

Header + subhead are suppressed on the mobile map view (per concept
reference); they remain on desktop and mobile list for orientation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 12:39:08 +10:00
parent 3bf5f72b4f
commit 8c818fd5ac
3 changed files with 679 additions and 178 deletions

View File

@@ -39,6 +39,26 @@ const POPUP_EXIT_MS = 180;
// ─── Types ──────────────────────────────────────────────────────────────────
/** Shape of the currently-active provider or cluster selection, emitted to
* callers that opt into external popup rendering (see `externalisePopups`). */
export interface ProviderMapActiveState {
/** Active single provider, if a pin was tapped (or a cluster row drilled into) */
provider: ProviderData | null;
/** Active cluster, if a cluster marker was tapped and no row has been drilled into */
cluster: { providers: ProviderData[]; position: { lat: number; lng: number } } | null;
/** True while the exit animation is running — callers may want to mirror it */
exiting: boolean;
}
/** Imperative handle exposed via ref. Used when rendering popups externally. */
export interface ProviderMapHandle {
/** Close the currently-active popup (animated). No-op if nothing is open. */
clearActive: () => void;
/** Pan + zoom the map to a provider's coords and set them as the active
* single-provider selection. Equivalent to a cluster-row tap. */
drillIntoProvider: (id: string) => void;
}
/** Props for the FA ProviderMap organism */
export interface ProviderMapProps {
/** Providers to render as pins. Providers without coords are filtered out silently. */
@@ -53,6 +73,14 @@ export interface ProviderMapProps {
defaultZoom?: number;
/** Google Maps API key. Defaults to `import.meta.env.VITE_GOOGLE_MAPS_API_KEY`. */
apiKey?: string;
/** When true, suppress the organism's own MapPopup + ClusterPopup rendering.
* The active state is still tracked internally (pins still hide when active)
* and emitted via `onActiveChange` so callers can render a drawer, sheet,
* or other external container. Used by the mobile map-first layout. */
externalisePopups?: boolean;
/** Fires whenever the active provider/cluster state changes. Paired with
* `externalisePopups` — the caller uses this to drive external UI. */
onActiveChange?: (state: ProviderMapActiveState) => void;
/** MUI sx prop for the root element */
sx?: SxProps<Theme>;
}
@@ -276,7 +304,7 @@ const MapEmptyState: React.FC<{ reason: 'no-key' | 'no-coords' }> = ({ reason })
* Composes `MapPin` + `ClusterMarker` (atoms) + `MapPopup` + `ClusterPopup`
* (molecules). Clustering via `@googlemaps/markerclusterer`.
*/
export const ProviderMap = React.forwardRef<HTMLDivElement, ProviderMapProps>(
export const ProviderMap = React.forwardRef<ProviderMapHandle, ProviderMapProps>(
(
{
providers,
@@ -285,6 +313,8 @@ export const ProviderMap = React.forwardRef<HTMLDivElement, ProviderMapProps>(
defaultCenter = FALLBACK_CENTER,
defaultZoom = FALLBACK_ZOOM,
apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY,
externalisePopups = false,
onActiveChange,
sx,
},
ref,
@@ -369,6 +399,27 @@ export const ProviderMap = React.forwardRef<HTMLDivElement, ProviderMapProps>(
const handleMapClick = closeWithExit;
const handleCloseCluster = closeWithExit;
// Emit active-state changes when the caller is rendering popups externally.
const onActiveChangeRef = React.useRef(onActiveChange);
React.useEffect(() => {
onActiveChangeRef.current = onActiveChange;
}, [onActiveChange]);
React.useEffect(() => {
onActiveChangeRef.current?.({
provider: activeProvider,
cluster: activeCluster
? {
providers: activeCluster.providers,
position: {
lat: activeCluster.position.lat,
lng: activeCluster.position.lng,
},
}
: null,
exiting,
});
}, [activeProvider, activeCluster, exiting]);
/** Cluster list → single-provider drill-in.
* Pans + zooms the map to the provider's coords (zoom 15 = past
* CLUSTER_MAX_ZOOM so nearby cluster members separate into individual
@@ -388,6 +439,16 @@ export const ProviderMap = React.forwardRef<HTMLDivElement, ProviderMapProps>(
[withCoords, cancelExit],
);
// Imperative handle for external callers (drawer close, cluster-row tap).
React.useImperativeHandle(
ref,
() => ({
clearActive: closeWithExit,
drillIntoProvider: handleDrillIntoProvider,
}),
[closeWithExit, handleDrillIntoProvider],
);
const rootSx = [
{
position: 'relative' as const,
@@ -404,14 +465,14 @@ export const ProviderMap = React.forwardRef<HTMLDivElement, ProviderMapProps>(
// Empty states
if (!apiKey) {
return (
<Box ref={ref} role="application" aria-label="Provider map" sx={rootSx}>
<Box role="application" aria-label="Provider map" sx={rootSx}>
<MapEmptyState reason="no-key" />
</Box>
);
}
if (withCoords.length === 0) {
return (
<Box ref={ref} role="application" aria-label="Provider map" sx={rootSx}>
<Box role="application" aria-label="Provider map" sx={rootSx}>
<MapEmptyState reason="no-coords" />
</Box>
);
@@ -440,8 +501,9 @@ export const ProviderMap = React.forwardRef<HTMLDivElement, ProviderMapProps>(
onClusterClick={handleClusterClick}
/>
{/* Single-provider popup (pin click OR post-zoom cluster drill-in) */}
{activeProvider && (
{/* Internal popups — skipped when caller externalises them (e.g.
mobile drawer). Active state still flows via onActiveChange. */}
{!externalisePopups && activeProvider && (
<AdvancedMarker position={activeProvider.coords!} zIndex={1000}>
<MapPopup
name={activeProvider.name}
@@ -459,7 +521,7 @@ export const ProviderMap = React.forwardRef<HTMLDivElement, ProviderMapProps>(
{/* Cluster list popup — shown while a cluster is active and no
provider has been drilled into. Drilling clears activeCluster,
which swaps this for the single-provider popup above. */}
{activeCluster && !activeProviderId && (
{!externalisePopups && activeCluster && !activeProviderId && (
<AdvancedMarker position={activeCluster.position} zIndex={1000}>
<ClusterPopup
providers={activeCluster.providers.map((p) => ({