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:
146
src/components/atoms/Radio/Radio.stories.tsx
Normal file
146
src/components/atoms/Radio/Radio.stories.tsx
Normal 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>
|
||||
),
|
||||
};
|
||||
33
src/components/atoms/Radio/Radio.tsx
Normal file
33
src/components/atoms/Radio/Radio.tsx
Normal 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;
|
||||
2
src/components/atoms/Radio/index.ts
Normal file
2
src/components/atoms/Radio/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Radio, default } from './Radio';
|
||||
export type { RadioProps } from './Radio';
|
||||
134
src/components/atoms/Switch/Switch.stories.tsx
Normal file
134
src/components/atoms/Switch/Switch.stories.tsx
Normal 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>
|
||||
),
|
||||
};
|
||||
33
src/components/atoms/Switch/Switch.tsx
Normal file
33
src/components/atoms/Switch/Switch.tsx
Normal 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;
|
||||
2
src/components/atoms/Switch/index.ts
Normal file
2
src/components/atoms/Switch/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Switch, default } from './Switch';
|
||||
export type { SwitchProps } from './Switch';
|
||||
@@ -26,6 +26,11 @@
|
||||
--fa-input-height-sm: 40px; /** Small — compact forms, admin layouts, matches Button medium height */
|
||||
--fa-input-height-md: 48px; /** Medium (default) — standard forms, matches Button large for alignment */
|
||||
--fa-input-icon-size-default: 20px; /** 20px — icon size inside input field, matches Figma trailing icon */
|
||||
--fa-radio-size-default: 20px; /** Default radio size — matches Figma 16px + padding for 44px touch target area */
|
||||
--fa-radio-dot-size-default: 10px; /** Selected indicator dot — 50% of outer size */
|
||||
--fa-switch-track-width: 44px; /** Track width — slightly narrower than Figma 52px for better proportion with 44px touch target */
|
||||
--fa-switch-track-height: 24px; /** Track height */
|
||||
--fa-switch-thumb-size: 18px; /** Thumb diameter — sits inside the track with 3px inset */
|
||||
--fa-color-brand-50: #fef9f5; /** Lightest warm tint — warm section backgrounds */
|
||||
--fa-color-brand-100: #f7ecdf; /** Light warm — hover backgrounds, subtle fills */
|
||||
--fa-color-brand-200: #ebdac8; /** Warm light — secondary backgrounds, divider tones */
|
||||
@@ -260,6 +265,7 @@
|
||||
--fa-input-font-size-default: var(--fa-font-size-base); /** 16px — prevents iOS auto-zoom on focus, matches Figma */
|
||||
--fa-input-border-radius-default: var(--fa-border-radius-sm); /** 4px — subtle rounding, consistent with Figma design */
|
||||
--fa-input-gap-default: var(--fa-spacing-2); /** 8px — vertical rhythm between label/input/helper, slightly more generous than Figma's 6px for readability */
|
||||
--fa-switch-track-border-radius: var(--fa-border-radius-full); /** Pill shape */
|
||||
--fa-color-text-primary: var(--fa-color-neutral-800); /** Primary text — body content, headings. Cool charcoal (#2C2E35) for comfortable extended reading */
|
||||
--fa-color-text-secondary: var(--fa-color-neutral-600); /** Secondary text — helper text, descriptions, metadata, less prominent content */
|
||||
--fa-color-text-tertiary: var(--fa-color-neutral-500); /** Tertiary text — placeholders, timestamps, attribution, meta information */
|
||||
|
||||
@@ -72,6 +72,12 @@ export const InputFontSizeDefault = "1rem"; // 16px — prevents iOS auto-zoom o
|
||||
export const InputBorderRadiusDefault = "4px"; // 4px — subtle rounding, consistent with Figma design
|
||||
export const InputGapDefault = "8px"; // 8px — vertical rhythm between label/input/helper, slightly more generous than Figma's 6px for readability
|
||||
export const InputIconSizeDefault = "20px"; // 20px — icon size inside input field, matches Figma trailing icon
|
||||
export const RadioSizeDefault = "20px"; // Default radio size — matches Figma 16px + padding for 44px touch target area
|
||||
export const RadioDotSizeDefault = "10px"; // Selected indicator dot — 50% of outer size
|
||||
export const SwitchTrackWidth = "44px"; // Track width — slightly narrower than Figma 52px for better proportion with 44px touch target
|
||||
export const SwitchTrackHeight = "24px"; // Track height
|
||||
export const SwitchTrackBorderRadius = "9999px"; // Pill shape
|
||||
export const SwitchThumbSize = "18px"; // Thumb diameter — sits inside the track with 3px inset
|
||||
export const ColorBrand50 = "#fef9f5"; // Lightest warm tint — warm section backgrounds
|
||||
export const ColorBrand100 = "#f7ecdf"; // Light warm — hover backgrounds, subtle fills
|
||||
export const ColorBrand200 = "#ebdac8"; // Warm light — secondary backgrounds, divider tones
|
||||
|
||||
@@ -636,6 +636,75 @@ export const theme = createTheme({
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiSwitch: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
width: parseInt(t.SwitchTrackWidth, 10) + 12,
|
||||
height: parseInt(t.SwitchTrackHeight, 10) + 12,
|
||||
padding: 6,
|
||||
},
|
||||
switchBase: {
|
||||
padding: 9,
|
||||
'&.Mui-checked': {
|
||||
transform: `translateX(${parseInt(t.SwitchTrackWidth, 10) - parseInt(t.SwitchTrackHeight, 10)}px)`,
|
||||
color: t.ColorWhite,
|
||||
'& + .MuiSwitch-track': {
|
||||
backgroundColor: t.ColorInteractiveDefault,
|
||||
borderColor: t.ColorInteractiveDefault,
|
||||
opacity: 1,
|
||||
},
|
||||
'&:hover + .MuiSwitch-track': {
|
||||
backgroundColor: t.ColorInteractiveHover,
|
||||
},
|
||||
},
|
||||
'&.Mui-disabled + .MuiSwitch-track': {
|
||||
opacity: 0.4,
|
||||
},
|
||||
'&:focus-visible + .MuiSwitch-track': {
|
||||
outline: `2px solid ${t.ColorInteractiveFocus}`,
|
||||
outlineOffset: '2px',
|
||||
},
|
||||
},
|
||||
thumb: {
|
||||
width: parseInt(t.SwitchThumbSize, 10),
|
||||
height: parseInt(t.SwitchThumbSize, 10),
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||
},
|
||||
track: {
|
||||
borderRadius: parseInt(t.SwitchTrackBorderRadius, 10),
|
||||
backgroundColor: t.ColorWhite,
|
||||
border: `1.5px solid ${t.ColorNeutral400}`,
|
||||
opacity: 1,
|
||||
transition: 'background-color 150ms ease-in-out, border-color 150ms ease-in-out',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiRadio: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
color: t.ColorNeutral400,
|
||||
transition: 'color 150ms ease-in-out',
|
||||
'&:hover': {
|
||||
color: t.ColorNeutral600,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
'&.Mui-checked': {
|
||||
color: t.ColorInteractiveDefault,
|
||||
'&:hover': {
|
||||
color: t.ColorInteractiveHover,
|
||||
},
|
||||
},
|
||||
'&:focus-visible': {
|
||||
outline: `2px solid ${t.ColorInteractiveFocus}`,
|
||||
outlineOffset: '2px',
|
||||
borderRadius: '50%',
|
||||
},
|
||||
'&.Mui-disabled': {
|
||||
color: t.ColorNeutral300,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user