ProvidersStep: extract MapProviderDrawer + unify control chrome + fix search button drift

- New molecule MapProviderDrawer lifts the mobile-map bottom drawer out
  of ProvidersStep (~120 lines): Paper + close-X header + single-pin
  ProviderCard content / cluster-list content + slide-up animation.
  Props: `active: ProviderMapActiveState | null`, `onClose`,
  `onSelectProvider`, `onDrillIntoProvider`. Three Storybook states
  (SingleProvider, Cluster, ClusterPair, Closed) so the drawer can be
  iterated without a live map. ProvidersStep now consumes it as a
  single line wired to mapRef.clearActive + mapRef.drillIntoProvider.

- Shared visual tokens for the control cluster (Search, Filters, Sort by,
  List/Map toggle) factored into a CONTROL_CHROME constant and three
  typed sx objects (controlButtonSx, controlToggleSx, controlInputSx,
  filterTriggerSx) so all four controls share the same outline, radius,
  fill, and shadow across mobile list, mobile map, and desktop. Desktop
  map-panel floating toggle also re-threaded through controlToggleSx.

- Mobile list control order now matches mobile map: Sort by is grouped
  left next to Filters (not pushed right with a ml:auto wrapper), and
  the List/Map toggle is right-pinned via ml:auto on xs. Desktop keeps
  Sort pushed right (no toggle rendered on desktop in this slot).

- Fix: the magnifying-glass commit button was drifting 19–30px left as
  the input filled with chips / draft text. Root cause: overriding
  `InputProps.endAdornment` on Autocomplete bypasses MUI's
  `.MuiAutocomplete-endAdornment` absolute positioning, leaving our
  `.MuiInputAdornment-positionEnd` as `position: static` in flex flow.
  controlInputSx now re-absolutely-anchors the end adornment at the
  right edge and reserves `pr: 5` so input content can't slide under it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 09:39:52 +10:00
parent 6434d11384
commit 30ec88ceaf
6 changed files with 566 additions and 368 deletions

View File

@@ -0,0 +1,146 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { MapProviderDrawer } from './MapProviderDrawer';
const meta: Meta<typeof MapProviderDrawer> = {
title: 'Molecules/MapProviderDrawer',
component: MapProviderDrawer,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
viewport: { defaultViewport: 'mobile1' },
},
decorators: [
// Simulate the mobile map-view container: fixed-size, relatively-positioned,
// with a faux map background behind the drawer.
(Story) => (
<Box
sx={{
position: 'relative',
width: 390,
height: 700,
mx: 'auto',
overflow: 'hidden',
// Very rough map-tile fill so the drawer has contrast behind it.
background: 'linear-gradient(135deg, #C9DFC4 0%, #B5D4F0 50%, #C9DFC4 100%)',
}}
>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof MapProviderDrawer>;
// ─── Fixtures ───────────────────────────────────────────────────────────────
const parsons = {
id: 'parsons',
name: 'H.Parsons Funeral Directors',
location: 'Wentworth, NSW',
verified: true,
imageUrl: '/images/funeral-homes/parsons-chapel.jpg',
logoUrl: '/images/providers/parsons-logo.png',
rating: 4.6,
reviewCount: 7,
startingPrice: 1800,
};
const clusterProviders = [
parsons,
{
id: 'rankins',
name: 'Rankins Funeral Services',
location: 'Warrawong, NSW',
verified: true,
rating: 4.8,
startingPrice: 2450,
},
{
id: 'killick',
name: 'Killick Family Funerals',
location: 'Kingaroy, QLD',
verified: true,
rating: 4.9,
startingPrice: 3100,
},
{
id: 'wollongong-city',
name: 'Wollongong City Funerals',
location: 'Wollongong, NSW',
verified: false,
rating: 4.2,
startingPrice: 3400,
},
];
const log =
(label: string) =>
(arg?: string): void => {
console.log(label, arg ?? '');
};
// ─── Stories ────────────────────────────────────────────────────────────────
/** Single-provider drawer — the whole ProviderCard is clickable and fires
* `onSelectProvider` (in production, this navigates to the packages page). */
export const SingleProvider: Story = {
args: {
active: {
provider: parsons,
cluster: null,
exiting: false,
},
onClose: log('close'),
onSelectProvider: log('select'),
onDrillIntoProvider: log('drillInto'),
},
};
/** Cluster drawer — verified-first list of rows. Tapping a row fires
* `onDrillIntoProvider`; in production this pans + zooms the map and
* swaps the drawer's `active` to a single-provider state. */
export const Cluster: Story = {
args: {
active: {
provider: null,
cluster: {
providers: clusterProviders,
position: { lat: -34.42, lng: 150.89 },
},
exiting: false,
},
onClose: log('close'),
onSelectProvider: log('select'),
onDrillIntoProvider: log('drillInto'),
},
};
/** Closed state — the drawer is in the DOM but translated off-screen. */
export const Closed: Story = {
args: {
active: null,
onClose: log('close'),
onSelectProvider: log('select'),
onDrillIntoProvider: log('drillInto'),
},
};
/** Small cluster of two — verified pair. */
export const ClusterPair: Story = {
args: {
active: {
provider: null,
cluster: {
providers: clusterProviders.slice(0, 2),
position: { lat: -34.42, lng: 150.89 },
},
exiting: false,
},
onClose: log('close'),
onSelectProvider: log('select'),
onDrillIntoProvider: log('drillInto'),
},
};

View File

@@ -0,0 +1,252 @@
import React from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import ButtonBase from '@mui/material/ButtonBase';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import type { SxProps, Theme } from '@mui/material/styles';
import { IconButton } from '../../atoms/IconButton';
import { Typography } from '../../atoms/Typography';
import { ProviderCard } from '../ProviderCard';
import type { ProviderData } from '../../pages/ProvidersStep';
import type { ProviderMapActiveState } from '../../organisms/ProviderMap';
// ─── Types ──────────────────────────────────────────────────────────────────
/** Props for the FA MapProviderDrawer molecule */
export interface MapProviderDrawerProps {
/** Current active state from `ProviderMap` (wire via `onActiveChange`).
* `null` = no active pin/cluster; drawer is hidden. */
active: ProviderMapActiveState | null;
/** Fires when the close X is tapped. Typically wired to the map's
* imperative `clearActive()`. */
onClose: () => void;
/** Fires when the single-provider card is tapped (entire card clickable).
* Typically navigates to that provider's packages. */
onSelectProvider: (id: string) => void;
/** Fires when a cluster row is tapped. Typically wired to the map's
* imperative `drillIntoProvider()` which pans + zooms + swaps the
* drawer's content to a single-provider card. */
onDrillIntoProvider: (id: string) => void;
/** MUI sx prop for the root Paper — merged onto the default positioning. */
sx?: SxProps<Theme>;
}
// ─── Cluster row ────────────────────────────────────────────────────────────
const ClusterRow: React.FC<{
provider: ProviderData;
onClick: () => void;
}> = ({ provider: p, onClick }) => (
<ButtonBase
onClick={onClick}
sx={{
width: '100%',
justifyContent: 'flex-start',
textAlign: 'left',
px: 2,
py: 1.25,
gap: 1,
// Start-align so the verified icon sits on the name's baseline —
// matches the desktop ClusterPopup row treatment.
alignItems: 'flex-start',
borderBottom: '1px solid',
borderColor: 'divider',
'&:last-of-type': { borderBottom: 'none' },
'&:hover': { bgcolor: 'var(--fa-color-surface-subtle)' },
}}
>
{/* Verified-icon slot — height tuned to the name's line-box so the
tick aligns with the title top, empty slot keeps other names
left-aligned on the same x-origin. */}
<Box
sx={{
width: 18,
height: '1.25em',
display: 'flex',
alignItems: 'center',
flexShrink: 0,
}}
>
{p.verified && <VerifiedOutlinedIcon sx={{ fontSize: 16, color: 'primary.main' }} />}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
variant="body2"
sx={{
fontWeight: 600,
color: p.verified ? 'primary.main' : 'text.primary',
mb: 0.25,
}}
>
{p.name}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: 'text.secondary' }}>
<Typography variant="caption">{p.location}</Typography>
{p.rating != null && (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.25 }}>
<StarRoundedIcon sx={{ fontSize: 14, color: 'warning.main' }} />
<Typography variant="caption">{p.rating.toFixed(1)}</Typography>
</Box>
)}
</Box>
</Box>
{p.startingPrice != null && (
<Box sx={{ flexShrink: 0, textAlign: 'right', pl: 1 }}>
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
From
</Typography>
<Typography
variant="body2"
sx={{ fontWeight: 600, color: p.verified ? 'primary.main' : 'text.primary' }}
>
${p.startingPrice.toLocaleString('en-AU')}
</Typography>
</Box>
)}
</ButtonBase>
);
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Bottom drawer that surfaces `ProviderMap`'s popup content outside the
* map itself. Used by the mobile map-first layout (see D045): the map
* runs full-bleed, and when a pin or cluster is tapped the drawer slides
* up from the bottom with the appropriate content.
*
* **Two content states, driven by `active`:**
* - `active.provider` → renders a `ProviderCard` edge-to-edge, entire card
* clickable (fires `onSelectProvider`)
* - `active.cluster` → renders a verified-first list of rows (verified icon
* slot + name + location + rating + "From $X"); tapping a row fires
* `onDrillIntoProvider` which is wired to the map's imperative
* `drillIntoProvider()` (pans + zooms, then swaps `active` to that
* provider — the drawer content flips to the single-provider card).
*
* **Animation:** slides up via `transform: translateY()` + 220ms transition.
* When `active.exiting` is true, the drawer slides down immediately (the
* map organism is in the middle of its 180ms exit fade on the hidden pin
* beneath). `visibility: hidden` kicks in only after the slide completes,
* so the drawer stays in the DOM for the exit animation.
*
* **Positioning:** uses `position: absolute; bottom: 0; left: 0; right: 0`
* by default — the consumer MUST render this inside a relatively-positioned
* container (typically the map-view `<main>`). Override via `sx` if needed.
*
* Related: row layout mirrors `ClusterPopup` (the anchored on-map variant);
* future consolidation possible if both container contracts converge.
*/
export const MapProviderDrawer = React.forwardRef<HTMLDivElement, MapProviderDrawerProps>(
({ active, onClose, onSelectProvider, onDrillIntoProvider, sx }, ref) => {
const provider = active?.provider ?? null;
const cluster = active?.cluster ?? null;
const isOpen = !!(active && !active.exiting && (provider || cluster));
const isExiting = !!active?.exiting;
const ariaLabel = provider
? `${provider.name} details`
: cluster
? `${cluster.providers.length} providers in this area`
: undefined;
return (
<Paper
ref={ref}
elevation={0}
role="dialog"
aria-label={ariaLabel}
aria-hidden={!isOpen}
sx={[
{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
zIndex: 3,
maxHeight: '60vh',
overflow: 'auto',
borderRadius: 0,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
boxShadow: 'var(--fa-shadow-lg)',
transform: isOpen ? 'translateY(0)' : 'translateY(100%)',
transition: 'transform 220ms ease-out',
pointerEvents: isOpen ? 'auto' : 'none',
visibility: isOpen || isExiting ? 'visible' : 'hidden',
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Header strip — holds the close X (and the cluster count when
applicable) so neither sits over the card image below. */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
minHeight: 40,
px: 1.5,
py: 0.5,
gap: 1,
}}
>
{cluster && !provider && (
<Typography variant="labelLg" sx={{ color: 'text.secondary', display: 'block' }}>
{cluster.providers.length} providers in this area
</Typography>
)}
<IconButton
aria-label="Close"
onClick={onClose}
size="small"
sx={{
ml: 'auto',
width: 32,
height: 32,
color: 'text.secondary',
'&:hover': { bgcolor: 'var(--fa-color-surface-subtle)' },
}}
>
<CloseRoundedIcon sx={{ fontSize: 20 }} />
</IconButton>
</Box>
{/* Single-provider content — entire card clickable. Card runs
edge-to-edge with all corners squared; the drawer Paper provides
the top radius. */}
{provider && (
<ProviderCard
name={provider.name}
location={provider.location}
verified={provider.verified}
imageUrl={provider.imageUrl}
logoUrl={provider.logoUrl}
rating={provider.rating}
reviewCount={provider.reviewCount}
startingPrice={provider.startingPrice}
onClick={() => onSelectProvider(provider.id)}
aria-label={`${provider.name}, ${provider.location}. Tap to view packages.`}
sx={{ borderRadius: 0, boxShadow: 'none', border: 'none' }}
/>
)}
{/* Cluster list content — tap a row to drill in */}
{cluster && !provider && (
<Box sx={{ pb: 1 }}>
{[...cluster.providers]
.sort((a, b) => Number(!!b.verified) - Number(!!a.verified))
.map((p) => (
<ClusterRow key={p.id} provider={p} onClick={() => onDrillIntoProvider(p.id)} />
))}
</Box>
)}
</Paper>
);
},
);
MapProviderDrawer.displayName = 'MapProviderDrawer';
export default MapProviderDrawer;

View File

@@ -0,0 +1 @@
export { MapProviderDrawer, type MapProviderDrawerProps } from './MapProviderDrawer';

View File

@@ -1,7 +1,5 @@
import React from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import ButtonBase from '@mui/material/ButtonBase';
import TextField from '@mui/material/TextField';
import InputAdornment from '@mui/material/InputAdornment';
import Autocomplete from '@mui/material/Autocomplete';
@@ -17,15 +15,13 @@ import SearchIcon from '@mui/icons-material/Search';
import ViewListOutlinedIcon from '@mui/icons-material/ViewListOutlined';
import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import PhoneIcon from '@mui/icons-material/Phone';
import type { SxProps, Theme } from '@mui/material/styles';
import { useTheme } from '@mui/material/styles';
import { WizardLayout } from '../../templates/WizardLayout';
import { ProviderCard } from '../../molecules/ProviderCard';
import { FilterPanel } from '../../molecules/FilterPanel';
import { MapProviderDrawer } from '../../molecules/MapProviderDrawer';
import {
ProviderMap,
type ProviderMapActiveState,
@@ -212,6 +208,98 @@ const chipWrapSx = {
gap: 1,
} as const;
/**
* Shared visual tokens for the ProvidersStep control chips. Search, Filters,
* Sort by, and the List/Map toggle all reference these so their outline /
* radius / fill / shadow / height read as one coherent set. Kept on the page
* (not promoted to a design-system-wide primitive) because this is a
* page-local "control cluster" pattern — Button and Input already own their
* own radii in the theme.
*/
const CONTROL_CHROME = {
height: 32,
borderColor: 'var(--fa-color-neutral-300)',
borderRadius: 'var(--fa-button-border-radius-default)',
bgcolor: 'background.paper',
boxShadow: 'var(--fa-shadow-sm)',
} as const;
/** sx for an outlined Button carrying CONTROL_CHROME (used for Sort by). */
const controlButtonSx = {
height: CONTROL_CHROME.height,
bgcolor: CONTROL_CHROME.bgcolor,
borderColor: CONTROL_CHROME.borderColor,
borderRadius: CONTROL_CHROME.borderRadius,
boxShadow: CONTROL_CHROME.boxShadow,
textTransform: 'none',
'&:hover': { bgcolor: CONTROL_CHROME.bgcolor, borderColor: CONTROL_CHROME.borderColor },
'&:focus-visible': { outline: 'none' },
} as const;
/** sx for the FilterPanel wrapper — targets its internal trigger Button. */
const filterTriggerSx = {
'& .MuiButton-root': controlButtonSx,
} as const;
/** sx for a ToggleButtonGroup carrying CONTROL_CHROME (used for List/Map). */
const controlToggleSx = {
borderRadius: CONTROL_CHROME.borderRadius,
boxShadow: CONTROL_CHROME.boxShadow,
'& .MuiToggleButton-root': {
height: CONTROL_CHROME.height,
px: 1.5,
py: 0,
textTransform: 'none',
fontSize: 'var(--fa-button-font-size-sm)',
fontWeight: 600,
borderColor: CONTROL_CHROME.borderColor,
bgcolor: CONTROL_CHROME.bgcolor,
'&:hover': { bgcolor: CONTROL_CHROME.bgcolor },
'&.Mui-selected': {
bgcolor: 'var(--fa-color-brand-100)',
color: 'primary.main',
'&:hover': { bgcolor: 'var(--fa-color-brand-200)' },
},
},
} as const;
/** sx for the Autocomplete/TextField search input carrying CONTROL_CHROME.
* Absolute-anchors the end adornment (commit button) to the right edge —
* MUI's stock Autocomplete does this on `.MuiAutocomplete-endAdornment`,
* but overriding `InputProps.endAdornment` puts the content in a
* `.MuiInputAdornment-positionEnd` (which is static by default), so the
* button slides left as chips/draft fill the input. `paddingRight` on the
* OutlinedInput reserves the lane so input content can't run under it. */
const controlInputSx = {
'& .MuiOutlinedInput-root': {
bgcolor: CONTROL_CHROME.bgcolor,
boxShadow: CONTROL_CHROME.boxShadow,
borderRadius: CONTROL_CHROME.borderRadius,
pr: 5,
position: 'relative',
},
'& .MuiOutlinedInput-root .MuiInputAdornment-positionEnd': {
position: 'absolute',
right: 8,
top: '50%',
transform: 'translateY(-50%)',
height: 'auto',
maxHeight: 'none',
m: 0,
},
'& .MuiOutlinedInput-notchedOutline': {
borderColor: CONTROL_CHROME.borderColor,
borderWidth: 1,
},
'& .MuiOutlinedInput-root.Mui-focused': {
boxShadow: CONTROL_CHROME.boxShadow,
'& .MuiOutlinedInput-notchedOutline': {
borderColor: CONTROL_CHROME.borderColor,
borderWidth: 1,
},
},
} as const;
// ─── Component ───────────────────────────────────────────────────────────────
/**
@@ -480,15 +568,6 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
// ─── Mobile map-first layout ───────────────────────────────────────────────
if (showMobileMapLayout) {
const active = mapActive ?? null;
// Drawer is "open" only when there's an active selection AND the map
// isn't in the middle of its exit animation. Flipping to false on
// `exiting` kicks off the slide-down transform immediately, so the user
// sees the drawer leave as soon as they tap the close X.
const drawerOpen = !!(active && !active.exiting && (active.provider || active.cluster));
const drawerProvider = active?.provider ?? null;
const drawerCluster = active?.cluster ?? null;
return (
<Box
sx={{
@@ -574,7 +653,7 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
</>
),
endAdornment: (
<InputAdornment position="end" sx={{ mr: 0.5 }}>
<InputAdornment position="end">
<IconButton
aria-label="Search"
onClick={() => commitSearch(searchDraft)}
@@ -595,53 +674,19 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
}}
/>
)}
sx={{
'& .MuiOutlinedInput-root': {
bgcolor: 'background.paper',
boxShadow: 'var(--fa-shadow-sm)',
borderRadius: 'var(--fa-button-border-radius-default)',
},
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'var(--fa-color-neutral-300)',
borderWidth: 1,
},
'& .MuiOutlinedInput-root.Mui-focused': {
boxShadow: 'var(--fa-shadow-sm)',
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'var(--fa-color-neutral-300)',
borderWidth: 1,
},
},
}}
sx={controlInputSx}
/>
{/* Control row: Filters, Sort (icon-only on mobile), view toggle.
Each control carries its own white fill so it reads cleanly
over any map tile — no shared container. Heights aligned at
32px to match Button small + ToggleButton small. */}
{/* Control row: Filters, Sort by, view toggle.
Each control reads as part of one chip set — shared outline,
radius, fill, and shadow via CONTROL_CHROME. */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FilterPanel
activeCount={activeCount}
onClear={handleClear}
sx={{
'& .MuiButton-root': {
height: 32,
bgcolor: 'background.paper',
borderColor: 'var(--fa-color-neutral-300)',
boxShadow: 'var(--fa-shadow-sm)',
'&:hover': {
bgcolor: 'background.paper',
borderColor: 'var(--fa-color-neutral-300)',
},
'&:focus-visible': { outline: 'none' },
},
}}
>
<FilterPanel activeCount={activeCount} onClear={handleClear} sx={filterTriggerSx}>
{filterDialogChildren}
</FilterPanel>
{/* Sort — compact text trigger on mobile. Current value lives
in the menu (selected state); aria-label spells it out. */}
{/* Sort — compact "Sort by" trigger; current value surfaces in
the menu's selected state and in the aria-label. */}
<Button
variant="outlined"
color="secondary"
@@ -649,18 +694,7 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
onClick={(e) => setSortAnchor(e.currentTarget)}
aria-haspopup="listbox"
aria-label={`Sort by ${SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? 'Recommended'}`}
sx={{
height: 32,
bgcolor: 'background.paper',
borderColor: 'var(--fa-color-neutral-300)',
boxShadow: 'var(--fa-shadow-sm)',
textTransform: 'none',
'&:hover': {
bgcolor: 'background.paper',
borderColor: 'var(--fa-color-neutral-300)',
},
'&:focus-visible': { outline: 'none' },
}}
sx={controlButtonSx}
>
Sort by
</Button>
@@ -686,35 +720,15 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
))}
</Menu>
{/* View toggle — text labels on mobile, aligned height with
the buttons; font matches Filters/Sort (14px / 600) */}
{/* View toggle — right-aligned; same outline/radius/fill/shadow
as Filters + Sort, with brand fill on the selected side. */}
<ToggleButtonGroup
value={viewMode}
exclusive
onChange={(_, val) => val && onViewModeChange?.(val as ListViewMode)}
size="small"
aria-label="View mode"
sx={{
ml: 'auto',
flexShrink: 0,
boxShadow: 'var(--fa-shadow-sm)',
'& .MuiToggleButton-root': {
height: 32,
px: 1.5,
py: 0,
textTransform: 'none',
fontSize: 'var(--fa-button-font-size-sm)',
fontWeight: 600,
borderColor: 'var(--fa-color-neutral-300)',
bgcolor: 'background.paper',
'&:hover': { bgcolor: 'background.paper' },
'&.Mui-selected': {
bgcolor: 'var(--fa-color-brand-100)',
color: 'primary.main',
'&:hover': { bgcolor: 'var(--fa-color-brand-200)' },
},
},
}}
sx={[{ ml: 'auto', flexShrink: 0 }, controlToggleSx]}
>
<ToggleButton value="list" aria-label="List view">
List
@@ -727,182 +741,12 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
</Box>
{/* Bottom drawer — slides up when a pin/cluster is active */}
<Paper
elevation={0}
role="dialog"
aria-label={
drawerProvider
? `${drawerProvider.name} details`
: drawerCluster
? `${drawerCluster.providers.length} providers in this area`
: undefined
}
aria-hidden={!drawerOpen}
sx={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
zIndex: 3,
maxHeight: '60vh',
overflow: 'auto',
borderRadius: 0,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
boxShadow: 'var(--fa-shadow-lg)',
transform: drawerOpen ? 'translateY(0)' : 'translateY(100%)',
transition: 'transform 220ms ease-out',
pointerEvents: drawerOpen ? 'auto' : 'none',
visibility: drawerOpen || mapActive?.exiting ? 'visible' : 'hidden',
}}
>
{/* Drawer header — holds the close X (and the cluster count when
applicable) so it doesn't sit over the card image */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
minHeight: 40,
px: 1.5,
py: 0.5,
gap: 1,
}}
>
{drawerCluster && !drawerProvider && (
<Typography variant="labelLg" sx={{ color: 'text.secondary', display: 'block' }}>
{drawerCluster.providers.length} providers in this area
</Typography>
)}
<IconButton
aria-label="Close"
onClick={() => mapRef.current?.clearActive()}
size="small"
sx={{
ml: 'auto',
width: 32,
height: 32,
color: 'text.secondary',
'&:hover': { bgcolor: 'var(--fa-color-surface-subtle)' },
}}
>
<CloseRoundedIcon sx={{ fontSize: 20 }} />
</IconButton>
</Box>
{/* Single-provider drawer content — entire card clickable. Card
runs edge-to-edge inside the drawer with its own corners
squared; the drawer Paper provides the top radius. */}
{drawerProvider && (
<ProviderCard
name={drawerProvider.name}
location={drawerProvider.location}
verified={drawerProvider.verified}
imageUrl={drawerProvider.imageUrl}
logoUrl={drawerProvider.logoUrl}
rating={drawerProvider.rating}
reviewCount={drawerProvider.reviewCount}
startingPrice={drawerProvider.startingPrice}
onClick={() => onSelectProvider(drawerProvider.id)}
aria-label={`${drawerProvider.name}, ${drawerProvider.location}. Tap to view packages.`}
sx={{ borderRadius: 0, boxShadow: 'none', border: 'none' }}
/>
)}
{/* Cluster list drawer content — tap row to drill in */}
{drawerCluster && !drawerProvider && (
<Box sx={{ pb: 1 }}>
<Box>
{[...drawerCluster.providers]
.sort((a, b) => Number(!!b.verified) - Number(!!a.verified))
.map((p) => (
<ButtonBase
key={p.id}
onClick={() => mapRef.current?.drillIntoProvider(p.id)}
sx={{
width: '100%',
justifyContent: 'flex-start',
textAlign: 'left',
px: 2,
py: 1.25,
gap: 1,
// Start-align so the verified icon sits on the
// name's baseline (matches desktop ClusterPopup)
alignItems: 'flex-start',
borderBottom: '1px solid',
borderColor: 'divider',
'&:last-of-type': { borderBottom: 'none' },
'&:hover': { bgcolor: 'var(--fa-color-surface-subtle)' },
}}
>
{/* Verified-icon slot — height tuned to the name's
line-box so the tick aligns with the title top */}
<Box
sx={{
width: 18,
height: '1.25em',
display: 'flex',
alignItems: 'center',
flexShrink: 0,
}}
>
{p.verified && (
<VerifiedOutlinedIcon sx={{ fontSize: 16, color: 'primary.main' }} />
)}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
variant="body2"
sx={{
fontWeight: 600,
color: p.verified ? 'primary.main' : 'text.primary',
mb: 0.25,
}}
>
{p.name}
</Typography>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
color: 'text.secondary',
}}
>
<Typography variant="caption">{p.location}</Typography>
{p.rating != null && (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.25 }}>
<StarRoundedIcon sx={{ fontSize: 14, color: 'warning.main' }} />
<Typography variant="caption">{p.rating.toFixed(1)}</Typography>
</Box>
)}
</Box>
</Box>
{/* Price column — right-aligned "From $X" */}
{p.startingPrice != null && (
<Box sx={{ flexShrink: 0, textAlign: 'right', pl: 1 }}>
<Typography
variant="caption"
sx={{ display: 'block', color: 'text.secondary' }}
>
From
</Typography>
<Typography
variant="body2"
sx={{
fontWeight: 600,
color: p.verified ? 'primary.main' : 'text.primary',
}}
>
${p.startingPrice.toLocaleString('en-AU')}
</Typography>
</Box>
)}
</ButtonBase>
))}
</Box>
</Box>
)}
</Paper>
<MapProviderDrawer
active={mapActive}
onClose={() => mapRef.current?.clearActive()}
onSelectProvider={onSelectProvider}
onDrillIntoProvider={(id) => mapRef.current?.drillIntoProvider(id)}
/>
</Box>
{/* Sticky help bar (matches WizardLayout) */}
@@ -943,38 +787,19 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
sx={sx}
secondaryPanel={
<Box sx={{ position: 'relative', flex: 1, display: 'flex' }}>
{/* Floating view toggle — sized to match Filters/Sort buttons */}
{/* Floating view toggle — same chrome as the sticky-bar controls,
anchored to the map panel's top-left. */}
<ToggleButtonGroup
value={viewMode}
exclusive
onChange={(_, val) => val && onViewModeChange?.(val as ListViewMode)}
size="small"
aria-label="View mode"
sx={{
position: 'absolute',
top: 12,
left: 12,
zIndex: 1,
bgcolor: 'background.paper',
boxShadow: 'var(--fa-shadow-md)',
'& .MuiToggleButton-root': {
height: 'var(--fa-button-height-sm)',
px: 1.5,
py: 0,
fontSize: 'var(--fa-button-font-size-sm)',
fontWeight: 600,
gap: 0.75,
border: '1px solid',
borderColor: 'divider',
textTransform: 'none',
'&.Mui-selected': {
bgcolor: 'var(--fa-color-brand-100)',
color: 'primary.main',
borderColor: 'primary.main',
'&:hover': { bgcolor: 'var(--fa-color-brand-200)' },
},
},
}}
sx={[
{ position: 'absolute', top: 12, left: 12, zIndex: 1 },
controlToggleSx,
{ '& .MuiToggleButton-root': { gap: 0.75 } },
]}
>
<ToggleButton value="list" aria-label="List view">
<ViewListOutlinedIcon sx={{ fontSize: 16 }} />
@@ -1113,17 +938,7 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
}}
/>
)}
sx={{
mb: 1.5,
// Kill the custom brand focus ring + border colour change on focus
'& .MuiOutlinedInput-root.Mui-focused': {
boxShadow: 'none',
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'var(--fa-color-neutral-300)',
borderWidth: 1,
},
},
}}
sx={[controlInputSx, { mb: 1.5 }]}
/>
{/* Control bar — filters + sort */}
@@ -1134,33 +949,14 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
gap: 1,
}}
>
{/* Filters — on mobile, matches the map-view floating chip style
(white fill, neutral-300 border, shadow-sm). On desktop,
default Button small look. */}
<FilterPanel
activeCount={activeCount}
onClear={handleClear}
sx={{
'& .MuiButton-root': {
height: { xs: 32, md: undefined },
bgcolor: { xs: 'background.paper', md: undefined },
borderColor: { xs: 'var(--fa-color-neutral-300)', md: undefined },
boxShadow: { xs: 'var(--fa-shadow-sm)', md: 'none' },
'&:hover': {
bgcolor: { xs: 'background.paper', md: undefined },
borderColor: { xs: 'var(--fa-color-neutral-300)', md: undefined },
},
'&:focus-visible': { outline: 'none' },
},
}}
>
<FilterPanel activeCount={activeCount} onClear={handleClear} sx={filterTriggerSx}>
{filterDialogChildren}
</FilterPanel>
{/* Sort — mobile shows a compact "Sort by" text button matching
the Filters chip style; desktop keeps the full "Sort: <value>"
label with its swap icon. */}
<Box sx={{ ml: 'auto' }}>
{/* Sort — mobile shows a compact "Sort by" (grouped left next to
Filters, matching the map-view order); desktop shows the full
"Sort: <value>" with its swap icon, pushed to the right. */}
<Box sx={{ ml: { xs: 0, md: 'auto' } }}>
<Button
variant="outlined"
color="secondary"
@@ -1169,18 +965,7 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
onClick={(e) => setSortAnchor(e.currentTarget)}
aria-haspopup="listbox"
aria-label={`Sort by ${SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? 'Recommended'}`}
sx={{
textTransform: 'none',
height: { xs: 32, md: undefined },
bgcolor: { xs: 'background.paper', md: undefined },
borderColor: { xs: 'var(--fa-color-neutral-300)', md: undefined },
boxShadow: { xs: 'var(--fa-shadow-sm)', md: 'none' },
'&:hover': {
bgcolor: { xs: 'background.paper', md: undefined },
borderColor: { xs: 'var(--fa-color-neutral-300)', md: undefined },
},
'&:focus-visible': { outline: 'none' },
}}
sx={controlButtonSx}
>
{isMobile ? (
'Sort by'
@@ -1216,36 +1001,18 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
</Menu>
</Box>
{/* Mobile-only view toggle — matches the map-view floating toggle:
text labels (List / Map), white fill, neutral-300 border, shadow,
14px / 600 type to align with the Filters + Sort by buttons. */}
{/* Mobile-only view toggle — pinned to the right via ml: auto on xs.
Shares the same CONTROL_CHROME as Filters + Sort. */}
<ToggleButtonGroup
value={viewMode}
exclusive
onChange={(_, val) => val && onViewModeChange?.(val as ListViewMode)}
size="small"
aria-label="View mode"
sx={{
display: { xs: 'inline-flex', md: 'none' },
flexShrink: 0,
boxShadow: 'var(--fa-shadow-sm)',
'& .MuiToggleButton-root': {
height: 32,
px: 1.5,
py: 0,
textTransform: 'none',
fontSize: 'var(--fa-button-font-size-sm)',
fontWeight: 600,
borderColor: 'var(--fa-color-neutral-300)',
bgcolor: 'background.paper',
'&:hover': { bgcolor: 'background.paper' },
'&.Mui-selected': {
bgcolor: 'var(--fa-color-brand-100)',
color: 'primary.main',
'&:hover': { bgcolor: 'var(--fa-color-brand-200)' },
},
},
}}
sx={[
{ display: { xs: 'inline-flex', md: 'none' }, ml: 'auto', flexShrink: 0 },
controlToggleSx,
]}
>
<ToggleButton value="list" aria-label="List view">
List