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:
@@ -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) => ({
|
||||
|
||||
Reference in New Issue
Block a user