Skip to content
Open
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
13 changes: 13 additions & 0 deletions .changeset/breadcrumbs-overflow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@cloudflare/kumo": minor
---

Add items-based API with overflow support to Breadcrumbs component

- New `items` prop accepts an array of `BreadcrumbItem` objects for declarative breadcrumb definition
- New `currentItem` prop for the current page item
- Automatic overflow detection collapses items that don't fit into a dropdown menu
- Tree visualization in overflow menu shows breadcrumb hierarchy with L-shaped SVG connectors
- Support for custom router links via `render` prop on items
- Loading state support on current item
- Fully backward compatible - existing compound component API continues to work
171 changes: 166 additions & 5 deletions packages/kumo-docs-astro/src/components/demos/BreadcrumbsDemo.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Breadcrumbs } from "@cloudflare/kumo";
import { House } from "@phosphor-icons/react";
import { useState } from "react";
import { Breadcrumbs, type BreadcrumbItem } from "@cloudflare/kumo";
import { HouseIcon, DatabaseIcon } from "@phosphor-icons/react";

export function BreadcrumbsDemo() {
return (
Expand All @@ -16,7 +17,7 @@ export function BreadcrumbsDemo() {
export function BreadcrumbsWithIconsDemo() {
return (
<Breadcrumbs>
<Breadcrumbs.Link href="#" icon={<House size={16} />}>
<Breadcrumbs.Link href="#" icon={<HouseIcon size={16} />}>
Home
</Breadcrumbs.Link>
<Breadcrumbs.Separator />
Expand All @@ -30,7 +31,7 @@ export function BreadcrumbsWithIconsDemo() {
export function BreadcrumbsLoadingDemo() {
return (
<Breadcrumbs>
<Breadcrumbs.Link href="#" icon={<House size={16} />}>
<Breadcrumbs.Link href="#" icon={<HouseIcon size={16} />}>
Home
</Breadcrumbs.Link>
<Breadcrumbs.Separator />
Expand All @@ -44,7 +45,7 @@ export function BreadcrumbsLoadingDemo() {
export function BreadcrumbsRootDemo() {
return (
<Breadcrumbs>
<Breadcrumbs.Current icon={<House size={16} />}>
<Breadcrumbs.Current icon={<HouseIcon size={16} />}>
Worker Analytics
</Breadcrumbs.Current>
</Breadcrumbs>
Expand All @@ -61,3 +62,163 @@ export function BreadcrumbsWithClipboardDemo() {
</Breadcrumbs>
);
}

/**
* Items-based API: basic usage with href for navigation.
*/
export function BreadcrumbsItemsDemo() {
const items: BreadcrumbItem[] = [
{ label: "Home", href: "/", icon: <HouseIcon size={16} /> },
{ label: "Projects", href: "/projects" },
];

return <Breadcrumbs items={items} currentItem={{ label: "My Project" }} />;
}

/**
* Items-based API with loading state on current item.
*/
export function BreadcrumbsItemsLoadingDemo() {
const items: BreadcrumbItem[] = [
{ label: "Home", href: "#", icon: <HouseIcon size={16} /> },
{ label: "Projects", href: "#" },
];

return (
<Breadcrumbs items={items} currentItem={{ label: "", loading: true }} />
);
}

/**
* Items-based API with custom render prop for router integration.
* Use the `render` prop to provide your own link component (e.g., Next.js Link, React Router Link).
* The component will clone your element and inject the label + icon as children.
*/
export function BreadcrumbsItemsCustomRenderDemo() {
// Simulating a router Link component
const RouterLink = ({
to,
children,
...props
}: {
to: string;
children?: React.ReactNode;
className?: string;
}) => (
<a href={to} {...props}>
{children}
</a>
);

const items: BreadcrumbItem[] = [
{
label: "Home",
icon: <HouseIcon size={16} />,
// Use render prop for custom link component
render: <RouterLink to="/" />,
},
{
label: "Projects",
render: <RouterLink to="/projects" />,
},
{
label: "Settings",
render: <RouterLink to="/projects/settings" />,
},
];

return (
<Breadcrumbs
items={items}
currentItem={{ label: "General", icon: <DatabaseIcon size={16} /> }}
/>
);
}

/**
* Interactive demo showing the items-based API with automatic overflow.
* Drag the slider to resize and watch items collapse into a dropdown.
*
* This demo showcases:
* - Automatic overflow with tree visualization in dropdown
* - Icons on breadcrumb items
* - Custom render prop for router integration (simulated)
* - Loading state toggle
*/
export function BreadcrumbsOverflowDemo() {
const [width, setWidth] = useState(600);
const [isLoading, setIsLoading] = useState(false);

// Simulated router Link component
const RouterLink = ({
to,
children,
...props
}: {
to: string;
children?: React.ReactNode;
className?: string;
}) => (
<a href={to} {...props}>
{children}
</a>
);

const items: BreadcrumbItem[] = [
{
label: "Acme Corp",
icon: <HouseIcon size={16} className="shrink-0" />,
// Using render prop for custom link component (e.g., Next.js Link)
render: <RouterLink to="#" />,
},
{ label: "Workers & Pages", href: "#" },
{ label: "production-db", href: "#" },
];

const currentItem: BreadcrumbItem = {
label: "Settings",
icon: <DatabaseIcon size={16} className="shrink-0" />,
loading: isLoading,
};

return (
<div className="flex w-full flex-col gap-4">
<div className="flex flex-wrap items-center gap-3">
<label className="text-sm text-kumo-subtle whitespace-nowrap">
Container width:
</label>
<input
type="range"
min={200}
max={700}
value={width}
onChange={(e) => setWidth(Number(e.target.value))}
className="flex-1 max-w-48"
/>
<span className="text-sm text-kumo-subtle tabular-nums w-14">
{width}px
</span>
<label className="flex items-center gap-2 text-sm text-kumo-subtle">
<input
type="checkbox"
checked={isLoading}
onChange={(e) => setIsLoading(e.target.checked)}
/>
Loading
</label>
</div>

<div
className="border border-kumo-line rounded-lg p-5 bg-kumo-base"
style={{ width }}
>
<Breadcrumbs
items={items}
currentItem={currentItem}
collapseFrom="start"
minVisibleItems={1}
/>
</div>
</div>
);
}
12 changes: 12 additions & 0 deletions packages/kumo-docs-astro/src/pages/components/breadcrumbs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
BreadcrumbsWithClipboardDemo,
BreadcrumbsLoadingDemo,
BreadcrumbsRootDemo,
BreadcrumbsOverflowDemo,
} from "~/components/demos/BreadcrumbsDemo";

<ComponentSection>
Expand Down Expand Up @@ -69,6 +70,17 @@ import {
</ComponentExample>
</ComponentSection>

<ComponentSection>
<Heading level={3}>Overflow (Prototype)</Heading>
<p>
When breadcrumbs overflow their container, items collapse into a dropdown
menu. Drag the slider to resize and watch items collapse.
</p>
<ComponentExample demo="BreadcrumbsOverflowDemo">
<BreadcrumbsOverflowDemo client:load />
</ComponentExample>
</ComponentSection>

<ComponentSection>
<Heading level={2}>API Reference</Heading>

Expand Down
121 changes: 121 additions & 0 deletions packages/kumo/src/components/breadcrumbs/breadcrumbs.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Breadcrumb as Breadcrumbs, type BreadcrumbItem } from "./breadcrumbs";

describe("Breadcrumbs", () => {
describe("Compound Component API (legacy)", () => {
it("renders breadcrumb links and current item", () => {
render(
<Breadcrumbs>
<Breadcrumbs.Link href="/home">Home</Breadcrumbs.Link>
<Breadcrumbs.Separator />
<Breadcrumbs.Link href="/docs">Docs</Breadcrumbs.Link>
<Breadcrumbs.Separator />
<Breadcrumbs.Current>Current Page</Breadcrumbs.Current>
</Breadcrumbs>,
);

// Component renders both mobile and desktop views, so use getAllBy
expect(screen.getAllByText("Home").length).toBeGreaterThan(0);
expect(screen.getAllByText("Docs").length).toBeGreaterThan(0);
expect(screen.getAllByText("Current Page").length).toBeGreaterThan(0);
});

it("renders current item with aria-current", () => {
render(
<Breadcrumbs>
<Breadcrumbs.Current>Current Page</Breadcrumbs.Current>
</Breadcrumbs>,
);

// Component renders both mobile and desktop views, so find within nav
const nav = document.querySelector('nav[aria-label="breadcrumb"]');
const current = nav?.querySelector("[aria-current='page']");
expect(current).toBeTruthy();
expect(current?.textContent).toContain("Current Page");
});

it("renders with icons", () => {
render(
<Breadcrumbs>
<Breadcrumbs.Link
href="/home"
icon={<span data-testid="home-icon" />}
>
Home
</Breadcrumbs.Link>
<Breadcrumbs.Separator />
<Breadcrumbs.Current icon={<span data-testid="current-icon" />}>
Current
</Breadcrumbs.Current>
</Breadcrumbs>,
);

// Icons appear in both mobile and desktop views
expect(screen.getAllByTestId("home-icon").length).toBeGreaterThan(0);
expect(screen.getAllByTestId("current-icon").length).toBeGreaterThan(0);
});
});

describe("Items API", () => {
it("renders items-based breadcrumbs", () => {
const items: BreadcrumbItem[] = [
{ label: "Home", href: "/" },
{ label: "Projects", href: "/projects" },
];

render(<Breadcrumbs items={items} currentItem={{ label: "Settings" }} />);

// Items appear in both measurement container and nav, so use getAllByText
expect(screen.getAllByText("Home").length).toBeGreaterThan(0);
expect(screen.getAllByText("Projects").length).toBeGreaterThan(0);
expect(screen.getAllByText("Settings").length).toBeGreaterThan(0);
});

it("renders nav with aria-label", () => {
render(
<Breadcrumbs
items={[{ label: "Home", href: "/" }]}
currentItem={{ label: "Settings" }}
/>,
);

const nav = document.querySelector('nav[aria-label="Breadcrumb"]');
expect(nav).toBeTruthy();
});

it("renders current item with aria-current", () => {
render(
<Breadcrumbs
items={[{ label: "Home", href: "/" }]}
currentItem={{ label: "Current" }}
/>,
);

const nav = document.querySelector('nav[aria-label="Breadcrumb"]');
const current = nav?.querySelector("[aria-current='page']");
expect(current).toBeTruthy();
expect(current?.textContent).toContain("Current");
});

it("renders items with icons", () => {
const items: BreadcrumbItem[] = [
{ label: "Home", href: "/", icon: <span data-testid="home-icon" /> },
];

render(
<Breadcrumbs
items={items}
currentItem={{
label: "Settings",
icon: <span data-testid="settings-icon" />,
}}
/>,
);

// Icons appear in measurement container too, so check for multiple
expect(screen.getAllByTestId("home-icon").length).toBeGreaterThan(0);
expect(screen.getAllByTestId("settings-icon").length).toBeGreaterThan(0);
});
});
});
Loading
Loading