From eb1099a4d01aad1819d3c078cd9adb4b0c2f89a2 Mon Sep 17 00:00:00 2001 From: Richie Date: Wed, 25 Mar 2026 16:05:04 +1100 Subject: [PATCH] Add Badge atom component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/components/atoms/Badge/Badge.stories.tsx | 268 +++++++++++++++++++ src/components/atoms/Badge/Badge.tsx | 141 ++++++++++ src/components/atoms/Badge/index.ts | 2 + src/theme/generated/tokens.css | 10 + src/theme/generated/tokens.js | 10 + tokens/component/badge.json | 39 +++ 6 files changed, 470 insertions(+) create mode 100644 src/components/atoms/Badge/Badge.stories.tsx create mode 100644 src/components/atoms/Badge/Badge.tsx create mode 100644 src/components/atoms/Badge/index.ts create mode 100644 tokens/component/badge.json diff --git a/src/components/atoms/Badge/Badge.stories.tsx b/src/components/atoms/Badge/Badge.stories.tsx new file mode 100644 index 0000000..a3be7cd --- /dev/null +++ b/src/components/atoms/Badge/Badge.stories.tsx @@ -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 = { + 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; + +// ─── 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: () => ( +
+ Default + Brand + Success + Warning + Error + Info +
+ ), +}; + +// ─── All Colours — Filled ─────────────────────────────────────────────────── + +/** Filled variant across all colour options */ +export const AllColoursFilled: Story = { + name: 'All Colours — Filled', + render: () => ( +
+ Default + Brand + Success + Warning + Error + Info +
+ ), +}; + +// ─── With Icons ───────────────────────────────────────────────────────────── + +/** Badges with leading icons */ +export const WithIcons: Story = { + name: 'With Icons', + render: () => ( +
+ }>Popular + }>Verified + }>Limited + }>Sold out + }>New +
+ ), +}; + +/** Filled badges with icons */ +export const WithIconsFilled: Story = { + name: 'With Icons — Filled', + render: () => ( +
+ }>Popular + }>Included + }>Attention + }>Unavailable + }>Updated +
+ ), +}; + +// ─── Sizes ────────────────────────────────────────────────────────────────── + +/** Both sizes side by side */ +export const Sizes: Story = { + render: () => ( +
+ }>Small + }>Medium +
+ ), +}; + +/** All colours in small size */ +export const SmallSizes: Story = { + name: 'Small — All Colours', + render: () => ( +
+ Default + }>Brand + }>Success + Warning + Error + Info +
+ ), +}; + +// ─── 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: () => ( +
+ + + + Essential + + Standard + + + $3,200 + + + A simple, respectful service with chapel ceremony. + + + + + + + + Premium + + }>Most popular + + + $5,800 + + + Comprehensive service with premium inclusions. + + + + + + + + Bespoke + + }>Best value + + + $8,500 + + + Fully customised farewell with dedicated coordinator. + + + +
+ ), +}; + +// ─── In Context: Service Status ───────────────────────────────────────────── + +/** + * Badges used as status indicators in a service listing. + */ +export const ServiceStatus: Story = { + name: 'In Context — Service Status', + render: () => ( +
+ {[ + { service: 'Chapel ceremony', badge: }>Confirmed }, + { service: 'Floral arrangements', badge: }>Pending }, + { service: 'Catering', badge: }>Unavailable }, + { service: 'Memorial printing', badge: }>New option }, + { service: 'Premium casket', badge: }>Included }, + ].map((item) => ( + + + {item.service} + {item.badge} + + + ))} +
+ ), +}; + +// ─── 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 ( +
+ {(['soft', 'filled'] as const).map((variant) => ( +
+
+ {variant} +
+
+ {(['medium', 'small'] as const).map((size) => ( +
+ {size} + {colors.map((color) => ( + }> + {color} + + ))} +
+ ))} +
+
+ ))} +
+ ); + }, +}; diff --git a/src/components/atoms/Badge/Badge.tsx b/src/components/atoms/Badge/Badge.tsx new file mode 100644 index 0000000..ecc1772 --- /dev/null +++ b/src/components/atoms/Badge/Badge.tsx @@ -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 { + /** 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 { 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 { 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( + ( + { + color = 'default', + variant = 'soft', + size = 'medium', + icon, + children, + sx, + ...props + }, + ref, + ) => { + const isSmall = size === 'small'; + + return ( + { + 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} + + ); + }, +); + +Badge.displayName = 'Badge'; +export default Badge; diff --git a/src/components/atoms/Badge/index.ts b/src/components/atoms/Badge/index.ts new file mode 100644 index 0000000..845bcf9 --- /dev/null +++ b/src/components/atoms/Badge/index.ts @@ -0,0 +1,2 @@ +export { Badge, type BadgeProps, type BadgeColor } from './Badge'; +export { default } from './Badge'; diff --git a/src/theme/generated/tokens.css b/src/theme/generated/tokens.css index 6bacfa3..eec0f4a 100644 --- a/src/theme/generated/tokens.css +++ b/src/theme/generated/tokens.css @@ -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 */ diff --git a/src/theme/generated/tokens.js b/src/theme/generated/tokens.js index 6af9551..66f0b5f 100644 --- a/src/theme/generated/tokens.js +++ b/src/theme/generated/tokens.js @@ -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 diff --git a/tokens/component/badge.json b/tokens/component/badge.json new file mode 100644 index 0000000..9f1aa8f --- /dev/null +++ b/tokens/component/badge.json @@ -0,0 +1,39 @@ +{ + "badge": { + "$description": "Badge component tokens — status indicator labels used in PriceCard, ServiceOption, and other contexts. Display-only, not interactive.", + "height": { + "$type": "dimension", + "$description": "Badge heights per size.", + "sm": { "$value": "22px", "$description": "Small — compact inline status indicators" }, + "md": { "$value": "26px", "$description": "Medium — default status badges, card labels" } + }, + "paddingX": { + "$type": "dimension", + "$description": "Horizontal padding per size.", + "sm": { "$value": "{spacing.2}", "$description": "8px — compact horizontal padding" }, + "md": { "$value": "{spacing.3}", "$description": "12px — default horizontal padding" } + }, + "fontSize": { + "$type": "dimension", + "$description": "Font size per badge size.", + "sm": { "$value": "{fontSize.2xs}", "$description": "11px — small badge text" }, + "md": { "$value": "{fontSize.xs}", "$description": "12px — default badge text" } + }, + "iconSize": { + "$type": "dimension", + "$description": "Icon dimensions per badge size.", + "sm": { "$value": "12px", "$description": "12px icons in small badges" }, + "md": { "$value": "14px", "$description": "14px icons in medium badges" } + }, + "iconGap": { + "$type": "dimension", + "$description": "Gap between icon and label text.", + "default": { "$value": "{spacing.1}", "$description": "4px icon-text gap" } + }, + "borderRadius": { + "$type": "dimension", + "$description": "Badge corner radius.", + "default": { "$value": "{borderRadius.full}", "$description": "Pill shape — fully rounded" } + } + } +}