Add ProvidersStep page (wizard step 2) + audit fixes

ProvidersStep: list-map split layout with provider card list (left)
and map slot (right). SearchBar + filter chips + radiogroup card
selection pattern. Back link, results count with aria-live,
grief-sensitive copy with pre-planning variant. Pure presentation.

Audit fixes (18/20):
- P1: Move role="radio" + aria-checked onto ProviderCard (focusable)
- P3: Add aria-live="polite" on results count
- ProviderCard: extend props to accept HTML/ARIA passthrough, add
  rest spread to Card for role/aria-checked/aria-label support

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 14:36:27 +11:00
parent 9e10a5e0f1
commit fa20599b67
4 changed files with 569 additions and 2 deletions

View File

@@ -13,8 +13,8 @@ import { Typography } from '../../atoms/Typography';
// ─── Types ─────────────────────────────────────────────────────────────────── // ─── Types ───────────────────────────────────────────────────────────────────
/** Props for the FA ProviderCard molecule */ /** Own props for the FA ProviderCard molecule (excludes HTML/Card passthrough) */
export interface ProviderCardProps { export interface ProviderCardOwnProps {
/** Provider display name */ /** Provider display name */
name: string; name: string;
/** Location text (suburb, city) */ /** Location text (suburb, city) */
@@ -45,6 +45,10 @@ export interface ProviderCardProps {
sx?: SxProps<Theme>; sx?: SxProps<Theme>;
} }
/** Props for the FA ProviderCard molecule — includes HTML/ARIA passthrough to Card */
export type ProviderCardProps = ProviderCardOwnProps &
Omit<React.HTMLAttributes<HTMLDivElement>, keyof ProviderCardOwnProps>;
// ─── Constants ─────────────────────────────────────────────────────────────── // ─── Constants ───────────────────────────────────────────────────────────────
const LOGO_SIZE = 'var(--fa-provider-card-logo-size)'; const LOGO_SIZE = 'var(--fa-provider-card-logo-size)';
@@ -102,6 +106,7 @@ export const ProviderCard = React.forwardRef<HTMLDivElement, ProviderCardProps>(
startingPrice, startingPrice,
onClick, onClick,
sx, sx,
...rest
}, },
ref, ref,
) => { ) => {
@@ -115,6 +120,7 @@ export const ProviderCard = React.forwardRef<HTMLDivElement, ProviderCardProps>(
selected={selected} selected={selected}
padding="none" padding="none"
onClick={onClick} onClick={onClick}
{...rest}
sx={[ sx={[
{ {
overflow: 'hidden', overflow: 'hidden',

View File

@@ -0,0 +1,302 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { ProvidersStep } from './ProvidersStep';
import type { ProviderData, ProviderFilter } from './ProvidersStep';
import { Navigation } from '../../organisms/Navigation';
import Box from '@mui/material/Box';
// ─── Helpers ─────────────────────────────────────────────────────────────────
const FALogo = () => (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Box
component="img"
src="/brandlogo/logo-full.svg"
alt="Funeral Arranger"
sx={{ height: 28, display: { xs: 'none', md: 'block' } }}
/>
<Box
component="img"
src="/brandlogo/logo-short.svg"
alt="Funeral Arranger"
sx={{ height: 28, display: { xs: 'block', md: 'none' } }}
/>
</Box>
);
const nav = (
<Navigation
logo={<FALogo />}
items={[
{ label: 'FAQ', href: '/faq' },
{ label: 'Contact Us', href: '/contact' },
{ label: 'Log in', href: '/login' },
]}
/>
);
const mockProviders: ProviderData[] = [
{
id: 'parsons',
name: 'H.Parsons Funeral Directors',
location: 'Wentworth, NSW',
verified: true,
imageUrl: 'https://placehold.co/600x200/E8E0D6/8B6F47?text=H.Parsons',
logoUrl: 'https://placehold.co/64x64/FEF9F5/BA834E?text=HP',
rating: 4.6,
reviewCount: 7,
startingPrice: 900,
distanceKm: 2.3,
description:
'H.Parsons delivers premium funeral services with exceptional care and support, guiding families through every step with empathy and expertise.',
},
{
id: 'rankins',
name: 'Rankins Funeral Services',
location: 'Wollongong, NSW',
verified: true,
imageUrl: 'https://placehold.co/600x200/D7E1E2/4C5B6B?text=Rankins',
logoUrl: 'https://placehold.co/64x64/F2F5F6/4C5B6B?text=R',
rating: 4.8,
reviewCount: 23,
startingPrice: 1200,
distanceKm: 5.1,
},
{
id: 'wollongong-city',
name: 'Wollongong City Funerals',
location: 'Wollongong, NSW',
verified: false,
rating: 4.2,
reviewCount: 15,
startingPrice: 750,
distanceKm: 6.8,
},
{
id: 'easy-funerals',
name: 'Easy Funerals',
location: 'Sydney, NSW',
verified: true,
imageUrl: 'https://placehold.co/600x200/F0F7F0/3B7A3B?text=Easy+Funerals',
logoUrl: 'https://placehold.co/64x64/F0F7F0/3B7A3B?text=EF',
rating: 4.5,
reviewCount: 42,
startingPrice: 850,
distanceKm: 12.4,
},
{
id: 'botanical',
name: 'Botanical Funerals',
location: 'Newtown, NSW',
verified: false,
rating: 4.9,
reviewCount: 8,
startingPrice: 2100,
distanceKm: 15.0,
},
];
const defaultFilters: ProviderFilter[] = [
{ label: 'Funeral Type', active: false },
{ label: 'Verified Only', active: false },
{ label: 'Under $1,500', active: false },
];
// ─── Meta ────────────────────────────────────────────────────────────────────
const meta: Meta<typeof ProvidersStep> = {
title: 'Pages/ProvidersStep',
component: ProvidersStep,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
};
export default meta;
type Story = StoryObj<typeof ProvidersStep>;
// ─── Interactive (default) ──────────────────────────────────────────────────
/** Fully interactive — search, filter, select a provider, continue */
export const Default: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [query, setQuery] = useState('');
const [filters, setFilters] = useState(defaultFilters);
const [error, setError] = useState<string | undefined>();
const filtered = mockProviders.filter((p) =>
p.name.toLowerCase().includes(query.toLowerCase()),
);
const handleContinue = () => {
if (!selectedId) {
setError('Please choose a funeral provider to continue.');
return;
}
setError(undefined);
alert(`Continue with provider: ${selectedId}`);
};
return (
<ProvidersStep
providers={filtered}
selectedProviderId={selectedId}
onSelectProvider={(id) => {
setSelectedId(id);
setError(undefined);
}}
searchQuery={query}
onSearchChange={setQuery}
filters={filters}
onFilterToggle={(i) =>
setFilters((prev) => prev.map((f, idx) => (idx === i ? { ...f, active: !f.active } : f)))
}
onContinue={handleContinue}
onBack={() => alert('Back')}
error={error}
navigation={nav}
/>
);
},
};
// ─── With selected provider ─────────────────────────────────────────────────
/** Provider already selected — ready to continue */
export const WithSelection: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>('parsons');
const [query, setQuery] = useState('');
return (
<ProvidersStep
providers={mockProviders}
selectedProviderId={selectedId}
onSelectProvider={setSelectedId}
searchQuery={query}
onSearchChange={setQuery}
onContinue={() => alert('Continue')}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
// ─── Pre-planning variant ───────────────────────────────────────────────────
/** Pre-planning flow — softer copy */
export const PrePlanning: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [query, setQuery] = useState('');
return (
<ProvidersStep
providers={mockProviders}
selectedProviderId={selectedId}
onSelectProvider={setSelectedId}
searchQuery={query}
onSearchChange={setQuery}
onContinue={() => alert('Continue')}
onBack={() => alert('Back')}
navigation={nav}
isPrePlanning
/>
);
},
};
// ─── Validation error ───────────────────────────────────────────────────────
/** No provider selected, error shown */
export const WithError: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [query, setQuery] = useState('');
return (
<ProvidersStep
providers={mockProviders}
selectedProviderId={selectedId}
onSelectProvider={setSelectedId}
searchQuery={query}
onSearchChange={setQuery}
onContinue={() => {}}
onBack={() => alert('Back')}
error="Please choose a funeral provider to continue."
navigation={nav}
/>
);
},
};
// ─── Empty results ──────────────────────────────────────────────────────────
/** Search yielded no results */
export const EmptyResults: Story = {
render: () => {
const [query, setQuery] = useState('xyz');
return (
<ProvidersStep
providers={[]}
selectedProviderId={null}
onSelectProvider={() => {}}
searchQuery={query}
onSearchChange={setQuery}
onContinue={() => {}}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
// ─── Loading state ──────────────────────────────────────────────────────────
/** Continue button loading */
export const Loading: Story = {
render: () => {
const [query, setQuery] = useState('');
return (
<ProvidersStep
providers={mockProviders}
selectedProviderId="parsons"
onSelectProvider={() => {}}
searchQuery={query}
onSearchChange={setQuery}
onContinue={() => {}}
onBack={() => alert('Back')}
loading
navigation={nav}
/>
);
},
};
// ─── Single provider ────────────────────────────────────────────────────────
/** Only one provider available */
export const SingleProvider: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [query, setQuery] = useState('');
return (
<ProvidersStep
providers={[mockProviders[0]]}
selectedProviderId={selectedId}
onSelectProvider={setSelectedId}
searchQuery={query}
onSearchChange={setQuery}
onContinue={() => alert('Continue')}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};

View File

@@ -0,0 +1,257 @@
import React from 'react';
import Box from '@mui/material/Box';
import type { SxProps, Theme } from '@mui/material/styles';
import { WizardLayout } from '../../templates/WizardLayout';
import { ProviderCard } from '../../molecules/ProviderCard';
import { SearchBar } from '../../molecules/SearchBar';
import { Chip } from '../../atoms/Chip';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Provider data for display in the list */
export interface ProviderData {
/** Unique provider ID (funeralBrandID) */
id: string;
/** Provider display name */
name: string;
/** Location text (suburb, city) */
location: string;
/** Whether this is a verified/trusted partner */
verified?: boolean;
/** Hero image URL */
imageUrl?: string;
/** Provider logo URL */
logoUrl?: string;
/** Average rating (e.g. 4.8) */
rating?: number;
/** Number of reviews */
reviewCount?: number;
/** Starting price in dollars */
startingPrice?: number;
/** Distance from user in km */
distanceKm?: number;
/** Brief description */
description?: string;
}
/** Filter chip state */
export interface ProviderFilter {
/** Filter label */
label: string;
/** Whether this filter is active */
active: boolean;
}
/** Props for the ProvidersStep page component */
export interface ProvidersStepProps {
/** List of providers to display */
providers: ProviderData[];
/** Currently selected provider ID */
selectedProviderId: string | null;
/** Callback when a provider is selected */
onSelectProvider: (id: string) => void;
/** Search query value */
searchQuery: string;
/** Callback when search query changes */
onSearchChange: (query: string) => void;
/** Callback when search is submitted */
onSearch?: (query: string) => void;
/** Filter chips */
filters?: ProviderFilter[];
/** Callback when a filter chip is toggled */
onFilterToggle?: (index: number) => void;
/** Callback for the Continue button */
onContinue: () => void;
/** Callback for the Back button */
onBack: () => void;
/** Validation error message */
error?: string;
/** Whether the Continue action is loading */
loading?: boolean;
/** Map panel content — slot for future map integration */
mapPanel?: React.ReactNode;
/** Navigation bar — passed through to WizardLayout */
navigation?: React.ReactNode;
/** Whether this is a pre-planning flow (shows softer copy) */
isPrePlanning?: boolean;
/** MUI sx prop for the root */
sx?: SxProps<Theme>;
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Step 2 — Provider selection page for the FA arrangement wizard.
*
* List + Map split layout. Left panel shows a scrollable list of
* provider cards with search and filter chips. Right panel is a
* slot for future map integration.
*
* Uses radiogroup pattern for card selection — arrow keys navigate
* between cards, Space/Enter selects.
*
* Pure presentation component — props in, callbacks out.
*
* Spec: documentation/steps/steps/02_providers.yaml
*/
export const ProvidersStep: React.FC<ProvidersStepProps> = ({
providers,
selectedProviderId,
onSelectProvider,
searchQuery,
onSearchChange,
onSearch,
filters,
onFilterToggle,
onContinue,
onBack,
error,
loading = false,
mapPanel,
navigation,
isPrePlanning = false,
sx,
}) => {
const subheading = isPrePlanning
? 'Take your time exploring providers. You can always come back and choose a different one.'
: 'These providers are near your location. Each has their own packages and pricing.';
return (
<WizardLayout
variant="list-map"
navigation={navigation}
showBackLink
backLabel="Back"
onBack={onBack}
sx={sx}
secondaryPanel={
mapPanel || (
<Box
sx={{
bgcolor: 'var(--fa-color-sage-50)',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderLeft: '1px solid',
borderColor: 'divider',
}}
>
<Typography variant="body1" color="text.secondary">
Map coming soon
</Typography>
</Box>
)
}
>
{/* Header */}
<Typography variant="h4" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>
Choose a funeral provider
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{subheading}
</Typography>
{/* Search bar */}
<Box sx={{ mb: 2 }}>
<SearchBar
value={searchQuery}
onChange={onSearchChange}
onSearch={onSearch}
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>
)}
{/* Results count */}
<Typography
variant="caption"
color="text.secondary"
sx={{ mb: 2, display: 'block' }}
aria-live="polite"
>
Showing results from {providers.length} provider{providers.length !== 1 ? 's' : ''}
</Typography>
{/* Error message */}
{error && (
<Typography variant="body2" color="error" sx={{ mb: 2 }} role="alert">
{error}
</Typography>
)}
{/* Provider list — radiogroup pattern */}
<Box
role="radiogroup"
aria-label="Funeral providers"
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}
>
{providers.map((provider) => (
<ProviderCard
key={provider.id}
name={provider.name}
location={provider.location}
verified={provider.verified}
imageUrl={provider.imageUrl}
logoUrl={provider.logoUrl}
rating={provider.rating}
reviewCount={provider.reviewCount}
startingPrice={provider.startingPrice}
selected={selectedProviderId === provider.id}
onClick={() => onSelectProvider(provider.id)}
role="radio"
aria-checked={selectedProviderId === provider.id}
aria-label={`${provider.name}, ${provider.location}${provider.rating ? `, rated ${provider.rating}` : ''}${provider.startingPrice ? `, from $${provider.startingPrice}` : ''}`}
/>
))}
{providers.length === 0 && (
<Box
sx={{
py: 6,
textAlign: 'center',
}}
>
<Typography variant="body1" color="text.secondary">
No providers found matching your search.
</Typography>
</Box>
)}
</Box>
{/* Continue button */}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', pb: 2 }}>
<Button
variant="contained"
size="large"
onClick={onContinue}
disabled={!selectedProviderId}
loading={loading}
>
Continue
</Button>
</Box>
</WizardLayout>
);
};
ProvidersStep.displayName = 'ProvidersStep';
export default ProvidersStep;

View File

@@ -0,0 +1,2 @@
export { default } from './ProvidersStep';
export * from './ProvidersStep';