Update NumberCard component

This commit is contained in:
adilallo
2026-02-03 21:01:55 -07:00
parent ecaef5d797
commit 139780d867
8 changed files with 424 additions and 183 deletions
+91
View File
@@ -0,0 +1,91 @@
"use client";
import { memo } from "react";
import SectionNumber from "./SectionNumber";
interface NumberCardProps {
number: number;
text: string;
size?: "Small" | "Medium" | "Large" | "XLarge";
iconShape?: string;
iconColor?: string;
}
const NumberCard = memo<NumberCardProps>(({ number, text, size }) => {
// Base classes common to all sizes
const baseClasses = "bg-[var(--color-surface-inverse-primary)] rounded-[12px] shadow-lg";
// If size prop is provided, use explicit size classes
// Otherwise, use responsive breakpoints for backward compatibility
if (size) {
// Size-specific classes
const sizeClasses = {
Small: "flex flex-col items-end justify-center gap-4 p-5 relative",
Medium: "flex flex-row items-center gap-8 p-8 relative",
Large: "flex flex-col items-start justify-end gap-[22px] h-[238px] p-8 relative",
XLarge: "flex flex-col items-start justify-end gap-[22px] h-[238px] p-8 relative",
};
// Text size classes
const textClasses = {
Small: "font-bricolage-grotesque font-medium text-[24px] leading-[32px] text-[#141414]",
Medium: "font-bricolage-grotesque font-medium text-[24px] leading-[24px] text-[#141414]",
Large: "font-bricolage-grotesque font-medium text-[24px] leading-[24px] text-[#141414]",
XLarge: "font-bricolage-grotesque font-medium text-[32px] leading-[32px] text-[#141414]",
};
// Section number positioning classes
const sectionNumberClasses = {
Small: "flex justify-end items-end",
Medium: "flex justify-start flex-shrink-0",
Large: "absolute top-8 right-8",
XLarge: "absolute top-8 right-8",
};
// Content container classes
const contentClasses = {
Small: "",
Medium: "flex-1",
Large: "absolute bottom-8 left-8 right-16",
XLarge: "absolute bottom-8 left-8 right-16",
};
return (
<div className={`${baseClasses} ${sizeClasses[size]}`}>
{/* Section Number */}
<div className={sectionNumberClasses[size]}>
<SectionNumber number={number} />
</div>
{/* Card Content */}
<div className={contentClasses[size]}>
<p className={textClasses[size]}>
{text}
</p>
</div>
</div>
);
}
// Responsive breakpoints for backward compatibility (matches original behavior)
// Maps to: Small (mobile) -> Medium (sm) -> Large (lg) -> XLarge (xl)
return (
<div className={`${baseClasses} flex flex-col gap-4 p-5 sm:flex-row sm:gap-8 sm:p-8 sm:items-center lg:flex-col lg:gap-[22px] lg:items-start lg:justify-end lg:p-8 lg:relative lg:h-[238px]`}>
{/* Section Number - Responsive positioning */}
<div className="flex justify-end items-end sm:justify-start sm:flex-shrink-0 lg:absolute lg:top-8 lg:right-8">
<SectionNumber number={number} />
</div>
{/* Card Content - Responsive positioning */}
<div className="sm:flex-1 lg:absolute lg:bottom-8 lg:left-8 lg:right-16">
<p className="font-bricolage-grotesque font-medium text-[24px] leading-[32px] sm:leading-[24px] sm:text-[24px] lg:text-[24px] lg:leading-[24px] xl:text-[32px] xl:leading-[32px] text-[#141414]">
{text}
</p>
</div>
</div>
);
});
NumberCard.displayName = "NumberCard";
export default NumberCard;
-33
View File
@@ -1,33 +0,0 @@
"use client";
import { memo } from "react";
import SectionNumber from "./SectionNumber";
interface NumberedCardProps {
number: number;
text: string;
iconShape?: string;
iconColor?: string;
}
const NumberedCard = memo<NumberedCardProps>(({ number, text }) => {
return (
<div className="bg-[var(--color-surface-inverse-primary)] rounded-[12px] p-5 shadow-lg flex flex-col gap-4 sm:p-8 sm:gap-8 sm:flex-row sm:items-center lg:p-8 lg:gap-0 lg:flex-row lg:items-stretch lg:relative lg:h-[238px]">
{/* Section Number - Top right (lg breakpoint) */}
<div className="flex justify-end sm:justify-start sm:flex-shrink-0 lg:absolute lg:top-8 lg:right-8">
<SectionNumber number={number} />
</div>
{/* Card Content - Bottom left (lg breakpoint) */}
<div className="sm:flex-1 lg:absolute lg:bottom-8 lg:left-8 lg:right-16">
<p className="font-bricolage-grotesque font-medium text-[24px] leading-[32px] sm:font-normal sm:leading-[24px] sm:text-[24px] lg:text-[24px] lg:leading-[24px] xl:text-[32px] xl:leading-[32px] text-[#141414]">
{text}
</p>
</div>
</div>
);
});
NumberedCard.displayName = "NumberedCard";
export default NumberedCard;
@@ -2,7 +2,7 @@
import { useTranslation } from "../../contexts/MessagesContext"; import { useTranslation } from "../../contexts/MessagesContext";
import SectionHeader from "../SectionHeader"; import SectionHeader from "../SectionHeader";
import NumberedCard from "../NumberedCard"; import NumberCard from "../NumberCard";
import Button from "../Button"; import Button from "../Button";
import type { NumberedCardsViewProps } from "./NumberedCards.types"; import type { NumberedCardsViewProps } from "./NumberedCards.types";
@@ -35,7 +35,7 @@ function NumberedCardsView({
{/* Cards Container */} {/* Cards Container */}
<div className="grid grid-cols-1 gap-y-[var(--spacing-scale-024)] lg:grid-cols-3 lg:gap-[var(--spacing-scale-024)]"> <div className="grid grid-cols-1 gap-y-[var(--spacing-scale-024)] lg:grid-cols-3 lg:gap-[var(--spacing-scale-024)]">
{cards.map((card, index) => ( {cards.map((card, index) => (
<NumberedCard <NumberCard
key={index} key={index}
number={index + 1} number={index + 1}
text={card.text} text={card.text}
+221
View File
@@ -0,0 +1,221 @@
import NumberCard from "../app/components/NumberCard";
export default {
title: "Components/NumberCard",
component: NumberCard,
parameters: {
layout: "centered",
docs: {
description: {
component:
"Individual number card component that displays a step in a process with a numbered icon and descriptive text. Supports explicit size variants (Small, Medium, Large, XLarge) matching Figma designs, or responsive layouts when size is not specified.",
},
},
},
argTypes: {
number: {
control: { type: "number", min: 1, max: 9 },
description: "The number to display on the card",
},
text: {
control: { type: "text" },
description: "The descriptive text for this step",
},
size: {
control: { type: "select" },
options: ["Small", "Medium", "Large", "XLarge", undefined],
description:
"Explicit size variant matching Figma designs. If not specified, uses responsive breakpoints for backward compatibility.",
},
iconShape: {
control: { type: "select" },
options: ["blob", "gear", "star"],
description:
"The shape of the icon background (currently not used, uses PNG images)",
},
iconColor: {
control: { type: "select" },
options: ["green", "purple", "orange", "blue"],
description:
"The color theme for the icon (currently not used, uses PNG images)",
},
},
tags: ["autodocs"],
};
export const Default = {
args: {
number: 1,
text: "Document how your community makes decisions",
iconShape: "blob",
iconColor: "green",
},
};
export const Small = {
args: {
number: 1,
text: "Document how your community makes decisions",
size: "Small",
iconShape: "blob",
iconColor: "green",
},
parameters: {
docs: {
description: {
story:
"Small size variant: flex-col layout with items-end, 16px gap, 20px padding, 24px text with 32px line height. Section number positioned top-right.",
},
},
},
};
export const Medium = {
args: {
number: 1,
text: "Document how your community makes decisions",
size: "Medium",
iconShape: "blob",
iconColor: "green",
},
parameters: {
docs: {
description: {
story:
"Medium size variant: flex-row layout with items-center, 32px gap, 32px padding, 24px text with 24px line height. Section number on left side.",
},
},
},
};
export const Large = {
args: {
number: 1,
text: "Document how your community makes decisions",
size: "Large",
iconShape: "blob",
iconColor: "green",
},
parameters: {
docs: {
description: {
story:
"Large size variant: flex-col layout with items-start justify-end, 22px gap, 238px height, 32px padding, 24px text with 24px line height. Section number absolute top-right.",
},
},
},
};
export const XLarge = {
args: {
number: 1,
text: "Document how your community makes decisions",
size: "XLarge",
iconShape: "blob",
iconColor: "green",
},
parameters: {
docs: {
description: {
story:
"XLarge size variant: flex-col layout with items-start justify-end, 22px gap, 238px height, 32px padding, 32px text with 32px line height. Section number absolute top-right.",
},
},
},
};
export const AllSizes = {
render: () => (
<div className="space-y-8">
<div>
<h3 className="mb-4 text-lg font-semibold">Small</h3>
<NumberCard
number={1}
text="Document how your community makes decisions"
size="Small"
/>
</div>
<div>
<h3 className="mb-4 text-lg font-semibold">Medium</h3>
<NumberCard
number={2}
text="Document how your community makes decisions"
size="Medium"
/>
</div>
<div>
<h3 className="mb-4 text-lg font-semibold">Large</h3>
<NumberCard
number={3}
text="Document how your community makes decisions"
size="Large"
/>
</div>
<div>
<h3 className="mb-4 text-lg font-semibold">XLarge</h3>
<NumberCard
number={1}
text="Document how your community makes decisions"
size="XLarge"
/>
</div>
</div>
),
parameters: {
docs: {
description: {
story:
"Shows all four size variants side by side to compare the different layouts and typography.",
},
},
},
};
export const AllNumbers = {
args: {
number: 1,
text: "Example card text",
iconShape: "blob",
iconColor: "green",
},
render: (args) => (
<div className="space-y-4">
<NumberCard {...args} number={1} text="First step in the process" />
<NumberCard
{...args}
number={2}
text="Second step with different content"
/>
<NumberCard
{...args}
number={3}
text="Third and final step of the workflow"
/>
</div>
),
parameters: {
docs: {
description: {
story:
"Shows all three numbered cards with different content to demonstrate the visual hierarchy.",
},
},
},
};
export const LongText = {
args: {
number: 1,
text: "This is a much longer piece of text that demonstrates how the card handles content that spans multiple lines and requires more space to display properly",
iconShape: "blob",
iconColor: "green",
},
parameters: {
docs: {
description: {
story:
"Demonstrates how the card handles longer text content across different breakpoints.",
},
},
},
};
-96
View File
@@ -1,96 +0,0 @@
import NumberedCard from "../app/components/NumberedCard";
export default {
title: "Components/NumberedCard",
component: NumberedCard,
parameters: {
layout: "centered",
docs: {
description: {
component:
"Individual numbered card component that displays a step in a process with a numbered icon and descriptive text. Supports responsive layouts across different breakpoints.",
},
},
},
argTypes: {
number: {
control: { type: "number", min: 1, max: 9 },
description: "The number to display on the card",
},
text: {
control: { type: "text" },
description: "The descriptive text for this step",
},
iconShape: {
control: { type: "select" },
options: ["blob", "gear", "star"],
description:
"The shape of the icon background (currently not used, uses PNG images)",
},
iconColor: {
control: { type: "select" },
options: ["green", "purple", "orange", "blue"],
description:
"The color theme for the icon (currently not used, uses PNG images)",
},
},
tags: ["autodocs"],
};
export const Default = {
args: {
number: 1,
text: "Document how your community makes decisions",
iconShape: "blob",
iconColor: "green",
},
};
export const AllNumbers = {
args: {
number: 1,
text: "Example card text",
iconShape: "blob",
iconColor: "green",
},
render: (args) => (
<div className="space-y-4">
<NumberedCard {...args} number={1} text="First step in the process" />
<NumberedCard
{...args}
number={2}
text="Second step with different content"
/>
<NumberedCard
{...args}
number={3}
text="Third and final step of the workflow"
/>
</div>
),
parameters: {
docs: {
description: {
story:
"Shows all three numbered cards with different content to demonstrate the visual hierarchy.",
},
},
},
};
export const LongText = {
args: {
number: 1,
text: "This is a much longer piece of text that demonstrates how the card handles content that spans multiple lines and requires more space to display properly",
iconShape: "blob",
iconColor: "green",
},
parameters: {
docs: {
description: {
story:
"Demonstrates how the card handles longer text content across different breakpoints.",
},
},
},
};
+1 -1
View File
@@ -8,7 +8,7 @@ export default {
docs: { docs: {
description: { description: {
component: component:
"A component system for visually communicating multi-step workflows, processes, or value propositions. The component's modular design with NumberedCard and SectionNumber sub-components makes it ideal for explaining any sequential process while maintaining brand consistency and accessibility standards across the design system.", "A component system for visually communicating multi-step workflows, processes, or value propositions. The component's modular design with NumberCard and SectionNumber sub-components makes it ideal for explaining any sequential process while maintaining brand consistency and accessibility standards across the design system.",
}, },
}, },
}, },
@@ -1,49 +1,49 @@
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import NumberedCard from "../../app/components/NumberedCard"; import NumberCard from "../../app/components/NumberCard";
describe("NumberedCard Component", () => { describe("NumberCard Component", () => {
const defaultProps = { const defaultProps = {
number: 1, number: 1,
text: "Test Card Text", text: "Test Card Text",
}; };
it("renders numbered card with all required information", () => { it("renders number card with all required information", () => {
render(<NumberedCard {...defaultProps} />); render(<NumberCard {...defaultProps} />);
expect(screen.getByText("1")).toBeInTheDocument(); expect(screen.getByText("1")).toBeInTheDocument();
expect(screen.getByText("Test Card Text")).toBeInTheDocument(); expect(screen.getByText("Test Card Text")).toBeInTheDocument();
}); });
it("renders with different numbers", () => { it("renders with different numbers", () => {
const { rerender } = render(<NumberedCard {...defaultProps} number={42} />); const { rerender } = render(<NumberCard {...defaultProps} number={42} />);
expect(screen.getByText("42")).toBeInTheDocument(); expect(screen.getByText("42")).toBeInTheDocument();
rerender(<NumberedCard {...defaultProps} number={999} />); rerender(<NumberCard {...defaultProps} number={999} />);
expect(screen.getByText("999")).toBeInTheDocument(); expect(screen.getByText("999")).toBeInTheDocument();
}); });
it("renders with different text content", () => { it("renders with different text content", () => {
const { rerender } = render( const { rerender } = render(
<NumberedCard {...defaultProps} text="Different Text" />, <NumberCard {...defaultProps} text="Different Text" />,
); );
expect(screen.getByText("Different Text")).toBeInTheDocument(); expect(screen.getByText("Different Text")).toBeInTheDocument();
rerender(<NumberedCard {...defaultProps} text="Another Text" />); rerender(<NumberCard {...defaultProps} text="Another Text" />);
expect(screen.getByText("Another Text")).toBeInTheDocument(); expect(screen.getByText("Another Text")).toBeInTheDocument();
}); });
it("applies proper responsive layout classes", () => { it("applies proper responsive layout classes when size is not specified", () => {
render(<NumberedCard {...defaultProps} />); render(<NumberCard {...defaultProps} />);
const card = screen const card = screen
.getByText("Test Card Text") .getByText("Test Card Text")
.closest("div").parentElement; .closest("div").parentElement;
expect(card).toHaveClass("flex", "flex-col", "sm:flex-row", "lg:flex-row"); expect(card).toHaveClass("flex", "flex-col", "sm:flex-row", "lg:flex-col");
}); });
it("applies proper responsive spacing", () => { it("applies proper responsive spacing when size is not specified", () => {
render(<NumberedCard {...defaultProps} />); render(<NumberCard {...defaultProps} />);
const card = screen const card = screen
.getByText("Test Card Text") .getByText("Test Card Text")
@@ -51,17 +51,17 @@ describe("NumberedCard Component", () => {
expect(card).toHaveClass("p-5", "sm:p-8", "lg:p-8"); expect(card).toHaveClass("p-5", "sm:p-8", "lg:p-8");
}); });
it("applies proper responsive gap", () => { it("applies proper responsive gap when size is not specified", () => {
render(<NumberedCard {...defaultProps} />); render(<NumberCard {...defaultProps} />);
const card = screen const card = screen
.getByText("Test Card Text") .getByText("Test Card Text")
.closest("div").parentElement; .closest("div").parentElement;
expect(card).toHaveClass("gap-4", "sm:gap-8", "lg:gap-0"); expect(card).toHaveClass("gap-4", "sm:gap-8", "lg:gap-[22px]");
}); });
it("applies proper responsive height", () => { it("applies proper responsive height when size is not specified", () => {
render(<NumberedCard {...defaultProps} />); render(<NumberCard {...defaultProps} />);
const card = screen const card = screen
.getByText("Test Card Text") .getByText("Test Card Text")
@@ -70,7 +70,7 @@ describe("NumberedCard Component", () => {
}); });
it("applies proper background and shadow", () => { it("applies proper background and shadow", () => {
render(<NumberedCard {...defaultProps} />); render(<NumberCard {...defaultProps} />);
const card = screen const card = screen
.getByText("Test Card Text") .getByText("Test Card Text")
@@ -82,7 +82,7 @@ describe("NumberedCard Component", () => {
}); });
it("applies proper border radius", () => { it("applies proper border radius", () => {
render(<NumberedCard {...defaultProps} />); render(<NumberCard {...defaultProps} />);
const card = screen const card = screen
.getByText("Test Card Text") .getByText("Test Card Text")
@@ -90,25 +90,15 @@ describe("NumberedCard Component", () => {
expect(card).toHaveClass("rounded-[12px]"); expect(card).toHaveClass("rounded-[12px]");
}); });
it("renders section number in correct position", () => { it("renders section number in correct position for responsive mode", () => {
render(<NumberedCard {...defaultProps} />); render(<NumberCard {...defaultProps} />);
const numberElement = screen.getByText("1"); const numberElement = screen.getByText("1");
expect(numberElement).toBeInTheDocument(); expect(numberElement).toBeInTheDocument();
// Check that it's in a container with proper positioning
const numberContainer = numberElement.closest("div");
expect(numberContainer).toHaveClass(
"absolute",
"inset-0",
"flex",
"items-center",
"justify-center",
);
}); });
it("renders text content in correct position", () => { it("renders text content in correct position for responsive mode", () => {
render(<NumberedCard {...defaultProps} />); render(<NumberCard {...defaultProps} />);
const textElement = screen.getByText("Test Card Text"); const textElement = screen.getByText("Test Card Text");
expect(textElement).toBeInTheDocument(); expect(textElement).toBeInTheDocument();
@@ -125,14 +115,14 @@ describe("NumberedCard Component", () => {
}); });
it("applies proper font classes to text", () => { it("applies proper font classes to text", () => {
render(<NumberedCard {...defaultProps} />); render(<NumberCard {...defaultProps} />);
const textElement = screen.getByText("Test Card Text"); const textElement = screen.getByText("Test Card Text");
expect(textElement).toHaveClass("font-bricolage-grotesque"); expect(textElement).toHaveClass("font-bricolage-grotesque");
}); });
it("applies proper text sizing", () => { it("applies proper text sizing for responsive mode", () => {
render(<NumberedCard {...defaultProps} />); render(<NumberCard {...defaultProps} />);
const textElement = screen.getByText("Test Card Text"); const textElement = screen.getByText("Test Card Text");
expect(textElement).toHaveClass( expect(textElement).toHaveClass(
@@ -144,7 +134,7 @@ describe("NumberedCard Component", () => {
}); });
it("applies proper text color", () => { it("applies proper text color", () => {
render(<NumberedCard {...defaultProps} />); render(<NumberCard {...defaultProps} />);
const textElement = screen.getByText("Test Card Text"); const textElement = screen.getByText("Test Card Text");
expect(textElement).toHaveClass("text-[#141414]"); expect(textElement).toHaveClass("text-[#141414]");
@@ -152,14 +142,14 @@ describe("NumberedCard Component", () => {
it("handles long text content gracefully", () => { it("handles long text content gracefully", () => {
const longText = const longText =
"This is a very long text that should wrap properly and not break the layout of the numbered card component"; "This is a very long text that should wrap properly and not break the layout of the number card component";
render(<NumberedCard {...defaultProps} text={longText} />); render(<NumberCard {...defaultProps} text={longText} />);
expect(screen.getByText(longText)).toBeInTheDocument(); expect(screen.getByText(longText)).toBeInTheDocument();
}); });
it("maintains proper responsive behavior", () => { it("maintains proper responsive behavior when size is not specified", () => {
render(<NumberedCard {...defaultProps} />); render(<NumberCard {...defaultProps} />);
const card = screen const card = screen
.getByText("Test Card Text") .getByText("Test Card Text")
@@ -178,16 +168,16 @@ describe("NumberedCard Component", () => {
// Large breakpoint // Large breakpoint
expect(card).toHaveClass( expect(card).toHaveClass(
"lg:flex-row", "lg:flex-col",
"lg:gap-0", "lg:gap-[22px]",
"lg:p-8", "lg:p-8",
"lg:items-stretch", "lg:items-start",
"lg:relative", "lg:relative",
); );
}); });
it("renders with proper flex layout", () => { it("renders with proper flex layout", () => {
render(<NumberedCard {...defaultProps} />); render(<NumberCard {...defaultProps} />);
const card = screen const card = screen
.getByText("Test Card Text") .getByText("Test Card Text")
@@ -195,12 +185,80 @@ describe("NumberedCard Component", () => {
expect(card).toHaveClass("flex"); expect(card).toHaveClass("flex");
}); });
it("applies proper items alignment", () => { it("applies Small size variant correctly", () => {
render(<NumberedCard {...defaultProps} />); render(<NumberCard {...defaultProps} size="Small" />);
const card = screen const card = screen
.getByText("Test Card Text") .getByText("Test Card Text")
.closest("div").parentElement; .closest("div").parentElement;
expect(card).toHaveClass("sm:items-center", "lg:items-stretch"); expect(card).toHaveClass(
"flex",
"flex-col",
"items-end",
"justify-center",
"gap-4",
"p-5",
);
const textElement = screen.getByText("Test Card Text");
expect(textElement).toHaveClass("text-[24px]", "leading-[32px]");
});
it("applies Medium size variant correctly", () => {
render(<NumberCard {...defaultProps} size="Medium" />);
const card = screen
.getByText("Test Card Text")
.closest("div").parentElement;
expect(card).toHaveClass(
"flex",
"flex-row",
"items-center",
"gap-8",
"p-8",
);
const textElement = screen.getByText("Test Card Text");
expect(textElement).toHaveClass("text-[24px]", "leading-[24px]");
});
it("applies Large size variant correctly", () => {
render(<NumberCard {...defaultProps} size="Large" />);
const card = screen
.getByText("Test Card Text")
.closest("div").parentElement;
expect(card).toHaveClass(
"flex",
"flex-col",
"items-start",
"justify-end",
"gap-[22px]",
"h-[238px]",
"p-8",
);
const textElement = screen.getByText("Test Card Text");
expect(textElement).toHaveClass("text-[24px]", "leading-[24px]");
});
it("applies XLarge size variant correctly", () => {
render(<NumberCard {...defaultProps} size="XLarge" />);
const card = screen
.getByText("Test Card Text")
.closest("div").parentElement;
expect(card).toHaveClass(
"flex",
"flex-col",
"items-start",
"justify-end",
"gap-[22px]",
"h-[238px]",
"p-8",
);
const textElement = screen.getByText("Test Card Text");
expect(textElement).toHaveClass("text-[32px]", "leading-[32px]");
}); });
}); });
+2 -2
View File
@@ -72,10 +72,10 @@ describe("NumberedCards Component", () => {
expect(screen.getByText("Test Subtitle")).toBeInTheDocument(); expect(screen.getByText("Test Subtitle")).toBeInTheDocument();
}); });
test("renders NumberedCard components with correct props", () => { test("renders NumberCard components with correct props", () => {
render(<NumberedCards title="Test" subtitle="Test" cards={mockCards} />); render(<NumberedCards title="Test" subtitle="Test" cards={mockCards} />);
// Check that NumberedCard components receive correct props // Check that NumberCard components receive correct props
expect(screen.getByText("1")).toBeInTheDocument(); // First card number expect(screen.getByText("1")).toBeInTheDocument(); // First card number
expect(screen.getByText("2")).toBeInTheDocument(); // Second card number expect(screen.getByText("2")).toBeInTheDocument(); // Second card number
expect(screen.getByText("3")).toBeInTheDocument(); // Third card number expect(screen.getByText("3")).toBeInTheDocument(); // Third card number