Add Badge atom component

- Create badge component tokens (height, paddingX, fontSize, iconSize,
  iconGap, borderRadius for sm/md sizes) — 10 tokens
- Build Badge component: pill-shaped status indicator label
  - 6 colours: default, brand, success, warning, error, info
  - 2 variants: soft (tonal, default) and filled (solid)
  - 2 sizes: small (22px) and medium (26px)
  - Optional leading icon prop
  - All colours use CSS variables from token system (no hardcoded hex)
- Create 10 Storybook stories: Default, AllColoursSoft, AllColoursFilled,
  WithIcons, WithIconsFilled, Sizes, SmallSizes, InPriceCard,
  ServiceStatus, CompleteMatrix
- Regenerate token pipeline outputs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 16:05:04 +11:00
parent ab14af9cc6
commit eb1099a4d0
6 changed files with 470 additions and 0 deletions

View File

@@ -0,0 +1,268 @@
import type { Meta, StoryObj } from '@storybook/react';
import { Badge } from './Badge';
import { Card } from '../Card';
import { Typography } from '../Typography';
import { Button } from '../Button';
import Box from '@mui/material/Box';
import StarIcon from '@mui/icons-material/Star';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import LocalOfferIcon from '@mui/icons-material/LocalOffer';
import NewReleasesIcon from '@mui/icons-material/NewReleases';
import VerifiedIcon from '@mui/icons-material/Verified';
const meta: Meta<typeof Badge> = {
title: 'Atoms/Badge',
component: Badge,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
argTypes: {
color: {
control: 'select',
options: ['default', 'brand', 'success', 'warning', 'error', 'info'],
description: 'Colour intent',
table: { defaultValue: { summary: 'default' } },
},
variant: {
control: 'select',
options: ['soft', 'filled'],
description: 'Visual style variant',
table: { defaultValue: { summary: 'soft' } },
},
size: {
control: 'select',
options: ['small', 'medium'],
description: 'Size preset',
table: { defaultValue: { summary: 'medium' } },
},
},
};
export default meta;
type Story = StoryObj<typeof Badge>;
// ─── Default ────────────────────────────────────────────────────────────────
/** Default badge — soft variant, default colour */
export const Default: Story = {
args: {
children: 'Status',
},
};
// ─── All Colours — Soft ─────────────────────────────────────────────────────
/** Soft variant across all colour options */
export const AllColoursSoft: Story = {
name: 'All Colours — Soft',
render: () => (
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
<Badge color="default">Default</Badge>
<Badge color="brand">Brand</Badge>
<Badge color="success">Success</Badge>
<Badge color="warning">Warning</Badge>
<Badge color="error">Error</Badge>
<Badge color="info">Info</Badge>
</div>
),
};
// ─── All Colours — Filled ───────────────────────────────────────────────────
/** Filled variant across all colour options */
export const AllColoursFilled: Story = {
name: 'All Colours — Filled',
render: () => (
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
<Badge variant="filled" color="default">Default</Badge>
<Badge variant="filled" color="brand">Brand</Badge>
<Badge variant="filled" color="success">Success</Badge>
<Badge variant="filled" color="warning">Warning</Badge>
<Badge variant="filled" color="error">Error</Badge>
<Badge variant="filled" color="info">Info</Badge>
</div>
),
};
// ─── With Icons ─────────────────────────────────────────────────────────────
/** Badges with leading icons */
export const WithIcons: Story = {
name: 'With Icons',
render: () => (
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
<Badge color="brand" icon={<StarIcon />}>Popular</Badge>
<Badge color="success" icon={<CheckCircleIcon />}>Verified</Badge>
<Badge color="warning" icon={<WarningAmberIcon />}>Limited</Badge>
<Badge color="error" icon={<ErrorOutlineIcon />}>Sold out</Badge>
<Badge color="info" icon={<InfoOutlinedIcon />}>New</Badge>
</div>
),
};
/** Filled badges with icons */
export const WithIconsFilled: Story = {
name: 'With Icons — Filled',
render: () => (
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
<Badge variant="filled" color="brand" icon={<StarIcon />}>Popular</Badge>
<Badge variant="filled" color="success" icon={<CheckCircleIcon />}>Included</Badge>
<Badge variant="filled" color="warning" icon={<WarningAmberIcon />}>Attention</Badge>
<Badge variant="filled" color="error" icon={<ErrorOutlineIcon />}>Unavailable</Badge>
<Badge variant="filled" color="info" icon={<InfoOutlinedIcon />}>Updated</Badge>
</div>
),
};
// ─── Sizes ──────────────────────────────────────────────────────────────────
/** Both sizes side by side */
export const Sizes: Story = {
render: () => (
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
<Badge size="small" color="brand" icon={<StarIcon />}>Small</Badge>
<Badge size="medium" color="brand" icon={<StarIcon />}>Medium</Badge>
</div>
),
};
/** All colours in small size */
export const SmallSizes: Story = {
name: 'Small — All Colours',
render: () => (
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<Badge size="small" color="default">Default</Badge>
<Badge size="small" color="brand" icon={<StarIcon />}>Brand</Badge>
<Badge size="small" color="success" icon={<CheckCircleIcon />}>Success</Badge>
<Badge size="small" color="warning">Warning</Badge>
<Badge size="small" color="error">Error</Badge>
<Badge size="small" color="info">Info</Badge>
</div>
),
};
// ─── In Context: Price Card ─────────────────────────────────────────────────
/**
* Badge used inside a PriceCard-style layout.
* Shows how badges label card content in realistic compositions.
*/
export const InPriceCard: Story = {
name: 'In Context — Price Card',
render: () => (
<div style={{ display: 'flex', gap: 24, maxWidth: 750 }}>
<Card sx={{ flex: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', mb: 2 }}>
<Typography variant="overline" color="text.secondary">
Essential
</Typography>
<Badge size="small" color="default">Standard</Badge>
</Box>
<Typography variant="display3" color="primary" sx={{ mb: 1 }}>
$3,200
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
A simple, respectful service with chapel ceremony.
</Typography>
<Button fullWidth>Select</Button>
</Card>
<Card sx={{ flex: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', mb: 2 }}>
<Typography variant="overline" color="text.secondary">
Premium
</Typography>
<Badge color="brand" icon={<StarIcon />}>Most popular</Badge>
</Box>
<Typography variant="display3" color="primary" sx={{ mb: 1 }}>
$5,800
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Comprehensive service with premium inclusions.
</Typography>
<Button fullWidth>Select</Button>
</Card>
<Card sx={{ flex: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', mb: 2 }}>
<Typography variant="overline" color="text.secondary">
Bespoke
</Typography>
<Badge color="info" icon={<LocalOfferIcon />}>Best value</Badge>
</Box>
<Typography variant="display3" color="primary" sx={{ mb: 1 }}>
$8,500
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Fully customised farewell with dedicated coordinator.
</Typography>
<Button fullWidth>Select</Button>
</Card>
</div>
),
};
// ─── In Context: Service Status ─────────────────────────────────────────────
/**
* Badges used as status indicators in a service listing.
*/
export const ServiceStatus: Story = {
name: 'In Context — Service Status',
render: () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 500 }}>
{[
{ service: 'Chapel ceremony', badge: <Badge color="success" icon={<CheckCircleIcon />}>Confirmed</Badge> },
{ service: 'Floral arrangements', badge: <Badge color="warning" icon={<WarningAmberIcon />}>Pending</Badge> },
{ service: 'Catering', badge: <Badge color="error" icon={<ErrorOutlineIcon />}>Unavailable</Badge> },
{ service: 'Memorial printing', badge: <Badge color="info" icon={<NewReleasesIcon />}>New option</Badge> },
{ service: 'Premium casket', badge: <Badge variant="filled" color="brand" icon={<VerifiedIcon />}>Included</Badge> },
].map((item) => (
<Card key={item.service} variant="outlined" padding="compact">
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="labelLg">{item.service}</Typography>
{item.badge}
</Box>
</Card>
))}
</div>
),
};
// ─── Complete Matrix ────────────────────────────────────────────────────────
/** Full variant × colour × size matrix for visual QA */
export const CompleteMatrix: Story = {
name: 'Complete Matrix',
render: () => {
const colors = ['default', 'brand', 'success', 'warning', 'error', 'info'] as const;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
{(['soft', 'filled'] as const).map((variant) => (
<div key={variant}>
<div style={{ marginBottom: 8, fontWeight: 600, fontSize: 14, textTransform: 'capitalize' }}>
{variant}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{(['medium', 'small'] as const).map((size) => (
<div key={size} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{ width: 60, fontSize: 12, color: '#737373' }}>{size}</span>
{colors.map((color) => (
<Badge key={color} variant={variant} color={color} size={size} icon={<StarIcon />}>
{color}
</Badge>
))}
</div>
))}
</div>
</div>
))}
</div>
);
},
};

View File

@@ -0,0 +1,141 @@
import React from 'react';
import Box from '@mui/material/Box';
import type { BoxProps } from '@mui/material/Box';
import type { Theme } from '@mui/material/styles';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Colour intent for the badge */
export type BadgeColor = 'default' | 'brand' | 'success' | 'warning' | 'error' | 'info';
/** Props for the FA Badge component */
export interface BadgeProps extends Omit<BoxProps, 'color'> {
/** Colour intent */
color?: BadgeColor;
/** Visual style: "filled" (solid background) or "soft" (tonal/subtle background) */
variant?: 'filled' | 'soft';
/** Size preset */
size?: 'small' | 'medium';
/** Optional leading icon */
icon?: React.ReactNode;
/** Label text */
children: React.ReactNode;
}
// ─── Colour maps ─────────────────────────────────────────────────────────────
const filledColors: Record<BadgeColor, (t: Theme) => { bg: string; text: string }> = {
default: (t) => ({ bg: t.palette.grey[700], text: t.palette.common.white }),
brand: (t) => ({ bg: t.palette.primary.main, text: t.palette.common.white }),
success: (t) => ({ bg: t.palette.success.main, text: t.palette.common.white }),
warning: (t) => ({ bg: t.palette.warning.main, text: t.palette.common.white }),
error: (t) => ({ bg: t.palette.error.main, text: t.palette.common.white }),
info: (t) => ({ bg: t.palette.info.main, text: t.palette.common.white }),
};
const softColors: Record<BadgeColor, (t: Theme) => { bg: string; text: string }> = {
default: (t) => ({ bg: t.palette.grey[200], text: t.palette.grey[700] }),
brand: () => ({
bg: 'var(--fa-color-brand-200)',
text: 'var(--fa-color-brand-700)',
}),
success: () => ({
bg: 'var(--fa-color-feedback-success-subtle)',
text: 'var(--fa-color-feedback-success)',
}),
warning: () => ({
bg: 'var(--fa-color-feedback-warning-subtle)',
text: 'var(--fa-color-text-warning)',
}),
error: () => ({
bg: 'var(--fa-color-feedback-error-subtle)',
text: 'var(--fa-color-feedback-error)',
}),
info: () => ({
bg: 'var(--fa-color-feedback-info-subtle)',
text: 'var(--fa-color-feedback-info)',
}),
};
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Status indicator label for the FA design system.
*
* Pill-shaped, display-only badge for communicating status, category,
* or emphasis. Used in PriceCard ("Popular"), ServiceOption ("Included"),
* and other contexts.
*
* Colour options:
* - `default` — neutral grey (general labels)
* - `brand` — warm gold/copper (promoted, featured)
* - `success` — green (confirmed, included, available)
* - `warning` — amber (limited, expiring, attention)
* - `error` — red (sold out, unavailable, urgent)
* - `info` — blue (new, updated, informational)
*
* Variant options:
* - `soft` (default) — tonal background, coloured text. Calmer, preferred for FA.
* - `filled` — solid background, white text. For high-priority emphasis.
*/
export const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
(
{
color = 'default',
variant = 'soft',
size = 'medium',
icon,
children,
sx,
...props
},
ref,
) => {
const isSmall = size === 'small';
return (
<Box
ref={ref}
component="span"
sx={[
(theme: Theme) => {
const colors = variant === 'filled'
? filledColors[color](theme)
: softColors[color](theme);
return {
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--fa-badge-icon-gap-default)',
minHeight: isSmall ? 'var(--fa-badge-height-sm)' : 'var(--fa-badge-height-md)',
px: isSmall ? 'var(--fa-badge-padding-x-sm)' : 'var(--fa-badge-padding-x-md)',
borderRadius: 'var(--fa-badge-border-radius-default)',
backgroundColor: colors.bg,
color: colors.text,
fontSize: isSmall ? 'var(--fa-badge-font-size-sm)' : 'var(--fa-badge-font-size-md)',
fontWeight: 600,
fontFamily: theme.typography.fontFamily,
lineHeight: 1,
letterSpacing: '0.02em',
whiteSpace: 'nowrap',
userSelect: 'none',
// Icon sizing
'& > .MuiSvgIcon-root, & > svg': {
fontSize: isSmall ? 'var(--fa-badge-icon-size-sm)' : 'var(--fa-badge-icon-size-md)',
flexShrink: 0,
},
};
},
...(Array.isArray(sx) ? sx : [sx]),
]}
{...props}
>
{icon}
{children}
</Box>
);
},
);
Badge.displayName = 'Badge';
export default Badge;

View File

@@ -0,0 +1,2 @@
export { Badge, type BadgeProps, type BadgeColor } from './Badge';
export { default } from './Badge';

View File

@@ -3,6 +3,10 @@
*/
:root {
--fa-badge-height-sm: 22px; /** Small — compact inline status indicators */
--fa-badge-height-md: 26px; /** Medium — default status badges, card labels */
--fa-badge-icon-size-sm: 12px; /** 12px icons in small badges */
--fa-badge-icon-size-md: 14px; /** 14px icons in medium badges */
--fa-button-height-xs: 28px; /** Extra-small — compact text buttons, inline actions */
--fa-button-height-sm: 32px; /** Small — secondary actions, toolbar buttons */
--fa-button-height-md: 40px; /** Medium — default size, form submissions */
@@ -206,6 +210,12 @@
--fa-typography-overline-letter-spacing: 1.5px;
--fa-typography-overline-sm-line-height: 1.273;
--fa-typography-overline-sm-letter-spacing: 1.5px;
--fa-badge-padding-x-sm: var(--fa-spacing-2); /** 8px — compact horizontal padding */
--fa-badge-padding-x-md: var(--fa-spacing-3); /** 12px — default horizontal padding */
--fa-badge-font-size-sm: var(--fa-font-size-2xs); /** 11px — small badge text */
--fa-badge-font-size-md: var(--fa-font-size-xs); /** 12px — default badge text */
--fa-badge-icon-gap-default: var(--fa-spacing-1); /** 4px icon-text gap */
--fa-badge-border-radius-default: var(--fa-border-radius-full); /** Pill shape — fully rounded */
--fa-button-padding-x-xs: var(--fa-spacing-2); /** 8px — compact horizontal padding */
--fa-button-padding-x-sm: var(--fa-spacing-3); /** 12px — small horizontal padding */
--fa-button-padding-x-md: var(--fa-spacing-4); /** 16px — default horizontal padding */

View File

@@ -2,6 +2,16 @@
* Do not edit directly, this file was auto-generated.
*/
export const BadgeHeightSm = "22px"; // Small — compact inline status indicators
export const BadgeHeightMd = "26px"; // Medium — default status badges, card labels
export const BadgePaddingXSm = "8px"; // 8px — compact horizontal padding
export const BadgePaddingXMd = "12px"; // 12px — default horizontal padding
export const BadgeFontSizeSm = "0.6875rem"; // 11px — small badge text
export const BadgeFontSizeMd = "0.75rem"; // 12px — default badge text
export const BadgeIconSizeSm = "12px"; // 12px icons in small badges
export const BadgeIconSizeMd = "14px"; // 14px icons in medium badges
export const BadgeIconGapDefault = "4px"; // 4px icon-text gap
export const BadgeBorderRadiusDefault = "9999px"; // Pill shape — fully rounded
export const ButtonHeightXs = "28px"; // Extra-small — compact text buttons, inline actions
export const ButtonHeightSm = "32px"; // Small — secondary actions, toolbar buttons
export const ButtonHeightMd = "40px"; // Medium — default size, form submissions