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