From 933dca3f0987280834d5dae89e8abd50a31e4383 Mon Sep 17 00:00:00 2001 From: Richie Date: Wed, 25 Mar 2026 20:16:23 +1100 Subject: [PATCH] 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) --- .../atoms/Divider/Divider.stories.tsx | 113 +++++++++++++ src/components/atoms/Divider/Divider.tsx | 41 +++++ src/components/atoms/Divider/index.ts | 2 + .../atoms/IconButton/IconButton.stories.tsx | 155 ++++++++++++++++++ .../atoms/IconButton/IconButton.tsx | 40 +++++ src/components/atoms/IconButton/index.ts | 2 + src/components/atoms/Link/Link.stories.tsx | 138 ++++++++++++++++ src/components/atoms/Link/Link.tsx | 38 +++++ src/components/atoms/Link/index.ts | 2 + src/theme/index.ts | 62 +++++++ 10 files changed, 593 insertions(+) create mode 100644 src/components/atoms/Divider/Divider.stories.tsx create mode 100644 src/components/atoms/Divider/Divider.tsx create mode 100644 src/components/atoms/Divider/index.ts create mode 100644 src/components/atoms/IconButton/IconButton.stories.tsx create mode 100644 src/components/atoms/IconButton/IconButton.tsx create mode 100644 src/components/atoms/IconButton/index.ts create mode 100644 src/components/atoms/Link/Link.stories.tsx create mode 100644 src/components/atoms/Link/Link.tsx create mode 100644 src/components/atoms/Link/index.ts diff --git a/src/components/atoms/Divider/Divider.stories.tsx b/src/components/atoms/Divider/Divider.stories.tsx new file mode 100644 index 0000000..387f676 --- /dev/null +++ b/src/components/atoms/Divider/Divider.stories.tsx @@ -0,0 +1,113 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Divider } from './Divider'; +import Box from '@mui/material/Box'; + +const meta: Meta = { + 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; + +// ─── Default ──────────────────────────────────────────────────────────────── + +export const Default: Story = { + decorators: [(Story) => ], +}; + +// ─── Variants ─────────────────────────────────────────────────────────────── + +/** fullWidth, inset, and middle variants */ +export const Variants: Story = { + render: () => ( + + + fullWidth (default) + + + + inset + + + + middle + + + + ), +}; + +// ─── Vertical ─────────────────────────────────────────────────────────────── + +/** Vertical divider inside a flex container */ +export const Vertical: Story = { + render: () => ( + + Left + + Right + + ), +}; + +// ─── With Text ────────────────────────────────────────────────────────────── + +/** Divider with centered text (MUI "textAlign" support) */ +export const WithText: Story = { + name: 'With Text', + render: () => ( + + OR + Section + End + + ), +}; + +// ─── In Content ───────────────────────────────────────────────────────────── + +/** Dividers separating content sections */ +export const InContent: Story = { + name: 'In Content', + render: () => ( + + Service Details + + Chapel service with traditional ceremony + + + Venue + + West Chapel, Strathfield + + + Total + $2,400 + + ), +}; + +// ─── Navigation List ──────────────────────────────────────────────────────── + +/** Dividers between navigation items (footer pattern) */ +export const NavigationList: Story = { + name: 'Navigation List', + render: () => ( + + FAQ + + Contact Us + + Privacy + + Terms + + ), +}; diff --git a/src/components/atoms/Divider/Divider.tsx b/src/components/atoms/Divider/Divider.tsx new file mode 100644 index 0000000..874b4a1 --- /dev/null +++ b/src/components/atoms/Divider/Divider.tsx @@ -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 + * + * + * + * ``` + */ +export const Divider = React.forwardRef( + (props, ref) => { + return ; + }, +); + +Divider.displayName = 'Divider'; +export default Divider; diff --git a/src/components/atoms/Divider/index.ts b/src/components/atoms/Divider/index.ts new file mode 100644 index 0000000..73ae9b4 --- /dev/null +++ b/src/components/atoms/Divider/index.ts @@ -0,0 +1,2 @@ +export { Divider, default } from './Divider'; +export type { DividerProps } from './Divider'; diff --git a/src/components/atoms/IconButton/IconButton.stories.tsx b/src/components/atoms/IconButton/IconButton.stories.tsx new file mode 100644 index 0000000..fe3904f --- /dev/null +++ b/src/components/atoms/IconButton/IconButton.stories.tsx @@ -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 = { + 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; + +// ─── Default ──────────────────────────────────────────────────────────────── + +export const Default: Story = { + args: { + 'aria-label': 'Close', + children: , + }, +}; + +// ─── Sizes ────────────────────────────────────────────────────────────────── + +/** Three sizes matching Button height scale */ +export const Sizes: Story = { + render: () => ( + + + + + + small (32px) + + + + + + medium (40px) + + + + + + large (48px) + + + ), +}; + +// ─── Colours ──────────────────────────────────────────────────────────────── + +/** Colour options for different contexts */ +export const Colours: Story = { + name: 'Colours', + render: () => ( + + + + + + + + + + + + + + + ), +}; + +// ─── States ───────────────────────────────────────────────────────────────── + +/** Interactive states: default, hover (try it), disabled */ +export const States: Story = { + render: () => ( + + + + + + Default + + + + + + Disabled + + + ), +}; + +// ─── Common Use Cases ─────────────────────────────────────────────────────── + +/** Real-world icon button patterns */ +export const CommonUseCases: Story = { + name: 'Common Use Cases', + render: () => ( + + {/* Card actions toolbar */} + + Card action toolbar + + + + + + + + + + + + + + {/* Dialog close */} + + Dialog close button + + Confirm Selection + + + + + + + {/* Navigation header */} + + Mobile navigation toggle + + + + + Funeral Arranger + + + + ), +}; diff --git a/src/components/atoms/IconButton/IconButton.tsx b/src/components/atoms/IconButton/IconButton.tsx new file mode 100644 index 0000000..8157b84 --- /dev/null +++ b/src/components/atoms/IconButton/IconButton.tsx @@ -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 + * + * + * + * ``` + */ +export const IconButton = React.forwardRef( + (props, ref) => { + return ; + }, +); + +IconButton.displayName = 'IconButton'; +export default IconButton; diff --git a/src/components/atoms/IconButton/index.ts b/src/components/atoms/IconButton/index.ts new file mode 100644 index 0000000..cba3249 --- /dev/null +++ b/src/components/atoms/IconButton/index.ts @@ -0,0 +1,2 @@ +export { IconButton, default } from './IconButton'; +export type { IconButtonProps } from './IconButton'; diff --git a/src/components/atoms/Link/Link.stories.tsx b/src/components/atoms/Link/Link.stories.tsx new file mode 100644 index 0000000..540f086 --- /dev/null +++ b/src/components/atoms/Link/Link.stories.tsx @@ -0,0 +1,138 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Link } from './Link'; +import Box from '@mui/material/Box'; + +const meta: Meta = { + title: 'Atoms/Link', + component: Link, + tags: ['autodocs'], + argTypes: { + underline: { control: 'select', options: ['none', 'hover', 'always'] }, + color: { control: 'text' }, + }, +}; + +export default meta; +type Story = StoryObj; + +// ─── Default ──────────────────────────────────────────────────────────────── + +export const Default: Story = { + args: { + href: '#', + children: 'Frequently Asked Questions', + }, +}; + +// ─── Underline Variants ───────────────────────────────────────────────────── + +/** Three underline modes */ +export const UnderlineVariants: Story = { + name: 'Underline Variants', + render: () => ( + + + Hover (default) + underline="hover" + + + Always underlined + underline="always" + + + No underline + underline="none" + + + ), +}; + +// ─── Colour Variants ──────────────────────────────────────────────────────── + +/** Brand (default) and secondary colour options */ +export const ColourVariants: Story = { + name: 'Colour Variants', + render: () => ( + + + Brand link (default — copper, 4.8:1 contrast) + + + Secondary link (neutral grey) + + + Primary text link (charcoal) + + + Error link (red — for destructive actions) + + + ), +}; + +// ─── Inline ───────────────────────────────────────────────────────────────── + +/** Links inline within body text */ +export const Inline: Story = { + render: () => ( + + If you need help planning a funeral, our{' '} + arrangement guide walks you through each + step. You can also browse our{' '} + provider directory to find local funeral + directors, or contact us directly for + personalised assistance. + + ), +}; + +// ─── Navigation ───────────────────────────────────────────────────────────── + +/** Links styled for navigation (no underline) */ +export const Navigation: Story = { + render: () => ( + + FAQ + Contact Us + Log In + + ), +}; + +// ─── Footer ───────────────────────────────────────────────────────────────── + +/** Footer link pattern — secondary colour, smaller text */ +export const FooterLinks: Story = { + name: 'Footer Links', + render: () => ( + + Privacy Policy + Terms of Service + Accessibility + Cookie Settings + + ), +}; + +// ─── On Different Backgrounds ─────────────────────────────────────────────── + +/** Links on white vs warm vs grey surfaces */ +export const OnDifferentBackgrounds: Story = { + name: 'On Different Backgrounds', + render: () => ( + + + White + Learn more + + + Warm (brand.50) + Learn more + + + Grey (neutral.50) + Learn more + + + ), +}; diff --git a/src/components/atoms/Link/Link.tsx b/src/components/atoms/Link/Link.tsx new file mode 100644 index 0000000..f9427a3 --- /dev/null +++ b/src/components/atoms/Link/Link.tsx @@ -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 + * Frequently Asked Questions + * Contact Us + * ``` + * + * 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( + (props, ref) => { + return ; + }, +); + +Link.displayName = 'Link'; +export default Link; diff --git a/src/components/atoms/Link/index.ts b/src/components/atoms/Link/index.ts new file mode 100644 index 0000000..a5e9d76 --- /dev/null +++ b/src/components/atoms/Link/index.ts @@ -0,0 +1,2 @@ +export { Link, default } from './Link'; +export type { LinkProps } from './Link'; diff --git a/src/theme/index.ts b/src/theme/index.ts index 131b5fa..3e081e5 100644 --- a/src/theme/index.ts +++ b/src/theme/index.ts @@ -679,6 +679,68 @@ export const theme = createTheme({ }, }, }, + MuiIconButton: { + styleOverrides: { + root: { + borderRadius: parseInt(t.ButtonBorderRadiusDefault, 10), + transition: 'background-color 150ms ease-in-out, box-shadow 150ms ease-in-out', + '&:focus-visible': { + outline: `2px solid ${t.ColorInteractiveFocus}`, + outlineOffset: '2px', + }, + }, + sizeSmall: { + width: parseInt(t.ButtonHeightSm, 10), + height: parseInt(t.ButtonHeightSm, 10), + '& .MuiSvgIcon-root': { fontSize: t.ButtonIconSizeSm }, + }, + sizeMedium: { + width: parseInt(t.ButtonHeightMd, 10), + height: parseInt(t.ButtonHeightMd, 10), + '& .MuiSvgIcon-root': { fontSize: t.ButtonIconSizeMd }, + }, + sizeLarge: { + width: parseInt(t.ButtonHeightLg, 10), + height: parseInt(t.ButtonHeightLg, 10), + '& .MuiSvgIcon-root': { fontSize: t.ButtonIconSizeLg }, + }, + colorPrimary: { + color: t.ColorInteractiveDefault, + '&:hover': { backgroundColor: t.ColorBrand100 }, + }, + colorSecondary: { + color: t.ColorNeutral600, + '&:hover': { backgroundColor: t.ColorNeutral200 }, + }, + }, + }, + MuiDivider: { + styleOverrides: { + root: { + borderColor: t.ColorBorderDefault, + }, + }, + }, + MuiLink: { + defaultProps: { + underline: 'hover', + }, + styleOverrides: { + root: { + color: t.ColorTextBrand, + fontWeight: 500, + transition: 'color 150ms ease-in-out', + '&:hover': { + color: t.ColorInteractiveActive, + }, + '&:focus-visible': { + outline: `2px solid ${t.ColorInteractiveFocus}`, + outlineOffset: '2px', + borderRadius: '2px', + }, + }, + }, + }, MuiRadio: { styleOverrides: { root: {