- 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>
566 lines
18 KiB
TypeScript
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 · No obligation
|
|
</Typography>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
});
|
|
|
|
FuneralFinderV3.displayName = 'FuneralFinderV3';
|
|
export default FuneralFinderV3;
|