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