Add ServiceOption molecule — selectable service item for arrangement flow

- Composes Card (interactive, selected) + Typography
- Heading row with name + optional price (brand colour, right-aligned)
- Optional description text below heading
- Selected state via Card's built-in brand border + warm bg
- Disabled state with opacity token + pointer-events: none
- role="radio" + aria-checked for single-select group semantics
- 7 stories: Default, Selected, ServiceTypeSelection (interactive),
  CoffinSelection (interactive), WithoutPrice, Disabled, EdgeCases
- Maps to Figma ListItemPurchaseOption (2349:39505)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 20:34:33 +11:00
parent dccf8e6514
commit dfa599d567
3 changed files with 353 additions and 0 deletions

View File

@@ -0,0 +1,233 @@
import React, { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { ServiceOption } from './ServiceOption';
import Box from '@mui/material/Box';
const meta: Meta<typeof ServiceOption> = {
title: 'Molecules/ServiceOption',
component: ServiceOption,
tags: ['autodocs'],
parameters: {
layout: 'centered',
design: {
type: 'figma',
url: 'https://www.figma.com/design/XUDUrw4yMkEexBCCYHXUvT/Parsons?node-id=2349-39505',
},
},
argTypes: {
name: { control: 'text' },
description: { control: 'text' },
price: { control: 'number' },
selected: { control: 'boolean' },
disabled: { control: 'boolean' },
onClick: { action: 'clicked' },
},
decorators: [
(Story) => (
<Box sx={{ width: 500 }}>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof ServiceOption>;
// ─── Default ────────────────────────────────────────────────────────────────
export const Default: Story = {
args: {
name: 'Traditional Burial',
description:
'Full service with chapel ceremony and graveside committal. Includes hearse, pallbearers, and flowers.',
price: 4200,
},
};
// ─── Selected ───────────────────────────────────────────────────────────────
export const Selected: Story = {
args: {
name: 'Traditional Burial',
description:
'Full service with chapel ceremony and graveside committal. Includes hearse, pallbearers, and flowers.',
price: 4200,
selected: true,
},
};
// ─── Service Type Selection ─────────────────────────────────────────────────
/**
* Interactive single-select group — click to choose a service type.
* Only one option is active at a time (radio semantics).
*/
export const ServiceTypeSelection: Story = {
name: 'Service Type Selection',
render: () => {
const [selected, setSelected] = useState('burial');
return (
<Box
role="radiogroup"
aria-label="Service type"
sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}
>
<ServiceOption
name="Traditional Burial"
description="Full service with chapel ceremony and graveside committal. Includes hearse, pallbearers, and flowers."
price={4200}
selected={selected === 'burial'}
onClick={() => setSelected('burial')}
/>
<ServiceOption
name="Cremation"
description="Chapel service followed by cremation. Ashes returned in selected urn within 5 business days."
price={2800}
selected={selected === 'cremation'}
onClick={() => setSelected('cremation')}
/>
<ServiceOption
name="Direct Cremation"
description="No service — cremation handled directly. Most affordable option for families who prefer a private farewell."
price={1200}
selected={selected === 'direct'}
onClick={() => setSelected('direct')}
/>
</Box>
);
},
};
// ─── Coffin Selection ───────────────────────────────────────────────────────
/** Coffin/casket options with varied pricing */
export const CoffinSelection: Story = {
name: 'Coffin Selection',
render: () => {
const [selected, setSelected] = useState('');
return (
<Box
role="radiogroup"
aria-label="Coffin type"
sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}
>
<ServiceOption
name="Chipboard Coffin"
description="Simple, dignified option. Suitable for cremation or burial."
price={450}
selected={selected === 'chipboard'}
onClick={() => setSelected('chipboard')}
/>
<ServiceOption
name="Solid Timber Coffin"
description="Handcrafted from sustainable Australian hardwood with brass handles."
price={1800}
selected={selected === 'timber'}
onClick={() => setSelected('timber')}
/>
<ServiceOption
name="Premium Casket"
description="Full-lined steel casket with satin interior and personalised nameplate."
price={3500}
selected={selected === 'premium'}
onClick={() => setSelected('premium')}
/>
<ServiceOption
name="Eco-Friendly"
description="Biodegradable wicker or cardboard. Ideal for natural burials."
price={650}
selected={selected === 'eco'}
onClick={() => setSelected('eco')}
/>
</Box>
);
},
};
// ─── Without Price ──────────────────────────────────────────────────────────
/** Options without prices — used when pricing varies or is bundled */
export const WithoutPrice: Story = {
name: 'Without Price',
render: () => (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<ServiceOption
name="Morning Service"
description="9:00 AM 12:00 PM. Preferred for traditional ceremonies."
onClick={() => {}}
/>
<ServiceOption
name="Afternoon Service"
description="1:00 PM 4:00 PM. Most popular time slot."
selected
onClick={() => {}}
/>
<ServiceOption
name="Evening Service"
description="5:00 PM 7:00 PM. Available at select venues."
onClick={() => {}}
/>
</Box>
),
};
// ─── Disabled ───────────────────────────────────────────────────────────────
/** Disabled option — unavailable service */
export const Disabled: Story = {
render: () => (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<ServiceOption
name="Chapel Service"
description="Traditional chapel ceremony with seating for up to 100 guests."
price={800}
onClick={() => {}}
/>
<ServiceOption
name="Beach Ceremony"
description="Currently unavailable due to council restrictions."
price={600}
disabled
/>
<ServiceOption
name="Garden Service"
description="Outdoor ceremony in landscaped memorial gardens."
price={900}
onClick={() => {}}
/>
</Box>
),
};
// ─── Edge Cases ─────────────────────────────────────────────────────────────
/** Edge cases: long text, no description, high price */
export const EdgeCases: Story = {
name: 'Edge Cases',
render: () => (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{/* Long name + description */}
<ServiceOption
name="Complete Funeral Package Including Transport, Venue, Catering, and Memorial"
description="This comprehensive package covers everything from initial collection through to the memorial service. Includes hearse, funeral director coordination, venue hire, catering for up to 50 guests, memorial booklets, and a dedicated family liaison throughout the process."
price={8500}
onClick={() => {}}
/>
{/* No description */}
<ServiceOption
name="Flowers"
price={250}
selected
onClick={() => {}}
/>
{/* No price, no description */}
<ServiceOption
name="Contact us for pricing"
onClick={() => {}}
/>
</Box>
),
};

View File

@@ -0,0 +1,118 @@
import React from 'react';
import Box from '@mui/material/Box';
import type { SxProps, Theme } from '@mui/material/styles';
import { Card } from '../../atoms/Card';
import { Typography } from '../../atoms/Typography';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Props for the FA ServiceOption molecule */
export interface ServiceOptionProps {
/** Option name/heading */
name: string;
/** Description text explaining the option */
description?: string;
/** Price in dollars — shown to the right of the heading */
price?: number;
/** Whether this option is currently selected */
selected?: boolean;
/** Whether this option is disabled/unavailable */
disabled?: boolean;
/** Click handler — toggles selection */
onClick?: () => void;
/** MUI sx prop for style overrides */
sx?: SxProps<Theme>;
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Selectable service option for the FA design system.
*
* Used in the arrangement flow for choosing between mutually exclusive
* options — coffin type, service style, transport method, etc.
* Users click to select; only one option is active at a time within
* a group (managed by parent).
*
* Composes Card (interactive, selected) + Typography. Maps to the
* Figma "ListItemPurchaseOption" component (4 states × 2 viewports).
*
* For add-on toggles (multi-select with Switch), use a future
* AddOnOption molecule or compose Card + Switch directly.
*
* Usage:
* ```tsx
* <ServiceOption
* name="Traditional Burial"
* description="Full service with chapel ceremony and graveside committal."
* price={4200}
* selected={selected === 'burial'}
* onClick={() => setSelected('burial')}
* />
* ```
*/
export const ServiceOption = React.forwardRef<HTMLDivElement, ServiceOptionProps>(
({ name, description, price, selected = false, disabled = false, onClick, sx }, ref) => {
return (
<Card
ref={ref}
interactive={!disabled}
selected={selected}
padding="none"
onClick={disabled ? undefined : onClick}
role="radio"
aria-checked={selected}
aria-disabled={disabled || undefined}
sx={[
{
p: 'var(--fa-card-padding-compact)',
...(disabled && {
opacity: 'var(--fa-opacity-disabled)',
cursor: 'not-allowed',
pointerEvents: 'none' as const,
}),
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Heading row: name + optional price */}
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 2,
}}
>
<Typography variant="h6" component="span">
{name}
</Typography>
{price != null && (
<Typography
variant="h6"
component="span"
color="primary"
sx={{ fontWeight: 600, whiteSpace: 'nowrap' }}
>
${price.toLocaleString('en-AU')}
</Typography>
)}
</Box>
{/* Description */}
{description && (
<Typography
variant="body2"
color="text.secondary"
sx={{ mt: 0.5 }}
>
{description}
</Typography>
)}
</Card>
);
},
);
ServiceOption.displayName = 'ServiceOption';
export default ServiceOption;

View File

@@ -0,0 +1,2 @@
export { ServiceOption, default } from './ServiceOption';
export type { ServiceOptionProps } from './ServiceOption';