Batch 3: FilterPanel molecule + integration across 3 steps (D-C, D-F)

New molecule:
- FilterPanel: Popover-based reusable filter trigger with active
  count badge, Clear all, Done actions. D-C: Popover for MVP.

Step integrations:
- ProvidersStep: inline Chip filter bar → FilterPanel Popover,
  search bar + filter button side-by-side in sticky header
- VenueStep: same pattern, filter chips moved into Popover
- CoffinsStep (D-F): grid-sidebar layout → wide-form (full-width
  4-col grid), category + price selects moved into FilterPanel

WizardLayout:
- Added wide-form variant (maxWidth lg, single column) for
  card grids that benefit from full width
- wide-form included in STEPPER_VARIANTS for progress bar

Storybook:
- FilterPanel stories: Default, WithActiveFilters, SelectFilters,
  CustomLabel

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 22:24:54 +11:00
parent 1c3cdbc101
commit c5581c6e9f
8 changed files with 576 additions and 252 deletions

View File

@@ -26,6 +26,35 @@ Each entry follows this structure:
## Sessions ## Sessions
### Session 2026-03-29e — Feedback iteration Batches 1 & 2
**Agent(s):** Claude Opus 4.6 (1M context)
**Work completed:**
- **Batch 1: Atom + Template Foundation**
- ToggleButtonGroup: label-to-options spacing `mb: 1→2`, top-align content `flex-start`, fixed selected border CSS specificity (added `&.Mui-selected` in grouped selector)
- Heading standardisation: all 6 split-layout steps changed from `h4``display3` (ProvidersStep, PackagesStep, PreviewStep, VenueStep, CoffinsStep, CoffinDetailsStep) per D-A
- DateTimeStep: normalised section gaps (scheduling fieldset `mb: 3→4`)
- CrematoriumStep: added subheading for consistency, normalised witness section `mb: 3→4`
- PackagesStep + DateTimeStep: fixed input label clipping (`pt: 0.5` on TextField containers)
- **Batch 2: List-Map Layout Rework (D-B)**
- WizardLayout ListMapLayout: 420px fixed left column, `flex: 1` right panel
- Back link rendered inside left panel (not above split) — eliminates gap above map
- LAYOUT_MAP type updated to accept `backLink` prop for list-map variant
- ProvidersStep + VenueStep: sticky header (heading + search + filters pinned at top of scrollable left panel)
**Decisions made:**
- None new (implementing D-A heading standardisation and D-B list-map layout from previous session)
**Open questions:**
- None
**Next steps:**
- Batch 3: FilterPanel component (reusable Popover-based filter for providers, venues, coffins)
- Remaining batches 47 from iteration plan
---
### Session 2026-03-29c — Grooming pass: critique/harden/polish all 15 steps ### Session 2026-03-29c — Grooming pass: critique/harden/polish all 15 steps
**Agent(s):** Claude Opus 4.6 (1M context) **Agent(s):** Claude Opus 4.6 (1M context)

View File

@@ -0,0 +1,100 @@
import React from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { FilterPanel } from './FilterPanel';
import Box from '@mui/material/Box';
import TextField from '@mui/material/TextField';
import MenuItem from '@mui/material/MenuItem';
import { Chip } from '../../atoms/Chip';
const meta: Meta<typeof FilterPanel> = {
title: 'Molecules/FilterPanel',
component: FilterPanel,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
argTypes: {
label: { control: 'text' },
activeCount: { control: 'number' },
minWidth: { control: 'number' },
},
decorators: [
(Story) => (
<Box sx={{ p: 4, minHeight: 400 }}>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof FilterPanel>;
/** Default state — no active filters */
export const Default: Story = {
args: {
activeCount: 0,
children: (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Chip label="Verified providers" variant="outlined" size="small" />
<Chip label="Within 10km" variant="outlined" size="small" />
<Chip label="Reviews 4+★" variant="outlined" size="small" />
</Box>
),
},
};
/** With active filters — badge count shown */
export const WithActiveFilters: Story = {
args: {
activeCount: 2,
onClear: () => {},
children: (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Chip label="Verified providers" variant="outlined" size="small" selected />
<Chip label="Within 10km" variant="outlined" size="small" selected />
<Chip label="Reviews 4+★" variant="outlined" size="small" />
</Box>
),
},
};
/** Select-based filters — category + price (CoffinsStep pattern) */
export const SelectFilters: Story = {
args: {
activeCount: 1,
onClear: () => {},
minWidth: 300,
children: (
<>
<TextField select label="Category" value="solid_timber" fullWidth>
<MenuItem value="all">All categories</MenuItem>
<MenuItem value="solid_timber">Solid Timber</MenuItem>
<MenuItem value="environmental">Environmental</MenuItem>
<MenuItem value="designer">Designer</MenuItem>
</TextField>
<TextField select label="Price range" value="all" fullWidth>
<MenuItem value="all">All prices</MenuItem>
<MenuItem value="under_2000">Under $2,000</MenuItem>
<MenuItem value="2000_4000">$2,000 $4,000</MenuItem>
<MenuItem value="over_4000">Over $4,000</MenuItem>
</TextField>
</>
),
},
};
/** Custom label */
export const CustomLabel: Story = {
args: {
label: 'Sort & Filter',
activeCount: 0,
children: (
<TextField select label="Sort by" value="popular" fullWidth>
<MenuItem value="popular">Most popular</MenuItem>
<MenuItem value="price_low">Price: Low to high</MenuItem>
<MenuItem value="price_high">Price: High to low</MenuItem>
</TextField>
),
},
};

View File

@@ -0,0 +1,166 @@
import React from 'react';
import Box from '@mui/material/Box';
import Popover from '@mui/material/Popover';
import TuneIcon from '@mui/icons-material/Tune';
import type { SxProps, Theme } from '@mui/material/styles';
import { Button } from '../../atoms/Button';
import { Badge } from '../../atoms/Badge';
import { Typography } from '../../atoms/Typography';
import { Link } from '../../atoms/Link';
import { Divider } from '../../atoms/Divider';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Props for the FilterPanel molecule */
export interface FilterPanelProps {
/** Trigger button label */
label?: string;
/** Number of active filters (shown as count on the trigger) */
activeCount?: number;
/** Filter controls — rendered inside the Popover body */
children: React.ReactNode;
/** Callback when "Clear all" is clicked */
onClear?: () => void;
/** Popover min-width */
minWidth?: number;
/** MUI sx prop for the trigger button */
sx?: SxProps<Theme>;
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Reusable filter panel for the FA arrangement wizard.
*
* Renders a trigger button ("Filters") that opens a Popover containing
* arbitrary filter controls (chips, selects, sliders, etc.) passed as
* children. Active filter count shown as a badge on the trigger.
*
* D-C: Popover for desktop MVP. Mobile Drawer variant planned for later.
*
* Used in ProvidersStep, VenueStep, and CoffinsStep.
*
* Usage:
* ```tsx
* <FilterPanel activeCount={2} onClear={handleClear}>
* <TextField select label="Category" ... />
* <TextField select label="Price" ... />
* </FilterPanel>
* ```
*/
export const FilterPanel: React.FC<FilterPanelProps> = ({
label = 'Filters',
activeCount = 0,
children,
onClear,
minWidth = 280,
sx,
}) => {
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null);
const open = Boolean(anchorEl);
const popoverId = open ? 'filter-panel-popover' : undefined;
const handleOpen = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
return (
<>
{/* Trigger button */}
<Box sx={[{ display: 'inline-flex' }, ...(Array.isArray(sx) ? sx : [sx])]}>
<Button
variant="outlined"
color="secondary"
size="small"
startIcon={<TuneIcon />}
onClick={handleOpen}
aria-describedby={popoverId}
aria-expanded={open}
aria-haspopup="dialog"
>
{label}
{activeCount > 0 && (
<Badge
variant="filled"
color="brand"
size="small"
sx={{ ml: 1 }}
aria-label={`${activeCount} active filter${activeCount !== 1 ? 's' : ''}`}
>
{activeCount}
</Badge>
)}
</Button>
</Box>
{/* Popover panel */}
<Popover
id={popoverId}
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
slotProps={{
paper: {
sx: {
minWidth,
mt: 1,
borderRadius: 2,
boxShadow: 3,
},
},
}}
>
{/* Header */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 2.5,
pt: 2,
pb: 1.5,
}}
>
<Typography variant="h6">Filters</Typography>
{onClear && activeCount > 0 && (
<Link
component="button"
onClick={() => {
onClear();
}}
underline="hover"
sx={{ fontSize: '0.8125rem' }}
>
Clear all
</Link>
)}
</Box>
<Divider />
{/* Filter controls */}
<Box sx={{ px: 2.5, py: 2, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{children}
</Box>
<Divider />
{/* Footer — done button */}
<Box sx={{ px: 2.5, py: 1.5, display: 'flex', justifyContent: 'flex-end' }}>
<Button variant="contained" size="small" onClick={handleClose}>
Done
</Button>
</Box>
</Popover>
</>
);
};
FilterPanel.displayName = 'FilterPanel';
export default FilterPanel;

View File

@@ -0,0 +1,3 @@
export { FilterPanel } from './FilterPanel';
export type { FilterPanelProps } from './FilterPanel';
export { default } from './FilterPanel';

View File

@@ -5,6 +5,7 @@ import MenuItem from '@mui/material/MenuItem';
import Pagination from '@mui/material/Pagination'; import Pagination from '@mui/material/Pagination';
import type { SxProps, Theme } from '@mui/material/styles'; import type { SxProps, Theme } from '@mui/material/styles';
import { WizardLayout } from '../../templates/WizardLayout'; import { WizardLayout } from '../../templates/WizardLayout';
import { FilterPanel } from '../../molecules/FilterPanel';
import { Card } from '../../atoms/Card'; import { Card } from '../../atoms/Card';
import { Badge } from '../../atoms/Badge'; import { Badge } from '../../atoms/Badge';
import { Typography } from '../../atoms/Typography'; import { Typography } from '../../atoms/Typography';
@@ -142,223 +143,20 @@ export const CoffinsStep: React.FC<CoffinsStepProps> = ({
sx, sx,
}) => { }) => {
const displayCount = totalCount ?? coffins.length; const displayCount = totalCount ?? coffins.length;
const activeFilterCount =
(values.categoryFilter !== 'all' ? 1 : 0) + (values.priceFilter !== 'all' ? 1 : 0);
const handleFilterChange = (field: 'categoryFilter' | 'priceFilter', value: string) => { const handleFilterChange = (field: 'categoryFilter' | 'priceFilter', value: string) => {
onChange({ ...values, [field]: value, page: 1 }); onChange({ ...values, [field]: value, page: 1 });
}; };
// ─── Sidebar content (filters) ─── const handleFilterClear = () => {
const sidebar = ( onChange({ ...values, categoryFilter: 'all', priceFilter: 'all', page: 1 });
<Box sx={{ py: { xs: 0, md: 2 } }}> };
<Typography variant="h5" sx={{ mb: 2, display: { xs: 'none', md: 'block' } }}>
Filters
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
select
label="Categories"
value={values.categoryFilter}
onChange={(e) => handleFilterChange('categoryFilter', e.target.value)}
fullWidth
>
{categories.map((cat) => (
<MenuItem key={cat.value} value={cat.value}>
{cat.label}
</MenuItem>
))}
</TextField>
<TextField
select
label="Price range"
value={values.priceFilter}
onChange={(e) => handleFilterChange('priceFilter', e.target.value)}
fullWidth
>
{priceRanges.map((range) => (
<MenuItem key={range.value} value={range.value}>
{range.label}
</MenuItem>
))}
</TextField>
</Box>
</Box>
);
// ─── Main content (card grid) ───
const mainContent = (
<Box
component="form"
noValidate
aria-busy={loading}
onSubmit={(e: React.FormEvent) => {
e.preventDefault();
if (!loading) onContinue();
}}
>
{/* Page heading */}
<Typography variant="display3" component="h1" sx={{ mb: 1 }} tabIndex={-1}>
Choose a coffin
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{isPrePlanning
? 'Browse the range to get an idea of styles and pricing. You can change your selection later.'
: 'Browse the range available with your selected provider. Use the filters to narrow your options.'}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ mb: 3, display: 'block' }}>
Selecting a coffin within your package allowance won&apos;t change your total. Coffins
outside the allowance will adjust the price.
</Typography>
{/* Results count */}
<Typography
variant="caption"
color="text.secondary"
sx={{ mb: 2, display: 'block' }}
aria-live="polite"
>
Showing {displayCount} coffin{displayCount !== 1 ? 's' : ''}
</Typography>
{/* Coffin card grid */}
<Box
role="radiogroup"
aria-label="Available coffins"
sx={{
display: 'grid',
gridTemplateColumns: {
xs: '1fr',
sm: 'repeat(2, 1fr)',
lg: 'repeat(3, 1fr)',
},
gap: 2,
mb: 3,
}}
>
{coffins.map((coffin, index) => (
<Card
key={coffin.id}
interactive
selected={coffin.id === values.selectedCoffinId}
padding="none"
onClick={() => onChange({ ...values, selectedCoffinId: coffin.id })}
role="radio"
aria-checked={coffin.id === values.selectedCoffinId}
tabIndex={
values.selectedCoffinId === null
? index === 0
? 0
: -1
: coffin.id === values.selectedCoffinId
? 0
: -1
}
sx={{ overflow: 'hidden' }}
>
{/* Image */}
<Box
sx={{
position: 'relative',
height: 180,
backgroundImage: `url(${coffin.imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundColor: 'var(--fa-color-surface-subtle)',
}}
role="img"
aria-label={`Photo of ${coffin.name}`}
>
{coffin.isPopular && (
<Box sx={{ position: 'absolute', top: 8, left: 8 }}>
<Badge variant="soft" color="brand" aria-label="Most popular choice">
Most Popular
</Badge>
</Box>
)}
</Box>
{/* Content */}
<Box sx={{ p: 2 }}>
<Typography variant="h6" maxLines={2} sx={{ mb: 0.5 }}>
{coffin.name}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{coffin.category}
</Typography>
<Typography variant="h6" color="primary">
${coffin.price.toLocaleString('en-AU')}
</Typography>
</Box>
</Card>
))}
{coffins.length === 0 && (
<Box sx={{ py: 6, textAlign: 'center', gridColumn: '1 / -1' }}>
<Typography variant="body1" color="text.secondary" sx={{ mb: 1 }}>
No coffins match your selected filters.
</Typography>
<Typography variant="body2" color="text.secondary">
Try adjusting the category or price range.
</Typography>
</Box>
)}
</Box>
{/* Validation error */}
{errors?.selectedCoffinId && (
<Typography
variant="body2"
sx={{ mb: 2, color: 'var(--fa-color-text-brand)' }}
role="alert"
>
{errors.selectedCoffinId}
</Typography>
)}
{/* Pagination */}
{totalPages > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 3 }}>
<Pagination
count={totalPages}
page={values.page}
onChange={(_, page) => onChange({ ...values, page })}
color="primary"
/>
</Box>
)}
<Divider sx={{ my: 3 }} />
{/* CTAs */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexDirection: { xs: 'column-reverse', sm: 'row' },
gap: 2,
}}
>
{onSaveAndExit ? (
<Button variant="text" color="secondary" onClick={onSaveAndExit} type="button">
Save and continue later
</Button>
) : (
<Box />
)}
<Button type="submit" variant="contained" size="large" loading={loading}>
Continue
</Button>
</Box>
</Box>
);
return ( return (
<WizardLayout <WizardLayout
variant="grid-sidebar" variant="wide-form"
navigation={navigation} navigation={navigation}
progressStepper={progressStepper} progressStepper={progressStepper}
runningTotal={runningTotal} runningTotal={runningTotal}
@@ -367,9 +165,208 @@ export const CoffinsStep: React.FC<CoffinsStepProps> = ({
onBack={onBack} onBack={onBack}
hideHelpBar={hideHelpBar} hideHelpBar={hideHelpBar}
sx={sx} sx={sx}
secondaryPanel={mainContent}
> >
{sidebar} <Box
component="form"
noValidate
aria-busy={loading}
onSubmit={(e: React.FormEvent) => {
e.preventDefault();
if (!loading) onContinue();
}}
>
{/* Page heading */}
<Typography variant="display3" component="h1" sx={{ mb: 1 }} tabIndex={-1}>
Choose a coffin
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{isPrePlanning
? 'Browse the range to get an idea of styles and pricing. You can change your selection later.'
: 'Browse the range available with your selected provider. Use the filters to narrow your options.'}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ mb: 3, display: 'block' }}>
Selecting a coffin within your package allowance won&apos;t change your total. Coffins
outside the allowance will adjust the price.
</Typography>
{/* Filter button + results count */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 3,
}}
>
<FilterPanel activeCount={activeFilterCount} onClear={handleFilterClear} minWidth={300}>
<TextField
select
label="Category"
value={values.categoryFilter}
onChange={(e) => handleFilterChange('categoryFilter', e.target.value)}
fullWidth
>
{categories.map((cat) => (
<MenuItem key={cat.value} value={cat.value}>
{cat.label}
</MenuItem>
))}
</TextField>
<TextField
select
label="Price range"
value={values.priceFilter}
onChange={(e) => handleFilterChange('priceFilter', e.target.value)}
fullWidth
>
{priceRanges.map((range) => (
<MenuItem key={range.value} value={range.value}>
{range.label}
</MenuItem>
))}
</TextField>
</FilterPanel>
<Typography variant="caption" color="text.secondary" aria-live="polite">
Showing {displayCount} coffin{displayCount !== 1 ? 's' : ''}
</Typography>
</Box>
{/* Coffin card grid — full width (D-F) */}
<Box
role="radiogroup"
aria-label="Available coffins"
sx={{
display: 'grid',
gridTemplateColumns: {
xs: '1fr',
sm: 'repeat(2, 1fr)',
md: 'repeat(3, 1fr)',
lg: 'repeat(4, 1fr)',
},
gap: 2,
mb: 3,
}}
>
{coffins.map((coffin, index) => (
<Card
key={coffin.id}
interactive
selected={coffin.id === values.selectedCoffinId}
padding="none"
onClick={() => onChange({ ...values, selectedCoffinId: coffin.id })}
role="radio"
aria-checked={coffin.id === values.selectedCoffinId}
tabIndex={
values.selectedCoffinId === null
? index === 0
? 0
: -1
: coffin.id === values.selectedCoffinId
? 0
: -1
}
sx={{ overflow: 'hidden' }}
>
{/* Image */}
<Box
sx={{
position: 'relative',
height: 180,
backgroundImage: `url(${coffin.imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundColor: 'var(--fa-color-surface-subtle)',
}}
role="img"
aria-label={`Photo of ${coffin.name}`}
>
{coffin.isPopular && (
<Box sx={{ position: 'absolute', top: 8, left: 8 }}>
<Badge variant="soft" color="brand" aria-label="Most popular choice">
Most Popular
</Badge>
</Box>
)}
</Box>
{/* Content */}
<Box sx={{ p: 2 }}>
<Typography variant="h6" maxLines={2} sx={{ mb: 0.5 }}>
{coffin.name}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{coffin.category}
</Typography>
<Typography variant="h6" color="primary">
${coffin.price.toLocaleString('en-AU')}
</Typography>
</Box>
</Card>
))}
{coffins.length === 0 && (
<Box sx={{ py: 6, textAlign: 'center', gridColumn: '1 / -1' }}>
<Typography variant="body1" color="text.secondary" sx={{ mb: 1 }}>
No coffins match your selected filters.
</Typography>
<Typography variant="body2" color="text.secondary">
Try adjusting the category or price range.
</Typography>
</Box>
)}
</Box>
{/* Validation error */}
{errors?.selectedCoffinId && (
<Typography
variant="body2"
sx={{ mb: 2, color: 'var(--fa-color-text-brand)' }}
role="alert"
>
{errors.selectedCoffinId}
</Typography>
)}
{/* Pagination */}
{totalPages > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 3 }}>
<Pagination
count={totalPages}
page={values.page}
onChange={(_, page) => onChange({ ...values, page })}
color="primary"
/>
</Box>
)}
<Divider sx={{ my: 3 }} />
{/* CTAs */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexDirection: { xs: 'column-reverse', sm: 'row' },
gap: 2,
}}
>
{onSaveAndExit ? (
<Button variant="text" color="secondary" onClick={onSaveAndExit} type="button">
Save and continue later
</Button>
) : (
<Box />
)}
<Button type="submit" variant="contained" size="large" loading={loading}>
Continue
</Button>
</Box>
</Box>
</WizardLayout> </WizardLayout>
); );
}; };

View File

@@ -4,6 +4,7 @@ import type { SxProps, Theme } from '@mui/material/styles';
import { WizardLayout } from '../../templates/WizardLayout'; import { WizardLayout } from '../../templates/WizardLayout';
import { ProviderCard } from '../../molecules/ProviderCard'; import { ProviderCard } from '../../molecules/ProviderCard';
import { SearchBar } from '../../molecules/SearchBar'; import { SearchBar } from '../../molecules/SearchBar';
import { FilterPanel } from '../../molecules/FilterPanel';
import { Chip } from '../../atoms/Chip'; import { Chip } from '../../atoms/Chip';
import { Typography } from '../../atoms/Typography'; import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button'; import { Button } from '../../atoms/Button';
@@ -62,6 +63,8 @@ export interface ProvidersStepProps {
filters?: ProviderFilter[]; filters?: ProviderFilter[];
/** Callback when a filter chip is toggled */ /** Callback when a filter chip is toggled */
onFilterToggle?: (index: number) => void; onFilterToggle?: (index: number) => void;
/** Callback to clear all filters */
onFilterClear?: () => void;
/** Callback for the Continue button */ /** Callback for the Continue button */
onContinue: () => void; onContinue: () => void;
/** Callback for the Back button */ /** Callback for the Back button */
@@ -105,6 +108,7 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
onSearch, onSearch,
filters, filters,
onFilterToggle, onFilterToggle,
onFilterClear,
onContinue, onContinue,
onBack, onBack,
error, error,
@@ -165,32 +169,37 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
{subheading} {subheading}
</Typography> </Typography>
{/* Search bar */} {/* Search bar + filter button */}
<Box sx={{ mb: 2 }}> <Box sx={{ display: 'flex', gap: 1, mb: 2, alignItems: 'flex-start' }}>
<SearchBar <Box sx={{ flex: 1 }}>
value={searchQuery} <SearchBar
onChange={onSearchChange} value={searchQuery}
onSearch={onSearch} onChange={onSearchChange}
placeholder="Search providers..." onSearch={onSearch}
size="small" placeholder="Search providers..."
/> size="small"
</Box> />
{/* Filter chips */}
{filters && filters.length > 0 && (
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap' }}>
{filters.map((filter, index) => (
<Chip
key={filter.label}
label={filter.label}
selected={filter.active}
onClick={onFilterToggle ? () => onFilterToggle(index) : undefined}
variant="outlined"
size="small"
/>
))}
</Box> </Box>
)} {filters && filters.length > 0 && (
<FilterPanel
activeCount={filters.filter((f) => f.active).length}
onClear={onFilterClear}
>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{filters.map((filter, index) => (
<Chip
key={filter.label}
label={filter.label}
selected={filter.active}
onClick={onFilterToggle ? () => onFilterToggle(index) : undefined}
variant="outlined"
size="small"
/>
))}
</Box>
</FilterPanel>
)}
</Box>
{/* Results count */} {/* Results count */}
<Typography <Typography

View File

@@ -9,6 +9,7 @@ import type { SxProps, Theme } from '@mui/material/styles';
import { WizardLayout } from '../../templates/WizardLayout'; import { WizardLayout } from '../../templates/WizardLayout';
import { VenueCard } from '../../molecules/VenueCard'; import { VenueCard } from '../../molecules/VenueCard';
import { AddOnOption } from '../../molecules/AddOnOption'; import { AddOnOption } from '../../molecules/AddOnOption';
import { FilterPanel } from '../../molecules/FilterPanel';
import { Collapse } from '../../atoms/Collapse'; import { Collapse } from '../../atoms/Collapse';
import { Chip } from '../../atoms/Chip'; import { Chip } from '../../atoms/Chip';
import { Typography } from '../../atoms/Typography'; import { Typography } from '../../atoms/Typography';
@@ -81,6 +82,8 @@ export interface VenueStepProps {
services?: VenueService[]; services?: VenueService[];
/** Filter chip options */ /** Filter chip options */
filterOptions?: Array<{ key: string; label: string }>; filterOptions?: Array<{ key: string; label: string }>;
/** Callback to clear all filters */
onFilterClear?: () => void;
/** Location name for the results count */ /** Location name for the results count */
locationName?: string; locationName?: string;
/** Whether this is a pre-planning flow */ /** Whether this is a pre-planning flow */
@@ -130,6 +133,7 @@ export const VenueStep: React.FC<VenueStepProps> = ({
{ key: 'features', label: 'Venue Features' }, { key: 'features', label: 'Venue Features' },
{ key: 'religion', label: 'Religion' }, { key: 'religion', label: 'Religion' },
], ],
onFilterClear,
locationName, locationName,
isPrePlanning = false, isPrePlanning = false,
mapPanel, mapPanel,
@@ -224,8 +228,8 @@ export const VenueStep: React.FC<VenueStepProps> = ({
: 'Choose a venue for the funeral service. You can filter by location, features, and religion.'} : 'Choose a venue for the funeral service. You can filter by location, features, and religion.'}
</Typography> </Typography>
{/* ─── Search + Filters ─── */} {/* ─── Search + Filter button ─── */}
<Box sx={{ mb: 2 }}> <Box sx={{ display: 'flex', gap: 1, mb: 2, alignItems: 'flex-start' }}>
<TextField <TextField
placeholder="Search a town or suburb..." placeholder="Search a town or suburb..."
value={values.search} value={values.search}
@@ -238,19 +242,19 @@ export const VenueStep: React.FC<VenueStepProps> = ({
</InputAdornment> </InputAdornment>
), ),
}} }}
sx={{ mb: 2 }}
/> />
<FilterPanel activeCount={values.activeFilters.length} onClear={onFilterClear}>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{filterOptions.map((filter) => ( {filterOptions.map((filter) => (
<Chip <Chip
key={filter.key} key={filter.key}
label={filter.label} label={filter.label}
onClick={() => handleFilterToggle(filter.key)} onClick={() => handleFilterToggle(filter.key)}
selected={values.activeFilters.includes(filter.key)} selected={values.activeFilters.includes(filter.key)}
/> />
))} ))}
</Box> </Box>
</FilterPanel>
</Box> </Box>
{/* ─── Results count ─── */} {/* ─── Results count ─── */}

View File

@@ -9,9 +9,10 @@ import { Typography } from '../../atoms/Typography';
// ─── Types ─────────────────────────────────────────────────────────────────── // ─── Types ───────────────────────────────────────────────────────────────────
/** Layout variant matching the 5 wizard page templates */ /** Layout variant matching the wizard page templates */
export type WizardLayoutVariant = export type WizardLayoutVariant =
| 'centered-form' | 'centered-form'
| 'wide-form'
| 'list-map' | 'list-map'
| 'list-detail' | 'list-detail'
| 'grid-sidebar' | 'grid-sidebar'
@@ -142,6 +143,20 @@ const CenteredFormLayout: React.FC<{ children: React.ReactNode }> = ({ children
</Container> </Container>
); );
/** Wide Form: single column maxWidth "lg", for card grids (coffins, etc.) */
const WideFormLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<Container
maxWidth="lg"
sx={{
py: { xs: 4, md: 6 },
px: { xs: 2, md: 3 },
flex: 1,
}}
>
{children}
</Container>
);
/** List + Map: 420px fixed scrollable list (left) / flex map (right) — D-B */ /** List + Map: 420px fixed scrollable list (left) / flex map (right) — D-B */
const ListMapLayout: React.FC<{ const ListMapLayout: React.FC<{
children: React.ReactNode; children: React.ReactNode;
@@ -256,6 +271,7 @@ const LAYOUT_MAP: Record<
}> }>
> = { > = {
'centered-form': CenteredFormLayout, 'centered-form': CenteredFormLayout,
'wide-form': WideFormLayout,
'list-map': ListMapLayout, 'list-map': ListMapLayout,
'list-detail': ListDetailLayout, 'list-detail': ListDetailLayout,
'grid-sidebar': GridSidebarLayout, 'grid-sidebar': GridSidebarLayout,
@@ -263,7 +279,7 @@ const LAYOUT_MAP: Record<
}; };
/** Variants that show the stepper/total bar */ /** Variants that show the stepper/total bar */
const STEPPER_VARIANTS: WizardLayoutVariant[] = ['grid-sidebar', 'detail-toggles']; const STEPPER_VARIANTS: WizardLayoutVariant[] = ['wide-form', 'grid-sidebar', 'detail-toggles'];
// ─── Component ─────────────────────────────────────────────────────────────── // ─── Component ───────────────────────────────────────────────────────────────