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