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:
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user