Add Footer + ServiceSelector organisms

Footer:
- Dark espresso (brand.950) bg, inverse white text
- Logo, tagline, contact (phone/email), link group columns, legal bar
- Semantic HTML: <footer>, <nav> per group, <ul> for lists
- Critique 38/40, P2 fixes applied (legal link size, nav landmarks)

ServiceSelector:
- Single-select panel for arrangement flow
- Composes ServiceOption × n + Typography + Button (radiogroup)
- Auto-disables continue when nothing selected
- 7 stories incl. InArrangementFlow with StepIndicator

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 22:04:30 +11:00
parent d52fb0c4ee
commit 9ce8a7e120
8 changed files with 908 additions and 4 deletions

View File

@@ -49,11 +49,11 @@ duplicates) and MUST update it after completing one.
| Component | Status | Composed of | Notes |
|-----------|--------|-------------|-------|
| ServiceSelector | planned | ServiceOption × n + Typography + Button | Full service selection panel |
| ServiceSelector | review | ServiceOption × n + Typography + Button | Single-select service panel for arrangement flow. Heading + subheading + ServiceOption list (radiogroup) + optional continue Button. Manages selection state via selectedId/onSelect. maxDescriptionLines pass-through. |
| PricingTable | planned | PriceCard × n + Typography | Comparative pricing display |
| ArrangementForm | planned | FormField × n + StepIndicator + Button | Multi-step arrangement flow |
| Navigation | done | AppBar + Link + IconButton + Button + Divider + Drawer | Responsive site header. Desktop: logo left, links right, optional CTA. Mobile: hamburger + drawer with nav items, CTA, help footer. Sticky, grey surface bg (surface.subtle). Real FA logo from brandassets/. Maps to Figma Main Nav (14:108) + Mobile Header (2391:41508). |
| Footer | planned | Link × n + Typography + Divider | Site footer |
| Footer | review | Link × n + Typography + Divider + Container + Grid | Dark espresso (brand.950) site footer. Logo + tagline + contact (phone/email) + link group columns + legal bar. Semantic HTML (footer, nav, ul). Critique: 38/40 (Excellent). |
## Future enhancements

View File

@@ -796,7 +796,51 @@ Each entry follows this structure:
- **Planned (4 organisms):** ServiceSelector, PricingTable, ArrangementForm, Footer
**Next steps:**
- Build Footer organism (Link + Typography + Divider — dependencies ready)
- Build ServiceSelector organism (ServiceOption × n + Typography + Button)
- ~~Build Footer organism~~ ✓ Done
- ~~Build ServiceSelector organism~~ ✓ Done
- Consider FormField molecule if Input's built-in label/helperText proves insufficient
- Remaining P3s: InfoOutlinedIcon sizing in ProviderCard, image loading placeholder, accent bar 3px→4px
### Session 2026-03-25p (continued) — Footer + ServiceSelector organisms
**Agent(s):** Claude Opus (via conversation)
**Work completed:**
- Built Footer organism (`src/components/organisms/Footer/`):
- Dark espresso (brand.950) surface with inverse white text
- Logo + tagline + contact info (phone/email with tel:/mailto: links) in left column
- Configurable link group columns with heading + `<ul>` list
- Bottom legal bar: copyright + legal links (Privacy, Terms, Accessibility)
- Semantic HTML: `<footer>`, `<nav aria-label>` per link group, `<ul>` for link lists
- Responsive: stacks to single column on mobile, side-by-side on desktop
- 5 stories: Default, Minimal, WithContactOnly, TwoColumns, FullPage (Nav + content + Footer)
- Critique: 38/40 (Excellent). P2 fixes applied: legal links bumped from 11px → 12px, nav landmarks added
- Built ServiceSelector organism (`src/components/organisms/ServiceSelector/`):
- Single-select panel composing Typography + ServiceOption × n + Button
- Heading + optional subheading + radiogroup of ServiceOption cards + optional continue button
- Continue button auto-disables when nothing selected (overridable via `continueDisabled`)
- `maxDescriptionLines` pass-through to ServiceOption for truncation
- 7 stories: Default, PreSelected, Interactive, CoffinSelection, WithTruncatedDescriptions, WithoutContinue, WithDisabledOptions, InArrangementFlow (with StepIndicator)
- No new tokens needed — both organisms reuse existing token system
- TypeScript compiles, Storybook builds
**Decisions made:**
- Footer uses brand.950 (espresso) background — darkest brand tone for maximum contrast with white text, warm but professional
- Footer link groups get individual `<nav aria-label>` landmarks — better screen reader navigation
- ServiceSelector is a controlled component: parent manages `selectedId` + `onSelect`. Keeps state lifting simple for arrangement flow.
- Continue button defaults to disabled when nothing selected — prevents empty submissions without explicit error messages
**Component status at end of session:**
- **Done (11 atoms):** Button, Typography, Input, Card, Badge, Chip, Switch, Radio, IconButton, Divider, Link
- **Done (6 molecules):** ProviderCard, VenueCard, ServiceOption, SearchBar, AddOnOption, StepIndicator
- **Done (1 organism):** Navigation
- **Review (2 organisms):** Footer (critique 38/40), ServiceSelector
- **Planned (3 atoms):** Icon, Avatar, ColourToggle, Slider
- **Planned (2 molecules):** MapCard, FormField
- **Planned (2 organisms):** PricingTable, ArrangementForm
**Next steps:**
- User to review Footer + ServiceSelector in Storybook
- Build ArrangementForm organism (StepIndicator + ServiceSelector + AddOnOption — multi-step flow)
- Consider FormField molecule if Input's built-in label/helperText proves insufficient
- P3 cleanup: inverse logo SVG, InfoOutlinedIcon sizing, image placeholder, accent bar 3px→4px

View File

@@ -0,0 +1,205 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { Footer } from './Footer';
import { Navigation } from '../Navigation';
import { Typography } from '../../atoms/Typography';
// Real FA logo — inverted version for dark background
const FALogoInverse = () => (
<Box
component="img"
src="/brandlogo/logo-full.svg"
alt="Funeral Arranger"
sx={{ height: 24, filter: 'brightness(0) invert(1)', opacity: 0.9 }}
/>
);
const FALogoNav = () => (
<Box
component="img"
src="/brandlogo/logo-full.svg"
alt="Funeral Arranger"
sx={{ height: 28 }}
/>
);
const defaultLinkGroups = [
{
heading: 'Services',
links: [
{ label: 'Find a Director', href: '/directors' },
{ label: 'Compare Venues', href: '/venues' },
{ label: 'Pricing Guide', href: '/pricing' },
{ label: 'Start Planning', href: '/arrange' },
],
},
{
heading: 'Support',
links: [
{ label: 'FAQ', href: '/faq' },
{ label: 'Contact Us', href: '/contact' },
{ label: 'Grief Resources', href: '/resources' },
],
},
{
heading: 'Company',
links: [
{ label: 'About Us', href: '/about' },
{ label: 'Provider Portal', href: '/provider-portal' },
{ label: 'Partner With Us', href: '/partners' },
],
},
];
const defaultLegalLinks = [
{ label: 'Privacy Policy', href: '/privacy' },
{ label: 'Terms of Service', href: '/terms' },
{ label: 'Accessibility', href: '/accessibility' },
];
const meta: Meta<typeof Footer> = {
title: 'Organisms/Footer',
component: Footer,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
};
export default meta;
type Story = StoryObj<typeof Footer>;
// --- Default -----------------------------------------------------------------
/** Full footer with all sections populated */
export const Default: Story = {
args: {
logo: <FALogoInverse />,
tagline: 'Helping Australian families plan with confidence',
linkGroups: defaultLinkGroups,
phone: '1800 987 888',
email: 'support@funeralarranger.com.au',
legalLinks: defaultLegalLinks,
},
};
// --- Minimal -----------------------------------------------------------------
/** Minimal footer — logo and copyright only */
export const Minimal: Story = {
args: {
logo: <FALogoInverse />,
},
};
// --- With Contact Only -------------------------------------------------------
/** Footer with contact info but no link columns */
export const WithContactOnly: Story = {
args: {
logo: <FALogoInverse />,
tagline: 'Helping Australian families plan with confidence',
phone: '1800 987 888',
email: 'support@funeralarranger.com.au',
legalLinks: defaultLegalLinks,
},
};
// --- Two Columns -------------------------------------------------------------
/** Footer with two link groups — common for simpler sites */
export const TwoColumns: Story = {
args: {
logo: <FALogoInverse />,
tagline: 'Transparent funeral planning for Australian families',
linkGroups: [
{
heading: 'Planning',
links: [
{ label: 'Find a Director', href: '/directors' },
{ label: 'Compare Venues', href: '/venues' },
{ label: 'Pricing Guide', href: '/pricing' },
],
},
{
heading: 'Help',
links: [
{ label: 'FAQ', href: '/faq' },
{ label: 'Contact Us', href: '/contact' },
{ label: 'About Us', href: '/about' },
],
},
],
phone: '1800 987 888',
legalLinks: [
{ label: 'Privacy Policy', href: '/privacy' },
{ label: 'Terms of Service', href: '/terms' },
],
},
};
// --- Full Page ---------------------------------------------------------------
/** Navigation + content + footer — complete page frame */
export const FullPage: Story = {
render: () => (
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<Navigation
logo={<FALogoNav />}
items={[
{ label: 'Provider Portal', href: '/provider-portal' },
{ label: 'FAQ', href: '/faq' },
{ label: 'Contact Us', href: '/contact' },
{ label: 'Log in', href: '/login' },
]}
ctaLabel="Start planning"
/>
<Box
sx={{
flex: 1,
maxWidth: 'lg',
mx: 'auto',
width: '100%',
px: { xs: 2, md: 4 },
py: 6,
}}
>
<Typography variant="h2" sx={{ mb: 3 }}>
Find a funeral director
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 4, maxWidth: 600 }}>
Compare trusted funeral directors in your area. View services,
pricing, and reviews to find the right support for your family.
</Typography>
{Array.from({ length: 4 }).map((_, i) => (
<Box
key={i}
sx={{
p: 3,
mb: 2,
bgcolor: 'background.paper',
borderRadius: 1,
border: '1px solid',
borderColor: 'divider',
}}
>
<Typography variant="h6">Provider {i + 1}</Typography>
<Typography variant="body2" color="text.secondary">
Placeholder content to demonstrate the full page frame.
</Typography>
</Box>
))}
</Box>
<Footer
logo={<FALogoInverse />}
tagline="Helping Australian families plan with confidence"
linkGroups={defaultLinkGroups}
phone="1800 987 888"
email="support@funeralarranger.com.au"
legalLinks={defaultLegalLinks}
/>
</Box>
),
};

View File

@@ -0,0 +1,264 @@
import React from 'react';
import Box from '@mui/material/Box';
import Container from '@mui/material/Container';
import Grid from '@mui/material/Grid';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
import { Link } from '../../atoms/Link';
import { Divider } from '../../atoms/Divider';
// ─── Types ───────────────────────────────────────────────────────────────────
/** A group of links in the footer */
export interface FooterLinkGroup {
/** Group heading */
heading: string;
/** Links within this group */
links: { label: string; href: string; onClick?: () => void }[];
}
/** Props for the FA Footer organism */
export interface FooterProps {
/** Site logo — rendered in the footer top row */
logo: React.ReactNode;
/** Optional tagline below the logo */
tagline?: string;
/** Link groups displayed as columns */
linkGroups?: FooterLinkGroup[];
/** Phone number displayed in the contact section */
phone?: string;
/** Email address displayed in the contact section */
email?: string;
/** Copyright text — defaults to current year + "Funeral Arranger" */
copyright?: string;
/** Optional legal links shown in the bottom bar (Privacy, Terms, etc.) */
legalLinks?: { label: string; href: string; onClick?: () => void }[];
/** MUI sx prop for the root element */
sx?: SxProps<Theme>;
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Site footer for the FA design system.
*
* Multi-column footer with logo, link groups, contact info, and legal bar.
* Designed for warmth and trust — uses the brand's dark surface with
* inverse (white) text.
*
* Composes Typography + Link + Divider.
*
* Usage:
* ```tsx
* <Footer
* logo={<img src="/brandlogo/logo-full.svg" alt="Funeral Arranger" height={28} />}
* tagline="Helping Australian families plan with confidence"
* linkGroups={[
* { heading: 'Services', links: [{ label: 'Find a Director', href: '/directors' }] },
* { heading: 'Support', links: [{ label: 'FAQ', href: '/faq' }] },
* ]}
* phone="1800 987 888"
* email="support@funeralarranger.com.au"
* legalLinks={[{ label: 'Privacy Policy', href: '/privacy' }]}
* />
* ```
*/
export const Footer = React.forwardRef<HTMLDivElement, FooterProps>(
(
{
logo,
tagline,
linkGroups = [],
phone,
email,
copyright,
legalLinks = [],
sx,
},
ref,
) => {
const year = new Date().getFullYear();
const copyrightText = copyright || `\u00A9 ${year} Funeral Arranger. All rights reserved.`;
return (
<Box
ref={ref}
component="footer"
sx={[
{
bgcolor: 'var(--fa-color-brand-950)',
color: 'var(--fa-color-white)',
pt: { xs: 5, md: 8 },
pb: 0,
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
<Container maxWidth="lg">
{/* Main footer content */}
<Grid container spacing={{ xs: 4, md: 6 }}>
{/* Logo + tagline column */}
<Grid item xs={12} md={4}>
<Box sx={{ mb: 2 }}>{logo}</Box>
{tagline && (
<Typography
variant="body2"
sx={{ color: 'var(--fa-color-brand-300)', maxWidth: 280 }}
>
{tagline}
</Typography>
)}
{/* Contact info below tagline */}
{(phone || email) && (
<Box sx={{ mt: 3 }}>
{phone && (
<Box sx={{ mb: 1 }}>
<Typography
variant="captionSm"
sx={{
color: 'var(--fa-color-brand-400)',
textTransform: 'uppercase',
letterSpacing: '0.08em',
display: 'block',
mb: 0.5,
}}
>
Call us
</Typography>
<Link
href={`tel:${phone.replace(/\s/g, '')}`}
sx={{
color: 'var(--fa-color-white)',
fontWeight: 600,
fontSize: '1rem',
'&:hover': { color: 'var(--fa-color-brand-300)' },
}}
>
{phone}
</Link>
</Box>
)}
{email && (
<Box>
<Typography
variant="captionSm"
sx={{
color: 'var(--fa-color-brand-400)',
textTransform: 'uppercase',
letterSpacing: '0.08em',
display: 'block',
mb: 0.5,
}}
>
Email
</Typography>
<Link
href={`mailto:${email}`}
sx={{
color: 'var(--fa-color-white)',
fontWeight: 500,
'&:hover': { color: 'var(--fa-color-brand-300)' },
}}
>
{email}
</Link>
</Box>
)}
</Box>
)}
</Grid>
{/* Link group columns */}
{linkGroups.map((group) => (
<Grid item xs={6} sm={4} md key={group.heading} component="nav" aria-label={group.heading}>
<Typography
variant="label"
sx={{
color: 'var(--fa-color-brand-300)',
mb: 2,
display: 'block',
}}
>
{group.heading}
</Typography>
<Box
component="ul"
sx={{ listStyle: 'none', p: 0, m: 0, display: 'flex', flexDirection: 'column', gap: 1.5 }}
>
{group.links.map((link) => (
<li key={link.label}>
<Link
href={link.href}
onClick={link.onClick}
sx={{
color: 'var(--fa-color-brand-200)',
fontSize: '0.875rem',
fontWeight: 500,
'&:hover': { color: 'var(--fa-color-white)' },
}}
>
{link.label}
</Link>
</li>
))}
</Box>
</Grid>
))}
</Grid>
{/* Bottom bar */}
<Divider sx={{ borderColor: 'var(--fa-color-brand-900)', mt: { xs: 5, md: 8 } }} />
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', md: 'row' },
justifyContent: 'space-between',
alignItems: { xs: 'center', md: 'center' },
gap: 1.5,
py: 3,
}}
>
<Typography
variant="captionSm"
sx={{ color: 'var(--fa-color-brand-400)' }}
>
{copyrightText}
</Typography>
{legalLinks.length > 0 && (
<Box
sx={{
display: 'flex',
gap: 3,
flexWrap: 'wrap',
justifyContent: 'center',
}}
>
{legalLinks.map((link) => (
<Link
key={link.label}
href={link.href}
onClick={link.onClick}
sx={{
color: 'var(--fa-color-brand-400)',
fontSize: '0.75rem',
fontWeight: 500,
'&:hover': { color: 'var(--fa-color-white)' },
}}
>
{link.label}
</Link>
))}
</Box>
)}
</Box>
</Container>
</Box>
);
},
);
Footer.displayName = 'Footer';
export default Footer;

View File

@@ -0,0 +1 @@
export { Footer, type FooterProps, type FooterLinkGroup } from './Footer';

View File

@@ -0,0 +1,235 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { ServiceSelector } from './ServiceSelector';
import { StepIndicator } from '../../molecules/StepIndicator';
import { Typography } from '../../atoms/Typography';
const serviceTypes = [
{
id: 'burial',
name: 'Traditional Burial',
price: 4200,
description:
'Full service with chapel ceremony, viewing, hearse, and graveside committal. Includes coordination with cemetery and clergy.',
},
{
id: 'cremation',
name: 'Cremation with Service',
price: 2800,
description:
'Chapel ceremony followed by cremation. Includes use of chapel, celebrant coordination, and return of ashes in a standard urn.',
},
{
id: 'direct-cremation',
name: 'Direct Cremation',
price: 1600,
description:
'Simple cremation without a formal service. A private, no-fuss option. Ashes returned to the family within 5 business days.',
},
{
id: 'memorial',
name: 'Memorial Service',
price: 3100,
description:
'Ceremony held after cremation or burial. Flexible timing allows for family to travel and gather. Venue of your choice.',
},
];
const coffinOptions = [
{ id: 'eco', name: 'Eco Willow', price: 850, description: 'Handwoven natural willow. Biodegradable and sustainable.' },
{ id: 'classic', name: 'Classic Maple', price: 1400, description: 'Solid maple with satin finish and brass handles.' },
{ id: 'premium', name: 'Premium Oak', price: 2200, description: 'Quarter-sawn oak with high-gloss lacquer and gold-plated handles.' },
{ id: 'simple', name: 'Simple Pine', price: 600, description: 'Unfinished pine. Can be personalised with paint, photos, or messages.' },
];
const meta: Meta<typeof ServiceSelector> = {
title: 'Organisms/ServiceSelector',
component: ServiceSelector,
tags: ['autodocs'],
parameters: {
layout: 'centered',
design: {
type: 'figma',
url: 'https://www.figma.com/design/XUDUrw4yMkEexBCCYHXUvT/Parsons?node-id=2349-39505',
},
},
decorators: [
(Story) => (
<Box sx={{ maxWidth: 560, width: '100%', mx: 'auto' }}>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof ServiceSelector>;
// --- Default -----------------------------------------------------------------
/** Static view — nothing selected */
export const Default: Story = {
args: {
heading: 'Choose a service type',
subheading: 'Select the type of service you\'d like to arrange. Prices are starting estimates.',
items: serviceTypes,
continueLabel: 'Continue',
},
};
// --- Pre-selected ------------------------------------------------------------
/** With an option already selected */
export const PreSelected: Story = {
args: {
heading: 'Choose a service type',
items: serviceTypes,
selectedId: 'cremation',
continueLabel: 'Continue',
},
};
// --- Interactive -------------------------------------------------------------
/** Full interactive demo with state management */
export const Interactive: Story = {
render: () => {
const [selected, setSelected] = useState<string | undefined>();
return (
<ServiceSelector
heading="Choose a service type"
subheading="Select the type of service you'd like to arrange. Prices are starting estimates."
items={serviceTypes}
selectedId={selected}
onSelect={setSelected}
continueLabel="Continue"
onContinue={() => alert(`Selected: ${selected}`)}
/>
);
},
};
// --- Coffin Selection --------------------------------------------------------
/** Coffin/casket selection — shorter descriptions */
export const CoffinSelection: Story = {
render: () => {
const [selected, setSelected] = useState<string | undefined>();
return (
<ServiceSelector
heading="Choose a coffin"
subheading="All coffins include a fitted interior lining and nameplate."
items={coffinOptions}
selectedId={selected}
onSelect={setSelected}
continueLabel="Continue"
onContinue={() => alert(`Selected: ${selected}`)}
/>
);
},
};
// --- With Truncated Descriptions ---------------------------------------------
/** Long descriptions with "View more" toggle */
export const WithTruncatedDescriptions: Story = {
render: () => {
const [selected, setSelected] = useState<string | undefined>();
return (
<ServiceSelector
heading="Choose a service type"
items={serviceTypes}
selectedId={selected}
onSelect={setSelected}
continueLabel="Continue"
onContinue={() => alert(`Selected: ${selected}`)}
maxDescriptionLines={2}
/>
);
},
};
// --- Without Continue Button -------------------------------------------------
/** Selection only — no continue action (parent manages flow) */
export const WithoutContinue: Story = {
render: () => {
const [selected, setSelected] = useState<string | undefined>('burial');
return (
<ServiceSelector
heading="Service type"
items={serviceTypes}
selectedId={selected}
onSelect={setSelected}
/>
);
},
};
// --- With Disabled Options ---------------------------------------------------
/** Some options are unavailable */
export const WithDisabledOptions: Story = {
render: () => {
const [selected, setSelected] = useState<string | undefined>();
const items = serviceTypes.map((item) =>
item.id === 'memorial' ? { ...item, disabled: true, description: item.description + ' (Currently unavailable at this location.)' } : item,
);
return (
<ServiceSelector
heading="Choose a service type"
subheading="Some options may not be available at your chosen location."
items={items}
selectedId={selected}
onSelect={setSelected}
continueLabel="Continue"
onContinue={() => alert(`Selected: ${selected}`)}
/>
);
},
};
// --- In Arrangement Flow Context ---------------------------------------------
/** ServiceSelector within a multi-step arrangement flow */
export const InArrangementFlow: Story = {
render: () => {
const [selected, setSelected] = useState<string | undefined>();
const steps = [
{ label: 'Service' },
{ label: 'Coffin' },
{ label: 'Venue' },
{ label: 'Extras' },
{ label: 'Review' },
];
return (
<Box>
<StepIndicator steps={steps} currentStep={0} sx={{ mb: 4 }} />
<ServiceSelector
heading="Choose a service type"
subheading="This is the first step in creating your arrangement. You can change this later."
items={serviceTypes}
selectedId={selected}
onSelect={setSelected}
continueLabel="Continue to coffin selection"
onContinue={() => alert('Next step: Coffin')}
maxDescriptionLines={2}
/>
<Typography variant="captionSm" color="text.secondary" sx={{ mt: 3, textAlign: 'center', display: 'block' }}>
All prices are estimates and may vary based on your specific requirements.
</Typography>
</Box>
);
},
};

View File

@@ -0,0 +1,154 @@
import React from 'react';
import Box from '@mui/material/Box';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
import { ServiceOption } from '../../molecules/ServiceOption';
// ─── Types ───────────────────────────────────────────────────────────────────
/** A service option within the selector */
export interface ServiceItem {
/** Unique identifier */
id: string;
/** Option name */
name: string;
/** Price in dollars */
price?: number;
/** Description text */
description?: string;
/** Whether this option is unavailable */
disabled?: boolean;
}
/** Props for the FA ServiceSelector organism */
export interface ServiceSelectorProps {
/** Section heading (e.g. "Choose a service type") */
heading: string;
/** Optional subheading providing context */
subheading?: string;
/** Available options */
items: ServiceItem[];
/** Currently selected item ID */
selectedId?: string;
/** Called when the user selects an option */
onSelect?: (id: string) => void;
/** Label for the continue/next button — omit to hide */
continueLabel?: string;
/** Called when the user clicks continue */
onContinue?: () => void;
/** Whether continue is disabled (e.g. nothing selected) — defaults to requiring selection */
continueDisabled?: boolean;
/** Max visible description lines before "View more" toggle */
maxDescriptionLines?: number;
/** MUI sx prop for the root element */
sx?: SxProps<Theme>;
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Service selection panel for the FA arrangement flow.
*
* Presents a heading, subheading, and a list of ServiceOption cards
* for single-select (radio) behaviour. An optional continue button
* advances to the next step.
*
* Composes Typography + ServiceOption + Button.
*
* Usage:
* ```tsx
* <ServiceSelector
* heading="Choose a service type"
* subheading="Select the type of service you'd like to arrange."
* items={[
* { id: 'burial', name: 'Traditional Burial', price: 4200, description: '...' },
* { id: 'cremation', name: 'Cremation', price: 2800, description: '...' },
* ]}
* selectedId={selected}
* onSelect={setSelected}
* continueLabel="Continue"
* onContinue={() => nextStep()}
* />
* ```
*/
export const ServiceSelector = React.forwardRef<HTMLDivElement, ServiceSelectorProps>(
(
{
heading,
subheading,
items,
selectedId,
onSelect,
continueLabel,
onContinue,
continueDisabled,
maxDescriptionLines,
sx,
},
ref,
) => {
const nothingSelected = !selectedId;
const isContinueDisabled = continueDisabled ?? nothingSelected;
return (
<Box
ref={ref}
sx={[
{ width: '100%' },
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Header */}
<Box sx={{ mb: 3 }}>
<Typography variant="h4" component="h2" sx={{ mb: subheading ? 1 : 0 }}>
{heading}
</Typography>
{subheading && (
<Typography variant="body2" color="text.secondary">
{subheading}
</Typography>
)}
</Box>
{/* Options list */}
<Box
role="radiogroup"
aria-label={heading}
sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}
>
{items.map((item) => (
<ServiceOption
key={item.id}
name={item.name}
price={item.price}
description={item.description}
selected={selectedId === item.id}
disabled={item.disabled}
onClick={() => onSelect?.(item.id)}
maxDescriptionLines={maxDescriptionLines}
/>
))}
</Box>
{/* Continue button */}
{continueLabel && (
<Box sx={{ mt: 4 }}>
<Button
variant="contained"
size="large"
fullWidth
disabled={isContinueDisabled}
onClick={onContinue}
>
{continueLabel}
</Button>
</Box>
)}
</Box>
);
},
);
ServiceSelector.displayName = 'ServiceSelector';
export default ServiceSelector;

View File

@@ -0,0 +1 @@
export { ServiceSelector, type ServiceSelectorProps, type ServiceItem } from './ServiceSelector';