Add sort dropdown + list/map view toggle to ProvidersStep & VenueStep

New control bar below search on both listing pages:
- Left: results count (passive)
- Right: sort select (Recommended/Nearest/Price), list/map toggle, filters

Sort: compact TextField select with 4 options, 0.813rem font.
View toggle: MUI ToggleButtonGroup with list/map icons, brand highlight.
Control bar wraps gracefully on narrow panels (flex-wrap).

New types: ProviderSortBy, VenueSortBy, ListViewMode.
Stories updated with interactive sort + view state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 16:47:50 +11:00
parent a3069b2ee6
commit e3090e6aed
4 changed files with 233 additions and 28 deletions

View File

@@ -1,7 +1,12 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { ProvidersStep } from './ProvidersStep';
import type { ProviderData, ProviderFilterValues } from './ProvidersStep';
import type {
ProviderData,
ProviderFilterValues,
ProviderSortBy,
ListViewMode,
} from './ProvidersStep';
import { EMPTY_FILTER_VALUES } from './ProvidersStep';
import { Navigation } from '../../organisms/Navigation';
import Box from '@mui/material/Box';
@@ -118,6 +123,8 @@ export const Default: Story = {
render: () => {
const [query, setQuery] = useState('');
const [filters, setFilters] = useState<ProviderFilterValues>(EMPTY_FILTER_VALUES);
const [sort, setSort] = useState<ProviderSortBy>('recommended');
const [view, setView] = useState<ListViewMode>('list');
const filtered = mockProviders.filter((p) =>
p.location.toLowerCase().includes(query.toLowerCase()),
@@ -131,6 +138,10 @@ export const Default: Story = {
onSearchChange={setQuery}
filterValues={filters}
onFilterChange={setFilters}
sortBy={sort}
onSortChange={setSort}
viewMode={view}
onViewModeChange={setView}
onBack={() => alert('Back')}
navigation={nav}
/>
@@ -151,6 +162,8 @@ export const WithActiveFilters: Story = {
onlineArrangements: false,
priceRange: [0, 2000],
});
const [sort, setSort] = useState<ProviderSortBy>('nearest');
const [view, setView] = useState<ListViewMode>('list');
const filtered = mockProviders.filter((p) =>
p.location.toLowerCase().includes(query.toLowerCase()),
@@ -164,6 +177,10 @@ export const WithActiveFilters: Story = {
onSearchChange={setQuery}
filterValues={filters}
onFilterChange={setFilters}
sortBy={sort}
onSortChange={setSort}
viewMode={view}
onViewModeChange={setView}
onBack={() => alert('Back')}
navigation={nav}
/>

View File

@@ -5,6 +5,11 @@ import InputAdornment from '@mui/material/InputAdornment';
import Autocomplete from '@mui/material/Autocomplete';
import FormControlLabel from '@mui/material/FormControlLabel';
import Slider from '@mui/material/Slider';
import MenuItem from '@mui/material/MenuItem';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import ToggleButton from '@mui/material/ToggleButton';
import ViewListOutlinedIcon from '@mui/icons-material/ViewListOutlined';
import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import type { SxProps, Theme } from '@mui/material/styles';
import { WizardLayout } from '../../templates/WizardLayout';
@@ -65,6 +70,12 @@ export interface ProviderFilterValues {
priceRange: [number, number];
}
/** Sort options for the provider list */
export type ProviderSortBy = 'recommended' | 'nearest' | 'price_low' | 'price_high';
/** View mode for the listing */
export type ListViewMode = 'list' | 'map';
/** Props for the ProvidersStep page component */
export interface ProvidersStepProps {
/** List of providers to display */
@@ -89,6 +100,14 @@ export interface ProvidersStepProps {
minPrice?: number;
/** Maximum price for the slider (default 15000) */
maxPrice?: number;
/** Current sort order */
sortBy?: ProviderSortBy;
/** Callback when sort order changes */
onSortChange?: (sort: ProviderSortBy) => void;
/** Current view mode */
viewMode?: ListViewMode;
/** Callback when view mode changes */
onViewModeChange?: (mode: ListViewMode) => void;
/** Callback for the Back button */
onBack: () => void;
/** Map panel content — slot for future map integration */
@@ -140,6 +159,13 @@ const DEFAULT_FUNERAL_TYPES: FuneralTypeOption[] = [
{ value: 'burial_only', label: 'Burial Only' },
];
const SORT_OPTIONS: { value: ProviderSortBy; label: string }[] = [
{ value: 'recommended', label: 'Recommended' },
{ value: 'nearest', label: 'Nearest' },
{ value: 'price_low', label: 'Price: Low to High' },
{ value: 'price_high', label: 'Price: High to Low' },
];
export const EMPTY_FILTER_VALUES: ProviderFilterValues = {
tradition: null,
funeralTypes: [],
@@ -197,6 +223,10 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
funeralTypeOptions = DEFAULT_FUNERAL_TYPES,
minPrice = 0,
maxPrice = 15000,
sortBy = 'recommended',
onSortChange,
viewMode = 'list',
onViewModeChange,
onBack,
mapPanel,
navigation,
@@ -335,8 +365,76 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
sx={{ mb: 1.5 }}
/>
{/* Filters — right-aligned below search */}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
{/* Control bar — results count, sort, view toggle, filters */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
flexWrap: 'wrap',
}}
>
{/* Results count — left */}
<Typography
variant="caption"
color="text.secondary"
sx={{ mr: 'auto' }}
aria-live="polite"
>
{providers.length} provider{providers.length !== 1 ? 's' : ''} found
</Typography>
{/* Sort */}
<TextField
select
value={sortBy}
onChange={(e) => onSortChange?.(e.target.value as ProviderSortBy)}
size="small"
aria-label="Sort providers"
sx={{
minWidth: 160,
'& .MuiOutlinedInput-root': { fontSize: '0.813rem' },
'& .MuiSelect-select': { py: '5px' },
}}
>
{SORT_OPTIONS.map((opt) => (
<MenuItem key={opt.value} value={opt.value} sx={{ fontSize: '0.813rem' }}>
{opt.label}
</MenuItem>
))}
</TextField>
{/* View toggle */}
<ToggleButtonGroup
value={viewMode}
exclusive
onChange={(_, val) => val && onViewModeChange?.(val as ListViewMode)}
size="small"
aria-label="View mode"
sx={{
'& .MuiToggleButton-root': {
px: 1,
py: 0.5,
border: '1px solid',
borderColor: 'divider',
'&.Mui-selected': {
bgcolor: 'var(--fa-color-brand-100)',
color: 'primary.main',
borderColor: 'primary.main',
'&:hover': { bgcolor: 'var(--fa-color-brand-200)' },
},
},
}}
>
<ToggleButton value="list" aria-label="List view">
<ViewListOutlinedIcon sx={{ fontSize: 18 }} />
</ToggleButton>
<ToggleButton value="map" aria-label="Map view">
<MapOutlinedIcon sx={{ fontSize: 18 }} />
</ToggleButton>
</ToggleButtonGroup>
{/* Filters */}
<FilterPanel activeCount={activeCount} onClear={handleClear}>
{/* ── Location ── */}
<Box>
@@ -511,16 +609,6 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
</Box>
</FilterPanel>
</Box>
{/* Results count */}
<Typography
variant="caption"
color="text.secondary"
sx={{ mb: 0, display: 'block' }}
aria-live="polite"
>
{providers.length} provider{providers.length !== 1 ? 's' : ''} found
</Typography>
</Box>
{/* Provider list — click-to-navigate (D-D) */}

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { VenueStep } from './VenueStep';
import type { Venue, VenueFilterValues } from './VenueStep';
import type { Venue, VenueFilterValues, VenueSortBy, ListViewMode } from './VenueStep';
import { EMPTY_VENUE_FILTERS } from './VenueStep';
import { Navigation } from '../../organisms/Navigation';
import Box from '@mui/material/Box';
@@ -91,6 +91,8 @@ export const Default: Story = {
render: () => {
const [search, setSearch] = useState('');
const [filters, setFilters] = useState<VenueFilterValues>(EMPTY_VENUE_FILTERS);
const [sort, setSort] = useState<VenueSortBy>('recommended');
const [view, setView] = useState<ListViewMode>('list');
return (
<VenueStep
@@ -100,6 +102,10 @@ export const Default: Story = {
onSearchChange={setSearch}
filterValues={filters}
onFilterChange={setFilters}
sortBy={sort}
onSortChange={setSort}
viewMode={view}
onViewModeChange={setView}
onBack={() => alert('Back')}
locationName="Strathfield"
navigation={nav}
@@ -110,7 +116,7 @@ export const Default: Story = {
// ─── With active filters ────────────────────────────────────────────────────
/** Filters pre-applied — chapel + video streaming + Catholic */
/** Filters pre-applied — chapel + video streaming + Catholic, sorted nearest */
export const WithActiveFilters: Story = {
render: () => {
const [search, setSearch] = useState('Strathfield');
@@ -120,6 +126,8 @@ export const WithActiveFilters: Story = {
photoDisplay: false,
tradition: 'Catholic',
});
const [sort, setSort] = useState<VenueSortBy>('nearest');
const [view, setView] = useState<ListViewMode>('list');
const filtered = sampleVenues.filter((v) =>
v.location.toLowerCase().includes(search.toLowerCase()),
@@ -133,6 +141,10 @@ export const WithActiveFilters: Story = {
onSearchChange={setSearch}
filterValues={filters}
onFilterChange={setFilters}
sortBy={sort}
onSortChange={setSort}
viewMode={view}
onViewModeChange={setView}
onBack={() => alert('Back')}
locationName="Strathfield"
navigation={nav}

View File

@@ -4,6 +4,11 @@ import TextField from '@mui/material/TextField';
import InputAdornment from '@mui/material/InputAdornment';
import Autocomplete from '@mui/material/Autocomplete';
import FormControlLabel from '@mui/material/FormControlLabel';
import MenuItem from '@mui/material/MenuItem';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import ToggleButton from '@mui/material/ToggleButton';
import ViewListOutlinedIcon from '@mui/icons-material/ViewListOutlined';
import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import type { SxProps, Theme } from '@mui/material/styles';
import { WizardLayout } from '../../templates/WizardLayout';
@@ -46,6 +51,12 @@ export interface VenueFilterValues {
tradition: string | null;
}
/** Sort options for the venue list */
export type VenueSortBy = 'recommended' | 'nearest' | 'price_low' | 'price_high';
/** View mode for the listing */
export type ListViewMode = 'list' | 'map';
/** Props for the VenueStep page component */
export interface VenueStepProps {
/** List of venues to display */
@@ -66,6 +77,14 @@ export interface VenueStepProps {
venueTypeOptions?: VenueTypeOption[];
/** Available service tradition options for the autocomplete */
traditionOptions?: string[];
/** Current sort order */
sortBy?: VenueSortBy;
/** Callback when sort order changes */
onSortChange?: (sort: VenueSortBy) => void;
/** Current view mode */
viewMode?: ListViewMode;
/** Callback when view mode changes */
onViewModeChange?: (mode: ListViewMode) => void;
/** Callback for back navigation */
onBack: () => void;
/** Location name for the results count */
@@ -120,6 +139,13 @@ const DEFAULT_TRADITIONS = [
'Uniting Church',
];
const SORT_OPTIONS: { value: VenueSortBy; label: string }[] = [
{ value: 'recommended', label: 'Recommended' },
{ value: 'nearest', label: 'Nearest' },
{ value: 'price_low', label: 'Price: Low to High' },
{ value: 'price_high', label: 'Price: High to Low' },
];
export const EMPTY_VENUE_FILTERS: VenueFilterValues = {
venueTypes: [],
videoStreaming: false,
@@ -171,6 +197,10 @@ export const VenueStep: React.FC<VenueStepProps> = ({
onFilterChange,
venueTypeOptions = DEFAULT_VENUE_TYPES,
traditionOptions = DEFAULT_TRADITIONS,
sortBy = 'recommended',
onSortChange,
viewMode = 'list',
onViewModeChange,
onBack,
locationName,
isPrePlanning = false,
@@ -280,8 +310,77 @@ export const VenueStep: React.FC<VenueStepProps> = ({
sx={{ mb: 1.5 }}
/>
{/* Filters — right-aligned below search */}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
{/* Control bar — results count, sort, view toggle, filters */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
flexWrap: 'wrap',
}}
>
{/* Results count — left */}
<Typography
variant="caption"
color="text.secondary"
sx={{ mr: 'auto' }}
aria-live="polite"
>
{venues.length} venue{venues.length !== 1 ? 's' : ''}
{locationName ? ` near ${locationName}` : ''} found
</Typography>
{/* Sort */}
<TextField
select
value={sortBy}
onChange={(e) => onSortChange?.(e.target.value as VenueSortBy)}
size="small"
aria-label="Sort venues"
sx={{
minWidth: 160,
'& .MuiOutlinedInput-root': { fontSize: '0.813rem' },
'& .MuiSelect-select': { py: '5px' },
}}
>
{SORT_OPTIONS.map((opt) => (
<MenuItem key={opt.value} value={opt.value} sx={{ fontSize: '0.813rem' }}>
{opt.label}
</MenuItem>
))}
</TextField>
{/* View toggle */}
<ToggleButtonGroup
value={viewMode}
exclusive
onChange={(_, val) => val && onViewModeChange?.(val as ListViewMode)}
size="small"
aria-label="View mode"
sx={{
'& .MuiToggleButton-root': {
px: 1,
py: 0.5,
border: '1px solid',
borderColor: 'divider',
'&.Mui-selected': {
bgcolor: 'var(--fa-color-brand-100)',
color: 'primary.main',
borderColor: 'primary.main',
'&:hover': { bgcolor: 'var(--fa-color-brand-200)' },
},
},
}}
>
<ToggleButton value="list" aria-label="List view">
<ViewListOutlinedIcon sx={{ fontSize: 18 }} />
</ToggleButton>
<ToggleButton value="map" aria-label="Map view">
<MapOutlinedIcon sx={{ fontSize: 18 }} />
</ToggleButton>
</ToggleButtonGroup>
{/* Filters */}
<FilterPanel activeCount={activeCount} onClear={handleClear}>
{/* ── Location ── */}
<Box>
@@ -395,17 +494,6 @@ export const VenueStep: React.FC<VenueStepProps> = ({
</Box>
</FilterPanel>
</Box>
{/* Results count */}
<Typography
variant="caption"
color="text.secondary"
sx={{ mb: 0, display: 'block' }}
aria-live="polite"
>
{venues.length} venue{venues.length !== 1 ? 's' : ''}
{locationName ? ` near ${locationName}` : ''} found
</Typography>
</Box>
{/* Venue list — click-to-navigate */}