mirror of
https://github.com/jhbruhn/respira.git
synced 2026-01-27 10:23:41 +00:00
feature: Add InfoCard shared component with tests
Co-authored-by: jhbruhn <1036566+jhbruhn@users.noreply.github.com>
This commit is contained in:
parent
2114bacdae
commit
a828bf4c8f
3 changed files with 357 additions and 0 deletions
160
src/components/ui/info-card.test.tsx
Normal file
160
src/components/ui/info-card.test.tsx
Normal file
|
|
@ -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(
|
||||||
|
<InfoCard>
|
||||||
|
<InfoCardTitle>Test Title</InfoCardTitle>
|
||||||
|
</InfoCard>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<InfoCard variant={variant}>
|
||||||
|
<InfoCardTitle variant={variant}>{variant} Card</InfoCardTitle>
|
||||||
|
</InfoCard>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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<SVGSVGElement>) => (
|
||||||
|
<svg data-testid="custom-icon" {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<InfoCard icon={CustomIcon}>
|
||||||
|
<InfoCardTitle>Title</InfoCardTitle>
|
||||||
|
</InfoCard>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("custom-icon")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not render icon when showDefaultIcon is false", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<InfoCard showDefaultIcon={false}>
|
||||||
|
<InfoCardTitle>Title</InfoCardTitle>
|
||||||
|
</InfoCard>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const svg = container.querySelector("svg");
|
||||||
|
expect(svg).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("InfoCardTitle", () => {
|
||||||
|
it("should render with correct variant colors", () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<InfoCardTitle variant="info">Info Title</InfoCardTitle>,
|
||||||
|
);
|
||||||
|
const infoTitle = screen.getByText("Info Title");
|
||||||
|
expect(infoTitle.classList.contains("text-info-900")).toBe(true);
|
||||||
|
|
||||||
|
rerender(<InfoCardTitle variant="error">Error Title</InfoCardTitle>);
|
||||||
|
const errorTitle = screen.getByText("Error Title");
|
||||||
|
expect(errorTitle.classList.contains("text-danger-900")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("InfoCardDescription", () => {
|
||||||
|
it("should render description text", () => {
|
||||||
|
render(
|
||||||
|
<InfoCardDescription>This is a description</InfoCardDescription>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("This is a description")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should apply variant colors", () => {
|
||||||
|
render(
|
||||||
|
<InfoCardDescription variant="warning">
|
||||||
|
Warning description
|
||||||
|
</InfoCardDescription>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<InfoCardList>
|
||||||
|
<InfoCardListItem>Item 1</InfoCardListItem>
|
||||||
|
<InfoCardListItem>Item 2</InfoCardListItem>
|
||||||
|
</InfoCardList>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<InfoCardList ordered>
|
||||||
|
<InfoCardListItem>First</InfoCardListItem>
|
||||||
|
<InfoCardListItem>Second</InfoCardListItem>
|
||||||
|
</InfoCardList>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const list = screen.getByRole("list");
|
||||||
|
expect(list.tagName).toBe("OL");
|
||||||
|
expect(list.classList.contains("list-decimal")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render list items", () => {
|
||||||
|
render(
|
||||||
|
<InfoCardList>
|
||||||
|
<InfoCardListItem>Item 1</InfoCardListItem>
|
||||||
|
<InfoCardListItem>Item 2</InfoCardListItem>
|
||||||
|
</InfoCardList>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Item 1")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Item 2")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("composition", () => {
|
||||||
|
it("should render full info card with all components", () => {
|
||||||
|
render(
|
||||||
|
<InfoCard variant="success">
|
||||||
|
<InfoCardTitle variant="success">Success!</InfoCardTitle>
|
||||||
|
<InfoCardDescription variant="success">
|
||||||
|
Operation completed successfully
|
||||||
|
</InfoCardDescription>
|
||||||
|
<InfoCardList variant="success" ordered>
|
||||||
|
<InfoCardListItem>First step completed</InfoCardListItem>
|
||||||
|
<InfoCardListItem>Second step completed</InfoCardListItem>
|
||||||
|
</InfoCardList>
|
||||||
|
</InfoCard>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
191
src/components/ui/info-card.tsx
Normal file
191
src/components/ui/info-card.tsx
Normal file
|
|
@ -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<HTMLDivElement>,
|
||||||
|
VariantProps<typeof infoCardVariants> {
|
||||||
|
icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||||
|
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 (
|
||||||
|
<div className={cn(infoCardVariants({ variant }), className)} {...props}>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{Icon && <Icon className={iconColorVariants({ variant })} />}
|
||||||
|
<div className="flex-1">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoCardTitle({
|
||||||
|
className,
|
||||||
|
variant = "info",
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLHeadingElement> &
|
||||||
|
VariantProps<typeof titleColorVariants>) {
|
||||||
|
return (
|
||||||
|
<h3 className={cn(titleColorVariants({ variant }), className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoCardDescription({
|
||||||
|
className,
|
||||||
|
variant = "info",
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLParagraphElement> &
|
||||||
|
VariantProps<typeof descriptionColorVariants>) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
className={cn(descriptionColorVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InfoCardListProps
|
||||||
|
extends React.HTMLAttributes<HTMLOListElement | HTMLUListElement>,
|
||||||
|
VariantProps<typeof listColorVariants> {
|
||||||
|
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 (
|
||||||
|
<ListComponent
|
||||||
|
className={cn(
|
||||||
|
listClass,
|
||||||
|
"list-inside space-y-1.5",
|
||||||
|
listColorVariants({ variant }),
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ListComponent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoCardListItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLLIElement>) {
|
||||||
|
return <li className={cn("pl-2", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
InfoCard,
|
||||||
|
InfoCardTitle,
|
||||||
|
InfoCardDescription,
|
||||||
|
InfoCardList,
|
||||||
|
InfoCardListItem,
|
||||||
|
};
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
|
import { resolve } from "path";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: "jsdom",
|
environment: "jsdom",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue