From 31263d34f2c46af46015830aabfc5ac80ad63f11 Mon Sep 17 00:00:00 2001 From: Matt Rothenberg Date: Thu, 9 Apr 2026 16:08:45 -0400 Subject: [PATCH 1/9] docs: add breadcrumbs overflow prototype demo --- .../src/components/demos/BreadcrumbsDemo.tsx | 346 +++++++++++++++++- .../src/pages/components/breadcrumbs.mdx | 19 + 2 files changed, 360 insertions(+), 5 deletions(-) diff --git a/packages/kumo-docs-astro/src/components/demos/BreadcrumbsDemo.tsx b/packages/kumo-docs-astro/src/components/demos/BreadcrumbsDemo.tsx index bdfb9db2d0..47b49cb66d 100644 --- a/packages/kumo-docs-astro/src/components/demos/BreadcrumbsDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/BreadcrumbsDemo.tsx @@ -1,5 +1,12 @@ -import { Breadcrumbs } from "@cloudflare/kumo"; -import { House } from "@phosphor-icons/react"; +import { useRef, useState, useEffect, type ReactNode } from "react"; +import { Breadcrumbs, Button } from "@cloudflare/kumo"; +import { Menu } from "@cloudflare/kumo/primitives/menu"; +import { + HouseIcon, + DotsThreeIcon, + CaretRightIcon, + DatabaseIcon, +} from "@phosphor-icons/react"; export function BreadcrumbsDemo() { return ( @@ -16,7 +23,7 @@ export function BreadcrumbsDemo() { export function BreadcrumbsWithIconsDemo() { return ( - }> + }> Home @@ -30,7 +37,7 @@ export function BreadcrumbsWithIconsDemo() { export function BreadcrumbsLoadingDemo() { return ( - }> + }> Home @@ -44,7 +51,7 @@ export function BreadcrumbsLoadingDemo() { export function BreadcrumbsRootDemo() { return ( - }> + }> Worker Analytics @@ -61,3 +68,332 @@ export function BreadcrumbsWithClipboardDemo() { ); } + +// ============================================================================ +// PROTOTYPE: Overflow Breadcrumbs +// ============================================================================ + +type BreadcrumbItem = { + label: string; + href: string; + icon?: ReactNode; +}; + +/** + * Prototype: Data-driven breadcrumbs with overflow collapse. + * Items that don't fit collapse into a dropdown menu. + * + * API inspired by Blueprint JS: + * - `items`: Array of breadcrumb data + * - `collapseFrom`: "start" (default) or "end" + * - `minVisibleItems`: Minimum items to always show + */ +function OverflowBreadcrumbs({ + items, + currentItem, + collapseFrom = "start", + minVisibleItems = 1, + className, +}: { + items: BreadcrumbItem[]; + currentItem: BreadcrumbItem; + collapseFrom?: "start" | "end"; + minVisibleItems?: number; + className?: string; +}) { + 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); + + // Measure all items once on mount + 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; + + const containerWidth = container.offsetWidth; + const overflowButtonWidth = 36; + const separatorWidth = 20; + + // Start with space needed for current item only + let usedWidth = currentItemWidth; + let visibleCount = 0; + + // Measure from the end (items closest to current) to preserve parent context + for (let i = items.length - 1; i >= 0; i--) { + const itemWidth = itemWidths[i] ?? 80; + // Each visible item needs: separator before it + the item itself + const neededWidth = separatorWidth + itemWidth; + + // If we have overflow, we need space for the overflow button + its separator + const itemsBeforeThis = i; + const willHaveOverflow = itemsBeforeThis > 0; + const overflowSpace = willHaveOverflow + ? overflowButtonWidth + separatorWidth + : 0; + + // Check if adding this item (plus overflow button if needed) fits + if (usedWidth + neededWidth + overflowSpace <= containerWidth) { + usedWidth += neededWidth; + visibleCount++; + } else { + // Can't fit this item - check if we need overflow button space + // All remaining items go to overflow + break; + } + } + + // Don't enforce minVisibleItems if it would cause overflow/truncation + // Only apply it if there's actually room + const overflowNeeded = items.length - visibleCount; + if (overflowNeeded > 0 && overflowNeeded < items.length) { + // We have some overflow - check if minVisibleItems fits + const minVisible = Math.min(minVisibleItems, items.length); + if (visibleCount < minVisible) { + // Check if forcing minVisibleItems would actually fit + 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 into overflow and visible + 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 - renders all items to measure their natural width */} +
+ {items.map((item, index) => ( + + {item.icon} + {item.label} + + ))} + + {currentItem.icon} + {currentItem.label} + +
+ + + + ); +} + +function Separator() { + return ( + + ); +} + +/** + * Interactive demo showing breadcrumbs with overflow behavior. + * Drag the slider to resize and watch items collapse into a dropdown. + */ +export function BreadcrumbsOverflowDemo() { + const [width, setWidth] = useState(500); + + // Realistic Cloudflare dashboard route: D1 database settings + const items: BreadcrumbItem[] = [ + { + label: "Acme Corp", + href: "#", + icon: , + }, + { label: "Workers & Pages", href: "#" }, + { label: "D1 SQL Databases", href: "#" }, + { label: "production-db", href: "#" }, + ]; + + const currentItem: BreadcrumbItem = { + label: "Settings", + href: "#", + icon: , + }; + + 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..df8464cdc8 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,24 @@ import { + + Overflow (Prototype) +

+ When breadcrumbs overflow their container, items collapse into a dropdown + menu. Drag the slider to resize and watch items collapse. API inspired by{" "} + + Blueprint JS + + . +

+ + + +
+ API Reference From 1ce6d7b37242ded3eb339ec577072d8d45844d33 Mon Sep 17 00:00:00 2001 From: Matt Rothenberg Date: Fri, 10 Apr 2026 10:12:58 -0400 Subject: [PATCH 2/9] feat(breadcrumbs): improve overflow menu with tree visualization and dropdown styling - Add TreeMenu component with SVG-based L-shaped connectors showing hierarchy - Use Menu.LinkItem for keyboard-navigable anchor links - Support custom render prop for router Link integration - Match Kumo DropdownMenu styling (bg-kumo-control, ring-kumo-line) - Add translucent highlight state to preserve tree line visibility - Remove problematic arrow, simplify to standard dropdown appearance --- .../src/components/demos/BreadcrumbsDemo.tsx | 189 +++++++++++++----- 1 file changed, 140 insertions(+), 49 deletions(-) diff --git a/packages/kumo-docs-astro/src/components/demos/BreadcrumbsDemo.tsx b/packages/kumo-docs-astro/src/components/demos/BreadcrumbsDemo.tsx index 47b49cb66d..00f118c2e3 100644 --- a/packages/kumo-docs-astro/src/components/demos/BreadcrumbsDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/BreadcrumbsDemo.tsx @@ -69,16 +69,135 @@ export function BreadcrumbsWithClipboardDemo() { ); } +function ArrowSvg(props: React.ComponentProps<"svg">) { + return ( + + + + + + ); +} + // ============================================================================ // PROTOTYPE: Overflow Breadcrumbs // ============================================================================ type BreadcrumbItem = { label: string; - href: string; + /** Anchor navigation (renders a real `` via `Menu.LinkItem`). */ + href?: string; + /** Override the underlying element (e.g. router `Link`). */ + render?: React.ReactElement; icon?: ReactNode; }; +/** + * Overflow menu list that visually shows a breadcrumb path as an indented tree. + * + * Uses Base UI `Menu.Item` for keyboard navigation, with a single SVG overlay + * to draw the connector lines. This keeps the semantics and interaction intact + * while letting us fully control the visual. + */ +function TreeMenu({ items }: { items: BreadcrumbItem[] }) { + if (items.length === 0) return null; + + // Keep these in sync with the Menu.Item layout below. + const ROW_H = 32; // `h-8` + const BASE_X = 6; // root spine x (keep left of root label) + const INDENT = 14; // per-level indent (relaxed depth) + const BRANCH = 6; // horizontal branch into label (shorter) + const TEXT_GAP = 10; // space between branch and text + + const totalHeight = items.length * ROW_H; + const yCenter = (index: number) => index * ROW_H + ROW_H / 2; + const xLevel = (level: number) => BASE_X + level * INDENT; + const xText = (level: number) => xLevel(level) + BRANCH + TEXT_GAP; + + return ( +
+ + {/* + One full L-connector per item. + This avoids overlapping stroke caps at joints (which can darken pixels + when using opacity) and keeps the connectors crisp. + */} + {items.map((_, i) => { + if (i === 0) return null; + + const x1 = xLevel(i - 1); + const x2 = xLevel(i) + BRANCH; + const y2 = yCenter(i); + // Avoid drawing through the root label: start below row 0 for the first connector. + const y1 = i === 1 ? yCenter(0) + ROW_H / 2 : yCenter(i - 1); + + return ( + + ); + })} + + +
+ {items.map((item, i) => + item.render ? ( + + {item.label} + + ) : ( + + {item.label} + + ), + )} +
+
+ ); +} + /** * Prototype: Data-driven breadcrumbs with overflow collapse. * Items that don't fit collapse into a dropdown menu. @@ -235,7 +354,7 @@ function OverflowBreadcrumbs({