diff --git a/.changeset/breadcrumbs-overflow.md b/.changeset/breadcrumbs-overflow.md new file mode 100644 index 0000000000..697ebac5bf --- /dev/null +++ b/.changeset/breadcrumbs-overflow.md @@ -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 diff --git a/packages/kumo-docs-astro/src/components/demos/BreadcrumbsDemo.tsx b/packages/kumo-docs-astro/src/components/demos/BreadcrumbsDemo.tsx index bdfb9db2d0..a2ccfe1fed 100644 --- a/packages/kumo-docs-astro/src/components/demos/BreadcrumbsDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/BreadcrumbsDemo.tsx @@ -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 ( @@ -16,7 +17,7 @@ export function BreadcrumbsDemo() { export function BreadcrumbsWithIconsDemo() { return ( - }> + }> Home @@ -30,7 +31,7 @@ export function BreadcrumbsWithIconsDemo() { export function BreadcrumbsLoadingDemo() { return ( - }> + }> Home @@ -44,7 +45,7 @@ export function BreadcrumbsLoadingDemo() { export function BreadcrumbsRootDemo() { return ( - }> + }> Worker Analytics @@ -61,3 +62,163 @@ export function BreadcrumbsWithClipboardDemo() { ); } + +/** + * Items-based API: basic usage with href for navigation. + */ +export function BreadcrumbsItemsDemo() { + const items: BreadcrumbItem[] = [ + { label: "Home", href: "/", icon: }, + { label: "Projects", href: "/projects" }, + ]; + + return ; +} + +/** + * Items-based API with loading state on current item. + */ +export function BreadcrumbsItemsLoadingDemo() { + const items: BreadcrumbItem[] = [ + { label: "Home", href: "#", icon: }, + { label: "Projects", href: "#" }, + ]; + + return ( + + ); +} + +/** + * 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; + }) => ( + + {children} + + ); + + const items: BreadcrumbItem[] = [ + { + label: "Home", + icon: , + // Use render prop for custom link component + render: , + }, + { + label: "Projects", + render: , + }, + { + label: "Settings", + render: , + }, + ]; + + return ( + }} + /> + ); +} + +/** + * 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; + }) => ( + + {children} + + ); + + const items: BreadcrumbItem[] = [ + { + label: "Acme Corp", + icon: , + // Using render prop for custom link component (e.g., Next.js Link) + render: , + }, + { label: "Workers & Pages", href: "#" }, + { label: "production-db", href: "#" }, + ]; + + const currentItem: BreadcrumbItem = { + label: "Settings", + icon: , + loading: isLoading, + }; + + return ( +
+
+ + setWidth(Number(e.target.value))} + className="flex-1 max-w-48" + /> + + {width}px + + +
+ +
+ +
+
+ ); +} diff --git a/packages/kumo-docs-astro/src/pages/components/breadcrumbs.mdx b/packages/kumo-docs-astro/src/pages/components/breadcrumbs.mdx index a622d459a9..308e91cb68 100644 --- a/packages/kumo-docs-astro/src/pages/components/breadcrumbs.mdx +++ b/packages/kumo-docs-astro/src/pages/components/breadcrumbs.mdx @@ -16,6 +16,7 @@ import { BreadcrumbsWithClipboardDemo, BreadcrumbsLoadingDemo, BreadcrumbsRootDemo, + BreadcrumbsOverflowDemo, } from "~/components/demos/BreadcrumbsDemo"; @@ -69,6 +70,17 @@ import { + + Overflow (Prototype) +

+ When breadcrumbs overflow their container, items collapse into a dropdown + menu. Drag the slider to resize and watch items collapse. +

+ + + +
+ API Reference diff --git a/packages/kumo/src/components/breadcrumbs/breadcrumbs.test.tsx b/packages/kumo/src/components/breadcrumbs/breadcrumbs.test.tsx new file mode 100644 index 0000000000..ebef1db4f2 --- /dev/null +++ b/packages/kumo/src/components/breadcrumbs/breadcrumbs.test.tsx @@ -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( + + Home + + Docs + + Current Page + , + ); + + // 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( + + Current Page + , + ); + + // 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( + + } + > + Home + + + }> + Current + + , + ); + + // 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(); + + // 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( + , + ); + + const nav = document.querySelector('nav[aria-label="Breadcrumb"]'); + expect(nav).toBeTruthy(); + }); + + it("renders current item with aria-current", () => { + render( + , + ); + + 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: }, + ]; + + render( + , + }} + />, + ); + + // 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); + }); + }); +}); diff --git a/packages/kumo/src/components/breadcrumbs/breadcrumbs.tsx b/packages/kumo/src/components/breadcrumbs/breadcrumbs.tsx index e0b52cad0b..338eb4137e 100644 --- a/packages/kumo/src/components/breadcrumbs/breadcrumbs.tsx +++ b/packages/kumo/src/components/breadcrumbs/breadcrumbs.tsx @@ -3,17 +3,23 @@ import { cloneElement, isValidElement, useEffect, + useRef, useState, type PropsWithChildren, type ReactElement, type ReactNode, } from "react"; -import { CheckIcon, CopyIcon } from "@phosphor-icons/react"; +import { CheckIcon, CopyIcon, DotsThree } from "@phosphor-icons/react"; +import { Menu } from "@base-ui/react/menu"; import { Button } from "../../components/button"; import { SkeletonLine } from "../../components/loader/skeleton-line"; import { useLinkComponent } from "../../utils/link-provider"; import { cn } from "../../utils/cn"; +// ============================================================================ +// Variant Definitions +// ============================================================================ + /** Breadcrumbs size variant definitions. */ export const KUMO_BREADCRUMBS_VARIANTS = { size: { @@ -53,6 +59,33 @@ export function breadcrumbsVariants({ ); } +// ============================================================================ +// Shared Components +// ============================================================================ + +function Separator() { + return ( + + ); +} + +// ============================================================================ +// Compound Component API (Legacy) +// ============================================================================ + export interface BreadcrumbsItemProps { href: string; icon?: React.ReactNode; @@ -106,25 +139,6 @@ function Current({ ); } -function Separator() { - return ( - - ); -} - function MobileEllipsis() { return ( @@ -173,8 +187,44 @@ function Clipboard({ text }: { text: string }) { ); } +function isComponentElement( + child: ReactNode, + component: unknown, +): child is ReactElement { + return isValidElement(child) && child.type === component; +} + +function getMobileBreadcrumbChildren(children: ReactNode[]): ReactNode[] { + const breadcrumbItems = children.filter( + (child) => + isComponentElement(child, Link) || isComponentElement(child, Current), + ) as ReactElement[]; + + if (breadcrumbItems.length <= 2) { + return children; + } + + const [parentItem, currentItem] = breadcrumbItems.slice(-2); + const trailingItems: ReactNode[] = [ + , + , + cloneElement(parentItem, { key: "kumo-breadcrumb-mobile-parent" }), + , + cloneElement(currentItem, { key: "kumo-breadcrumb-mobile-current" }), + ]; + + const extras = children.filter( + (child) => + !isComponentElement(child, Link) && + !isComponentElement(child, Current) && + !isComponentElement(child, Separator), + ); + + return [...trailingItems, ...extras]; +} + /** - * Breadcrumbs component props. + * Breadcrumbs component props for compound component API. * * @example * ```tsx @@ -188,26 +238,12 @@ function Clipboard({ text }: { text: string }) { * ``` */ export interface BreadcrumbsProps - extends PropsWithChildren, - KumoBreadcrumbsVariantsProps { + extends PropsWithChildren, KumoBreadcrumbsVariantsProps { /** Additional CSS classes merged via `cn()`. */ className?: string; } -/** - * Navigation breadcrumb trail showing the current page's location in a hierarchy. - * Compound component with `Breadcrumbs.Link`, `Breadcrumbs.Current`, `Breadcrumbs.Separator`, and `Breadcrumbs.Clipboard`. - * - * @example - * ```tsx - * - * Home - * - * Dashboard - * - * ``` - */ -export function Breadcrumb({ +function CompoundBreadcrumbs({ children, size = "base", className, @@ -226,42 +262,406 @@ export function Breadcrumb({ ); } -function isComponentElement( - child: ReactNode, - component: unknown, -): child is ReactElement { - return isValidElement(child) && child.type === component; +// ============================================================================ +// Items-based API with Overflow Support +// ============================================================================ + +/** + * A single breadcrumb item in the items-based API. + */ +export interface BreadcrumbItem { + /** Display text for the breadcrumb. */ + label: string; + /** URL for anchor navigation (renders `` element). */ + href?: string; + /** Custom element to render (e.g., router Link). Takes precedence over `href`. */ + render?: ReactElement; + /** Optional icon displayed before the label. */ + icon?: ReactNode; + /** Show loading skeleton instead of label (only applies to currentItem). */ + loading?: boolean; } -function getMobileBreadcrumbChildren(children: ReactNode[]): ReactNode[] { - const breadcrumbItems = children.filter( - (child) => - isComponentElement(child, Link) || isComponentElement(child, Current), - ) as ReactElement[]; +/** + * Props for items-based Breadcrumbs API with automatic overflow handling. + */ +export interface BreadcrumbsItemsProps extends KumoBreadcrumbsVariantsProps { + /** Array of breadcrumb items (ancestors of current page). */ + items: BreadcrumbItem[]; + /** The current page item (never collapses into overflow). */ + currentItem: BreadcrumbItem; + /** + * Which end to collapse items from when space is limited. + * @default "start" + */ + collapseFrom?: "start" | "end"; + /** + * Minimum number of ancestor items to keep visible (excluding current). + * Only applied if there's room; won't cause truncation. + * @default 1 + */ + minVisibleItems?: number; + /** Additional CSS classes. */ + className?: string; +} - if (breadcrumbItems.length <= 2) { - return children; - } +// Layout constants for TreeMenu (pixels) +const TREE_INDENT = 8; - const [parentItem, currentItem] = breadcrumbItems.slice(-2); - const trailingItems: ReactNode[] = [ - , - , - cloneElement(parentItem, { key: "kumo-breadcrumb-mobile-parent" }), - , - cloneElement(currentItem, { key: "kumo-breadcrumb-mobile-current" }), - ]; +const TREE_MENU_ITEM_CLASS = + "flex h-9 min-w-0 cursor-pointer items-center gap-2 rounded px-3 text-sm text-kumo-default outline-none select-none data-highlighted:bg-kumo-tint/60"; - const extras = children.filter( - (child) => - !isComponentElement(child, Link) && - !isComponentElement(child, Current) && - !isComponentElement(child, Separator), +/** + * L-shaped connector icon for tree hierarchy visualization. + */ +function TreeConnector() { + return ( + + + ); +} - return [...trailingItems, ...extras]; +/** + * Renders overflow breadcrumb items as an indented tree structure + * with disconnected L-shaped connectors showing hierarchy. + */ +function TreeMenu({ items }: { items: BreadcrumbItem[] }) { + if (items.length === 0) return null; + + return ( +
+ {items.map((item, i) => { + const paddingLeft = i === 0 ? 12 : 12 + i * TREE_INDENT; + + return ( + + ) + } + closeOnClick + className={TREE_MENU_ITEM_CLASS} + style={{ paddingLeft }} + > + {i > 0 && } + {item.label} + + ); + })} +
+ ); +} + +/** + * Items-based breadcrumbs with automatic overflow handling. + * Measures items and collapses them into a dropdown when they don't fit. + */ +function ItemsBreadcrumbs({ + items, + currentItem, + collapseFrom = "start", + minVisibleItems = 0, + size = "base", + className, +}: BreadcrumbsItemsProps) { + const containerRef = useRef(null); + const measureRef = useRef(null); + const [overflowCount, setOverflowCount] = useState(0); + const [itemWidths, setItemWidths] = useState([]); + const [currentItemWidth, setCurrentItemWidth] = useState(0); + const [measured, setMeasured] = useState(false); + const LinkComponent = useLinkComponent(); + + // Measure all items once on mount/change + useEffect(() => { + if (!measureRef.current) return; + + const measureContainer = measureRef.current; + const itemEls = measureContainer.querySelectorAll("[data-measure-item]"); + const currentEl = measureContainer.querySelector("[data-measure-current]"); + + const widths = Array.from(itemEls).map( + (el) => el.getBoundingClientRect().width, + ); + const currentWidth = currentEl?.getBoundingClientRect().width ?? 100; + + setItemWidths(widths); + setCurrentItemWidth(currentWidth); + setMeasured(true); + }, [items, currentItem]); + + // Compute overflow based on cached widths + useEffect(() => { + if (!measured || !containerRef.current) return; + + const computeOverflow = () => { + const container = containerRef.current; + if (!container) return; + + // Be conservative - subtract buffer for measurement inaccuracies + const containerWidth = container.offsetWidth - 16; + const overflowButtonWidth = 48; // button + ring + padding + const separatorWidth = 32; // separator + gaps (24px icon + gaps) + + // Start with current item width + its preceding separator + let usedWidth = currentItemWidth + separatorWidth; + let visibleCount = 0; + + // Measure from end to preserve parent context + for (let i = items.length - 1; i >= 0; i--) { + const itemWidth = itemWidths[i] ?? 80; + const neededWidth = separatorWidth + itemWidth; + + const willHaveOverflow = i > 0; + const overflowSpace = willHaveOverflow + ? overflowButtonWidth + separatorWidth + : 0; + + if (usedWidth + neededWidth + overflowSpace <= containerWidth) { + usedWidth += neededWidth; + visibleCount++; + } else { + break; + } + } + + // Honor minVisibleItems if it fits + const overflowNeeded = items.length - visibleCount; + if (overflowNeeded > 0 && overflowNeeded < items.length) { + const minVisible = Math.min(minVisibleItems, items.length); + if (visibleCount < minVisible) { + let testWidth = + currentItemWidth + overflowButtonWidth + separatorWidth; + for (let i = items.length - minVisible; i < items.length; i++) { + testWidth += separatorWidth + (itemWidths[i] ?? 80); + } + if (testWidth <= containerWidth) { + visibleCount = minVisible; + } + } + } + + setOverflowCount(items.length - visibleCount); + }; + + computeOverflow(); + + const observer = new ResizeObserver(computeOverflow); + observer.observe(containerRef.current); + return () => observer.disconnect(); + }, [measured, items.length, itemWidths, currentItemWidth, minVisibleItems]); + + // Split items + const overflowItems = + collapseFrom === "start" + ? items.slice(0, overflowCount) + : items.slice(items.length - overflowCount); + + const visibleItems = + collapseFrom === "start" + ? items.slice(overflowCount) + : items.slice(0, items.length - overflowCount); + + return ( + <> + {/* Hidden measurement container */} +
+ {items.map((item, index) => ( + + {item.icon} + {item.label} + + ))} + + {currentItem.icon} + {currentItem.label} + +
+ + + + ); +} + +// ============================================================================ +// Unified Breadcrumb Component +// ============================================================================ + +/** + * Combined props type supporting both APIs. + * When `items` is provided, uses items-based API with overflow. + * Otherwise, uses compound component API with children. + */ +export type BreadcrumbsCombinedProps = + | BreadcrumbsProps + | (BreadcrumbsItemsProps & { children?: never }); + +function hasItemsProps( + props: BreadcrumbsCombinedProps, +): props is BreadcrumbsItemsProps { + return "items" in props && Array.isArray(props.items); +} + +/** + * Navigation breadcrumb trail showing the current page's location in a hierarchy. + * + * Supports two APIs: + * + * **Compound Component API** (legacy): + * ```tsx + * + * Home + * + * Dashboard + * + * ``` + * + * **Items API** (with automatic overflow): + * ```tsx + * + * ``` + */ +export function Breadcrumb(props: BreadcrumbsCombinedProps) { + if (hasItemsProps(props)) { + return ; + } + return ; } +Breadcrumb.displayName = "Breadcrumbs"; Breadcrumb.Link = Link; Breadcrumb.Current = Current; Breadcrumb.Separator = Separator; diff --git a/packages/kumo/src/components/breadcrumbs/index.ts b/packages/kumo/src/components/breadcrumbs/index.ts index 6c7a3a0945..f505b876cd 100644 --- a/packages/kumo/src/components/breadcrumbs/index.ts +++ b/packages/kumo/src/components/breadcrumbs/index.ts @@ -3,7 +3,10 @@ export { breadcrumbsVariants, KUMO_BREADCRUMBS_VARIANTS, KUMO_BREADCRUMBS_DEFAULT_VARIANTS, + type BreadcrumbItem, type BreadcrumbsProps, + type BreadcrumbsItemsProps, + type BreadcrumbsCombinedProps, type KumoBreadcrumbsSize, type KumoBreadcrumbsVariantsProps, } from "./breadcrumbs"; diff --git a/packages/kumo/src/index.ts b/packages/kumo/src/index.ts index 872fe1fb28..08c87eaa4b 100644 --- a/packages/kumo/src/index.ts +++ b/packages/kumo/src/index.ts @@ -167,7 +167,13 @@ export { type KumoLinkVariant, type KumoLinkVariantsProps, } from "./components/link"; -export { Breadcrumbs, type BreadcrumbsProps } from "./components/breadcrumbs"; +export { + Breadcrumbs, + type BreadcrumbItem, + type BreadcrumbsProps, + type BreadcrumbsItemsProps, + type BreadcrumbsCombinedProps, +} from "./components/breadcrumbs"; export { Empty, type EmptyProps } from "./components/empty"; export { Grid,