Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/icons/SignContractIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export const SignContractIcon = (
props: Omit<IconProps, "children" | "aria-label">
) => (
<Icon aria-label="Sign Contract Icon" viewBox="0 0 512 512" {...props}>
<g transform="translate(0,512) scale(0.1,-0.1)" fill="currentColor">
<g fill="currentColor" transform="translate(0,512) scale(0.1,-0.1)">
<path d="M361 5109 c-172 -34 -318 -182 -351 -358 -14 -74 -14 -4308 0 -4382 34 -180 179 -325 359 -359 74 -14 3088 -14 3162 0 180 34 325 179 359 359 6 33 10 313 10 735 l0 680 573 576 c530 532 575 580 601 638 58 133 57 259 -3 388 -80 170 -283 278 -466 247 -140 -24 -174 -49 -452 -326 l-253 -251 0 611 c0 366 -4 632 -10 664 -23 125 -52 163 -334 445 -282 282 -320 311 -445 334 -67 12 -2686 12 -2750 -1z m2339 -680 c0 -387 0 -392 22 -431 44 -78 45 -78 488 -78 l390 0 0 -583 0 -582 -340 -340 -340 -340 -115 -346 c-63 -190 -115 -359 -115 -375 1 -69 76 -144 145 -144 48 0 714 226 742 251 l23 22 0 -532 c0 -513 -1 -533 -20 -571 -13 -26 -34 -47 -60 -60 -39 -20 -56 -20 -1570 -20 -1483 0 -1532 1 -1568 19 -22 11 -46 35 -60 59 l-22 40 0 2143 0 2144 24 39 c49 81 -51 75 1234 76 l1142 0 0 -391z m377 381 c37 -14 491 -466 509 -507 8 -18 14 -44 14 -58 l0 -25 -300 0 -300 0 0 300 0 300 24 0 c14 0 37 -5 53 -10z m1663 -1490 c43 -22 80 -80 80 -126 0 -48 -37 -107 -117 -186 l-78 -78 -108 108 -107 107 82 82 c46 45 97 89 113 97 41 22 88 20 135 -4z m-690 -965 l-360 -360 -107 108 -108 107 360 360 360 360 107 -107 108 -108 -360 -360z m-680 -470 l105 -105 -40 -39 c-34 -33 -62 -46 -194 -90 -85 -28 -156 -49 -158 -48 -1 2 20 73 48 158 42 126 58 161 87 192 20 20 39 37 42 37 3 0 52 -47 110 -105z" />
<path d="M681 3601 c-42 -22 -81 -85 -81 -130 0 -48 38 -110 82 -132 36 -18 77 -19 1118 -19 1063 0 1081 0 1120 20 45 23 80 80 80 130 0 50 -35 107 -80 130 -39 20 -56 20 -1122 20 -1023 -1 -1085 -2 -1117 -19z" />
<path d="M681 3001 c-42 -22 -81 -85 -81 -130 0 -48 38 -110 82 -132 36 -18 71 -19 818 -19 763 0 782 0 820 20 45 23 80 80 80 130 0 50 -35 107 -80 130 -38 20 -57 20 -822 20 -735 -1 -785 -2 -817 -19z" />
Expand Down
2 changes: 1 addition & 1 deletion src/components/icons/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,8 @@ export { SearchIcon } from "./SearchIcon";
export { SendIcon } from "./SendIcon";
export { ShareIcon } from "./ShareIcon";
export { ShieldCheckIcon } from "./ShieldCheckIcon";
export { SignContractIcon } from "./SignContractIcon";
export { ShowerIcon } from "./ShowerIcon";
export { SignContractIcon } from "./SignContractIcon";
export { SparklesIcon } from "./SparklesIcon";
export { StarIcon } from "./StarIcon";
export { StopIcon } from "./StopIcon";
Expand Down
59 changes: 59 additions & 0 deletions src/components/ui/Breadcrumb/Breadcrumb.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Breadcrumb } from "./Breadcrumb";

const meta: Meta<typeof Breadcrumb> = {
title: "Components/Breadcrumb",
component: Breadcrumb,
parameters: {
jest: "Breadcrumb.test.tsx",
layout: "centered",
},
tags: ["autodocs"],
};

export default meta;
type Story = StoryObj<typeof Breadcrumb>;

export const Default: Story = {
args: {
items: [
{ label: "Home", href: "/" },
{ label: "Products", href: "/products" },
{ label: "Product Detail" },
],
},
};

export const TwoItems: Story = {
args: {
items: [{ label: "Home", href: "/" }, { label: "Current Page" }],
},
};

export const WithOnClick: Story = {
args: {
items: [
{ label: "Home", onClick: () => alert("Home clicked") },
{ label: "Settings", onClick: () => alert("Settings clicked") },
{ label: "Profile" },
],
},
};

export const SingleItem: Story = {
args: {
items: [{ label: "Home" }],
},
};

export const ManyItems: Story = {
args: {
items: [
{ label: "Home", href: "/" },
{ label: "Category", href: "/category" },
{ label: "Subcategory", href: "/category/sub" },
{ label: "Product", href: "/category/sub/product" },
{ label: "Details" },
],
},
};
128 changes: 128 additions & 0 deletions src/components/ui/Breadcrumb/Breadcrumb.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { render, screen } from "@/tests/app-test-utils";
import { Breadcrumb } from "./Breadcrumb";

describe("Breadcrumb", () => {
it("renders a single item as current page", () => {
render(<Breadcrumb items={[{ label: "Home" }]} />);
const text = screen.getByText("Home");
expect(text).toHaveAttribute("aria-current", "page");
expect(text).toHaveClass("text-gray-700");
});

it("renders multiple items with separators between them", () => {
render(
<Breadcrumb
items={[
{ label: "Home", href: "/" },
{ label: "Configs", href: "/configs" },
{ label: "Meta" },
]}
/>
);
expect(screen.getByText("Home")).toBeVisible();
expect(screen.getByText("Configs")).toBeVisible();
expect(screen.getByText("Meta")).toBeVisible();

const separators = document.querySelectorAll('[aria-hidden="true"]');
expect(separators).toHaveLength(2);
});

it("renders links for non-last items with href", () => {
render(
<Breadcrumb
items={[{ label: "Home", href: "/" }, { label: "Current" }]}
/>
);
const link = screen.getByText("Home").closest("a");
expect(link).toHaveAttribute("href", "/");
});

it("renders buttons for non-last items with onClick", () => {
const onClick = jest.fn();
render(
<Breadcrumb items={[{ label: "Back", onClick }, { label: "Current" }]} />
);
const button = screen.getByRole("button", { name: "Back" });
button.click();
expect(onClick).toHaveBeenCalledTimes(1);
});

it("does not render link or button for the last item even with href", () => {
render(<Breadcrumb items={[{ label: "Only", href: "/only" }]} />);
expect(screen.queryByRole("link")).not.toBeInTheDocument();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
expect(screen.getByText("Only")).toHaveAttribute("aria-current", "page");
});

it("does not render button for the last item even with onClick", () => {
render(<Breadcrumb items={[{ label: "Only", onClick: jest.fn() }]} />);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});

it("uses renderLink for custom link rendering", () => {
const renderLink = (href: string, children: React.ReactNode) => (
<a data-testid="custom-link" href={href}>
{children}
</a>
);
render(
<Breadcrumb
items={[{ label: "Home", href: "/" }, { label: "Current" }]}
renderLink={renderLink}
/>
);
const customLink = screen.getByTestId("custom-link");
expect(customLink).toHaveAttribute("href", "/");
});

it("has proper nav element with aria-label", () => {
render(<Breadcrumb items={[{ label: "Home" }]} />);
expect(
screen.getByRole("navigation", { name: "Breadcrumb" })
).toBeInTheDocument();
});

it("uses an ordered list for semantic structure", () => {
render(<Breadcrumb items={[{ label: "A", href: "/" }, { label: "B" }]} />);
const list = document.querySelector('[data-slot="breadcrumb-list"]');
expect(list?.tagName).toBe("OL");
const listItems = list?.querySelectorAll("li");
expect(listItems).toHaveLength(2);
});

it("applies custom className", () => {
const { container } = render(
<Breadcrumb className="my-breadcrumb" items={[{ label: "Home" }]} />
);
const nav = container.querySelector('[data-slot="breadcrumb"]');
expect(nav).toHaveClass("my-breadcrumb");
});

it("passes extra nav props through", () => {
const { container } = render(
<Breadcrumb data-testid="custom-breadcrumb" items={[{ label: "Home" }]} />
);
expect(
container.querySelector('[data-testid="custom-breadcrumb"]')
).toBeInTheDocument();
});

it("renders correctly with many items", () => {
const items = [
{ label: "Home", href: "/" },
{ label: "Section", href: "/section" },
{ label: "Subsection", href: "/section/sub" },
{ label: "Page", href: "/section/sub/page" },
{ label: "Current" },
];
render(<Breadcrumb items={items} />);

for (const item of items) {
expect(screen.getByText(item.label)).toBeVisible();
}
const separators = document.querySelectorAll('[aria-hidden="true"]');
expect(separators).toHaveLength(4);

expect(screen.getByText("Current")).toHaveAttribute("aria-current", "page");
});
});
112 changes: 112 additions & 0 deletions src/components/ui/Breadcrumb/Breadcrumb.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"use client";

import type { ComponentProps, ReactNode } from "react";
import { ChevronRightIcon } from "@/components/icons";
import { cn } from "@/lib/utils";

export type BreadcrumbItem = {
label: string;
href?: string;
onClick?: () => void;
};

export type BreadcrumbProps = {
items: BreadcrumbItem[];
/**
* Custom link renderer for framework-specific routing (e.g. Next.js Link).
* Receives the href and children; should return a link element.
* Falls back to a plain `<a>` tag when not provided.
*/
renderLink?: (href: string, children: ReactNode) => ReactNode;
className?: string;
} & Omit<ComponentProps<"nav">, "children">;

function Breadcrumb({
items,
renderLink,
className,
...props
}: BreadcrumbProps) {
return (
<nav
aria-label="Breadcrumb"
className={cn("flex items-center gap-1 text-sm", className)}
data-slot="breadcrumb"
{...props}
>
<ol className="flex items-center gap-1" data-slot="breadcrumb-list">
{items.map((item, index) => {
const isLast = index === items.length - 1;

const renderItem = () => {
if (!isLast && item.onClick) {
return (
<button
className="cursor-pointer text-gray-500 hover:text-gray-700"
data-slot="breadcrumb-button"
onClick={item.onClick}
type="button"
>
{item.label}
</button>
);
}

if (!isLast && item.href) {
const linkContent = (
<span className="text-gray-500 hover:text-gray-700">
{item.label}
</span>
);

if (renderLink) {
return renderLink(item.href, linkContent);
}

return (
<a
className="text-gray-500 hover:text-gray-700"
data-slot="breadcrumb-link"
href={item.href}
>
{item.label}
</a>
);
}

return (
<span
aria-current={isLast ? "page" : undefined}
className={isLast ? "text-gray-700" : "text-gray-500"}
data-slot="breadcrumb-text"
>
{item.label}
</span>
);
};

return (
<li
className="flex items-center gap-1"
data-slot="breadcrumb-item"
key={`${index}-${item.label}`}
>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{index > 0 && (
<ChevronRightIcon
aria-hidden="true"
className="text-gray-400"
size="sm"
/>
)}
{renderItem()}
</li>
);
})}
</ol>
</nav>
);
}

Breadcrumb.displayName = "Breadcrumb";

export { Breadcrumb };
2 changes: 2 additions & 0 deletions src/components/ui/Breadcrumb/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export type { BreadcrumbItem, BreadcrumbProps } from "./Breadcrumb";
export { Breadcrumb } from "./Breadcrumb";
2 changes: 2 additions & 0 deletions src/components/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export {
AlertDialogTrigger,
} from "./AlertDialog";
export { Badge, badgeVariants } from "./Badge";
export type { BreadcrumbItem, BreadcrumbProps } from "./Breadcrumb";
export { Breadcrumb } from "./Breadcrumb";
export { Button, buttonVariants } from "./Button";
export type { MonthYearPickerProps } from "./Calendar";
export {
Expand Down