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:
302
src/components/pages/ProvidersStep/ProvidersStep.stories.tsx
Normal file
302
src/components/pages/ProvidersStep/ProvidersStep.stories.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user