From 61f4c24b2960d952009661d86b7f517e4e4b8672 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Nov 2025 12:09:15 +0000 Subject: [PATCH 1/5] Initial plan From a0c14b628ed84de565363e8599439e1b3de44d67 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Nov 2025 12:21:51 +0000 Subject: [PATCH 2/5] feat: add Card component with comprehensive features - Create Card component with TypeScript in src/components/ui/card/ - Support title with configurable heading level (h2-h4) - Support description text - Optional CTA button or link (internal/external) - Optional image with top/bottom positioning - Use class-variance-authority for variants - Use Tailwind CSS for styling with hover effects - Add comprehensive unit tests (22 tests, 100% coverage) - Add extensive Storybook stories for documentation - Add barrel export via index.ts - Full accessibility support with a11y tests Co-authored-by: DanStuartDept <87612831+DanStuartDept@users.noreply.github.com> --- src/components/ui/card/card.stories.tsx | 303 ++++++++++++++++++++++++ src/components/ui/card/card.test.tsx | 220 +++++++++++++++++ src/components/ui/card/card.tsx | 114 +++++++++ src/components/ui/card/index.ts | 2 + 4 files changed, 639 insertions(+) create mode 100644 src/components/ui/card/card.stories.tsx create mode 100644 src/components/ui/card/card.test.tsx create mode 100644 src/components/ui/card/card.tsx create mode 100644 src/components/ui/card/index.ts diff --git a/src/components/ui/card/card.stories.tsx b/src/components/ui/card/card.stories.tsx new file mode 100644 index 0000000..11a6da9 --- /dev/null +++ b/src/components/ui/card/card.stories.tsx @@ -0,0 +1,303 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { Card } from "./card"; + +const meta = { + title: "UI/Card", + component: Card, + 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..0e6e46c --- /dev/null +++ b/src/components/ui/card/card.test.tsx @@ -0,0 +1,220 @@ +import React from "react"; +import { vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { Card } from "./card"; +import { expectNoA11yViolations } from "@/lib/test-utils/accessibility"; + +describe("Card", () => { + 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 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..3012a4a --- /dev/null +++ b/src/components/ui/card/card.tsx @@ -0,0 +1,114 @@ +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"; + +const cardVariants = cva( + "rounded-lg border bg-card text-card-foreground shadow-sm 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 CardProps + extends React.HTMLAttributes, + 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 Card = React.forwardRef( + ( + { + className, + title, + titleHeadingLevel = "h2", + description, + cta, + image, + imagePosition = image ? "top" : "none", + ...props + }, + ref + ) => { + const HeadingTag = titleHeadingLevel; + + return ( +
+ {image && ( +
+ {image.alt} +
+ )} +
+ + {title} + +

{description}

+ {cta && ( +
+ {cta.href ? ( + + ) : ( + + )} +
+ )} +
+
+ ); + } +); +Card.displayName = "Card"; + +export { Card, cardVariants }; diff --git a/src/components/ui/card/index.ts b/src/components/ui/card/index.ts new file mode 100644 index 0000000..b11f072 --- /dev/null +++ b/src/components/ui/card/index.ts @@ -0,0 +1,2 @@ +export { Card, cardVariants } from "./card"; +export type { CardProps } from "./card"; From 97c13ebb9bf1e1404f542b59743654c194bd6dc4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Nov 2025 12:25:18 +0000 Subject: [PATCH 3/5] fix: address code review feedback for Card component - Fix image border radius to respect imagePosition (top vs bottom) - Improve external URL detection to include http://, https://, and // - Fix imagePosition default logic to prevent inconsistent behavior - Add test for protocol-relative URLs - Maintain 100% test coverage (23 tests passing) Co-authored-by: DanStuartDept <87612831+DanStuartDept@users.noreply.github.com> --- src/components/ui/card/card.test.tsx | 12 ++++++++++++ src/components/ui/card/card.tsx | 26 ++++++++++++++++++++------ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/components/ui/card/card.test.tsx b/src/components/ui/card/card.test.tsx index 0e6e46c..aa959de 100644 --- a/src/components/ui/card/card.test.tsx +++ b/src/components/ui/card/card.test.tsx @@ -84,6 +84,18 @@ describe("Card", () => { 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"); diff --git a/src/components/ui/card/card.tsx b/src/components/ui/card/card.tsx index 3012a4a..a3fd0e7 100644 --- a/src/components/ui/card/card.tsx +++ b/src/components/ui/card/card.tsx @@ -54,21 +54,35 @@ const Card = React.forwardRef( description, cta, image, - imagePosition = image ? "top" : "none", + imagePosition, ...props }, ref ) => { const HeadingTag = titleHeadingLevel; + // 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("//"); return (
- {image && ( -
+ {image && effectiveImagePosition !== "none" && ( +
{image.alt}( - ) : ( - - )} -
- )} -
-
+ {description} + + {cta && ( + + {cta.href ? ( + + ) : ( + + )} + + )} + ); } ); -Card.displayName = "Card"; +ProductCard.displayName = "ProductCard"; + +// Export shadcn primitives for direct use +export { + CardPrimitive as Card, + CardHeader, + CardFooter, + CardTitlePrimitive as CardTitle, + CardDescription, + CardContent, +}; -export { Card, cardVariants }; +// Export custom product card +export { ProductCard, productCardVariants }; diff --git a/src/components/ui/card/index.ts b/src/components/ui/card/index.ts index b11f072..0456a3d 100644 --- a/src/components/ui/card/index.ts +++ b/src/components/ui/card/index.ts @@ -1,2 +1,13 @@ -export { Card, cardVariants } from "./card"; -export type { CardProps } from "./card"; +// 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"; From c7c949212aeb4bef144bf28ad16594415ea7bb0d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Nov 2025 15:51:04 +0000 Subject: [PATCH 5/5] fix: correct CardTitle ref type to HTMLHeadingElement Co-authored-by: DanStuartDept <87612831+DanStuartDept@users.noreply.github.com> --- src/components/ui/card/card-primitives.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ui/card/card-primitives.tsx b/src/components/ui/card/card-primitives.tsx index bfa65fc..5bc27b2 100644 --- a/src/components/ui/card/card-primitives.tsx +++ b/src/components/ui/card/card-primitives.tsx @@ -30,7 +30,7 @@ const CardHeader = React.forwardRef< CardHeader.displayName = "CardHeader"; const CardTitle = React.forwardRef< - HTMLParagraphElement, + HTMLHeadingElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( // eslint-disable-next-line jsx-a11y/heading-has-content