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:
233
src/components/molecules/ServiceOption/ServiceOption.stories.tsx
Normal file
233
src/components/molecules/ServiceOption/ServiceOption.stories.tsx
Normal 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>
|
||||||
|
),
|
||||||
|
};
|
||||||
118
src/components/molecules/ServiceOption/ServiceOption.tsx
Normal file
118
src/components/molecules/ServiceOption/ServiceOption.tsx
Normal 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;
|
||||||
2
src/components/molecules/ServiceOption/index.ts
Normal file
2
src/components/molecules/ServiceOption/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { ServiceOption, default } from './ServiceOption';
|
||||||
|
export type { ServiceOptionProps } from './ServiceOption';
|
||||||
Reference in New Issue
Block a user