Skip to content

Commit 7e4e136

Browse files
authored
Merge pull request #454 from ayshadogo/layout
Implement dark mode toggle with persistence
2 parents b8c4ea5 + f7a7ca2 commit 7e4e136

43 files changed

Lines changed: 3578 additions & 257 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 79 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,92 @@
1-
import { render, screen } from "@testing-library/react";
2-
import userEvent from "@testing-library/user-event";
3-
import { ArrowRight } from "lucide-react";
4-
import Button from "./Button";
1+
import { fireEvent, render, screen } from "@testing-library/react";
2+
import "@testing-library/jest-dom";
3+
import Button from "./index";
54

65
describe("Button", () => {
7-
it("renders with default styles and children", () => {
6+
// --- Renders correctly ---
7+
it("renders with children text", () => {
88
render(<Button>Click me</Button>);
9+
expect(screen.getByRole("button", { name: /click me/i })).toBeInTheDocument();
10+
});
11+
12+
it("renders with primary variant by default", () => {
13+
render(<Button>Primary</Button>);
14+
expect(screen.getByRole("button")).toHaveClass("bg-indigo-600");
15+
});
16+
17+
it("renders with secondary variant", () => {
18+
render(<Button variant="secondary">Secondary</Button>);
19+
expect(screen.getByRole("button")).toHaveClass("border-gray-300");
20+
});
21+
22+
it("renders with destructive variant", () => {
23+
render(<Button variant="destructive">Delete</Button>);
24+
expect(screen.getByRole("button")).toHaveClass("bg-red-600");
25+
});
26+
27+
it("renders with ghost variant", () => {
28+
render(<Button variant="ghost">Ghost</Button>);
29+
expect(screen.getByRole("button")).toHaveClass("hover:bg-gray-100");
30+
});
31+
32+
// --- Handles user interaction ---
33+
it("calls onClick when clicked", () => {
34+
const handleClick = jest.fn();
35+
render(<Button onClick={handleClick}>Click</Button>);
36+
fireEvent.click(screen.getByRole("button"));
37+
expect(handleClick).toHaveBeenCalledTimes(1);
38+
});
39+
40+
it("does not call onClick when disabled", () => {
41+
const handleClick = jest.fn();
42+
render(<Button disabled onClick={handleClick}>Disabled</Button>);
43+
fireEvent.click(screen.getByRole("button"));
44+
expect(handleClick).not.toHaveBeenCalled();
45+
});
46+
47+
// --- Shows correct state (loading / disabled) ---
48+
it("shows loading spinner when loading", () => {
49+
render(<Button loading>Submit</Button>);
50+
expect(screen.getByRole("button")).toBeDisabled();
51+
expect(screen.getByRole("button").querySelector("svg")).toBeInTheDocument();
52+
});
953

10-
const btn = screen.getByRole("button", { name: /click me/i });
11-
expect(btn).toBeInTheDocument();
12-
expect(btn).toHaveClass("bg-indigo-600");
54+
it("is disabled when disabled prop is true", () => {
55+
render(<Button disabled>Disabled</Button>);
56+
expect(screen.getByRole("button")).toBeDisabled();
57+
expect(screen.getByRole("button")).toHaveClass("disabled:opacity-60");
1358
});
1459

15-
it("shows a spinner and disables interaction when loading", async () => {
16-
const user = userEvent.setup();
17-
render(<Button isLoading>Submitting</Button>);
60+
it("is disabled when loading is true (even without disabled prop)", () => {
61+
render(<Button loading>Loading</Button>);
62+
expect(screen.getByRole("button")).toBeDisabled();
63+
});
1864

19-
const btn = screen.getByRole("button", { name: /submitting/i });
20-
expect(btn).toBeDisabled();
21-
expect(screen.getByTestId("spinner")).toBeInTheDocument();
65+
// --- Size variants ---
66+
it("applies sm size classes", () => {
67+
render(<Button size="sm">Small</Button>);
68+
expect(screen.getByRole("button")).toHaveClass("text-xs");
69+
});
2270

23-
await user.click(btn);
24-
expect(btn).toBeDisabled();
71+
it("applies lg size classes", () => {
72+
render(<Button size="lg">Large</Button>);
73+
expect(screen.getByRole("button")).toHaveClass("text-base");
2574
});
2675

27-
it("renders left and right icons", () => {
28-
render(
29-
<Button leftIcon={<span data-testid="left-icon" />} rightIcon={<ArrowRight data-testid="right-icon" />}>
30-
Next
31-
</Button>
32-
);
76+
// --- Full width ---
77+
it("applies full width class when fullWidth is true", () => {
78+
render(<Button fullWidth>Full Width</Button>);
79+
expect(screen.getByRole("button")).toHaveClass("w-full");
80+
});
81+
82+
// --- Supports native button attributes ---
83+
it("forwards type attribute", () => {
84+
render(<Button type="submit">Submit</Button>);
85+
expect(screen.getByRole("button")).toHaveAttribute("type", "submit");
86+
});
3387

34-
expect(screen.getByTestId("left-icon")).toBeInTheDocument();
35-
expect(screen.getByTestId("right-icon")).toBeInTheDocument();
88+
it("merges custom className", () => {
89+
render(<Button className="my-custom">Custom</Button>);
90+
expect(screen.getByRole("button")).toHaveClass("my-custom");
3691
});
3792
});
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"use client";
2+
3+
import React from "react";
4+
5+
export type ButtonVariant = "primary" | "secondary" | "destructive" | "ghost";
6+
export type ButtonSize = "sm" | "md" | "lg";
7+
8+
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
9+
variant?: ButtonVariant;
10+
size?: ButtonSize;
11+
loading?: boolean;
12+
fullWidth?: boolean;
13+
}
14+
15+
const variantClasses: Record<ButtonVariant, string> = {
16+
primary:
17+
"bg-indigo-600 text-white hover:bg-indigo-700 active:bg-indigo-800 focus:ring-indigo-200",
18+
secondary:
19+
"border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 active:bg-gray-100 focus:ring-gray-200",
20+
destructive:
21+
"bg-red-600 text-white hover:bg-red-700 active:bg-red-800 focus:ring-red-200",
22+
ghost:
23+
"text-gray-700 hover:bg-gray-100 active:bg-gray-200 focus:ring-gray-200",
24+
};
25+
26+
const sizeClasses: Record<ButtonSize, string> = {
27+
sm: "px-3 py-1.5 text-xs min-h-[32px]",
28+
md: "px-4 py-2 text-sm min-h-[40px]",
29+
lg: "px-6 py-3 text-base min-h-[48px]",
30+
};
31+
32+
export default function Button({
33+
variant = "primary",
34+
size = "md",
35+
loading = false,
36+
fullWidth = false,
37+
disabled,
38+
children,
39+
className,
40+
...props
41+
}: ButtonProps) {
42+
return (
43+
<button
44+
disabled={disabled || loading}
45+
className={`inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors focus:outline-none focus:ring-2 disabled:cursor-not-allowed disabled:opacity-60 ${variantClasses[variant]} ${sizeClasses[size]} ${fullWidth ? "w-full" : ""} ${className ?? ""}`}
46+
{...props}
47+
>
48+
{loading && (
49+
<svg
50+
className="h-4 w-4 animate-spin"
51+
fill="none"
52+
viewBox="0 0 24 24"
53+
aria-hidden="true"
54+
>
55+
<circle
56+
className="opacity-25"
57+
cx="12"
58+
cy="12"
59+
r="10"
60+
stroke="currentColor"
61+
strokeWidth="4"
62+
/>
63+
<path
64+
className="opacity-75"
65+
fill="currentColor"
66+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
67+
/>
68+
</svg>
69+
)}
70+
{children}
71+
</button>
72+
);
73+
}

0 commit comments

Comments
 (0)