Files
Parsons/src/components/organisms/FuneralFinder/FuneralFinderV3.tsx
Richie b15c96fa44 Restyle FuneralFinderV3 to standard design system palette
- Container: opaque white (surface.raised), standard card shadow/radius
- Status cards: white bg + neutral border, brand border + warm bg when selected
- Fields: white bg, neutral-200 border, brand border on focus, no focus ring
- CTA: standard Button contained, no custom shadows
- Keep: overline labels in brand-800, layout structure, form logic, a11y

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 19:34:56 +11:00

566 lines
18 KiB
TypeScript

import React from 'react';
import Box from '@mui/material/Box';
import Select, { type SelectChangeEvent } from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import OutlinedInput from '@mui/material/OutlinedInput';
import InputAdornment from '@mui/material/InputAdornment';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
import { Divider } from '../../atoms/Divider';
// ─── Types ───────────────────────────────────────────────────────────────────
type Status = 'immediate' | 'preplanning';
type FuneralType =
| 'cremation-funeral'
| 'cremation-only'
| 'burial-funeral'
| 'graveside-only'
| 'water-cremation'
| 'show-all';
/** Search parameters returned on form submission */
export interface FuneralFinderV3SearchParams {
/** User's current situation */
status: Status;
/** Type of funeral selected (defaults to show-all if not chosen) */
funeralType: FuneralType;
/** Suburb or postcode */
location: string;
}
/** Props for the FuneralFinder v3 organism */
export interface FuneralFinderV3Props {
/** Called when the user submits with valid data */
onSearch?: (params: FuneralFinderV3SearchParams) => void;
/** Shows loading state on the CTA */
loading?: boolean;
/** Optional heading override */
heading?: string;
/** Optional subheading override */
subheading?: string;
/** MUI sx override for the root container */
sx?: SxProps<Theme>;
}
// ─── Options ─────────────────────────────────────────────────────────────────
const STATUS_OPTIONS: { key: Status; title: string; description: string }[] = [
{
key: 'immediate',
title: 'Immediate Need',
description: 'For a recent or expected loss',
},
{
key: 'preplanning',
title: 'Pre-planning',
description: 'For yourself or a loved one',
},
];
const FUNERAL_TYPE_OPTIONS: { value: FuneralType; label: string }[] = [
{ value: 'cremation-funeral', label: 'Cremation with funeral' },
{
value: 'cremation-only',
label: 'Cremation only (no funeral, no attendance)',
},
{ value: 'burial-funeral', label: 'Burial with funeral' },
{ value: 'graveside-only', label: 'Graveside burial only' },
{ value: 'water-cremation', label: 'Water cremation (QLD only)' },
{ value: 'show-all', label: 'Show all options' },
];
/** Hoisted outside component to avoid re-creation on render */
const selectPlaceholder = (
<span style={{ color: 'var(--fa-color-text-disabled)' }}>
Select funeral type
</span>
);
// ─── Sub-components ──────────────────────────────────────────────────────────
/** Uppercase section label — overline style */
function SectionLabel({
children,
id,
}: {
children: React.ReactNode;
id?: string;
}) {
return (
<Typography
variant="overline"
component="div"
id={id}
sx={{ color: 'var(--fa-color-brand-800, #6B3C13)' }}
>
{children}
</Typography>
);
}
/** Tappable status card with roving tabindex support for radiogroup pattern */
const StatusCard = React.forwardRef<
HTMLButtonElement,
{
title: string;
description: string;
selected: boolean;
onClick: () => void;
tabIndex?: number;
onKeyDown?: React.KeyboardEventHandler;
}
>(({ title, description, selected, onClick, tabIndex, onKeyDown }, ref) => (
<Box
component="button"
type="button"
role="radio"
aria-checked={selected}
onClick={onClick}
ref={ref}
tabIndex={tabIndex}
onKeyDown={onKeyDown}
sx={{
width: '100%',
px: 2.5,
py: 2.5,
border: '2px solid',
borderColor: selected
? 'var(--fa-color-border-brand, #BA834E)'
: 'var(--fa-color-border-default, #E8E8E8)',
borderRadius: 'var(--fa-card-border-radius-default, 8px)',
bgcolor: selected
? 'var(--fa-color-surface-warm, #FEF9F5)'
: 'var(--fa-color-surface-default, #fff)',
cursor: 'pointer',
fontFamily: 'inherit',
textAlign: 'center',
transition:
'border-color 200ms ease, background-color 200ms ease, transform 100ms ease',
'&:hover': {
borderColor: selected
? 'var(--fa-color-border-brand, #BA834E)'
: 'var(--fa-color-border-strong, #BFBFBF)',
bgcolor: selected
? 'var(--fa-color-surface-warm, #FEF9F5)'
: 'var(--fa-color-surface-subtle, #FAFAFA)',
},
'&:active': {
transform: 'scale(0.98)',
},
'&:focus-visible': {
outline: '2px solid var(--fa-color-interactive-focus, #BA834E)',
outlineOffset: 2,
},
}}
>
<Typography
variant="body1"
component="span"
sx={{
fontWeight: 600,
display: 'block',
mb: 0.75,
color: selected
? 'var(--fa-color-text-brand, #B0610F)'
: 'text.primary',
}}
>
{title}
</Typography>
<Typography
variant="caption"
component="span"
sx={{
display: 'block',
color: 'text.secondary',
lineHeight: 1.4,
}}
>
{description}
</Typography>
</Box>
));
StatusCard.displayName = 'StatusCard';
// ─── Shared field styles ────────────────────────────────────────────────────
/** Standard outlined fields — white bg, neutral border, no focus ring per design spec */
const fieldBaseSx = {
width: '100%',
bgcolor: 'var(--fa-color-surface-default, #fff)',
borderRadius: 'var(--fa-border-radius-md, 8px)',
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'var(--fa-color-border-default, #E8E8E8)',
borderRadius: 'var(--fa-border-radius-md, 8px)',
},
'&:hover:not(.Mui-disabled) .MuiOutlinedInput-notchedOutline': {
borderColor: 'var(--fa-color-border-strong, #BFBFBF)',
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: 'var(--fa-color-border-brand, #BA834E)',
borderWidth: 1,
},
'&.Mui-focused': {
boxShadow: 'none',
},
};
const fieldInputStyles = {
py: '14px',
px: 2,
fontSize: '1rem',
fontFamily: 'var(--fa-font-family-body)',
};
const selectMenuProps = {
PaperProps: {
sx: {
mt: 0.5,
borderRadius: 'var(--fa-border-radius-md, 8px)',
boxShadow: 'var(--fa-shadow-md)',
'& .MuiMenuItem-root': {
py: 1.5,
px: 2,
minHeight: 44,
fontSize: '0.9375rem',
fontFamily: 'var(--fa-font-family-body)',
whiteSpace: 'normal' as const,
'&:hover': { bgcolor: 'var(--fa-color-surface-subtle)' },
'&.Mui-selected': {
bgcolor: 'var(--fa-color-surface-warm)',
fontWeight: 600,
'&:hover': { bgcolor: 'var(--fa-color-surface-warm)' },
},
},
},
},
};
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Hero search widget v3 — clean vertical form with status cards.
*
* Two tappable status cards (Immediate Need / Pre-planning), a funeral type
* dropdown, a location input, and a CTA. Standard card container with
* overline section labels. CTA is always active —
* clicking it with missing required fields scrolls to the first gap.
*
* Required fields: status + location (min 3 chars).
* Funeral type defaults to "show all" if not selected.
*/
export const FuneralFinderV3 = React.forwardRef<
HTMLDivElement,
FuneralFinderV3Props
>((props, ref) => {
const {
onSearch,
loading = false,
heading = 'Find funeral directors near you',
subheading =
"Tell us what you need and we\u2019ll show options in your area.",
sx,
} = props;
// ─── IDs for aria-labelledby ──────────────────────────────
const id = React.useId();
const statusLabelId = `${id}-status`;
const funeralTypeLabelId = `${id}-funeral-type`;
const locationLabelId = `${id}-location`;
// ─── State ───────────────────────────────────────────────
const [status, setStatus] = React.useState<Status | ''>('');
const [funeralType, setFuneralType] = React.useState<FuneralType | ''>('');
const [location, setLocation] = React.useState('');
const [errors, setErrors] = React.useState<{
status?: boolean;
location?: boolean;
}>({});
// ─── Refs ────────────────────────────────────────────────
const statusSectionRef = React.useRef<HTMLDivElement>(null);
const locationSectionRef = React.useRef<HTMLDivElement>(null);
const locationInputRef = React.useRef<HTMLInputElement>(null);
const cardRefs = React.useRef<(HTMLButtonElement | null)[]>([null, null]);
// ─── Clear errors as fields are filled ───────────────────
const prevStatus = React.useRef(status);
React.useEffect(() => {
if (status !== prevStatus.current) {
prevStatus.current = status;
if (status && errors.status) {
setErrors((prev) => ({ ...prev, status: false }));
}
}
}, [status, errors.status]);
const prevLocation = React.useRef(location);
React.useEffect(() => {
if (location !== prevLocation.current) {
prevLocation.current = location;
if (location.trim().length >= 3 && errors.location) {
setErrors((prev) => ({ ...prev, location: false }));
}
}
}, [location, errors.location]);
// ─── Radiogroup keyboard nav (WAI-ARIA pattern) ──────────
const activeStatusIndex = status
? STATUS_OPTIONS.findIndex((o) => o.key === status)
: 0;
const handleStatusKeyDown = (e: React.KeyboardEvent) => {
const isNext = e.key === 'ArrowRight' || e.key === 'ArrowDown';
const isPrev = e.key === 'ArrowLeft' || e.key === 'ArrowUp';
if (!isNext && !isPrev) return;
e.preventDefault();
const current = cardRefs.current.indexOf(
e.target as HTMLButtonElement,
);
if (current === -1) return;
const next = isNext
? Math.min(current + 1, STATUS_OPTIONS.length - 1)
: Math.max(current - 1, 0);
if (next !== current) {
cardRefs.current[next]?.focus();
setStatus(STATUS_OPTIONS[next].key);
}
};
// ─── Handlers ────────────────────────────────────────────
const handleFuneralType = (e: SelectChangeEvent<string>) => {
setFuneralType(e.target.value as FuneralType);
};
const handleSubmit = () => {
if (!status) {
setErrors({ status: true });
statusSectionRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
return;
}
if (location.trim().length < 3) {
setErrors({ location: true });
locationSectionRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
locationInputRef.current?.focus();
return;
}
setErrors({});
onSearch?.({
status,
funeralType: funeralType || 'show-all',
location: location.trim(),
});
};
// ─── Render ──────────────────────────────────────────────
return (
<Box
ref={ref}
role="search"
aria-label="Find funeral directors"
sx={[
{
bgcolor: 'var(--fa-color-surface-raised, #fff)',
borderRadius: 'var(--fa-card-border-radius-default, 8px)',
boxShadow: 'var(--fa-card-shadow-default)',
px: { xs: 3.5, sm: 5 },
py: { xs: 4, sm: 5 },
display: 'flex',
flexDirection: 'column',
gap: 4,
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* ── Header ──────────────────────────────────────────── */}
<Box sx={{ textAlign: 'center' }}>
<Typography
variant="h3"
component="h2"
sx={{
fontFamily: 'var(--fa-font-family-display)',
fontWeight: 400,
mb: 1,
}}
>
{heading}
</Typography>
<Typography variant="body2" color="text.secondary">
{subheading}
</Typography>
</Box>
<Divider />
{/* ── Current Status ──────────────────────────────────── */}
<Box ref={statusSectionRef}>
<SectionLabel id={statusLabelId}>Current Status</SectionLabel>
<Box
role="radiogroup"
aria-labelledby={statusLabelId}
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' },
gap: 2,
mt: 2,
}}
>
{STATUS_OPTIONS.map((opt, i) => (
<StatusCard
key={opt.key}
ref={(el) => {
cardRefs.current[i] = el;
}}
title={opt.title}
description={opt.description}
selected={status === opt.key}
onClick={() => setStatus(opt.key)}
tabIndex={i === activeStatusIndex ? 0 : -1}
onKeyDown={handleStatusKeyDown}
/>
))}
</Box>
<Box
sx={{ minHeight: '1.5rem', mt: 1, textAlign: 'center' }}
aria-live="polite"
>
{errors.status && (
<Typography
variant="caption"
role="alert"
sx={{ color: 'var(--fa-color-text-brand, #B0610F)' }}
>
Please select your current situation
</Typography>
)}
</Box>
</Box>
{/* ── Funeral Type ────────────────────────────────────── */}
<Box>
<SectionLabel id={funeralTypeLabelId}>Funeral Type</SectionLabel>
<Box sx={{ mt: 2 }}>
<Select
value={funeralType}
onChange={handleFuneralType}
displayEmpty
renderValue={(v) =>
v
? FUNERAL_TYPE_OPTIONS.find((o) => o.value === v)?.label
: selectPlaceholder
}
MenuProps={selectMenuProps}
sx={{
...fieldBaseSx,
'& .MuiSelect-select': {
...fieldInputStyles,
minHeight: 'unset !important',
},
'& .MuiSelect-icon': {
color: 'var(--fa-color-text-disabled)',
right: 12,
},
}}
inputProps={{ 'aria-labelledby': funeralTypeLabelId }}
>
{FUNERAL_TYPE_OPTIONS.map((o) => (
<MenuItem key={o.value} value={o.value}>
{o.label}
</MenuItem>
))}
</Select>
</Box>
</Box>
{/* ── Location ────────────────────────────────────────── */}
<Box ref={locationSectionRef}>
<SectionLabel id={locationLabelId}>Location</SectionLabel>
<Box sx={{ mt: 2 }}>
<OutlinedInput
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="Enter suburb or postcode"
inputRef={locationInputRef}
startAdornment={
<InputAdornment position="start" sx={{ ml: 0.5 }}>
<LocationOnOutlinedIcon
sx={{
fontSize: 20,
color: 'var(--fa-color-text-disabled)',
}}
/>
</InputAdornment>
}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSubmit();
}}
sx={{
...fieldBaseSx,
'& .MuiOutlinedInput-input': {
...fieldInputStyles,
'&::placeholder': {
color: 'var(--fa-color-text-disabled)',
opacity: 1,
},
},
}}
inputProps={{
'aria-labelledby': locationLabelId,
'aria-required': true,
}}
/>
</Box>
<Box
sx={{ minHeight: '1.5rem', mt: 1 }}
aria-live="polite"
>
{errors.location && (
<Typography
variant="caption"
role="alert"
sx={{ color: 'var(--fa-color-text-brand, #B0610F)' }}
>
Please enter a suburb or postcode
</Typography>
)}
</Box>
</Box>
{/* ── CTA ─────────────────────────────────────────────── */}
<Box>
<Button
variant="contained"
size="large"
fullWidth
loading={loading}
endIcon={!loading ? <ArrowForwardIcon /> : undefined}
onClick={handleSubmit}
sx={{ minHeight: 52 }}
>
Find Funeral Directors
</Button>
<Typography
variant="captionSm"
color="text.secondary"
sx={{ textAlign: 'center', display: 'block', mt: 1.5 }}
>
Free to use &middot; No obligation
</Typography>
</Box>
</Box>
);
});
FuneralFinderV3.displayName = 'FuneralFinderV3';
export default FuneralFinderV3;