Add Switch and Radio atom components

Switch:
- Wraps MUI Switch with FA brand tokens
- Bordered pill track (Figma Style One), brand.500 fill when active
- 4 component tokens: track width/height/borderRadius, thumb size
- Stories include interactive service add-ons demo

Radio:
- Wraps MUI Radio with FA brand tokens
- Brand.500 fill when selected, neutral.400 unchecked
- 2 component tokens: outer size, dot size
- Stories include card selection and payment method patterns

Also:
- Added ColourToggle and Slider to component registry (deferred)
- Updated token registry and session log

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 17:04:37 +11:00
parent b2349d6c78
commit c10a5e4e1c
14 changed files with 529 additions and 2 deletions

View File

@@ -0,0 +1,146 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { Radio } from './Radio';
import { Typography } from '../Typography';
import { Card } from '../Card';
import Box from '@mui/material/Box';
import FormControlLabel from '@mui/material/FormControlLabel';
import RadioGroup from '@mui/material/RadioGroup';
import FormControl from '@mui/material/FormControl';
const meta: Meta<typeof Radio> = {
title: 'Atoms/Radio',
component: Radio,
tags: ['autodocs'],
parameters: {
layout: 'centered',
design: {
type: 'figma',
url: 'https://www.figma.com/design/XUDUrw4yMkEexBCCYHXUvT/Parsons?node-id=2322-42538',
},
},
argTypes: {
checked: {
control: 'boolean',
description: 'Whether the radio is selected',
},
disabled: {
control: 'boolean',
description: 'Disable the radio',
table: { defaultValue: { summary: 'false' } },
},
},
};
export default meta;
type Story = StoryObj<typeof Radio>;
// ─── Default ────────────────────────────────────────────────────────────────
/** Default radio — unchecked */
export const Default: Story = {
args: {},
};
// ─── States ─────────────────────────────────────────────────────────────────
/** All visual states */
export const States: Story = {
render: () => (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<FormControlLabel control={<Radio />} label="Unchecked" />
<FormControlLabel control={<Radio checked />} label="Checked" />
<FormControlLabel control={<Radio disabled />} label="Disabled unchecked" />
<FormControlLabel control={<Radio disabled checked />} label="Disabled checked" />
</Box>
),
};
// ─── Radio Group ────────────────────────────────────────────────────────────
/** Standard radio group with keyboard navigation */
export const Group: Story = {
name: 'Radio Group',
render: () => (
<FormControl>
<Typography variant="label" sx={{ mb: 1 }}>Service type</Typography>
<RadioGroup defaultValue="chapel">
<FormControlLabel value="chapel" control={<Radio />} label="Chapel ceremony" />
<FormControlLabel value="graveside" control={<Radio />} label="Graveside service" />
<FormControlLabel value="memorial" control={<Radio />} label="Memorial service" />
<FormControlLabel value="direct" control={<Radio />} label="Direct cremation" />
</RadioGroup>
</FormControl>
),
};
// ─── Interactive: Card Selection ────────────────────────────────────────────
/**
* Radio buttons inside interactive cards — the recommended pattern
* for option selection in FA. Combines Card's selected state with
* Radio for accessible single-select.
*/
export const CardSelection: Story = {
name: 'Interactive — Card Selection',
render: () => {
const CardSelectDemo = () => {
const [selected, setSelected] = useState('standard');
const options = [
{ value: 'direct', label: 'Direct cremation', desc: 'Simple, dignified cremation with no service.', price: '$1,800' },
{ value: 'standard', label: 'Standard service', desc: 'Traditional chapel ceremony with viewing.', price: '$4,200' },
{ value: 'premium', label: 'Premium service', desc: 'Full service with personalised memorial.', price: '$7,500' },
];
return (
<Box sx={{ maxWidth: 420 }}>
<Typography variant="h4" sx={{ mb: 2 }}>Choose a package</Typography>
<RadioGroup value={selected} onChange={(e) => setSelected(e.target.value)}>
{options.map((opt) => (
<Card
key={opt.value}
variant="outlined"
interactive
selected={selected === opt.value}
padding="compact"
sx={{ mb: 1 }}
onClick={() => setSelected(opt.value)}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
<Radio value={opt.value} sx={{ mt: -0.5 }} />
<Box sx={{ flex: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="label">{opt.label}</Typography>
<Typography variant="labelLg" color="primary">{opt.price}</Typography>
</Box>
<Typography variant="body2" color="text.secondary">{opt.desc}</Typography>
</Box>
</Box>
</Card>
))}
</RadioGroup>
</Box>
);
};
return <CardSelectDemo />;
},
};
// ─── Interactive: Payment Method ────────────────────────────────────────────
/** Horizontal radio group for payment method selection */
export const PaymentMethod: Story = {
name: 'Interactive — Payment Method',
render: () => (
<FormControl>
<Typography variant="label" sx={{ mb: 1 }}>Payment method</Typography>
<RadioGroup defaultValue="card" row>
<FormControlLabel value="card" control={<Radio />} label="Credit card" />
<FormControlLabel value="bank" control={<Radio />} label="Bank transfer" />
<FormControlLabel value="plan" control={<Radio />} label="Payment plan" />
</RadioGroup>
</FormControl>
),
};

View File

@@ -0,0 +1,33 @@
import React from 'react';
import MuiRadio from '@mui/material/Radio';
import type { RadioProps as MuiRadioProps } from '@mui/material/Radio';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Props for the FA Radio component */
export interface RadioProps extends MuiRadioProps {}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Radio button for the FA design system.
*
* Single-select control for mutually exclusive options. Wraps MUI Radio
* with FA brand tokens — warm gold fill when selected.
*
* From Parsons 1.0 Figma radio component — 16px circle with brand dot.
*
* Usage:
* - Always use inside a RadioGroup for proper keyboard navigation
* - Pair with FormControlLabel for accessible labels
* - For binary on/off, use Switch instead
* - For multi-select, use Checkbox (planned) or Chip
*/
export const Radio = React.forwardRef<HTMLButtonElement, RadioProps>(
(props, ref) => {
return <MuiRadio ref={ref} {...props} />;
},
);
Radio.displayName = 'Radio';
export default Radio;

View File

@@ -0,0 +1,2 @@
export { Radio, default } from './Radio';
export type { RadioProps } from './Radio';

View File

@@ -0,0 +1,134 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { Switch } from './Switch';
import { Typography } from '../Typography';
import { Card } from '../Card';
import Box from '@mui/material/Box';
import FormControlLabel from '@mui/material/FormControlLabel';
import FormGroup from '@mui/material/FormGroup';
const meta: Meta<typeof Switch> = {
title: 'Atoms/Switch',
component: Switch,
tags: ['autodocs'],
parameters: {
layout: 'centered',
design: {
type: 'figma',
url: 'https://www.figma.com/design/XUDUrw4yMkEexBCCYHXUvT/Parsons?node-id=2322-42538',
},
},
argTypes: {
checked: {
control: 'boolean',
description: 'Whether the switch is on',
},
disabled: {
control: 'boolean',
description: 'Disable the switch',
table: { defaultValue: { summary: 'false' } },
},
},
};
export default meta;
type Story = StoryObj<typeof Switch>;
// ─── Default ────────────────────────────────────────────────────────────────
/** Default switch — unchecked */
export const Default: Story = {
args: {},
};
// ─── States ─────────────────────────────────────────────────────────────────
/** All visual states */
export const States: Story = {
render: () => (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<FormControlLabel control={<Switch />} label="Unchecked" />
<FormControlLabel control={<Switch defaultChecked />} label="Checked" />
<FormControlLabel control={<Switch disabled />} label="Disabled unchecked" />
<FormControlLabel control={<Switch disabled defaultChecked />} label="Disabled checked" />
</Box>
),
};
// ─── Interactive: Service Add-ons ───────────────────────────────────────────
/**
* Realistic arrangement form pattern — toggle add-on services.
*/
export const ServiceAddOns: Story = {
name: 'Interactive — Service Add-ons',
render: () => {
const AddOnDemo = () => {
const [addOns, setAddOns] = useState({
catering: true,
flowers: true,
music: false,
memorial: false,
guestBook: false,
});
const toggle = (key: keyof typeof addOns) => {
setAddOns((prev) => ({ ...prev, [key]: !prev[key] }));
};
const items = [
{ key: 'catering' as const, label: 'Catering', desc: 'Light refreshments after the service', price: '$450' },
{ key: 'flowers' as const, label: 'Floral arrangements', desc: 'Seasonal flowers for the chapel', price: '$280' },
{ key: 'music' as const, label: 'Live music', desc: 'Organist or solo musician', price: '$350' },
{ key: 'memorial' as const, label: 'Memorial video', desc: 'Photo slideshow with music', price: '$200' },
{ key: 'guestBook' as const, label: 'Guest book', desc: 'Leather-bound memorial guest book', price: '$85' },
];
const total = items.reduce((sum, item) =>
addOns[item.key] ? sum + parseInt(item.price.replace('$', ''), 10) : sum, 0,
);
return (
<Box sx={{ maxWidth: 420 }}>
<Typography variant="h4" sx={{ mb: 2 }}>Service add-ons</Typography>
<FormGroup>
{items.map((item) => (
<Card key={item.key} variant="outlined" padding="compact" sx={{ mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ flex: 1 }}>
<Typography variant="label">{item.label}</Typography>
<Typography variant="body2" color="text.secondary">{item.desc}</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="label" color="text.secondary">{item.price}</Typography>
<Switch checked={addOns[item.key]} onChange={() => toggle(item.key)} />
</Box>
</Box>
</Card>
))}
</FormGroup>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 2, pt: 2, borderTop: 1, borderColor: 'divider' }}>
<Typography variant="labelLg">Total add-ons</Typography>
<Typography variant="labelLg" color="primary">${total}</Typography>
</Box>
</Box>
);
};
return <AddOnDemo />;
},
};
// ─── With Labels ────────────────────────────────────────────────────────────
/** FormControlLabel pairing — the recommended usage pattern */
export const WithLabels: Story = {
name: 'With Labels',
render: () => (
<FormGroup>
<FormControlLabel control={<Switch defaultChecked />} label="Email notifications" />
<FormControlLabel control={<Switch />} label="SMS notifications" />
<FormControlLabel control={<Switch defaultChecked />} label="Save arrangement progress" />
</FormGroup>
),
};

View File

@@ -0,0 +1,33 @@
import React from 'react';
import MuiSwitch from '@mui/material/Switch';
import type { SwitchProps as MuiSwitchProps } from '@mui/material/Switch';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Props for the FA Switch component */
export interface SwitchProps extends MuiSwitchProps {}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Toggle switch for the FA design system.
*
* Binary on/off control for enabling add-ons and options in arrangement
* forms. Wraps MUI Switch with FA brand tokens — warm gold track when
* active, bordered pill shape when inactive.
*
* From Parsons 1.0 Figma "Style One" — bordered variant with brand fill.
*
* Usage:
* - Use for boolean settings ("Include catering", "Add memorial video")
* - Pair with a label via FormControlLabel for accessibility
* - For mutually exclusive options, use Radio instead
*/
export const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
(props, ref) => {
return <MuiSwitch ref={ref} {...props} />;
},
);
Switch.displayName = 'Switch';
export default Switch;

View File

@@ -0,0 +1,2 @@
export { Switch, default } from './Switch';
export type { SwitchProps } from './Switch';