import * as React from "react"; /** * Card — content container with optional media, clamped title, body, footer. * * Two variants: * - static : passive surface; put links/buttons in the footer. * - clickable: the WHOLE card is one target via a stretched link. Do not nest * other tap targets inside a clickable card. * * SaglitzDesign: border OR shadow (never both), one consistent radius, * only transform/opacity/shadow animate, ease-out on enter. */ type CardBaseProps = { /** "border" = containment, "shadow" = elevation. Never both. */ surface?: "border" | "shadow"; className?: string; children: React.ReactNode; }; const RADIUS = "rounded-xl"; // single radius token for card + media top corners function surfaceClasses(surface: "border" | "shadow") { return surface === "shadow" ? "shadow-sm hover:shadow-md" : "border border-neutral-200 dark:border-neutral-800"; } /* -------------------------------------------------------------------------- */ /* Static card */ /* -------------------------------------------------------------------------- */ export const Card = React.forwardRef( function Card({ surface = "border", className = "", children }, ref) { return (
{children}
); } ); /* -------------------------------------------------------------------------- */ /* Clickable card — whole surface is one target (stretched link) */ /* -------------------------------------------------------------------------- */ type CardLinkProps = Omit & { href: string; /** Media + body live here; the title element wires up the stretched link. */ children: React.ReactNode; }; /** * The stretched link is rendered by when `href` is passed. * `focus-within` draws the ring on the card so keyboard focus is visible even * though the actual focusable element is the (visually flush) title link. */ export const CardLink = React.forwardRef( function CardLink({ surface = "shadow", className = "", href, children }, ref) { return (
{children}
); } ); /* -------------------------------------------------------------------------- */ /* Parts */ /* -------------------------------------------------------------------------- */ export function CardMedia({ src, alt, ratio = "16/9", }: { src: string; /** Meaningful alt, or "" if purely decorative. */ alt: string; ratio?: string; }) { return (
{alt}
); } /** * Title. When `href` is set, renders a stretched link: the ::after overlay * (via peer class) covers the whole card, so a single Tab focuses it and the * whole surface is clickable — no nested tap targets. */ export function CardTitle({ children, href, as: As = "h3", }: { children: React.ReactNode; href?: string; as?: "h2" | "h3" | "h4"; }) { return ( {href ? ( {children} ) : ( children )} ); } export function CardBody({ children, clamp = true, }: { children: React.ReactNode; clamp?: boolean; }) { return (

{children}

); } export function CardContent({ children }: { children: React.ReactNode }) { return
{children}
; } /** Footer actions — static variant only (nesting tap targets in CardLink is disallowed). */ export function CardFooter({ children }: { children: React.ReactNode }) { // z-20 keeps footer controls above a sibling stretched-link overlay, but the // convention is to only use CardFooter inside the static Card. return (
{children}
); } /* -------------------------------------------------------------------------- */ /* Usage */ /* -------------------------------------------------------------------------- */ export function CardExamples() { return (
{/* Static card with footer actions */} A descriptive title that may run onto two lines and then clamp Supporting copy explaining the content. Clamped to three lines so every card in the grid keeps a consistent height regardless of text length. {/* Whole-card-clickable */} Whole card is clickable — one tap target, no nested links The title link stretches over the card via ::after. Keyboard users Tab once; the focus ring is drawn on the card through focus-within.
); }