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