diff --git a/app/components-preview/page.tsx b/app/components-preview/page.tsx index d23e021..633710d 100644 --- a/app/components-preview/page.tsx +++ b/app/components-preview/page.tsx @@ -9,6 +9,8 @@ import Progress from "../components/Progress"; import Create from "../components/Create"; import Input from "../components/Input"; import InputWithCounter from "../components/InputWithCounter"; +import IconCard from "../components/IconCard"; +import { getAssetPath } from "../../lib/assetUtils"; export default function ComponentsPreview() { const [alertVisible, setAlertVisible] = useState({ @@ -413,6 +415,32 @@ export default function ComponentsPreview() { + + {/* IconCard Component Section */} +
+

+ IconCard Component +

+ +
+
+ + } + title="Worker's cooperatives" + description="Employee-owned businesses often need to clarify how power is shared, decisions are made, and how processes operate within their organizations." + onClick={() => console.log("IconCard clicked")} + /> +
+
+
); diff --git a/app/components/IconCard/IconCard.container.tsx b/app/components/IconCard/IconCard.container.tsx new file mode 100644 index 0000000..205b82d --- /dev/null +++ b/app/components/IconCard/IconCard.container.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { memo } from "react"; +import { IconCardView } from "./IconCard.view"; +import type { IconCardProps } from "./IconCard.types"; + +const IconCardContainer = memo( + ({ icon, title, description, className = "", onClick }) => { + const handleClick = () => { + if (onClick) onClick(); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleClick(); + } + }; + + return ( + + ); + }, +); + +IconCardContainer.displayName = "IconCard"; + +export default IconCardContainer; diff --git a/app/components/IconCard/IconCard.types.ts b/app/components/IconCard/IconCard.types.ts new file mode 100644 index 0000000..04f6c11 --- /dev/null +++ b/app/components/IconCard/IconCard.types.ts @@ -0,0 +1,16 @@ +export interface IconCardProps { + icon: React.ReactNode; + title: string; + description: string; + className?: string; + onClick?: () => void; +} + +export interface IconCardViewProps { + icon: React.ReactNode; + title: string; + description: string; + className: string; + onClick: () => void; + onKeyDown: (event: React.KeyboardEvent) => void; +} diff --git a/app/components/IconCard/IconCard.view.tsx b/app/components/IconCard/IconCard.view.tsx new file mode 100644 index 0000000..d911e72 --- /dev/null +++ b/app/components/IconCard/IconCard.view.tsx @@ -0,0 +1,38 @@ +"use client"; + +import type { IconCardViewProps } from "./IconCard.types"; + +export function IconCardView({ + icon, + title, + description, + className, + onClick, + onKeyDown, +}: IconCardViewProps) { + return ( +
+ {/* Icon */} +
+ {icon} +
+ + {/* Title - Centered with auto space above and below */} +

+ {title} +

+ + {/* Description */} +

+ {description} +

+
+ ); +} diff --git a/app/components/IconCard/index.tsx b/app/components/IconCard/index.tsx new file mode 100644 index 0000000..ed26dd5 --- /dev/null +++ b/app/components/IconCard/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./IconCard.container"; +export type { IconCardProps } from "./IconCard.types"; diff --git a/public/assets/Vector_Dao.svg b/public/assets/Vector_Dao.svg new file mode 100644 index 0000000..3581b6a --- /dev/null +++ b/public/assets/Vector_Dao.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/public/assets/Vector_Default.svg b/public/assets/Vector_Default.svg new file mode 100644 index 0000000..2bd6a9b --- /dev/null +++ b/public/assets/Vector_Default.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/Vector_MutualAid.svg b/public/assets/Vector_MutualAid.svg new file mode 100644 index 0000000..b4654f8 --- /dev/null +++ b/public/assets/Vector_MutualAid.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/assets/Vector_OpenSource.svg b/public/assets/Vector_OpenSource.svg new file mode 100644 index 0000000..49ae29b --- /dev/null +++ b/public/assets/Vector_OpenSource.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/assets/Vector_Shapes.svg b/public/assets/Vector_Shapes.svg new file mode 100644 index 0000000..d8e7d57 --- /dev/null +++ b/public/assets/Vector_Shapes.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/public/assets/Vector_WorkerCoop.svg b/public/assets/Vector_WorkerCoop.svg new file mode 100644 index 0000000..cb4b277 --- /dev/null +++ b/public/assets/Vector_WorkerCoop.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/stories/IconCard.stories.js b/stories/IconCard.stories.js new file mode 100644 index 0000000..e6bab37 --- /dev/null +++ b/stories/IconCard.stories.js @@ -0,0 +1,80 @@ +import IconCard from "../app/components/IconCard"; +import { getAssetPath } from "../lib/assetUtils"; + +export default { + title: "Components/IconCard", + component: IconCard, + parameters: { + layout: "centered", + docs: { + description: { + component: + "An interactive card component that displays an icon, title, and description. Features hover states, keyboard navigation, and accessibility support. Use Tab key to test focus indicators and Enter/Space to activate.", + }, + }, + }, + argTypes: { + icon: { + control: false, + description: "The icon element to display at the top of the card", + }, + title: { + control: { type: "text" }, + description: "The main title of the card", + }, + description: { + control: { type: "text" }, + description: "The description text displayed in uppercase", + }, + onClick: { action: "clicked" }, + }, + tags: ["autodocs"], +}; + +// Worker's Coop icon +const WorkerCoopIcon = () => ( + +); + +export const Default = { + args: { + icon: , + title: "Worker's cooperatives", + description: + "Employee-owned businesses often need to clarify how power is shared, decisions are made, and how processes operate within their organizations.", + }, +}; + +export const WithLongTitle = { + args: { + icon: , + title: "This is a very long title that might wrap to multiple lines", + description: + "Employee-owned businesses often need to clarify how power is shared.", + }, +}; + +export const WithShortDescription = { + args: { + icon: , + title: "Worker's cooperatives", + description: "Short description", + }, +}; + +export const Interactive = { + args: { + icon: , + title: "Clickable Card", + description: "This card has an onClick handler", + onClick: () => { + console.log("Card clicked!"); + }, + }, +}; diff --git a/tests/components/IconCard.test.tsx b/tests/components/IconCard.test.tsx new file mode 100644 index 0000000..18ff92f --- /dev/null +++ b/tests/components/IconCard.test.tsx @@ -0,0 +1,110 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import "@testing-library/jest-dom/vitest"; +import IconCard from "../../app/components/IconCard"; +import { + componentTestSuite, + type ComponentTestSuiteConfig, +} from "../utils/componentTestSuite"; + +type IconCardProps = React.ComponentProps; + +const baseProps: IconCardProps = { + icon:
Icon
, + title: "Worker's cooperatives", + description: "Employee-owned businesses often need to clarify how power is shared", +}; + +const config: ComponentTestSuiteConfig = { + component: IconCard, + name: "IconCard", + props: baseProps, + requiredProps: ["icon", "title", "description"], + optionalProps: { + className: "custom-class", + onClick: vi.fn(), + }, + primaryRole: "button", + testCases: { + renders: true, + accessibility: true, + keyboardNavigation: true, + disabledState: false, + errorState: false, + }, +}; + +componentTestSuite(config); + +describe("IconCard (behavioral tests)", () => { + it("calls onClick when clicked", () => { + const handleClick = vi.fn(); + render( + Icon} + title="Test Title" + description="Test Description" + onClick={handleClick} + />, + ); + const card = screen.getByRole("button"); + fireEvent.click(card); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it("calls onClick when Enter key is pressed", () => { + const handleClick = vi.fn(); + render( + Icon} + title="Test Title" + description="Test Description" + onClick={handleClick} + />, + ); + const card = screen.getByRole("button"); + fireEvent.keyDown(card, { key: "Enter" }); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it("calls onClick when Space key is pressed", () => { + const handleClick = vi.fn(); + render( + Icon} + title="Test Title" + description="Test Description" + onClick={handleClick} + />, + ); + const card = screen.getByRole("button"); + fireEvent.keyDown(card, { key: " " }); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it("renders icon, title, and description", () => { + render( + Icon} + title="Worker's cooperatives" + description="Employee-owned businesses" + />, + ); + expect(screen.getByTestId("icon")).toBeInTheDocument(); + expect(screen.getByText("Worker's cooperatives")).toBeInTheDocument(); + expect(screen.getByText("Employee-owned businesses")).toBeInTheDocument(); + }); + + it("has proper ARIA label", () => { + render( + Icon} + title="Test Title" + description="Test Description" + />, + ); + const card = screen.getByRole("button"); + expect(card).toHaveAttribute("aria-label", "Test Title: Test Description"); + }); +});