- Card: add Enter/Space keyboard activation for interactive cards (P1 a11y) - Card: responsive padding — 16px mobile / 24px desktop (P1 responsive) - Card: focus-visible outline uses focus token CSS var instead of palette - Card: remove unused Theme import - Input: convert raw px strings to MUI spacing (mb: 2.5, mt: 1.5) Phase 1 retroactive review: atoms normalize + audit (Button 20/20, Input 20/20, Card 18→20/20 after fixes) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
128 lines
4.5 KiB
TypeScript
128 lines
4.5 KiB
TypeScript
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<MuiCardProps, 'raised' | 'variant'> {
|
|
/** Visual style: "elevated" uses shadow, "outlined" uses border */
|
|
variant?: 'elevated' | 'outlined';
|
|
/** 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" (16px mobile / 24px desktop), "compact" (12px mobile / 16px desktop), "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, 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 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.
|
|
*/
|
|
export const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
|
(
|
|
{
|
|
variant = 'elevated',
|
|
interactive = false,
|
|
selected = false,
|
|
padding = 'default',
|
|
children,
|
|
sx,
|
|
...props
|
|
},
|
|
ref,
|
|
) => {
|
|
// Map FA variant names to MUI Card variant
|
|
const muiVariant = variant === 'outlined' ? 'outlined' : undefined;
|
|
|
|
// Interactive cards need keyboard operability
|
|
const handleKeyDown = interactive
|
|
? (e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
e.currentTarget.click();
|
|
}
|
|
}
|
|
: undefined;
|
|
|
|
const interactiveProps = interactive
|
|
? { tabIndex: 0 as const, role: 'button' as const, onKeyDown: handleKeyDown }
|
|
: {};
|
|
|
|
return (
|
|
<MuiCard
|
|
ref={ref}
|
|
variant={muiVariant}
|
|
elevation={0}
|
|
{...interactiveProps}
|
|
sx={[
|
|
// Selected state: brand border + warm background
|
|
// Border width is always 2px (set in theme) — only colour changes here
|
|
selected && {
|
|
borderColor: 'var(--fa-card-border-selected)',
|
|
backgroundColor: 'var(--fa-card-background-selected)',
|
|
},
|
|
// Interactive: hover fill + shadow lift + pointer
|
|
interactive && {
|
|
cursor: 'pointer',
|
|
'&: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
|
|
interactive && {
|
|
'&:focus-visible': {
|
|
outline: '2px solid var(--fa-color-interactive-focus)',
|
|
outlineOffset: '2px',
|
|
},
|
|
},
|
|
...(Array.isArray(sx) ? sx : [sx]),
|
|
]}
|
|
{...props}
|
|
>
|
|
{padding !== 'none' ? (
|
|
<CardContent
|
|
sx={{
|
|
p: padding === 'compact' ? { xs: 3, md: 4 } : { xs: 4, md: 6 },
|
|
'&:last-child': {
|
|
pb: padding === 'compact' ? { xs: 3, md: 4 } : { xs: 4, md: 6 },
|
|
},
|
|
}}
|
|
>
|
|
{children}
|
|
</CardContent>
|
|
) : (
|
|
children
|
|
)}
|
|
</MuiCard>
|
|
);
|
|
},
|
|
);
|
|
|
|
Card.displayName = 'Card';
|
|
export default Card;
|