Add selected state and hover background to Card

- Add `selected` prop: brand border (warm gold) + warm background tint (brand.50)
- Add hover background fill (neutral.50) for interactive cards
- Add 3 new card tokens: border.selected, background.hover, background.selected
- Add stories: Selected, OptionSelect (single-select), MultiSelect (toggle),
  OnDifferentBackgrounds (white vs grey surface comparison)
- Informed by FA 1.0 Figma ListItemPurchaseOption pattern (node 2349:39505)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 15:45:57 +11:00
parent 3be3afd80c
commit 74ee0b87da
5 changed files with 254 additions and 48 deletions

View File

@@ -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<typeof Card> = {
},
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: () => (
<div style={{ display: 'flex', gap: 24, maxWidth: 800 }}>
@@ -103,7 +109,7 @@ export const Interactive: Story = {
Elevated + Interactive
</Typography>
<Typography variant="body2" color="text.secondary">
Hover to see the shadow lift. Click to trigger action.
Hover to see the background fill and shadow lift.
</Typography>
</Card>
<Card
@@ -117,13 +123,223 @@ export const Interactive: Story = {
Outlined + Interactive
</Typography>
<Typography variant="body2" color="text.secondary">
Outlined cards can also be interactive with hover effects.
Outlined cards get a subtle background fill on hover.
</Typography>
</Card>
</div>
),
};
// ─── Selected ───────────────────────────────────────────────────────────────
/** Selected state — brand border + warm background tint */
export const Selected: Story = {
render: () => (
<div style={{ display: 'flex', gap: 24, maxWidth: 800 }}>
<Card variant="outlined" sx={{ flex: 1 }}>
<Typography variant="h5" gutterBottom>
Not selected
</Typography>
<Typography variant="body2" color="text.secondary">
Standard outlined card in its resting state.
</Typography>
</Card>
<Card variant="outlined" selected sx={{ flex: 1 }}>
<Typography variant="h5" gutterBottom>
Selected
</Typography>
<Typography variant="body2" color="text.secondary">
Brand border and warm background tint show this is the active choice.
</Typography>
</Card>
</div>
),
};
// ─── 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<string | null>('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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 500 }}>
<Typography variant="h5" sx={{ mb: 1 }}>
Choose your service type
</Typography>
{options.map((option) => (
<Card
key={option.id}
variant="outlined"
interactive
selected={selectedId === option.id}
padding="compact"
tabIndex={0}
onClick={() => setSelectedId(option.id)}
role="radio"
aria-checked={selectedId === option.id}
>
<Typography variant="labelLg" gutterBottom>
{option.title}
</Typography>
<Typography variant="body2" color="text.secondary">
{option.desc}
</Typography>
</Card>
))}
</div>
);
},
};
// ─── 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<Set<string>>(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 (
<div style={{ maxWidth: 500 }}>
<Typography variant="h5" sx={{ mb: 1 }}>
Select add-ons
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Choose as many as you like
</Typography>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{addOns.map((item) => (
<Card
key={item.id}
variant="outlined"
interactive
selected={selected.has(item.id)}
padding="compact"
tabIndex={0}
onClick={() => toggle(item.id)}
role="checkbox"
aria-checked={selected.has(item.id)}
>
<Typography variant="labelLg" gutterBottom>
{item.title}
</Typography>
<Typography variant="body2" color="text.secondary">
{item.desc}
</Typography>
</Card>
))}
</div>
</div>
);
},
};
// ─── 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: () => (
<div style={{ display: 'flex', gap: 32, maxWidth: 900 }}>
{/* White background */}
<div
style={{
flex: 1,
padding: 24,
backgroundColor: '#ffffff',
borderRadius: 8,
border: '1px dashed #d4d4d4',
}}
>
<Typography variant="caption" color="text.secondary" sx={{ mb: 2, display: 'block' }}>
On white surface
</Typography>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Card variant="elevated">
<Typography variant="labelLg">Elevated</Typography>
<Typography variant="body2" color="text.secondary">Shadow defines edges</Typography>
</Card>
<Card variant="outlined">
<Typography variant="labelLg">Outlined</Typography>
<Typography variant="body2" color="text.secondary">Border defines edges</Typography>
</Card>
</div>
</div>
{/* Grey background */}
<div
style={{
flex: 1,
padding: 24,
backgroundColor: '#f5f5f5',
borderRadius: 8,
}}
>
<Typography variant="caption" color="text.secondary" sx={{ mb: 2, display: 'block' }}>
On grey surface
</Typography>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Card variant="elevated">
<Typography variant="labelLg">Elevated</Typography>
<Typography variant="body2" color="text.secondary">White card + shadow on grey</Typography>
</Card>
<Card variant="outlined">
<Typography variant="labelLg">Outlined</Typography>
<Typography variant="body2" color="text.secondary">Contrast + border on grey</Typography>
</Card>
</div>
</div>
</div>
),
};
// ─── 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: () => (
<div style={{ display: 'flex', gap: 16, maxWidth: 700 }}>
{[
{ 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) => (
<Card
key={option.title}
variant="outlined"
interactive
padding="compact"
tabIndex={0}
sx={{ flex: 1 }}
>
<Typography variant="labelLg" gutterBottom>
{option.title}
</Typography>
<Typography variant="body2" color="text.secondary">
{option.desc}
</Typography>
</Card>
))}
</div>
),
};
// ─── 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 = {

View File

@@ -9,8 +9,10 @@ import CardContent from '@mui/material/CardContent';
export interface CardProps extends Omit<MuiCardProps, 'raised' | 'variant'> {
/** 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<MuiCardProps, 'raised' | 'variant'> {
* 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<HTMLDivElement, CardProps>(
{
variant = 'elevated',
interactive = false,
selected = false,
padding = 'default',
children,
sx,
@@ -56,11 +62,21 @@ export const Card = React.forwardRef<HTMLDivElement, CardProps>(
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

View File

@@ -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 */
}

View File

@@ -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

View File

@@ -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" }
}
}
}