Add IconButton, Divider, and Link atom components

IconButton:
- Wraps MUI IconButton with FA theme (focus ring, brand hover colours)
- 3 sizes reusing Button height tokens: small 32px, medium 40px, large 48px
- 5 stories: Default, Sizes, Colours, States, CommonUseCases

Divider:
- Wraps MUI Divider with FA border token
- Horizontal/vertical, fullWidth/inset/middle variants, text support
- 6 stories: Default, Variants, Vertical, WithText, InContent, NavigationList

Link:
- Wraps MUI Link with FA brand colour (copper brand.600, 4.8:1 contrast)
- Underline on hover by default, focus-visible ring, fontWeight 500
- 7 stories: Default, UnderlineVariants, ColourVariants, Inline, Navigation,
  FooterLinks, OnDifferentBackgrounds

Theme: Added MuiIconButton, MuiDivider, MuiLink overrides

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 20:16:23 +11:00
parent 7c9d71cc84
commit 933dca3f09
10 changed files with 593 additions and 0 deletions

View File

@@ -0,0 +1,113 @@
import type { Meta, StoryObj } from '@storybook/react';
import { Divider } from './Divider';
import Box from '@mui/material/Box';
const meta: Meta<typeof Divider> = {
title: 'Atoms/Divider',
component: Divider,
tags: ['autodocs'],
argTypes: {
orientation: { control: 'select', options: ['horizontal', 'vertical'] },
variant: { control: 'select', options: ['fullWidth', 'inset', 'middle'] },
light: { control: 'boolean' },
},
};
export default meta;
type Story = StoryObj<typeof Divider>;
// ─── Default ────────────────────────────────────────────────────────────────
export const Default: Story = {
decorators: [(Story) => <Box sx={{ width: 400 }}><Story /></Box>],
};
// ─── Variants ───────────────────────────────────────────────────────────────
/** fullWidth, inset, and middle variants */
export const Variants: Story = {
render: () => (
<Box sx={{ width: 400, display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box>
<Box sx={{ fontSize: 12, color: 'text.secondary', mb: 1 }}>fullWidth (default)</Box>
<Divider />
</Box>
<Box>
<Box sx={{ fontSize: 12, color: 'text.secondary', mb: 1 }}>inset</Box>
<Divider variant="inset" />
</Box>
<Box>
<Box sx={{ fontSize: 12, color: 'text.secondary', mb: 1 }}>middle</Box>
<Divider variant="middle" />
</Box>
</Box>
),
};
// ─── Vertical ───────────────────────────────────────────────────────────────
/** Vertical divider inside a flex container */
export const Vertical: Story = {
render: () => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, height: 40 }}>
<Box>Left</Box>
<Divider orientation="vertical" flexItem />
<Box>Right</Box>
</Box>
),
};
// ─── With Text ──────────────────────────────────────────────────────────────
/** Divider with centered text (MUI "textAlign" support) */
export const WithText: Story = {
name: 'With Text',
render: () => (
<Box sx={{ width: 400, display: 'flex', flexDirection: 'column', gap: 3 }}>
<Divider>OR</Divider>
<Divider textAlign="left">Section</Divider>
<Divider textAlign="right">End</Divider>
</Box>
),
};
// ─── In Content ─────────────────────────────────────────────────────────────
/** Dividers separating content sections */
export const InContent: Story = {
name: 'In Content',
render: () => (
<Box sx={{ width: 400, p: 3, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
<Box sx={{ fontWeight: 600, mb: 1 }}>Service Details</Box>
<Box sx={{ fontSize: 14, color: 'text.secondary', mb: 2 }}>
Chapel service with traditional ceremony
</Box>
<Divider />
<Box sx={{ fontWeight: 600, mt: 2, mb: 1 }}>Venue</Box>
<Box sx={{ fontSize: 14, color: 'text.secondary', mb: 2 }}>
West Chapel, Strathfield
</Box>
<Divider />
<Box sx={{ fontWeight: 600, mt: 2, mb: 1 }}>Total</Box>
<Box sx={{ fontSize: 14, color: 'text.primary' }}>$2,400</Box>
</Box>
),
};
// ─── Navigation List ────────────────────────────────────────────────────────
/** Dividers between navigation items (footer pattern) */
export const NavigationList: Story = {
name: 'Navigation List',
render: () => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, height: 20 }}>
<Box sx={{ fontSize: 14, color: 'text.secondary' }}>FAQ</Box>
<Divider orientation="vertical" flexItem />
<Box sx={{ fontSize: 14, color: 'text.secondary' }}>Contact Us</Box>
<Divider orientation="vertical" flexItem />
<Box sx={{ fontSize: 14, color: 'text.secondary' }}>Privacy</Box>
<Divider orientation="vertical" flexItem />
<Box sx={{ fontSize: 14, color: 'text.secondary' }}>Terms</Box>
</Box>
),
};

View File

@@ -0,0 +1,41 @@
import React from 'react';
import MuiDivider from '@mui/material/Divider';
import type { DividerProps as MuiDividerProps } from '@mui/material/Divider';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Props for the FA Divider component */
export interface DividerProps extends MuiDividerProps {}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Visual separator for the FA design system.
*
* Thin line for separating content sections, navigation groups, or
* list items. Wraps MUI Divider with FA border tokens.
*
* Orientations:
* - `horizontal` (default) — full-width horizontal line
* - `vertical` — full-height vertical line (use inside flex containers)
*
* Variants:
* - `fullWidth` (default) — spans the full container
* - `inset` — indented from the left (for list item separators)
* - `middle` — indented from both sides
*
* Usage:
* ```tsx
* <Divider />
* <Divider orientation="vertical" flexItem />
* <Divider variant="inset" />
* ```
*/
export const Divider = React.forwardRef<HTMLHRElement, DividerProps>(
(props, ref) => {
return <MuiDivider ref={ref} {...props} />;
},
);
Divider.displayName = 'Divider';
export default Divider;

View File

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

View File

@@ -0,0 +1,155 @@
import type { Meta, StoryObj } from '@storybook/react';
import { IconButton } from './IconButton';
import Box from '@mui/material/Box';
import CloseIcon from '@mui/icons-material/Close';
import MenuIcon from '@mui/icons-material/Menu';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import SearchIcon from '@mui/icons-material/Search';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
import ShareOutlinedIcon from '@mui/icons-material/ShareOutlined';
const meta: Meta<typeof IconButton> = {
title: 'Atoms/IconButton',
component: IconButton,
tags: ['autodocs'],
argTypes: {
size: { control: 'select', options: ['small', 'medium', 'large'] },
color: { control: 'select', options: ['default', 'primary', 'secondary', 'error'] },
disabled: { control: 'boolean' },
},
};
export default meta;
type Story = StoryObj<typeof IconButton>;
// ─── Default ────────────────────────────────────────────────────────────────
export const Default: Story = {
args: {
'aria-label': 'Close',
children: <CloseIcon />,
},
};
// ─── Sizes ──────────────────────────────────────────────────────────────────
/** Three sizes matching Button height scale */
export const Sizes: Story = {
render: () => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box sx={{ textAlign: 'center' }}>
<IconButton size="small" aria-label="Search">
<SearchIcon />
</IconButton>
<Box sx={{ fontSize: 11, color: 'text.secondary', mt: 0.5 }}>small (32px)</Box>
</Box>
<Box sx={{ textAlign: 'center' }}>
<IconButton size="medium" aria-label="Search">
<SearchIcon />
</IconButton>
<Box sx={{ fontSize: 11, color: 'text.secondary', mt: 0.5 }}>medium (40px)</Box>
</Box>
<Box sx={{ textAlign: 'center' }}>
<IconButton size="large" aria-label="Search">
<SearchIcon />
</IconButton>
<Box sx={{ fontSize: 11, color: 'text.secondary', mt: 0.5 }}>large (48px)</Box>
</Box>
</Box>
),
};
// ─── Colours ────────────────────────────────────────────────────────────────
/** Colour options for different contexts */
export const Colours: Story = {
name: 'Colours',
render: () => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<IconButton color="default" aria-label="Menu">
<MenuIcon />
</IconButton>
<IconButton color="primary" aria-label="Edit">
<EditOutlinedIcon />
</IconButton>
<IconButton color="secondary" aria-label="More options">
<MoreVertIcon />
</IconButton>
<IconButton color="error" aria-label="Delete">
<DeleteOutlineIcon />
</IconButton>
</Box>
),
};
// ─── States ─────────────────────────────────────────────────────────────────
/** Interactive states: default, hover (try it), disabled */
export const States: Story = {
render: () => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box sx={{ textAlign: 'center' }}>
<IconButton aria-label="Default">
<CloseIcon />
</IconButton>
<Box sx={{ fontSize: 11, color: 'text.secondary', mt: 0.5 }}>Default</Box>
</Box>
<Box sx={{ textAlign: 'center' }}>
<IconButton disabled aria-label="Disabled">
<CloseIcon />
</IconButton>
<Box sx={{ fontSize: 11, color: 'text.secondary', mt: 0.5 }}>Disabled</Box>
</Box>
</Box>
),
};
// ─── Common Use Cases ───────────────────────────────────────────────────────
/** Real-world icon button patterns */
export const CommonUseCases: Story = {
name: 'Common Use Cases',
render: () => (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Card actions toolbar */}
<Box>
<Box sx={{ fontSize: 12, color: 'text.secondary', mb: 1 }}>Card action toolbar</Box>
<Box sx={{ display: 'flex', gap: 1, p: 1, border: '1px solid', borderColor: 'divider', borderRadius: 1, width: 'fit-content' }}>
<IconButton size="small" color="primary" aria-label="Favourite">
<FavoriteBorderIcon />
</IconButton>
<IconButton size="small" color="primary" aria-label="Share">
<ShareOutlinedIcon />
</IconButton>
<IconButton size="small" aria-label="More options">
<MoreVertIcon />
</IconButton>
</Box>
</Box>
{/* Dialog close */}
<Box>
<Box sx={{ fontSize: 12, color: 'text.secondary', mb: 1 }}>Dialog close button</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 1, width: 300 }}>
<Box sx={{ fontWeight: 600 }}>Confirm Selection</Box>
<IconButton size="small" aria-label="Close dialog">
<CloseIcon />
</IconButton>
</Box>
</Box>
{/* Navigation header */}
<Box>
<Box sx={{ fontSize: 12, color: 'text.secondary', mb: 1 }}>Mobile navigation toggle</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, p: 1, backgroundColor: 'var(--fa-color-brand-50)', borderRadius: 1, width: 'fit-content' }}>
<IconButton size="large" aria-label="Open menu">
<MenuIcon />
</IconButton>
<Box sx={{ fontWeight: 600 }}>Funeral Arranger</Box>
</Box>
</Box>
</Box>
),
};

View File

@@ -0,0 +1,40 @@
import React from 'react';
import MuiIconButton from '@mui/material/IconButton';
import type { IconButtonProps as MuiIconButtonProps } from '@mui/material/IconButton';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Props for the FA IconButton component */
export interface IconButtonProps extends MuiIconButtonProps {}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Icon-only button for the FA design system.
*
* Square button containing a single icon — used for close buttons, menu
* toggles, toolbar actions, and anywhere a text label would be redundant.
* Wraps MUI IconButton with FA brand tokens and consistent sizing.
*
* Sizes use the same height scale as Button:
* - `small` — 32px (compact toolbars, card actions)
* - `medium` — 40px (default, general actions)
* - `large` — 48px (mobile CTAs, meets 44px touch target)
*
* **Accessibility**: Always provide an `aria-label` prop. Icon-only
* buttons have no visible text, so screen readers rely entirely on
* the aria-label to announce the action.
* ```tsx
* <IconButton aria-label="Close dialog">
* <CloseIcon />
* </IconButton>
* ```
*/
export const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>(
(props, ref) => {
return <MuiIconButton ref={ref} {...props} />;
},
);
IconButton.displayName = 'IconButton';
export default IconButton;

View File

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

View File

@@ -0,0 +1,138 @@
import type { Meta, StoryObj } from '@storybook/react';
import { Link } from './Link';
import Box from '@mui/material/Box';
const meta: Meta<typeof Link> = {
title: 'Atoms/Link',
component: Link,
tags: ['autodocs'],
argTypes: {
underline: { control: 'select', options: ['none', 'hover', 'always'] },
color: { control: 'text' },
},
};
export default meta;
type Story = StoryObj<typeof Link>;
// ─── Default ────────────────────────────────────────────────────────────────
export const Default: Story = {
args: {
href: '#',
children: 'Frequently Asked Questions',
},
};
// ─── Underline Variants ─────────────────────────────────────────────────────
/** Three underline modes */
export const UnderlineVariants: Story = {
name: 'Underline Variants',
render: () => (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Link href="#" underline="hover">Hover (default)</Link>
<Box sx={{ fontSize: 11, color: 'text.secondary' }}>underline="hover"</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Link href="#" underline="always">Always underlined</Link>
<Box sx={{ fontSize: 11, color: 'text.secondary' }}>underline="always"</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Link href="#" underline="none">No underline</Link>
<Box sx={{ fontSize: 11, color: 'text.secondary' }}>underline="none"</Box>
</Box>
</Box>
),
};
// ─── Colour Variants ────────────────────────────────────────────────────────
/** Brand (default) and secondary colour options */
export const ColourVariants: Story = {
name: 'Colour Variants',
render: () => (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box>
<Link href="#">Brand link (default copper, 4.8:1 contrast)</Link>
</Box>
<Box>
<Link href="#" color="text.secondary">Secondary link (neutral grey)</Link>
</Box>
<Box>
<Link href="#" color="text.primary">Primary text link (charcoal)</Link>
</Box>
<Box>
<Link href="#" color="error.main">Error link (red for destructive actions)</Link>
</Box>
</Box>
),
};
// ─── Inline ─────────────────────────────────────────────────────────────────
/** Links inline within body text */
export const Inline: Story = {
render: () => (
<Box sx={{ maxWidth: 500, lineHeight: 1.7 }}>
If you need help planning a funeral, our{' '}
<Link href="#">arrangement guide</Link> walks you through each
step. You can also browse our{' '}
<Link href="#">provider directory</Link> to find local funeral
directors, or <Link href="#">contact us</Link> directly for
personalised assistance.
</Box>
),
};
// ─── Navigation ─────────────────────────────────────────────────────────────
/** Links styled for navigation (no underline) */
export const Navigation: Story = {
render: () => (
<Box sx={{ display: 'flex', gap: 3 }}>
<Link href="#" underline="none" sx={{ fontWeight: 600 }}>FAQ</Link>
<Link href="#" underline="none" sx={{ fontWeight: 600 }}>Contact Us</Link>
<Link href="#" underline="none" sx={{ fontWeight: 600 }}>Log In</Link>
</Box>
),
};
// ─── Footer ─────────────────────────────────────────────────────────────────
/** Footer link pattern — secondary colour, smaller text */
export const FooterLinks: Story = {
name: 'Footer Links',
render: () => (
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<Link href="#" color="text.secondary" variant="body2">Privacy Policy</Link>
<Link href="#" color="text.secondary" variant="body2">Terms of Service</Link>
<Link href="#" color="text.secondary" variant="body2">Accessibility</Link>
<Link href="#" color="text.secondary" variant="body2">Cookie Settings</Link>
</Box>
),
};
// ─── On Different Backgrounds ───────────────────────────────────────────────
/** Links on white vs warm vs grey surfaces */
export const OnDifferentBackgrounds: Story = {
name: 'On Different Backgrounds',
render: () => (
<Box sx={{ display: 'flex', gap: 3 }}>
<Box sx={{ p: 3, backgroundColor: 'background.default', borderRadius: 1, border: '1px solid', borderColor: 'divider' }}>
<Box sx={{ fontSize: 11, color: 'text.secondary', mb: 1 }}>White</Box>
<Link href="#">Learn more</Link>
</Box>
<Box sx={{ p: 3, backgroundColor: 'var(--fa-color-surface-warm)', borderRadius: 1 }}>
<Box sx={{ fontSize: 11, color: 'text.secondary', mb: 1 }}>Warm (brand.50)</Box>
<Link href="#">Learn more</Link>
</Box>
<Box sx={{ p: 3, backgroundColor: 'var(--fa-color-surface-subtle)', borderRadius: 1 }}>
<Box sx={{ fontSize: 11, color: 'text.secondary', mb: 1 }}>Grey (neutral.50)</Box>
<Link href="#">Learn more</Link>
</Box>
</Box>
),
};

View File

@@ -0,0 +1,38 @@
import React from 'react';
import MuiLink from '@mui/material/Link';
import type { LinkProps as MuiLinkProps } from '@mui/material/Link';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Props for the FA Link component */
export interface LinkProps extends MuiLinkProps {}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Navigation text link for the FA design system.
*
* Inline or standalone text link with FA brand styling — copper colour
* (brand.600) for WCAG AA compliance on white backgrounds. Underline
* appears on hover by default.
*
* Wraps MUI Link with FA theme tokens. Uses `color.text.brand`
* (#B0610F, 4.8:1 contrast ratio on white).
*
* Usage:
* ```tsx
* <Link href="/faq">Frequently Asked Questions</Link>
* <Link href="/contact" color="text.secondary">Contact Us</Link>
* ```
*
* For button-styled links, use `Button` with `component="a"` and `href`.
* For navigation menu items, use Link with `underline="none"`.
*/
export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(
(props, ref) => {
return <MuiLink ref={ref} {...props} />;
},
);
Link.displayName = 'Link';
export default Link;

View File

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