diff --git a/src/components/ui/info-card.test.tsx b/src/components/ui/info-card.test.tsx new file mode 100644 index 0000000..637dcff --- /dev/null +++ b/src/components/ui/info-card.test.tsx @@ -0,0 +1,160 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { + InfoCard, + InfoCardTitle, + InfoCardDescription, + InfoCardList, + InfoCardListItem, +} from "./info-card"; + +describe("InfoCard", () => { + describe("rendering", () => { + it("should render with default info variant", () => { + render( + + Test Title + , + ); + + expect(screen.getByText("Test Title")).toBeTruthy(); + }); + + it("should render with all variants", () => { + const variants = ["info", "warning", "error", "success"] as const; + + variants.forEach((variant) => { + const { container } = render( + + {variant} Card + , + ); + + expect(screen.getByText(`${variant} Card`)).toBeTruthy(); + expect(container.firstChild?.classList.contains("border-l-4")).toBe(true); + }); + }); + + it("should render custom icon when provided", () => { + const CustomIcon = (props: React.SVGProps) => ( + + ); + + render( + + Title + , + ); + + expect(screen.getByTestId("custom-icon")).toBeTruthy(); + }); + + it("should not render icon when showDefaultIcon is false", () => { + const { container } = render( + + Title + , + ); + + const svg = container.querySelector("svg"); + expect(svg).toBeNull(); + }); + }); + + describe("InfoCardTitle", () => { + it("should render with correct variant colors", () => { + const { rerender } = render( + Info Title, + ); + const infoTitle = screen.getByText("Info Title"); + expect(infoTitle.classList.contains("text-info-900")).toBe(true); + + rerender(Error Title); + const errorTitle = screen.getByText("Error Title"); + expect(errorTitle.classList.contains("text-danger-900")).toBe(true); + }); + }); + + describe("InfoCardDescription", () => { + it("should render description text", () => { + render( + This is a description, + ); + expect(screen.getByText("This is a description")).toBeTruthy(); + }); + + it("should apply variant colors", () => { + render( + + Warning description + , + ); + const desc = screen.getByText("Warning description"); + expect(desc.classList.contains("text-warning-800")).toBe(true); + }); + }); + + describe("InfoCardList", () => { + it("should render unordered list by default", () => { + render( + + Item 1 + Item 2 + , + ); + + const list = screen.getByRole("list"); + expect(list.tagName).toBe("UL"); + expect(list.classList.contains("list-disc")).toBe(true); + }); + + it("should render ordered list when specified", () => { + render( + + First + Second + , + ); + + const list = screen.getByRole("list"); + expect(list.tagName).toBe("OL"); + expect(list.classList.contains("list-decimal")).toBe(true); + }); + + it("should render list items", () => { + render( + + Item 1 + Item 2 + , + ); + + expect(screen.getByText("Item 1")).toBeTruthy(); + expect(screen.getByText("Item 2")).toBeTruthy(); + }); + }); + + describe("composition", () => { + it("should render full info card with all components", () => { + render( + + Success! + + Operation completed successfully + + + First step completed + Second step completed + + , + ); + + expect(screen.getByText("Success!")).toBeTruthy(); + expect( + screen.getByText("Operation completed successfully"), + ).toBeTruthy(); + expect(screen.getByText("First step completed")).toBeTruthy(); + expect(screen.getByText("Second step completed")).toBeTruthy(); + }); + }); +}); diff --git a/src/components/ui/info-card.tsx b/src/components/ui/info-card.tsx new file mode 100644 index 0000000..a2b7bee --- /dev/null +++ b/src/components/ui/info-card.tsx @@ -0,0 +1,191 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { + InformationCircleIcon, + ExclamationTriangleIcon, + CheckCircleIcon, +} from "@heroicons/react/24/solid"; + +import { cn } from "@/lib/utils"; + +const infoCardVariants = cva( + "border-l-4 p-4 rounded-lg backdrop-blur-sm", + { + variants: { + variant: { + info: "bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500", + warning: + "bg-warning-50 dark:bg-warning-900/95 border-warning-600 dark:border-warning-500", + error: + "bg-danger-50 dark:bg-danger-900/95 border-danger-600 dark:border-danger-500", + success: + "bg-success-50 dark:bg-success-900/95 border-success-600 dark:border-success-500", + }, + }, + defaultVariants: { + variant: "info", + }, + }, +); + +const iconColorVariants = cva("w-6 h-6 flex-shrink-0 mt-0.5", { + variants: { + variant: { + info: "text-info-600 dark:text-info-400", + warning: "text-warning-600 dark:text-warning-400", + error: "text-danger-600 dark:text-danger-400", + success: "text-success-600 dark:text-success-400", + }, + }, + defaultVariants: { + variant: "info", + }, +}); + +const titleColorVariants = cva("text-base font-semibold mb-2", { + variants: { + variant: { + info: "text-info-900 dark:text-info-200", + warning: "text-warning-900 dark:text-warning-200", + error: "text-danger-900 dark:text-danger-200", + success: "text-success-900 dark:text-success-200", + }, + }, + defaultVariants: { + variant: "info", + }, +}); + +const descriptionColorVariants = cva("text-sm mb-3", { + variants: { + variant: { + info: "text-info-800 dark:text-info-300", + warning: "text-warning-800 dark:text-warning-300", + error: "text-danger-800 dark:text-danger-300", + success: "text-success-800 dark:text-success-300", + }, + }, + defaultVariants: { + variant: "info", + }, +}); + +const listColorVariants = cva("text-sm", { + variants: { + variant: { + info: "text-info-700 dark:text-info-300", + warning: "text-yellow-700 dark:text-yellow-300", + error: "text-red-700 dark:text-red-300", + success: "text-green-700 dark:text-green-300", + }, + }, + defaultVariants: { + variant: "info", + }, +}); + +interface InfoCardProps + extends React.HTMLAttributes, + VariantProps { + icon?: React.ComponentType>; + showDefaultIcon?: boolean; +} + +function InfoCard({ + className, + variant = "info", + icon: CustomIcon, + showDefaultIcon = true, + children, + ...props +}: InfoCardProps) { + // Default icons based on variant + const defaultIcons = { + info: InformationCircleIcon, + warning: ExclamationTriangleIcon, + error: ExclamationTriangleIcon, + success: CheckCircleIcon, + }; + + const Icon = CustomIcon || (showDefaultIcon ? defaultIcons[variant!] : null); + + return ( +
+
+ {Icon && } +
{children}
+
+
+ ); +} + +function InfoCardTitle({ + className, + variant = "info", + ...props +}: React.HTMLAttributes & + VariantProps) { + return ( +

+ ); +} + +function InfoCardDescription({ + className, + variant = "info", + ...props +}: React.HTMLAttributes & + VariantProps) { + return ( +

+ ); +} + +interface InfoCardListProps + extends React.HTMLAttributes, + VariantProps { + ordered?: boolean; +} + +function InfoCardList({ + className, + variant = "info", + ordered = false, + children, + ...props +}: InfoCardListProps) { + const ListComponent = ordered ? "ol" : "ul"; + const listClass = ordered ? "list-decimal" : "list-disc"; + + return ( + + {children} + + ); +} + +function InfoCardListItem({ + className, + ...props +}: React.HTMLAttributes) { + return

  • ; +} + +export { + InfoCard, + InfoCardTitle, + InfoCardDescription, + InfoCardList, + InfoCardListItem, +}; diff --git a/vitest.config.ts b/vitest.config.ts index 527491c..a265845 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,12 @@ import { defineConfig } from "vitest/config"; +import { resolve } from "path"; export default defineConfig({ + resolve: { + alias: { + "@": resolve(__dirname, "./src"), + }, + }, test: { globals: true, environment: "jsdom",