diff --git a/src/components/atoms/Card/Card.stories.tsx b/src/components/atoms/Card/Card.stories.tsx index e86cc0d..c8873bf 100644 --- a/src/components/atoms/Card/Card.stories.tsx +++ b/src/components/atoms/Card/Card.stories.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import { Card } from './Card'; import { Typography } from '../Typography'; @@ -20,7 +21,12 @@ const meta: Meta = { }, interactive: { control: 'boolean', - description: 'Adds hover shadow lift and pointer cursor', + description: 'Adds hover background fill, shadow lift, and pointer cursor', + table: { defaultValue: { summary: 'false' } }, + }, + selected: { + control: 'boolean', + description: 'Highlights the card as selected — brand border + warm background', table: { defaultValue: { summary: 'false' } }, }, padding: { @@ -89,7 +95,7 @@ export const Variants: Story = { // ─── Interactive ──────────────────────────────────────────────────────────── -/** Interactive cards with hover elevation (click/hover to see effect) */ +/** Interactive cards with hover background fill and shadow lift */ export const Interactive: Story = { render: () => (
@@ -103,7 +109,7 @@ export const Interactive: Story = { Elevated + Interactive - Hover to see the shadow lift. Click to trigger action. + Hover to see the background fill and shadow lift. - Outlined cards can also be interactive with hover effects. + Outlined cards get a subtle background fill on hover.
), }; +// ─── Selected ─────────────────────────────────────────────────────────────── + +/** Selected state — brand border + warm background tint */ +export const Selected: Story = { + render: () => ( +
+ + + Not selected + + + Standard outlined card in its resting state. + + + + + Selected + + + Brand border and warm background tint show this is the active choice. + + +
+ ), +}; + +// ─── Option Select Pattern ────────────────────────────────────────────────── + +/** + * Interactive option selection matching the FA 1.0 "ListItemPurchaseOption" pattern. + * Click a card to select it. Matches the Figma states: + * inactive → hover (bg fill) → active (brand border + warm bg). + */ +export const OptionSelect: Story = { + name: 'Option Select', + render: function OptionSelectDemo() { + const [selectedId, setSelectedId] = useState('chapel'); + + const options = [ + { + id: 'chapel', + title: 'Chapel service', + desc: 'Traditional ceremony in our heritage-listed chapel, seating up to 120 guests.', + }, + { + id: 'graveside', + title: 'Graveside service', + desc: 'An intimate outdoor farewell at the final resting place.', + }, + { + id: 'memorial', + title: 'Memorial service', + desc: 'A celebration of life gathering at a venue of your choosing.', + }, + ]; + + return ( +
+ + Choose your service type + + {options.map((option) => ( + setSelectedId(option.id)} + role="radio" + aria-checked={selectedId === option.id} + > + + {option.title} + + + {option.desc} + + + ))} +
+ ); + }, +}; + +// ─── Multi-Select Pattern ─────────────────────────────────────────────────── + +/** + * Multi-select variant — click to toggle multiple cards. + * Useful for add-on services, package inclusions, etc. + */ +export const MultiSelect: Story = { + name: 'Multi-Select', + render: function MultiSelectDemo() { + const [selected, setSelected] = useState>(new Set(['flowers'])); + + const addOns = [ + { id: 'flowers', title: 'Floral arrangements', desc: 'Custom flowers for the service' }, + { id: 'catering', title: 'Catering', desc: 'Light refreshments after the service' }, + { id: 'music', title: 'Live musician', desc: 'Solo musician for the ceremony' }, + { id: 'printing', title: 'Memorial printing', desc: 'Order of service booklets' }, + ]; + + const toggle = (id: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + return ( +
+ + Select add-ons + + + Choose as many as you like + +
+ {addOns.map((item) => ( + toggle(item.id)} + role="checkbox" + aria-checked={selected.has(item.id)} + > + + {item.title} + + + {item.desc} + + + ))} +
+
+ ); + }, +}; + +// ─── On Different Backgrounds ─────────────────────────────────────────────── + +/** + * Demonstrates how cards adapt to different surface colours. + * Elevated cards stand out via shadow on any surface. + * Outlined cards use borders on white, contrast on grey. + */ +export const OnDifferentBackgrounds: Story = { + name: 'On Different Backgrounds', + render: () => ( +
+ {/* White background */} +
+ + On white surface + +
+ + Elevated + Shadow defines edges + + + Outlined + Border defines edges + +
+
+ {/* Grey background */} +
+ + On grey surface + +
+ + Elevated + White card + shadow on grey + + + Outlined + Contrast + border on grey + +
+
+
+ ), +}; + // ─── Padding Presets ──────────────────────────────────────────────────────── /** All three padding options */ @@ -190,41 +406,6 @@ export const PriceCardPreview: Story = { ), }; -// ─── 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" */ @@ -260,7 +441,7 @@ export const WithImage: Story = { ), }; -// ─── Nested Content ───────────────────────────────────────────────────────── +// ─── Rich Content ─────────────────────────────────────────────────────────── /** Card with rich nested content to verify layout flexibility */ export const RichContent: Story = { diff --git a/src/components/atoms/Card/Card.tsx b/src/components/atoms/Card/Card.tsx index 536f531..bcacf57 100644 --- a/src/components/atoms/Card/Card.tsx +++ b/src/components/atoms/Card/Card.tsx @@ -9,8 +9,10 @@ import CardContent from '@mui/material/CardContent'; 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 */ + /** Adds hover background fill, shadow lift, and pointer cursor for clickable cards */ interactive?: boolean; + /** Highlights the card as selected — brand border + warm background tint */ + selected?: boolean; /** Padding preset: "default" (24px), "compact" (16px), "none" (no wrapper) */ padding?: 'default' | 'compact' | 'none'; /** Content to render inside the card */ @@ -23,14 +25,17 @@ export interface CardProps extends Omit { * 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. + * optional hover interactivity, selected state, 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. + * adds background fill on hover, shadow lift, and cursor pointer. + * + * Use `selected` for option-select patterns — applies brand border + * (warm gold) and warm background tint (brand.50). * * Use `padding="none"` when composing with CardMedia or custom layouts * that need full-bleed content. @@ -40,6 +45,7 @@ export const Card = React.forwardRef( { variant = 'elevated', interactive = false, + selected = false, padding = 'default', children, sx, @@ -56,11 +62,21 @@ export const Card = React.forwardRef( variant={muiVariant} elevation={0} sx={[ - // Interactive: hover lift + pointer + // Selected state: brand border + warm background + selected && { + borderColor: 'var(--fa-card-border-selected)', + borderWidth: '2px', + borderStyle: 'solid', + backgroundColor: 'var(--fa-card-background-selected)', + }, + // Interactive: hover fill + shadow lift + pointer interactive && { cursor: 'pointer', '&:hover': { - boxShadow: 'var(--fa-card-shadow-hover)', + backgroundColor: selected + ? 'var(--fa-card-background-selected)' + : 'var(--fa-card-background-hover)', + boxShadow: variant === 'elevated' ? 'var(--fa-card-shadow-hover)' : undefined, }, }, // Focus-visible for keyboard accessibility on interactive cards diff --git a/src/theme/generated/tokens.css b/src/theme/generated/tokens.css index 32d13e0..6bacfa3 100644 --- a/src/theme/generated/tokens.css +++ b/src/theme/generated/tokens.css @@ -357,5 +357,8 @@ --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-border-selected: var(--fa-color-border-brand); /** Brand border for selected/active cards — warm gold accent */ --fa-card-background-default: var(--fa-color-surface-raised); /** White — standard card background (raised surface) */ + --fa-card-background-hover: var(--fa-color-surface-subtle); /** Subtle grey fill on hover — neutral.50 for soft interactive feedback */ + --fa-card-background-selected: var(--fa-color-surface-warm); /** Warm tint for selected cards — brand.50 reinforces active state */ } diff --git a/src/theme/generated/tokens.js b/src/theme/generated/tokens.js index 240b845..6af9551 100644 --- a/src/theme/generated/tokens.js +++ b/src/theme/generated/tokens.js @@ -33,7 +33,10 @@ export const CardPaddingCompact = "16px"; // 16px — compact card padding (mobi 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 CardBorderSelected = "#ba834e"; // Brand border for selected/active cards — warm gold accent export const CardBackgroundDefault = "#ffffff"; // White — standard card background (raised surface) +export const CardBackgroundHover = "#fafafa"; // Subtle grey fill on hover — neutral.50 for soft interactive feedback +export const CardBackgroundSelected = "#fef9f5"; // Warm tint for selected cards — brand.50 reinforces active state 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/tokens/component/card.json b/tokens/component/card.json index f105b11..5472487 100644 --- a/tokens/component/card.json +++ b/tokens/component/card.json @@ -19,13 +19,16 @@ }, "border": { "$type": "color", - "$description": "Border colours for the outlined card variant.", - "default": { "$value": "{color.border.default}", "$description": "Neutral border for outlined cards" } + "$description": "Border colours for card variants and states.", + "default": { "$value": "{color.border.default}", "$description": "Neutral border for outlined cards" }, + "selected": { "$value": "{color.border.brand}", "$description": "Brand border for selected/active cards — warm gold accent" } }, "background": { "$type": "color", "$description": "Card background colours.", - "default": { "$value": "{color.surface.raised}", "$description": "White — standard card background (raised surface)" } + "default": { "$value": "{color.surface.raised}", "$description": "White — standard card background (raised surface)" }, + "hover": { "$value": "{color.surface.subtle}", "$description": "Subtle grey fill on hover — neutral.50 for soft interactive feedback" }, + "selected": { "$value": "{color.surface.warm}", "$description": "Warm tint for selected cards — brand.50 reinforces active state" } } } }