From 7169a6559bf48f41c25ec5f0be30fcb26bbfd534 Mon Sep 17 00:00:00 2001 From: Richie Date: Wed, 25 Mar 2026 15:31:10 +1100 Subject: [PATCH] Add Card atom component - Create card component tokens (borderRadius, padding, shadow, border, background) - Build Card component with elevated/outlined variants, interactive hover, padding presets - Add MUI theme overrides using card tokens (shadow.md resting, border for outlined) - Create 8 Storybook stories: Default, Variants, Interactive, PaddingPresets, PriceCardPreview, ServiceOptionPreview, WithImage, RichContent - Regenerate token pipeline outputs (7 new card tokens) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/atoms/Card/Card.stories.tsx | 309 +++++++++++++++++++++ src/components/atoms/Card/Card.tsx | 98 +++++++ src/components/atoms/Card/index.ts | 2 + src/theme/generated/tokens.css | 7 + src/theme/generated/tokens.js | 7 + src/theme/index.ts | 14 +- tokens/component/card.json | 31 +++ 7 files changed, 467 insertions(+), 1 deletion(-) create mode 100644 src/components/atoms/Card/Card.stories.tsx create mode 100644 src/components/atoms/Card/Card.tsx create mode 100644 src/components/atoms/Card/index.ts create mode 100644 tokens/component/card.json diff --git a/src/components/atoms/Card/Card.stories.tsx b/src/components/atoms/Card/Card.stories.tsx new file mode 100644 index 0000000..e86cc0d --- /dev/null +++ b/src/components/atoms/Card/Card.stories.tsx @@ -0,0 +1,309 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Card } from './Card'; +import { Typography } from '../Typography'; +import { Button } from '../Button'; +import Box from '@mui/material/Box'; + +const meta: Meta = { + title: 'Atoms/Card', + component: Card, + tags: ['autodocs'], + parameters: { + layout: 'centered', + }, + argTypes: { + variant: { + control: 'select', + options: ['elevated', 'outlined'], + description: 'Visual style variant', + table: { defaultValue: { summary: 'elevated' } }, + }, + interactive: { + control: 'boolean', + description: 'Adds hover shadow lift and pointer cursor', + table: { defaultValue: { summary: 'false' } }, + }, + padding: { + control: 'select', + options: ['default', 'compact', 'none'], + description: 'Padding preset', + table: { defaultValue: { summary: 'default' } }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +// ─── Default ──────────────────────────────────────────────────────────────── + +/** Default card — elevated with standard padding */ +export const Default: Story = { + args: { + children: ( + <> + + Funeral package + + + A comprehensive service including chapel ceremony, transport, and + preparation. Suitable for families seeking a traditional farewell. + + + ), + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +// ─── Variants ─────────────────────────────────────────────────────────────── + +/** Both visual variants side by side */ +export const Variants: Story = { + render: () => ( +
+ + + Elevated + + + Uses shadow for depth. Default variant for most content cards. + + + + + Outlined + + + Uses a subtle border. Good for less prominent or grouped content. + + +
+ ), +}; + +// ─── Interactive ──────────────────────────────────────────────────────────── + +/** Interactive cards with hover elevation (click/hover to see effect) */ +export const Interactive: Story = { + render: () => ( +
+ alert('Card clicked')} + > + + Elevated + Interactive + + + Hover to see the shadow lift. Click to trigger action. + + + alert('Card clicked')} + > + + Outlined + Interactive + + + Outlined cards can also be interactive with hover effects. + + +
+ ), +}; + +// ─── Padding Presets ──────────────────────────────────────────────────────── + +/** All three padding options */ +export const PaddingPresets: Story = { + name: 'Padding Presets', + render: () => ( +
+ + + Default (24px) + + + Standard spacing for desktop cards. + + + + + Compact (16px) + + + Tighter spacing for mobile or dense layouts. + + + + + + None (manual) + + + Full control — add your own padding. + + + +
+ ), +}; + +// ─── Price Card Preview ───────────────────────────────────────────────────── + +/** + * Preview of how Card will be used in the PriceCard molecule. + * Demonstrates realistic content composition with FA typography and brand colours. + */ +export const PriceCardPreview: Story = { + name: 'Price Card Preview', + render: () => ( +
+ + + Essential + + + $3,200 + + + A respectful and simple service with chapel ceremony, transport, and + professional preparation. + + + +
+ ), +}; + +// ─── Service Option Preview ───────────────────────────────────────────────── + +/** + * Preview of how Card will be used in the ServiceOption molecule. + * Shows a selectable option card pattern. + */ +export const ServiceOptionPreview: Story = { + name: 'Service Option Preview', + render: () => ( +
+ {[ + { title: 'Chapel service', desc: 'Traditional ceremony in our chapel' }, + { title: 'Graveside service', desc: 'Intimate outdoor farewell' }, + { title: 'Memorial service', desc: 'Celebration of life gathering' }, + ].map((option) => ( + + + {option.title} + + + {option.desc} + + + ))} +
+ ), +}; + +// ─── With Image ───────────────────────────────────────────────────────────── + +/** Card with full-bleed image using padding="none" */ +export const WithImage: Story = { + name: 'With Image (No Padding)', + render: () => ( +
+ + + + Image placeholder + + + + + Parsons Chapel + + + Our heritage-listed chapel seats up to 120 guests and features + modern audio-visual facilities. + + + +
+ ), +}; + +// ─── Nested Content ───────────────────────────────────────────────────────── + +/** Card with rich nested content to verify layout flexibility */ +export const RichContent: Story = { + name: 'Rich Content', + render: () => ( +
+ + + Package details + + + Premium farewell + + +
  • + Chapel ceremony (up to 120 guests) +
  • +
  • + Premium timber casket +
  • +
  • + Transport within 50km +
  • +
  • + Professional preparation +
  • +
    + + + + +
    +
    + ), +}; diff --git a/src/components/atoms/Card/Card.tsx b/src/components/atoms/Card/Card.tsx new file mode 100644 index 0000000..536f531 --- /dev/null +++ b/src/components/atoms/Card/Card.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import MuiCard from '@mui/material/Card'; +import type { CardProps as MuiCardProps } from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +/** Props for the FA Card component */ +export interface CardProps extends Omit { + /** Visual style: "elevated" uses shadow, "outlined" uses border */ + variant?: 'elevated' | 'outlined'; + /** Adds hover shadow lift and pointer cursor for clickable cards */ + interactive?: boolean; + /** Padding preset: "default" (24px), "compact" (16px), "none" (no wrapper) */ + padding?: 'default' | 'compact' | 'none'; + /** Content to render inside the card */ + children?: React.ReactNode; +} + +// ─── Component ─────────────────────────────────────────────────────────────── + +/** + * Content container for the FA design system. + * + * Wraps MUI Card with FA brand tokens, two visual variants (elevated/outlined), + * optional hover interactivity, and padding presets. + * + * Variant mapping from design: + * - `elevated` (default) — shadow.md resting, white background + * - `outlined` — neutral border, no shadow, white background + * + * Use `interactive` for clickable cards (PriceCard, ServiceOption) — + * adds shadow.lg hover lift and cursor pointer. + * + * Use `padding="none"` when composing with CardMedia or custom layouts + * that need full-bleed content. + */ +export const Card = React.forwardRef( + ( + { + variant = 'elevated', + interactive = false, + padding = 'default', + children, + sx, + ...props + }, + ref, + ) => { + // Map FA variant names to MUI Card variant + const muiVariant = variant === 'outlined' ? 'outlined' : undefined; + + return ( + ) => + `2px solid ${theme.palette.primary.main}`, + outlineOffset: '2px', + }, + }, + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...props} + > + {padding !== 'none' ? ( + + {children} + + ) : ( + children + )} + + ); + }, +); + +Card.displayName = 'Card'; +export default Card; diff --git a/src/components/atoms/Card/index.ts b/src/components/atoms/Card/index.ts new file mode 100644 index 0000000..4b3054b --- /dev/null +++ b/src/components/atoms/Card/index.ts @@ -0,0 +1,2 @@ +export { Card, type CardProps } from './Card'; +export { default } from './Card'; diff --git a/src/theme/generated/tokens.css b/src/theme/generated/tokens.css index 981775d..32d13e0 100644 --- a/src/theme/generated/tokens.css +++ b/src/theme/generated/tokens.css @@ -223,6 +223,11 @@ --fa-button-icon-gap-md: var(--fa-spacing-2); /** 8px icon-text gap */ --fa-button-icon-gap-lg: var(--fa-spacing-2); /** 8px icon-text gap */ --fa-button-border-radius-default: var(--fa-border-radius-md); /** 8px — standard button rounding */ + --fa-card-border-radius-default: var(--fa-border-radius-md); /** 8px — standard card rounding */ + --fa-card-padding-default: var(--fa-spacing-6); /** 24px — standard card padding (desktop) */ + --fa-card-padding-compact: var(--fa-spacing-4); /** 16px — compact card padding (mobile, tight layouts) */ + --fa-card-shadow-default: var(--fa-shadow-md); /** Medium shadow — resting elevated card */ + --fa-card-shadow-hover: var(--fa-shadow-lg); /** High shadow — interactive card on hover */ --fa-input-padding-x-default: var(--fa-spacing-3); /** 12px — inner horizontal padding matching Figma design */ --fa-input-padding-y-sm: var(--fa-spacing-2); /** 8px — compact vertical padding for small size */ --fa-input-padding-y-md: var(--fa-spacing-3); /** 12px — standard vertical padding for medium size */ @@ -351,4 +356,6 @@ --fa-typography-overline-sm-font-family: var(--fa-font-family-body); --fa-typography-overline-sm-font-size: var(--fa-font-size-2xs); /** 11px — accessibility floor */ --fa-typography-overline-sm-font-weight: var(--fa-font-weight-semibold); + --fa-card-border-default: var(--fa-color-border-default); /** Neutral border for outlined cards */ + --fa-card-background-default: var(--fa-color-surface-raised); /** White — standard card background (raised surface) */ } diff --git a/src/theme/generated/tokens.js b/src/theme/generated/tokens.js index 5c1d97e..240b845 100644 --- a/src/theme/generated/tokens.js +++ b/src/theme/generated/tokens.js @@ -27,6 +27,13 @@ export const ButtonIconGapSm = "4px"; // 4px icon-text gap export const ButtonIconGapMd = "8px"; // 8px icon-text gap export const ButtonIconGapLg = "8px"; // 8px icon-text gap export const ButtonBorderRadiusDefault = "8px"; // 8px — standard button rounding +export const CardBorderRadiusDefault = "8px"; // 8px — standard card rounding +export const CardPaddingDefault = "24px"; // 24px — standard card padding (desktop) +export const CardPaddingCompact = "16px"; // 16px — compact card padding (mobile, tight layouts) +export const CardShadowDefault = "0 4px 6px rgba(0,0,0,0.07)"; // Medium shadow — resting elevated card +export const CardShadowHover = "0 10px 15px rgba(0,0,0,0.1)"; // High shadow — interactive card on hover +export const CardBorderDefault = "#e8e8e8"; // Neutral border for outlined cards +export const CardBackgroundDefault = "#ffffff"; // White — standard card background (raised surface) export const InputHeightSm = "40px"; // Small — compact forms, admin layouts, matches Button medium height export const InputHeightMd = "48px"; // Medium (default) — standard forms, matches Button large for alignment export const InputPaddingXDefault = "12px"; // 12px — inner horizontal padding matching Figma design diff --git a/src/theme/index.ts b/src/theme/index.ts index 83a59db..5317abd 100644 --- a/src/theme/index.ts +++ b/src/theme/index.ts @@ -476,9 +476,21 @@ export const theme = createTheme({ MuiCard: { styleOverrides: { root: { - borderRadius: parseInt(t.BorderRadiusMd, 10), + borderRadius: parseInt(t.CardBorderRadiusDefault, 10), + backgroundColor: t.CardBackgroundDefault, + boxShadow: t.CardShadowDefault, + transition: 'box-shadow 150ms ease-in-out', }, }, + variants: [ + { + props: { variant: 'outlined' }, + style: { + boxShadow: 'none', + borderColor: t.CardBorderDefault, + }, + }, + ], }, MuiOutlinedInput: { styleOverrides: { diff --git a/tokens/component/card.json b/tokens/component/card.json new file mode 100644 index 0000000..f105b11 --- /dev/null +++ b/tokens/component/card.json @@ -0,0 +1,31 @@ +{ + "card": { + "$description": "Card component tokens — content container with elevation or border. Used by PriceCard, ServiceOption, and other molecule/organism compositions.", + "borderRadius": { + "$type": "dimension", + "$description": "Card corner radius.", + "default": { "$value": "{borderRadius.md}", "$description": "8px — standard card rounding" } + }, + "padding": { + "$type": "dimension", + "$description": "Internal padding for card content area.", + "default": { "$value": "{spacing.6}", "$description": "24px — standard card padding (desktop)" }, + "compact": { "$value": "{spacing.4}", "$description": "16px — compact card padding (mobile, tight layouts)" } + }, + "shadow": { + "$description": "Elevation shadows for card variants.", + "default": { "$value": "{shadow.md}", "$description": "Medium shadow — resting elevated card" }, + "hover": { "$value": "{shadow.lg}", "$description": "High shadow — interactive card on hover" } + }, + "border": { + "$type": "color", + "$description": "Border colours for the outlined card variant.", + "default": { "$value": "{color.border.default}", "$description": "Neutral border for outlined cards" } + }, + "background": { + "$type": "color", + "$description": "Card background colours.", + "default": { "$value": "{color.surface.raised}", "$description": "White — standard card background (raised surface)" } + } + } +}