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:
@@ -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'),
|
||||
},
|
||||
};
|
||||
252
src/components/molecules/MapProviderDrawer/MapProviderDrawer.tsx
Normal file
252
src/components/molecules/MapProviderDrawer/MapProviderDrawer.tsx
Normal 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;
|
||||
1
src/components/molecules/MapProviderDrawer/index.ts
Normal file
1
src/components/molecules/MapProviderDrawer/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { MapProviderDrawer, type MapProviderDrawerProps } from './MapProviderDrawer';
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user