Add AddOnOption molecule — toggleable add-on with Switch

- Composes Card (interactive, selected) + Typography + Switch
- Maps to Figma ListItemAddItem (2350:40658) — toggle for optional extras
- Heading + optional price + description + Switch right-aligned
- Selected state via Card brand border + warm bg when checked
- Clicking anywhere on card toggles the switch (stopPropagation on switch)
- aria-labelledby connects switch to heading text
- 7 stories: Default, Checked, ServiceAddOns (interactive list with total),
  WithoutPrice, WithoutDescription, Disabled, EdgeCases

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 20:52:15 +11:00
parent 667c97a237
commit 1e7fbc0dc5
3 changed files with 360 additions and 0 deletions

View File

@@ -0,0 +1,216 @@
import React from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { AddOnOption } from './AddOnOption';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Divider from '@mui/material/Divider';
const meta: Meta<typeof AddOnOption> = {
title: 'Molecules/AddOnOption',
component: AddOnOption,
tags: ['autodocs'],
parameters: {
layout: 'centered',
design: {
type: 'figma',
url: 'https://www.figma.com/design/XUDUrw4yMkEexBCCYHXUvT/Parsons?node-id=2350-40658',
},
},
argTypes: {
name: { control: 'text' },
description: { control: 'text' },
price: { control: 'number' },
checked: { control: 'boolean' },
disabled: { control: 'boolean' },
},
decorators: [
(Story) => (
<Box sx={{ width: 480 }}>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof AddOnOption>;
// ─── Default ────────────────────────────────────────────────────────────────
/** Default — unchecked add-on with description */
export const Default: Story = {
args: {
name: 'Memorial video',
description:
'Professional video tribute played during the service, compiled from family photos and footage.',
price: 350,
},
};
// ─── Checked ────────────────────────────────────────────────────────────────
/** Checked — add-on is enabled, card shows selected state */
export const Checked: Story = {
args: {
name: 'Memorial video',
description:
'Professional video tribute played during the service, compiled from family photos and footage.',
price: 350,
checked: true,
},
};
// ─── Service Add-Ons ────────────────────────────────────────────────────────
/** Realistic — arrangement flow add-ons list */
export const ServiceAddOns: Story = {
render: function Render() {
const [addOns, setAddOns] = React.useState({
catering: false,
video: true,
flowers: false,
transport: false,
webcast: false,
});
const toggle = (key: keyof typeof addOns) => (checked: boolean) =>
setAddOns({ ...addOns, [key]: checked });
const total = [
addOns.catering ? 1200 : 0,
addOns.video ? 350 : 0,
addOns.flowers ? 280 : 0,
addOns.transport ? 450 : 0,
addOns.webcast ? 150 : 0,
].reduce((a, b) => a + b, 0);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="h5">Optional extras</Typography>
<Typography variant="body2" color="text.secondary">
Customise the service with additional options. All prices are GST inclusive.
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<AddOnOption
name="Catering"
description="Light refreshments for up to 50 guests after the service."
price={1200}
checked={addOns.catering}
onChange={toggle('catering')}
/>
<AddOnOption
name="Memorial video"
description="Professional video tribute compiled from family photos and footage."
price={350}
checked={addOns.video}
onChange={toggle('video')}
/>
<AddOnOption
name="Floral arrangements"
description="Casket spray and two standing arrangements for the chapel."
price={280}
checked={addOns.flowers}
onChange={toggle('flowers')}
/>
<AddOnOption
name="Premium transport"
description="Vintage hearse and one family limousine for the procession."
price={450}
checked={addOns.transport}
onChange={toggle('transport')}
/>
<AddOnOption
name="Live webcast"
description="Stream the service online for family and friends who cannot attend."
price={150}
checked={addOns.webcast}
onChange={toggle('webcast')}
/>
</Box>
<Divider />
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<Typography variant="label" color="text.secondary">
Extras total
</Typography>
<Typography variant="h6" color="primary">
${total.toLocaleString('en-AU')}
</Typography>
</Box>
</Box>
);
},
};
// ─── Without Price ──────────────────────────────────────────────────────────
/** No price — some add-ons are complimentary */
export const WithoutPrice: Story = {
args: {
name: 'Order of service booklet',
description: 'Complimentary printed booklet with the service programme and a photo of your loved one.',
},
};
// ─── Without Description ────────────────────────────────────────────────────
/** No description — compact variant for simple toggles */
export const WithoutDescription: Story = {
render: function Render() {
const [checked, setChecked] = React.useState(false);
return (
<AddOnOption
name="Include GST in pricing"
checked={checked}
onChange={setChecked}
/>
);
},
};
// ─── Disabled ───────────────────────────────────────────────────────────────
/** Disabled — add-on unavailable (e.g. venue restriction) */
export const Disabled: Story = {
args: {
name: 'Catering',
description: 'Not available at this venue. Please contact the venue directly for catering options.',
price: 1200,
disabled: true,
},
};
// ─── Edge Cases ─────────────────────────────────────────────────────────────
/** Edge cases — long text, high prices, missing fields */
export const EdgeCases: Story = {
render: function Render() {
const [checks, setChecks] = React.useState({ a: false, b: true, c: false });
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<AddOnOption
name="Premium memorial video with extended family interview package and professional editing"
description="Our most comprehensive video tribute including on-site filmed interviews with family members, professional editing with music selection, up to 20 minutes of curated content, plus a USB copy and online streaming access for 12 months."
price={2500}
checked={checks.a}
onChange={(v) => setChecks({ ...checks, a: v })}
/>
<AddOnOption
name="Flowers"
checked={checks.b}
onChange={(v) => setChecks({ ...checks, b: v })}
/>
<AddOnOption
name="Complimentary parking"
description="Reserved parking for family vehicles at the venue."
price={0}
checked={checks.c}
onChange={(v) => setChecks({ ...checks, c: v })}
/>
</Box>
);
},
};

View File

@@ -0,0 +1,142 @@
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';
import { Switch } from '../../atoms/Switch';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Props for the FA AddOnOption molecule */
export interface AddOnOptionProps {
/** Add-on name/heading */
name: string;
/** Description text explaining the add-on */
description?: string;
/** Price in dollars — shown after the heading */
price?: number;
/** Whether this add-on is currently enabled */
checked?: boolean;
/** Called when the toggle changes */
onChange?: (checked: boolean) => void;
/** Whether this add-on is disabled/unavailable */
disabled?: boolean;
/** MUI sx prop for style overrides */
sx?: SxProps<Theme>;
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Toggleable add-on option for the FA design system.
*
* Used in the arrangement flow for optional extras — catering, memorial
* video, flowers, transport upgrades, etc. Users toggle the switch to
* include or exclude; multiple add-ons can be active simultaneously.
*
* Composes Card + Typography + Switch. Maps to the Figma
* "ListItemAddItem" component (desktop + mobile viewports).
*
* For mutually exclusive options (single-select), use ServiceOption instead.
*
* Usage:
* ```tsx
* <AddOnOption
* name="Memorial video"
* description="Professional video tribute played during the service."
* price={350}
* checked={addOns.memorialVideo}
* onChange={(on) => setAddOns({ ...addOns, memorialVideo: on })}
* />
* ```
*/
export const AddOnOption = React.forwardRef<HTMLDivElement, AddOnOptionProps>(
({ name, description, price, checked = false, onChange, disabled = false, sx }, ref) => {
const switchId = React.useId();
const handleToggle = () => {
if (!disabled && onChange) {
onChange(!checked);
}
};
const handleSwitchChange = (_e: React.ChangeEvent<HTMLInputElement>, value: boolean) => {
if (onChange) {
onChange(value);
}
};
return (
<Card
ref={ref}
interactive={!disabled}
selected={checked}
padding="none"
onClick={handleToggle}
sx={[
{
p: 'var(--fa-card-padding-compact)',
cursor: disabled ? 'not-allowed' : 'pointer',
...(disabled && {
opacity: 'var(--fa-opacity-disabled)',
pointerEvents: 'none' as const,
}),
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Heading row: name + optional price + switch */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
}}
>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1, flex: 1, minWidth: 0 }}>
<Typography
variant="h6"
component="span"
id={`${switchId}-label`}
>
{name}
</Typography>
{price != null && (
<Typography
variant="body2"
color="text.secondary"
sx={{ whiteSpace: 'nowrap' }}
>
${price.toLocaleString('en-AU')}
</Typography>
)}
</Box>
<Switch
checked={checked}
onChange={handleSwitchChange}
disabled={disabled}
onClick={(e) => e.stopPropagation()}
inputProps={{ 'aria-labelledby': `${switchId}-label` }}
sx={{ flexShrink: 0 }}
/>
</Box>
{/* Description */}
{description && (
<Typography
variant="body2"
color="text.secondary"
sx={{ mt: 0.5 }}
>
{description}
</Typography>
)}
</Card>
);
},
);
AddOnOption.displayName = 'AddOnOption';
export default AddOnOption;

View File

@@ -0,0 +1,2 @@
export { AddOnOption } from './AddOnOption';
export type { AddOnOptionProps } from './AddOnOption';