diff --git a/src/components/ui/card/card-primitives.tsx b/src/components/ui/card/card-primitives.tsx new file mode 100644 index 0000000..5bc27b2 --- /dev/null +++ b/src/components/ui/card/card-primitives.tsx @@ -0,0 +1,87 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +Card.displayName = "Card"; + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef< + HTMLHeadingElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + // eslint-disable-next-line jsx-a11y/heading-has-content +

+)); +CardTitle.displayName = "CardTitle"; + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardDescription.displayName = "CardDescription"; + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardContent.displayName = "CardContent"; + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardFooter.displayName = "CardFooter"; + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, +}; diff --git a/src/components/ui/card/card.stories.tsx b/src/components/ui/card/card.stories.tsx new file mode 100644 index 0000000..5261514 --- /dev/null +++ b/src/components/ui/card/card.stories.tsx @@ -0,0 +1,303 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { ProductCard } from "./card"; + +const meta = { + title: "UI/Card", + component: ProductCard, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + title: { + control: "text", + description: "The title text to display", + }, + titleHeadingLevel: { + control: "select", + options: ["h2", "h3", "h4"], + description: "The heading level for the title", + }, + description: { + control: "text", + description: "The description text to display", + }, + imagePosition: { + control: "select", + options: ["top", "bottom", "none"], + description: "Position of the image relative to content", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** + * Default card with just title and description. + * This is the simplest form of the card component. + */ +export const Default: Story = { + args: { + title: "Card Title", + description: + "This is a card description that provides more information about the card content.", + }, +}; + +/** + * Card with a call-to-action button. + * Use this when you need user interaction with a button. + */ +export const WithCTAButton: Story = { + args: { + title: "Card with Action", + description: + "This card includes a call-to-action button that can trigger an action.", + cta: { + text: "Click me", + onClick: () => alert("Button clicked!"), + }, + }, +}; + +/** + * Card with a call-to-action link to an internal page. + * Use this for navigation within your app. + */ +export const WithCTALink: Story = { + args: { + title: "Card with Link", + description: + "This card includes a call-to-action link for navigation to another page.", + cta: { + text: "Learn more", + href: "/about", + }, + }, +}; + +/** + * Card with external link. + * Opens in a new tab with proper security attributes. + */ +export const WithExternalLink: Story = { + args: { + title: "External Resource", + description: "This card links to an external website for more information.", + cta: { + text: "Visit website", + href: "https://example.com", + }, + }, +}; + +/** + * Card with image positioned at the top. + * Great for showcasing visual content. + */ +export const WithImageTop: Story = { + args: { + title: "Beautiful Landscape", + description: "A stunning view captured at the perfect moment.", + image: { + src: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=400&h=300&fit=crop", + alt: "Mountain landscape", + }, + imagePosition: "top", + }, +}; + +/** + * Card with image positioned at the bottom. + * Alternative layout for different visual hierarchy. + */ +export const WithImageBottom: Story = { + args: { + title: "Ocean View", + description: "The calming waves and endless horizon.", + image: { + src: "https://images.unsplash.com/photo-1505142468610-359e7d316be0?w=400&h=300&fit=crop", + alt: "Ocean view", + }, + imagePosition: "bottom", + }, +}; + +/** + * Complete card with all features. + * Demonstrates all available options combined. + */ +export const Complete: Story = { + args: { + title: "Feature Showcase", + titleHeadingLevel: "h3", + description: + "This card demonstrates all available features including image, custom heading level, and call-to-action.", + image: { + src: "https://images.unsplash.com/photo-1682687220742-aba13b6e50ba?w=400&h=300&fit=crop", + alt: "Abstract colorful art", + }, + imagePosition: "top", + cta: { + text: "Explore more", + href: "#", + }, + }, +}; + +/** + * Card with h3 heading. + * Use for section-level cards in hierarchical content. + */ +export const WithH3Heading: Story = { + args: { + title: "Section Card", + titleHeadingLevel: "h3", + description: "This card uses an h3 heading for proper content hierarchy.", + }, +}; + +/** + * Card with h4 heading. + * Use for subsection-level cards in deeply nested content. + */ +export const WithH4Heading: Story = { + args: { + title: "Subsection Card", + titleHeadingLevel: "h4", + description: + "This card uses an h4 heading for deeper content hierarchy levels.", + }, +}; + +/** + * Card with custom styling. + * Demonstrates how to apply custom classes. + */ +export const CustomStyling: Story = { + args: { + title: "Custom Styled Card", + description: "This card has custom styling applied through className.", + className: "border-2 border-primary bg-primary/5", + }, +}; + +/** + * Multiple cards in a grid layout. + * Shows how cards work together in a common use case. + */ +export const GridLayout: Story = { + args: { + title: "", + description: "", + }, + render: () => ( +
+ + + alert("Clicked!") }} + /> + + + +
+ ), +}; + +/** + * Comparison of heading levels. + * Shows all three heading level options side by side. + */ +export const HeadingLevels: Story = { + args: { + title: "", + description: "", + }, + render: () => ( +
+ + + +
+ ), +}; + +/** + * Image position comparison. + * Shows how images can be positioned at top or bottom. + */ +export const ImagePositions: Story = { + args: { + title: "", + description: "", + }, + render: () => ( +
+ + +
+ ), +}; diff --git a/src/components/ui/card/card.test.tsx b/src/components/ui/card/card.test.tsx new file mode 100644 index 0000000..ae744c3 --- /dev/null +++ b/src/components/ui/card/card.test.tsx @@ -0,0 +1,244 @@ +import React from "react"; +import { vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { ProductCard } from "./card"; +import { expectNoA11yViolations } from "@/lib/test-utils/accessibility"; + +describe("ProductCard", () => { + const defaultProps = { + title: "Card Title", + description: "This is a card description", + }; + + it("renders with required props", () => { + render(); + expect(screen.getByText("Card Title")).toBeInTheDocument(); + expect(screen.getByText("This is a card description")).toBeInTheDocument(); + }); + + it("renders with h2 heading by default", () => { + render(); + const heading = screen.getByRole("heading", { name: /card title/i }); + expect(heading.tagName).toBe("H2"); + }); + + it("renders with h3 heading when specified", () => { + render(); + const heading = screen.getByRole("heading", { name: /card title/i }); + expect(heading.tagName).toBe("H3"); + }); + + it("renders with h4 heading when specified", () => { + render(); + const heading = screen.getByRole("heading", { name: /card title/i }); + expect(heading.tagName).toBe("H4"); + }); + + it("renders without CTA when not provided", () => { + render(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + }); + + it("renders CTA button with onClick handler", () => { + const handleClick = vi.fn(); + render( + + ); + const button = screen.getByRole("button", { name: /click me/i }); + expect(button).toBeInTheDocument(); + button.click(); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it("renders CTA as link with href", () => { + render( + + ); + const link = screen.getByRole("link", { name: /learn more/i }); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute("href", "/about"); + }); + + it("renders external link with target and rel attributes", () => { + render( + + ); + const link = screen.getByRole("link", { name: /external link/i }); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("renders internal link without target and rel attributes", () => { + render( + + ); + const link = screen.getByRole("link", { name: /internal link/i }); + expect(link).not.toHaveAttribute("target"); + expect(link).not.toHaveAttribute("rel"); + }); + + it("renders protocol-relative URL with target and rel attributes", () => { + render( + + ); + const link = screen.getByRole("link", { name: /protocol relative/i }); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("renders without image when not provided", () => { + const { container } = render(); + const images = container.querySelectorAll("img"); + expect(images).toHaveLength(0); + }); + + it("renders with image when provided", () => { + render( + + ); + const img = screen.getByRole("img", { name: /test image/i }); + expect(img).toBeInTheDocument(); + }); + + it("renders image with custom dimensions", () => { + render( + + ); + const img = screen.getByRole("img", { name: /test image/i }); + expect(img).toBeInTheDocument(); + }); + + it("applies imagePosition variant correctly for top", () => { + const { container } = render( + + ); + const card = container.firstChild as HTMLElement; + expect(card).toHaveClass("flex-col"); + }); + + it("applies imagePosition variant correctly for bottom", () => { + const { container } = render( + + ); + const card = container.firstChild as HTMLElement; + expect(card).toHaveClass("flex-col-reverse"); + }); + + it("applies custom className", () => { + const { container } = render( + + ); + const card = container.firstChild as HTMLElement; + expect(card).toHaveClass("custom-class"); + }); + + it("forwards ref correctly", () => { + const ref = React.createRef(); + render(); + expect(ref.current).toBeInstanceOf(HTMLDivElement); + }); + + it("renders complete card with all features", () => { + const handleClick = vi.fn(); + render( + + ); + + expect( + screen.getByRole("heading", { name: /card title/i }) + ).toBeInTheDocument(); + expect(screen.getByText("This is a card description")).toBeInTheDocument(); + expect( + screen.getByRole("img", { name: /test image/i }) + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /action/i })).toBeInTheDocument(); + }); + + // Accessibility tests + describe("Accessibility", () => { + it("should have no accessibility violations with default props", async () => { + const renderResult = render(); + await expectNoA11yViolations(renderResult); + }); + + it("should have no accessibility violations with CTA button", async () => { + const handleClick = vi.fn(); + const renderResult = render( + + ); + await expectNoA11yViolations(renderResult); + }); + + it("should have no accessibility violations with CTA link", async () => { + const renderResult = render( + + ); + await expectNoA11yViolations(renderResult); + }); + + it("should have no accessibility violations with image", async () => { + const renderResult = render( + + ); + await expectNoA11yViolations(renderResult); + }); + + it("should have no accessibility violations with all features", async () => { + const handleClick = vi.fn(); + const renderResult = render( + + ); + await expectNoA11yViolations(renderResult); + }); + }); +}); diff --git a/src/components/ui/card/card.tsx b/src/components/ui/card/card.tsx new file mode 100644 index 0000000..cf0f575 --- /dev/null +++ b/src/components/ui/card/card.tsx @@ -0,0 +1,146 @@ +import * as React from "react"; +import Image from "next/image"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { + Card as CardPrimitive, + CardHeader, + CardTitle as CardTitlePrimitive, + CardDescription, + CardContent, + CardFooter, +} from "./card-primitives"; + +const productCardVariants = cva("transition-shadow hover:shadow-md", { + variants: { + imagePosition: { + top: "flex flex-col", + bottom: "flex flex-col-reverse", + none: "flex flex-col", + }, + }, + defaultVariants: { + imagePosition: "none", + }, +}); + +export interface ProductCardProps + extends Omit, "title">, + VariantProps { + /** The title text to display */ + title: string; + /** The heading level for the title (h2, h3, or h4) */ + titleHeadingLevel?: "h2" | "h3" | "h4"; + /** The description text to display */ + description: string; + /** Optional call-to-action configuration */ + cta?: { + text: string; + onClick?: () => void; + href?: string; + }; + /** Optional image configuration */ + image?: { + src: string; + alt: string; + width?: number; + height?: number; + }; +} + +const ProductCard = React.forwardRef( + ( + { + className, + title, + titleHeadingLevel = "h2", + description, + cta, + image, + imagePosition, + ...props + }, + ref + ) => { + // Determine image position: default to 'top' if image provided, otherwise 'none' + const effectiveImagePosition = imagePosition ?? (image ? "top" : "none"); + // Helper to check if URL is external + const isExternalUrl = (url: string) => + url.startsWith("http://") || + url.startsWith("https://") || + url.startsWith("//"); + + const HeadingTag = titleHeadingLevel; + + return ( + + {image && effectiveImagePosition !== "none" && ( +
+ {image.alt} +
+ )} + + + {title} + + {description} + + {cta && ( + + {cta.href ? ( + + ) : ( + + )} + + )} +
+ ); + } +); +ProductCard.displayName = "ProductCard"; + +// Export shadcn primitives for direct use +export { + CardPrimitive as Card, + CardHeader, + CardFooter, + CardTitlePrimitive as CardTitle, + CardDescription, + CardContent, +}; + +// Export custom product card +export { ProductCard, productCardVariants }; diff --git a/src/components/ui/card/index.ts b/src/components/ui/card/index.ts new file mode 100644 index 0000000..0456a3d --- /dev/null +++ b/src/components/ui/card/index.ts @@ -0,0 +1,13 @@ +// Export shadcn card primitives for direct use +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, +} from "./card"; + +// Export custom product card +export { ProductCard, productCardVariants } from "./card"; +export type { ProductCardProps } from "./card";