From 1aa6c111ed29d6ea32ab884f275142a964284a58 Mon Sep 17 00:00:00 2001 From: paanSinghCoder Date: Thu, 5 Mar 2026 18:13:45 +0530 Subject: [PATCH 1/3] feat (breadcrumb): add automatic ellipsis support and hide items --- apps/www/src/app/examples/page.tsx | 125 ++++++++++ apps/www/src/components/demo/demo.tsx | 8 + .../docs/components/breadcrumb/demo.ts | 89 +++++-- .../docs/components/breadcrumb/index.mdx | 20 +- .../docs/components/breadcrumb/props.ts | 16 ++ .../breadcrumb/__tests__/breadcrumb.test.tsx | 223 ++++++++++++++++++ .../components/breadcrumb/breadcrumb-root.tsx | 163 ++++++++++++- 7 files changed, 612 insertions(+), 32 deletions(-) diff --git a/apps/www/src/app/examples/page.tsx b/apps/www/src/app/examples/page.tsx index f0cd70f19..25815bc20 100644 --- a/apps/www/src/app/examples/page.tsx +++ b/apps/www/src/app/examples/page.tsx @@ -3,6 +3,7 @@ import { Amount, Avatar, AvatarGroup, + Breadcrumb, Button, Calendar, Callout, @@ -37,6 +38,35 @@ import { import dayjs from 'dayjs'; import React, { useState } from 'react'; +const breadcrumbTrail = ( + <> + Home + + Products + + Electronics + + + Laptops + + + + Gaming + + + + Accessories + + + + Footwear + + +); + const Page = () => { const [dialogOpen, setDialogOpen] = useState(false); const [nestedDialogOpen, setNestedDialogOpen] = useState(false); @@ -226,6 +256,101 @@ const Page = () => { + {/* Breadcrumb Examples */} + + Breadcrumb Examples + + + + + 1. (no props) – manual ellipsis + + + Home + + + + + Footwear + + + + + + 2. maxItems=8 + + {breadcrumbTrail} + + + + 3. maxItems=5 + + {breadcrumbTrail} + + + + 4. maxItems=3 + + {breadcrumbTrail} + + + + 5. maxItems=5 itemsBeforeCollapse=2 + + + {breadcrumbTrail} + + + + + 6. maxItems=5 itemsBeforeCollapse=5 (4 before + 1 after) + + + Home + + Products + + + Electronics + + + + Laptops + + + + Gaming + + + + Accessories + + + + Footwear + + + + + + 7. size=small maxItems=4 + + + {breadcrumbTrail} + + + + - - Home - - Products - - Shoes - - - Home - - Products - - Shoes - - ` + tabs: [ + { + name: 'Small', + code: ` + + Home + + Products + + Shoes + ` + }, + { + name: 'Medium', + code: ` + + Home + + Products + + Shoes + ` + } + ] }; export const separatorDemo = { @@ -78,6 +85,42 @@ export const ellipsisDemo = { ` }; +export const maxItemsDemo = { + type: 'code', + code: ` + + Home + + Products + + Electronics + + Laptops + + Gaming + ` +}; + +export const itemsBeforeCollapseDemo = { + type: 'code', + code: ` + + Home + + Products + + Electronics + + Laptops + + Gaming + + Accessories + + Footwear + ` +}; + export const dropdownDemo = { type: 'code', code: ` @@ -113,22 +156,22 @@ export const iconsDemo = { name: 'Text with Icon', code: ` - H}>Home + }>Home - D}>Documents + }>Documents - S}>Settings + }>Settings ` }, { name: 'Only Icon', code: ` - H}/> + }/> - D}/> + }/> - S}/> + }/> ` } ] diff --git a/apps/www/src/content/docs/components/breadcrumb/index.mdx b/apps/www/src/content/docs/components/breadcrumb/index.mdx index 0b2449853..7cc54e572 100644 --- a/apps/www/src/content/docs/components/breadcrumb/index.mdx +++ b/apps/www/src/content/docs/components/breadcrumb/index.mdx @@ -10,6 +10,8 @@ import { separatorDemo, iconsDemo, ellipsisDemo, + maxItemsDemo, + itemsBeforeCollapseDemo, dropdownDemo, asDemo, } from "./demo.ts"; @@ -42,7 +44,7 @@ Groups all parts of the breadcrumb navigation. ### Item -Renders an individual breadcrumb link. +Renders an individual breadcrumb link. Use the `current` prop on the item that represents the current page so it is styled and exposed to assistive tech (e.g. `aria-current="page"`). @@ -72,12 +74,24 @@ Customize the separator between breadcrumb items using the `separator` prop. -### Ellipsis +### Ellipsis (manual) -Use the `Breadcrumb.Ellipsis` component to truncate the breadcrumb trail when you need to display a large number of items in a limited space. +Use the `Breadcrumb.Ellipsis` component to manually truncate the breadcrumb trail when you need to display a large number of items in a limited space. +### Auto-collapse (maxItems) + +Set `maxItems` to automatically collapse the trail when there are more items: the first few and the last items are shown with an ellipsis in between. When collapsed, there is always at least 1 item at the start and 1 at the end—there cannot be fewer than 2 visible items. The number of items before the ellipsis is controlled by `itemsBeforeCollapse` (when not set, it is derived from `maxItems`). The number of items after the ellipsis is always derived (`maxItems` minus before). Values of `maxItems` less than 2 are treated as 2. + + + +### itemsBeforeCollapse + +Control how many items appear before the ellipsis when collapsed. With `maxItems={5}` and `itemsBeforeCollapse={2}`, you get 2 items before and 3 after (until the cap). + + + ### Icons Breadcrumb items can include icons either alongside text or as standalone elements. diff --git a/apps/www/src/content/docs/components/breadcrumb/props.ts b/apps/www/src/content/docs/components/breadcrumb/props.ts index 02b7d4ed9..5305722f6 100644 --- a/apps/www/src/content/docs/components/breadcrumb/props.ts +++ b/apps/www/src/content/docs/components/breadcrumb/props.ts @@ -36,6 +36,9 @@ export interface BreadcrumbItem { * @default "" */ as?: ReactElement; + + /** Custom CSS class name applied to the list item wrapper */ + className?: string; } export interface BreadcrumbProps { @@ -45,6 +48,19 @@ export interface BreadcrumbProps { */ size?: 'small' | 'medium'; + /** + * Maximum number of breadcrumb items to show. When there are more items, the list is collapsed. + * When collapsed: at least 1 item is always shown at the start and 1 at the end (minimum 2 visible items; there cannot be fewer than 2). + * Values less than 2 are treated as 2. + */ + maxItems?: number; + + /** + * Number of items to show before the ellipsis when collapsed. + * When not set, derived from maxItems (e.g. maxItems=5 → 2 before, rest after). + */ + itemsBeforeCollapse?: number; + /** Custom CSS class names */ className?: string; } diff --git a/packages/raystack/components/breadcrumb/__tests__/breadcrumb.test.tsx b/packages/raystack/components/breadcrumb/__tests__/breadcrumb.test.tsx index 95b9587d7..5671d87d7 100644 --- a/packages/raystack/components/breadcrumb/__tests__/breadcrumb.test.tsx +++ b/packages/raystack/components/breadcrumb/__tests__/breadcrumb.test.tsx @@ -413,4 +413,227 @@ describe('Breadcrumb', () => { expect(screen.getByText('Product')).toBeInTheDocument(); }); }); + + describe('maxItems auto-collapse', () => { + // API: maxItems (required for collapse) + optional itemsBeforeCollapse. After count is always derived (maxItems - before); at least 1 item is shown after the ellipsis when there are hidden items. + it('renders all items when item count is less than or equal to maxItems', () => { + render( + + Home + + A + + + B + + + ); + + expect(screen.getByText('Home')).toBeInTheDocument(); + expect(screen.getByText('A')).toBeInTheDocument(); + expect(screen.getByText('B')).toBeInTheDocument(); + const ellipsis = document.querySelector( + `.${styles['breadcrumb-ellipsis']}` + ); + expect(ellipsis).not.toBeInTheDocument(); + }); + + it('collapses middle items when item count exceeds maxItems', () => { + render( + + Home + + Products + + Electronics + + + Laptops + + + ); + + // maxItems=3 → 1 before, 2 after: Home, ..., Electronics, Laptops + expect(screen.getByText('Home')).toBeInTheDocument(); + expect(screen.getByText('Electronics')).toBeInTheDocument(); + expect(screen.getByText('Laptops')).toBeInTheDocument(); + expect(screen.queryByText('Products')).not.toBeInTheDocument(); + const ellipsis = document.querySelector( + `.${styles['breadcrumb-ellipsis']}` + ); + expect(ellipsis).toBeInTheDocument(); + }); + + it('respects itemsBeforeCollapse (after count derived from maxItems)', () => { + render( + + Home + + A + + B + + + C + + + ); + + expect(screen.getByText('Home')).toBeInTheDocument(); + expect(screen.getByText('A')).toBeInTheDocument(); + expect(screen.getByText('C')).toBeInTheDocument(); + expect(screen.queryByText('B')).not.toBeInTheDocument(); + const ellipsis = document.querySelector( + `.${styles['breadcrumb-ellipsis']}` + ); + expect(ellipsis).toBeInTheDocument(); + }); + + it('shows at least 1 item after ellipsis when before uses full budget (4 before, 1 after)', () => { + render( + + Home + + Products + + Electronics + + Laptops + + Gaming + + Accessories + + + Footwear + + + ); + + expect(screen.getByText('Home')).toBeInTheDocument(); + expect(screen.getByText('Products')).toBeInTheDocument(); + expect(screen.getByText('Electronics')).toBeInTheDocument(); + expect(screen.getByText('Laptops')).toBeInTheDocument(); + expect(screen.queryByText('Gaming')).not.toBeInTheDocument(); + expect(screen.queryByText('Accessories')).not.toBeInTheDocument(); + expect(screen.getByText('Footwear')).toBeInTheDocument(); + const ellipsis = document.querySelector( + `.${styles['breadcrumb-ellipsis']}` + ); + expect(ellipsis).toBeInTheDocument(); + }); + + it('uses custom separator when collapsed', () => { + render( + + Home + | + A + | + + B + + + ); + + expect(screen.getByText('Home')).toBeInTheDocument(); + expect(screen.getByText('B')).toBeInTheDocument(); + const separators = screen.getAllByText('|'); + expect(separators.length).toBeGreaterThanOrEqual(2); + }); + + it('renders as normal when maxItems is not set (backward compatibility)', () => { + render( + + Home + + A + + B + + + C + + + ); + + expect(screen.getByText('Home')).toBeInTheDocument(); + expect(screen.getByText('A')).toBeInTheDocument(); + expect(screen.getByText('B')).toBeInTheDocument(); + expect(screen.getByText('C')).toBeInTheDocument(); + const ellipsis = document.querySelector( + `.${styles['breadcrumb-ellipsis']}` + ); + expect(ellipsis).not.toBeInTheDocument(); + }); + + it('collapses when children are passed via a Fragment (e.g. {trail})', () => { + const trail = ( + <> + Home + + A + + + B + + + ); + render({trail}); + + expect(screen.getByText('Home')).toBeInTheDocument(); + expect(screen.getByText('B')).toBeInTheDocument(); + expect(screen.queryByText('A')).not.toBeInTheDocument(); + const ellipsis = document.querySelector( + `.${styles['breadcrumb-ellipsis']}` + ); + expect(ellipsis).toBeInTheDocument(); + }); + + it('treats negative maxItems (e.g. -10) as 2 and collapses', () => { + const trail = ( + <> + Home + + A + + + B + + + ); + render({trail}); + + expect(screen.getByText('Home')).toBeInTheDocument(); + expect(screen.getByText('B')).toBeInTheDocument(); + expect(screen.queryByText('A')).not.toBeInTheDocument(); + const ellipsis = document.querySelector( + `.${styles['breadcrumb-ellipsis']}` + ); + expect(ellipsis).toBeInTheDocument(); + }); + + it('treats maxItems=1 as 2 (1 before, 1 after) so collapse always shows first + last', () => { + const trail = ( + <> + Home + + A + + + B + + + ); + render({trail}); + + expect(screen.getByText('Home')).toBeInTheDocument(); + expect(screen.getByText('B')).toBeInTheDocument(); + expect(screen.queryByText('A')).not.toBeInTheDocument(); + const ellipsis = document.querySelector( + `.${styles['breadcrumb-ellipsis']}` + ); + expect(ellipsis).toBeInTheDocument(); + }); + }); }); diff --git a/packages/raystack/components/breadcrumb/breadcrumb-root.tsx b/packages/raystack/components/breadcrumb/breadcrumb-root.tsx index 2fdded41e..f22d5abfe 100644 --- a/packages/raystack/components/breadcrumb/breadcrumb-root.tsx +++ b/packages/raystack/components/breadcrumb/breadcrumb-root.tsx @@ -1,8 +1,18 @@ 'use client'; -import { type VariantProps, cva } from 'class-variance-authority'; -import { HTMLAttributes, forwardRef } from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import React, { + Children, + cloneElement, + forwardRef, + HTMLAttributes, + isValidElement, + ReactElement, + ReactNode +} from 'react'; import styles from './breadcrumb.module.css'; +import { BreadcrumbItem } from './breadcrumb-item'; +import { BreadcrumbEllipsis, BreadcrumbSeparator } from './breadcrumb-misc'; const breadcrumbVariants = cva(styles['breadcrumb'], { variants: { @@ -16,19 +26,160 @@ const breadcrumbVariants = cva(styles['breadcrumb'], { } }); +type BreadcrumbNodeType = 'item' | 'separator' | 'ellipsis'; + +interface BreadcrumbNode { + type: BreadcrumbNodeType; + element: ReactElement; +} + +function isFragment(element: ReactElement): boolean { + const type = element.type; + return ( + type === React.Fragment || + (typeof type === 'symbol' && + (type as symbol).toString() === 'Symbol(react.fragment)') + ); +} + +function flattenFragments(children: ReactNode): ReactNode[] { + const result: ReactNode[] = []; + Children.forEach(children, child => { + if (isValidElement(child) && isFragment(child)) { + const props = (child as ReactElement<{ children?: ReactNode }>).props; + result.push(...flattenFragments(props.children)); + } else { + result.push(child); + } + }); + return result; +} + +function isBreadcrumbPart( + type: unknown, + part: unknown, + displayName: string +): boolean { + const t = type as { displayName?: string }; + // Prefer displayName so we match across module boundaries (e.g. in tests) + if (t?.displayName === displayName) return true; + return type === part; +} + +function parseBreadcrumbChildren(children: ReactNode): BreadcrumbNode[] { + const nodes: BreadcrumbNode[] = []; + const flat = flattenFragments(Children.toArray(children)); + flat.forEach(child => { + if (!isValidElement(child)) return; + const type = child.type as { displayName?: string }; + if (isBreadcrumbPart(type, BreadcrumbItem, 'BreadcrumbItem')) { + nodes.push({ type: 'item', element: child }); + } else if ( + isBreadcrumbPart(type, BreadcrumbSeparator, 'BreadcrumbSeparator') + ) { + nodes.push({ type: 'separator', element: child }); + } else if ( + isBreadcrumbPart(type, BreadcrumbEllipsis, 'BreadcrumbEllipsis') + ) { + nodes.push({ type: 'ellipsis', element: child }); + } + }); + return nodes; +} + +/** + * Breadcrumb root: renders a nav with an ordered list of items and separators. + * When `maxItems` is set and the number of items exceeds it, the trail is collapsed. + * When collapsed: there is always at least 1 item at the start and 1 at the end (so never fewer than 2 visible items). + * Values less than 2 for maxItems are treated as 2. + */ export interface BreadcrumbProps extends VariantProps, - HTMLAttributes {} + HTMLAttributes { + /** + * Maximum number of breadcrumb items to show. When there are more items, the list is collapsed. + * When collapsed: at least 1 item is always shown at the start and 1 at the end (minimum 2 visible items). + * Values less than 2 (e.g. 0, 1, or negative) are treated as 2. + */ + maxItems?: number; + /** + * Number of items to show before the ellipsis when collapsed. When not set, derived from maxItems + * (e.g. maxItems=5 → 2 before, rest after). + */ + itemsBeforeCollapse?: number; +} export const BreadcrumbRoot = forwardRef( - ({ className, children, size = 'medium', ...props }, ref) => { + ( + { + className, + children, + size = 'medium', + maxItems, + itemsBeforeCollapse, + ...props + }, + ref + ) => { + const shouldCollapse = maxItems != null && typeof maxItems === 'number'; + + let content: ReactNode = children; + + if (shouldCollapse) { + const nodes = parseBreadcrumbChildren(children); + const items = nodes.filter(n => n.type === 'item').map(n => n.element); + const max = Math.max(2, maxItems); + + if (items.length > max) { + const sep = nodes.find(n => n.type === 'separator')?.element ?? ( + + ); + const defaultBefore = Math.max(1, Math.floor(max / 2)); + let before = Math.min( + itemsBeforeCollapse ?? defaultBefore, + items.length, + max + ); + if (items.length > before && before === max) before--; + const after = Math.min(items.length - before, max - before); + const beforeItems = items.slice(0, before); + const afterItems = items.slice(before).slice(-after); + + const keyed = (el: ReactElement, k: string) => + isValidElement(el) && el.key != null + ? el + : cloneElement(el, { key: k }); + const out: ReactNode[] = []; + beforeItems.forEach((item, i) => { + out.push(keyed(item, `b-${i}`)); + if (i < beforeItems.length - 1) + out.push(cloneElement(sep, { key: `s-${i}` })); + }); + out.push( + cloneElement(sep, { key: 's-mid' }), + , + cloneElement(sep, { key: 's-mid2' }) + ); + afterItems.forEach((item, i) => { + if (i > 0) out.push(cloneElement(sep, { key: `s-a-${i}` })); + out.push(keyed(item, `a-${i}`)); + }); + content = out; + } + } + + const { children: _propsChildren, ...restProps } = props as typeof props & { + children?: ReactNode; + }; + return ( ); } From 685aea4d26f152ce1e8768dcaa160f9b72a60e1c Mon Sep 17 00:00:00 2001 From: paanSinghCoder Date: Mon, 9 Mar 2026 13:34:03 +0530 Subject: [PATCH 2/3] revert (breadcrumb): revert maxItems and itemsBeforeCollapse logic --- apps/www/src/app/examples/page.tsx | 124 ---------- .../docs/components/breadcrumb/demo.ts | 36 --- .../docs/components/breadcrumb/index.mdx | 18 +- .../docs/components/breadcrumb/props.ts | 13 - .../breadcrumb/__tests__/breadcrumb.test.tsx | 223 ------------------ .../components/breadcrumb/breadcrumb-root.tsx | 153 +----------- 6 files changed, 7 insertions(+), 560 deletions(-) diff --git a/apps/www/src/app/examples/page.tsx b/apps/www/src/app/examples/page.tsx index 25815bc20..076e26463 100644 --- a/apps/www/src/app/examples/page.tsx +++ b/apps/www/src/app/examples/page.tsx @@ -38,35 +38,6 @@ import { import dayjs from 'dayjs'; import React, { useState } from 'react'; -const breadcrumbTrail = ( - <> - Home - - Products - - Electronics - - - Laptops - - - - Gaming - - - - Accessories - - - - Footwear - - -); - const Page = () => { const [dialogOpen, setDialogOpen] = useState(false); const [nestedDialogOpen, setNestedDialogOpen] = useState(false); @@ -256,101 +227,6 @@ const Page = () => { - {/* Breadcrumb Examples */} - - Breadcrumb Examples - - - - - 1. (no props) – manual ellipsis - - - Home - - - - - Footwear - - - - - - 2. maxItems=8 - - {breadcrumbTrail} - - - - 3. maxItems=5 - - {breadcrumbTrail} - - - - 4. maxItems=3 - - {breadcrumbTrail} - - - - 5. maxItems=5 itemsBeforeCollapse=2 - - - {breadcrumbTrail} - - - - - 6. maxItems=5 itemsBeforeCollapse=5 (4 before + 1 after) - - - Home - - Products - - - Electronics - - - - Laptops - - - - Gaming - - - - Accessories - - - - Footwear - - - - - - 7. size=small maxItems=4 - - - {breadcrumbTrail} - - - - ` }; -export const maxItemsDemo = { - type: 'code', - code: ` - - Home - - Products - - Electronics - - Laptops - - Gaming - ` -}; - -export const itemsBeforeCollapseDemo = { - type: 'code', - code: ` - - Home - - Products - - Electronics - - Laptops - - Gaming - - Accessories - - Footwear - ` -}; - export const dropdownDemo = { type: 'code', code: ` diff --git a/apps/www/src/content/docs/components/breadcrumb/index.mdx b/apps/www/src/content/docs/components/breadcrumb/index.mdx index 7cc54e572..7e3528f67 100644 --- a/apps/www/src/content/docs/components/breadcrumb/index.mdx +++ b/apps/www/src/content/docs/components/breadcrumb/index.mdx @@ -10,8 +10,6 @@ import { separatorDemo, iconsDemo, ellipsisDemo, - maxItemsDemo, - itemsBeforeCollapseDemo, dropdownDemo, asDemo, } from "./demo.ts"; @@ -74,24 +72,12 @@ Customize the separator between breadcrumb items using the `separator` prop. -### Ellipsis (manual) +### Ellipsis -Use the `Breadcrumb.Ellipsis` component to manually truncate the breadcrumb trail when you need to display a large number of items in a limited space. +Use the `Breadcrumb.Ellipsis` component to truncate the breadcrumb trail when you need to display a large number of items in a limited space. -### Auto-collapse (maxItems) - -Set `maxItems` to automatically collapse the trail when there are more items: the first few and the last items are shown with an ellipsis in between. When collapsed, there is always at least 1 item at the start and 1 at the end—there cannot be fewer than 2 visible items. The number of items before the ellipsis is controlled by `itemsBeforeCollapse` (when not set, it is derived from `maxItems`). The number of items after the ellipsis is always derived (`maxItems` minus before). Values of `maxItems` less than 2 are treated as 2. - - - -### itemsBeforeCollapse - -Control how many items appear before the ellipsis when collapsed. With `maxItems={5}` and `itemsBeforeCollapse={2}`, you get 2 items before and 3 after (until the cap). - - - ### Icons Breadcrumb items can include icons either alongside text or as standalone elements. diff --git a/apps/www/src/content/docs/components/breadcrumb/props.ts b/apps/www/src/content/docs/components/breadcrumb/props.ts index 5305722f6..6862d0c81 100644 --- a/apps/www/src/content/docs/components/breadcrumb/props.ts +++ b/apps/www/src/content/docs/components/breadcrumb/props.ts @@ -48,19 +48,6 @@ export interface BreadcrumbProps { */ size?: 'small' | 'medium'; - /** - * Maximum number of breadcrumb items to show. When there are more items, the list is collapsed. - * When collapsed: at least 1 item is always shown at the start and 1 at the end (minimum 2 visible items; there cannot be fewer than 2). - * Values less than 2 are treated as 2. - */ - maxItems?: number; - - /** - * Number of items to show before the ellipsis when collapsed. - * When not set, derived from maxItems (e.g. maxItems=5 → 2 before, rest after). - */ - itemsBeforeCollapse?: number; - /** Custom CSS class names */ className?: string; } diff --git a/packages/raystack/components/breadcrumb/__tests__/breadcrumb.test.tsx b/packages/raystack/components/breadcrumb/__tests__/breadcrumb.test.tsx index 5671d87d7..95b9587d7 100644 --- a/packages/raystack/components/breadcrumb/__tests__/breadcrumb.test.tsx +++ b/packages/raystack/components/breadcrumb/__tests__/breadcrumb.test.tsx @@ -413,227 +413,4 @@ describe('Breadcrumb', () => { expect(screen.getByText('Product')).toBeInTheDocument(); }); }); - - describe('maxItems auto-collapse', () => { - // API: maxItems (required for collapse) + optional itemsBeforeCollapse. After count is always derived (maxItems - before); at least 1 item is shown after the ellipsis when there are hidden items. - it('renders all items when item count is less than or equal to maxItems', () => { - render( - - Home - - A - - - B - - - ); - - expect(screen.getByText('Home')).toBeInTheDocument(); - expect(screen.getByText('A')).toBeInTheDocument(); - expect(screen.getByText('B')).toBeInTheDocument(); - const ellipsis = document.querySelector( - `.${styles['breadcrumb-ellipsis']}` - ); - expect(ellipsis).not.toBeInTheDocument(); - }); - - it('collapses middle items when item count exceeds maxItems', () => { - render( - - Home - - Products - - Electronics - - - Laptops - - - ); - - // maxItems=3 → 1 before, 2 after: Home, ..., Electronics, Laptops - expect(screen.getByText('Home')).toBeInTheDocument(); - expect(screen.getByText('Electronics')).toBeInTheDocument(); - expect(screen.getByText('Laptops')).toBeInTheDocument(); - expect(screen.queryByText('Products')).not.toBeInTheDocument(); - const ellipsis = document.querySelector( - `.${styles['breadcrumb-ellipsis']}` - ); - expect(ellipsis).toBeInTheDocument(); - }); - - it('respects itemsBeforeCollapse (after count derived from maxItems)', () => { - render( - - Home - - A - - B - - - C - - - ); - - expect(screen.getByText('Home')).toBeInTheDocument(); - expect(screen.getByText('A')).toBeInTheDocument(); - expect(screen.getByText('C')).toBeInTheDocument(); - expect(screen.queryByText('B')).not.toBeInTheDocument(); - const ellipsis = document.querySelector( - `.${styles['breadcrumb-ellipsis']}` - ); - expect(ellipsis).toBeInTheDocument(); - }); - - it('shows at least 1 item after ellipsis when before uses full budget (4 before, 1 after)', () => { - render( - - Home - - Products - - Electronics - - Laptops - - Gaming - - Accessories - - - Footwear - - - ); - - expect(screen.getByText('Home')).toBeInTheDocument(); - expect(screen.getByText('Products')).toBeInTheDocument(); - expect(screen.getByText('Electronics')).toBeInTheDocument(); - expect(screen.getByText('Laptops')).toBeInTheDocument(); - expect(screen.queryByText('Gaming')).not.toBeInTheDocument(); - expect(screen.queryByText('Accessories')).not.toBeInTheDocument(); - expect(screen.getByText('Footwear')).toBeInTheDocument(); - const ellipsis = document.querySelector( - `.${styles['breadcrumb-ellipsis']}` - ); - expect(ellipsis).toBeInTheDocument(); - }); - - it('uses custom separator when collapsed', () => { - render( - - Home - | - A - | - - B - - - ); - - expect(screen.getByText('Home')).toBeInTheDocument(); - expect(screen.getByText('B')).toBeInTheDocument(); - const separators = screen.getAllByText('|'); - expect(separators.length).toBeGreaterThanOrEqual(2); - }); - - it('renders as normal when maxItems is not set (backward compatibility)', () => { - render( - - Home - - A - - B - - - C - - - ); - - expect(screen.getByText('Home')).toBeInTheDocument(); - expect(screen.getByText('A')).toBeInTheDocument(); - expect(screen.getByText('B')).toBeInTheDocument(); - expect(screen.getByText('C')).toBeInTheDocument(); - const ellipsis = document.querySelector( - `.${styles['breadcrumb-ellipsis']}` - ); - expect(ellipsis).not.toBeInTheDocument(); - }); - - it('collapses when children are passed via a Fragment (e.g. {trail})', () => { - const trail = ( - <> - Home - - A - - - B - - - ); - render({trail}); - - expect(screen.getByText('Home')).toBeInTheDocument(); - expect(screen.getByText('B')).toBeInTheDocument(); - expect(screen.queryByText('A')).not.toBeInTheDocument(); - const ellipsis = document.querySelector( - `.${styles['breadcrumb-ellipsis']}` - ); - expect(ellipsis).toBeInTheDocument(); - }); - - it('treats negative maxItems (e.g. -10) as 2 and collapses', () => { - const trail = ( - <> - Home - - A - - - B - - - ); - render({trail}); - - expect(screen.getByText('Home')).toBeInTheDocument(); - expect(screen.getByText('B')).toBeInTheDocument(); - expect(screen.queryByText('A')).not.toBeInTheDocument(); - const ellipsis = document.querySelector( - `.${styles['breadcrumb-ellipsis']}` - ); - expect(ellipsis).toBeInTheDocument(); - }); - - it('treats maxItems=1 as 2 (1 before, 1 after) so collapse always shows first + last', () => { - const trail = ( - <> - Home - - A - - - B - - - ); - render({trail}); - - expect(screen.getByText('Home')).toBeInTheDocument(); - expect(screen.getByText('B')).toBeInTheDocument(); - expect(screen.queryByText('A')).not.toBeInTheDocument(); - const ellipsis = document.querySelector( - `.${styles['breadcrumb-ellipsis']}` - ); - expect(ellipsis).toBeInTheDocument(); - }); - }); }); diff --git a/packages/raystack/components/breadcrumb/breadcrumb-root.tsx b/packages/raystack/components/breadcrumb/breadcrumb-root.tsx index f22d5abfe..abd7bc455 100644 --- a/packages/raystack/components/breadcrumb/breadcrumb-root.tsx +++ b/packages/raystack/components/breadcrumb/breadcrumb-root.tsx @@ -1,18 +1,8 @@ 'use client'; import { cva, type VariantProps } from 'class-variance-authority'; -import React, { - Children, - cloneElement, - forwardRef, - HTMLAttributes, - isValidElement, - ReactElement, - ReactNode -} from 'react'; +import React, { forwardRef, HTMLAttributes } from 'react'; import styles from './breadcrumb.module.css'; -import { BreadcrumbItem } from './breadcrumb-item'; -import { BreadcrumbEllipsis, BreadcrumbSeparator } from './breadcrumb-misc'; const breadcrumbVariants = cva(styles['breadcrumb'], { variants: { @@ -26,150 +16,17 @@ const breadcrumbVariants = cva(styles['breadcrumb'], { } }); -type BreadcrumbNodeType = 'item' | 'separator' | 'ellipsis'; - -interface BreadcrumbNode { - type: BreadcrumbNodeType; - element: ReactElement; -} - -function isFragment(element: ReactElement): boolean { - const type = element.type; - return ( - type === React.Fragment || - (typeof type === 'symbol' && - (type as symbol).toString() === 'Symbol(react.fragment)') - ); -} - -function flattenFragments(children: ReactNode): ReactNode[] { - const result: ReactNode[] = []; - Children.forEach(children, child => { - if (isValidElement(child) && isFragment(child)) { - const props = (child as ReactElement<{ children?: ReactNode }>).props; - result.push(...flattenFragments(props.children)); - } else { - result.push(child); - } - }); - return result; -} - -function isBreadcrumbPart( - type: unknown, - part: unknown, - displayName: string -): boolean { - const t = type as { displayName?: string }; - // Prefer displayName so we match across module boundaries (e.g. in tests) - if (t?.displayName === displayName) return true; - return type === part; -} - -function parseBreadcrumbChildren(children: ReactNode): BreadcrumbNode[] { - const nodes: BreadcrumbNode[] = []; - const flat = flattenFragments(Children.toArray(children)); - flat.forEach(child => { - if (!isValidElement(child)) return; - const type = child.type as { displayName?: string }; - if (isBreadcrumbPart(type, BreadcrumbItem, 'BreadcrumbItem')) { - nodes.push({ type: 'item', element: child }); - } else if ( - isBreadcrumbPart(type, BreadcrumbSeparator, 'BreadcrumbSeparator') - ) { - nodes.push({ type: 'separator', element: child }); - } else if ( - isBreadcrumbPart(type, BreadcrumbEllipsis, 'BreadcrumbEllipsis') - ) { - nodes.push({ type: 'ellipsis', element: child }); - } - }); - return nodes; -} - /** * Breadcrumb root: renders a nav with an ordered list of items and separators. - * When `maxItems` is set and the number of items exceeds it, the trail is collapsed. - * When collapsed: there is always at least 1 item at the start and 1 at the end (so never fewer than 2 visible items). - * Values less than 2 for maxItems are treated as 2. */ export interface BreadcrumbProps extends VariantProps, - HTMLAttributes { - /** - * Maximum number of breadcrumb items to show. When there are more items, the list is collapsed. - * When collapsed: at least 1 item is always shown at the start and 1 at the end (minimum 2 visible items). - * Values less than 2 (e.g. 0, 1, or negative) are treated as 2. - */ - maxItems?: number; - /** - * Number of items to show before the ellipsis when collapsed. When not set, derived from maxItems - * (e.g. maxItems=5 → 2 before, rest after). - */ - itemsBeforeCollapse?: number; -} + HTMLAttributes {} export const BreadcrumbRoot = forwardRef( - ( - { - className, - children, - size = 'medium', - maxItems, - itemsBeforeCollapse, - ...props - }, - ref - ) => { - const shouldCollapse = maxItems != null && typeof maxItems === 'number'; - - let content: ReactNode = children; - - if (shouldCollapse) { - const nodes = parseBreadcrumbChildren(children); - const items = nodes.filter(n => n.type === 'item').map(n => n.element); - const max = Math.max(2, maxItems); - - if (items.length > max) { - const sep = nodes.find(n => n.type === 'separator')?.element ?? ( - - ); - const defaultBefore = Math.max(1, Math.floor(max / 2)); - let before = Math.min( - itemsBeforeCollapse ?? defaultBefore, - items.length, - max - ); - if (items.length > before && before === max) before--; - const after = Math.min(items.length - before, max - before); - const beforeItems = items.slice(0, before); - const afterItems = items.slice(before).slice(-after); - - const keyed = (el: ReactElement, k: string) => - isValidElement(el) && el.key != null - ? el - : cloneElement(el, { key: k }); - const out: ReactNode[] = []; - beforeItems.forEach((item, i) => { - out.push(keyed(item, `b-${i}`)); - if (i < beforeItems.length - 1) - out.push(cloneElement(sep, { key: `s-${i}` })); - }); - out.push( - cloneElement(sep, { key: 's-mid' }), - , - cloneElement(sep, { key: 's-mid2' }) - ); - afterItems.forEach((item, i) => { - if (i > 0) out.push(cloneElement(sep, { key: `s-a-${i}` })); - out.push(keyed(item, `a-${i}`)); - }); - content = out; - } - } - + ({ className, children, size = 'medium', ...props }, ref) => { const { children: _propsChildren, ...restProps } = props as typeof props & { - children?: ReactNode; + children?: React.ReactNode; }; return ( @@ -179,7 +36,7 @@ export const BreadcrumbRoot = forwardRef( aria-label='Breadcrumb' {...restProps} > -
    {content}
+
    {children}
); } From 813f1f4925b6ba3c7a959b500a98b64cd1e19626 Mon Sep 17 00:00:00 2001 From: Gaurav Singh Date: Sat, 21 Mar 2026 12:54:08 +0530 Subject: [PATCH 3/3] feat(2-breadcrumb): add trailing icon and disabled item support (#668) * feat: add disabled prop support for item * feat: add trailing item support from item * feat(breadcrumb): update separator accessibility attributes to use role="presentation" and aria-hidden="true" * fix: replace
with for current item * style(breadcrumb): enhance active link styling with hover effect and default cursor * chore: Remove breadcrumb examples * refactor: merge jsx * feat (3-breadcrumb): add href support and expose data-disabled and data-current attributes (#669) * feat (breadcrumb-dropdown): add href support * feat: add data attributes * refactor: merge react 19 * refactor: pass all menuItem props --- .../playground/breadcrumb-examples.tsx | 4 +- .../docs/components/breadcrumb/demo.ts | 57 +++++- .../docs/components/breadcrumb/index.mdx | 39 +++- .../docs/components/breadcrumb/props.ts | 27 ++- .../breadcrumb/__tests__/breadcrumb.test.tsx | 189 +++++++++++++++++- .../components/breadcrumb/breadcrumb-item.tsx | 82 ++++++-- .../components/breadcrumb/breadcrumb-misc.tsx | 9 +- .../breadcrumb/breadcrumb.module.css | 24 +++ 8 files changed, 380 insertions(+), 51 deletions(-) diff --git a/apps/www/src/components/playground/breadcrumb-examples.tsx b/apps/www/src/components/playground/breadcrumb-examples.tsx index 9d9fbb7f1..a02b4477b 100644 --- a/apps/www/src/components/playground/breadcrumb-examples.tsx +++ b/apps/www/src/components/playground/breadcrumb-examples.tsx @@ -13,13 +13,13 @@ export function BreadcrumbExamples() { { console.log('Clothes'); } }, { - label: 'Electronics', + children: 'Electronics', onClick: () => { console.log('Electronics'); } diff --git a/apps/www/src/content/docs/components/breadcrumb/demo.ts b/apps/www/src/content/docs/components/breadcrumb/demo.ts index a7fa20e61..9d19dec0d 100644 --- a/apps/www/src/content/docs/components/breadcrumb/demo.ts +++ b/apps/www/src/content/docs/components/breadcrumb/demo.ts @@ -94,13 +94,30 @@ export const dropdownDemo = { Category {console.log('Option 1')}}, - { label: 'Option 2', onClick: () => {console.log('Option 2')}} + { children: 'Option 1', onClick: () => {console.log('Option 1')}}, + { children: 'Option 2', onClick: () => {console.log('Option 2')}} ]}>Subcategory Current Page ` }; + +export const dropdownLinksDemo = { + type: 'code', + code: ` + + Home + + }, + { children: 'Clothing', render: }, + { children: 'Books', onClick: () => {console.log('Books')}} + ]}>Categories + + Current + ` +}; + export const asDemo = { type: 'code', code: ` @@ -113,11 +130,23 @@ export const asDemo = { ` }; +export const disabledDemo = { + type: 'code', + code: ` + + Home + + Loading… + + Products + ` +}; + export const iconsDemo = { type: 'code', tabs: [ { - name: 'Text with Icon', + name: 'Leading Icon', code: ` }>Home @@ -127,6 +156,28 @@ export const iconsDemo = { }>Settings ` }, + { + name: 'Trailing Icon', + code: ` + + }>Home + + }>Documents + + }>Settings + ` + }, + { + name: 'Both Icons', + code: ` + + } trailingIcon={}>Home + + } trailingIcon={}>Documents + + } trailingIcon={}>Settings + ` + }, { name: 'Only Icon', code: ` diff --git a/apps/www/src/content/docs/components/breadcrumb/index.mdx b/apps/www/src/content/docs/components/breadcrumb/index.mdx index 7e3528f67..57333ab5e 100644 --- a/apps/www/src/content/docs/components/breadcrumb/index.mdx +++ b/apps/www/src/content/docs/components/breadcrumb/index.mdx @@ -11,7 +11,9 @@ import { iconsDemo, ellipsisDemo, dropdownDemo, + dropdownLinksDemo, asDemo, + disabledDemo, } from "./demo.ts"; @@ -42,7 +44,25 @@ Groups all parts of the breadcrumb navigation. ### Item -Renders an individual breadcrumb link. Use the `current` prop on the item that represents the current page so it is styled and exposed to assistive tech (e.g. `aria-current="page"`). +Renders an individual breadcrumb link. Use the `current` prop on the item that represents the current page so it is styled and exposed to assistive tech (e.g. `aria-current="page"`). Use the `disabled` prop for non-clickable, visually muted items (e.g. loading or no access). + +Item elements expose data attributes for CSS state targeting so you can style current and disabled states without relying on internal class names: + +| Attribute | When present | +|-----------|----------------| +| `data-current="true"` | Item is the current page (`current` prop) | +| `data-disabled="true"` | Item is disabled (`disabled` prop) | + +Example: + +```css +[data-current="true"] { + color: var(--my-brand); +} +[data-disabled="true"] { + opacity: 0.6; +} +``` @@ -80,18 +100,20 @@ Use the `Breadcrumb.Ellipsis` component to truncate the breadcrumb trail when yo ### Icons -Breadcrumb items can include icons either alongside text or as standalone elements. +Breadcrumb items can include icons via `leadingIcon` (before the label) or `trailingIcon` (after the label), either alongside text or as standalone elements. ### Dropdown -Breadcrumb items can include dropdown menus for additional navigation options. Specify the dropdown items using the `dropdownItems` prop. +Breadcrumb items can include dropdown menus for additional navigation options. Specify them with the `dropdownItems` prop: each entry is the same props as `` (e.g. `children` for the label, `onClick`, `disabled`, `render` for a link such as ``, etc.). You can also pass `key` for stable list keys. -**Note:** When `dropdownItems` is provided, the `as` and `href` props are ignored. +**Note:** When `dropdownItems` is provided, the `as` and `href` props on the breadcrumb item are ignored. + + ### As Use the `as` prop to render the breadcrumb item as a custom component. By default, breadcrumb items are rendered as `a` tags. @@ -100,8 +122,15 @@ When a custom component is provided, the props are merged, with the custom compo +### Disabled + +Use the `disabled` prop for non-clickable, visually muted items—for example, loading states or segments the user does not have access to. Disabled items render as a span with `aria-disabled="true"` and do not navigate. + + + ## Accessibility - Uses `nav` element with `aria-label="Breadcrumb"` for proper landmark identification - Current page is indicated with `aria-current="page"` -- Separator elements are hidden from screen readers with `aria-hidden` +- Disabled items use `aria-disabled="true"` +- Separator elements are decorative and use `role="presentation"` and `aria-hidden="true"` so screen readers skip them diff --git a/apps/www/src/content/docs/components/breadcrumb/props.ts b/apps/www/src/content/docs/components/breadcrumb/props.ts index bbcfa785c..075671adc 100644 --- a/apps/www/src/content/docs/components/breadcrumb/props.ts +++ b/apps/www/src/content/docs/components/breadcrumb/props.ts @@ -1,4 +1,4 @@ -import { ReactElement, ReactEventHandler, ReactNode } from 'react'; +import { ReactElement, ReactNode } from 'react'; export interface BreadcrumbItem { /** Text to display for the item */ @@ -7,9 +7,12 @@ export interface BreadcrumbItem { /** URL for the item link */ href?: string; - /** Optional icon element to display */ + /** Optional icon element to display before the label */ leadingIcon?: ReactNode; + /** Optional icon element to display after the label */ + trailingIcon?: ReactNode; + /** * Whether the item is the current page * @defaultValue false @@ -17,18 +20,22 @@ export interface BreadcrumbItem { current?: boolean; /** - * Optional array of dropdown items + * When true, the item is non-clickable and visually muted (e.g. loading or no access). + * @defaultValue false + */ + disabled?: boolean; + + /** + * Optional array of dropdown entries; each object is passed to `` + * (e.g. `children`, `onClick`, `render` for a link, etc.), plus optional `key` + * for React list reconciliation (not forwarded to `Menu.Item`). * * When `dropdownItems` is provided, the `as` and `href` props are ignored. */ - dropdownItems?: { - /** Optional stable key for list reconciliation. Falls back to index if omitted. */ + dropdownItems?: (Record & { key?: string; - /** Text to display for the dropdown item */ - label: string; - /** Callback function when a dropdown item is clicked */ - onClick?: ReactEventHandler; - }[]; + children?: ReactNode; + })[]; /** * Custom element used to render the Item. diff --git a/packages/raystack/components/breadcrumb/__tests__/breadcrumb.test.tsx b/packages/raystack/components/breadcrumb/__tests__/breadcrumb.test.tsx index 95b9587d7..4eaa2dd7d 100644 --- a/packages/raystack/components/breadcrumb/__tests__/breadcrumb.test.tsx +++ b/packages/raystack/components/breadcrumb/__tests__/breadcrumb.test.tsx @@ -128,7 +128,56 @@ describe('Breadcrumb', () => { expect(screen.getByText('Home')).toBeInTheDocument(); }); - it('applies current/active state', () => { + it('renders with trailing icon', () => { + render( + + ▶} + > + Next + + + ); + + const icon = screen.getByTestId('trailing-icon'); + expect(icon).toBeInTheDocument(); + expect(icon.parentElement).toHaveClass(styles['breadcrumb-icon']); + expect(screen.getByText('Next')).toBeInTheDocument(); + }); + + it('renders with both leading and trailing icons', () => { + const { container } = render( + + L} + trailingIcon={T} + > + Label + + + ); + + const leading = screen.getByTestId('leading'); + const trailing = screen.getByTestId('trailing'); + const label = screen.getByText('Label'); + + expect(leading).toBeInTheDocument(); + expect(trailing).toBeInTheDocument(); + expect(label).toBeInTheDocument(); + expect(leading.parentElement).toHaveClass(styles['breadcrumb-icon']); + expect(trailing.parentElement).toHaveClass(styles['breadcrumb-icon']); + + const link = container.querySelector(`.${styles['breadcrumb-link']}`); + const iconWrappers = link?.querySelectorAll( + `.${styles['breadcrumb-icon']}` + ); + expect(iconWrappers).toHaveLength(2); + expect(iconWrappers?.[0]).toContainElement(leading); + expect(iconWrappers?.[1]).toContainElement(trailing); + expect(link?.textContent).toMatch(/L\s*Label\s*T/); + }); + + it('applies current/active state and renders as span with aria-current', () => { const { container } = render( Current Page @@ -136,7 +185,17 @@ describe('Breadcrumb', () => { ); const link = container.querySelector('a'); - expect(link).toHaveClass(styles['breadcrumb-link-active']); + expect(link).not.toBeInTheDocument(); + + const span = container.querySelector( + `span.${styles['breadcrumb-link-active']}` + ); + expect(span).toBeInTheDocument(); + expect(span).toHaveClass(styles['breadcrumb-link']); + expect(span).toHaveClass(styles['breadcrumb-link-active']); + expect(span).toHaveAttribute('aria-current', 'page'); + expect(span).toHaveAttribute('data-current', 'true'); + expect(span).toHaveTextContent('Current Page'); }); it('renders with custom element using as prop', () => { @@ -191,13 +250,72 @@ describe('Breadcrumb', () => { expect(link).toHaveAttribute('aria-label', 'Products'); expect(link).toHaveAttribute('data-testid', 'item'); }); + + it('renders as span with disabled styles when disabled', () => { + const { container } = render( + + Loading… + + ); + + const link = container.querySelector('a'); + expect(link).not.toBeInTheDocument(); + + const span = container.querySelector( + `span.${styles['breadcrumb-link-disabled']}` + ); + expect(span).toBeInTheDocument(); + expect(span).toHaveClass(styles['breadcrumb-link']); + expect(span).toHaveClass(styles['breadcrumb-link-disabled']); + expect(span).toHaveAttribute('aria-disabled', 'true'); + expect(span).toHaveAttribute('data-disabled', 'true'); + expect(span).toHaveTextContent('Loading…'); + }); + + it('disabled item has no href and is not focusable as link', () => { + const { container } = render( + + + No access + + + ); + + const span = container.querySelector( + `span.${styles['breadcrumb-link-disabled']}` + ); + expect(span).toBeInTheDocument(); + expect(container.querySelector('a')).not.toBeInTheDocument(); + }); + + it('disabled with dropdownItems renders as disabled span not dropdown', () => { + const items = [ + { children: 'Option 1', onClick: vi.fn() }, + { children: 'Option 2', onClick: vi.fn() } + ]; + const { container } = render( + + + Categories + + + ); + + const span = container.querySelector( + `span.${styles['breadcrumb-link-disabled']}` + ); + expect(span).toBeInTheDocument(); + expect(span).toHaveTextContent('Categories'); + fireEvent.click(span!); + expect(screen.queryByText('Option 1')).not.toBeInTheDocument(); + }); }); describe('BreadcrumbItem with Dropdown', () => { it('renders dropdown trigger when dropdownItems provided', () => { const items = [ - { label: 'Option 1', onClick: vi.fn() }, - { label: 'Option 2', onClick: vi.fn() } + { children: 'Option 1', onClick: vi.fn() }, + { children: 'Option 2', onClick: vi.fn() } ]; render( @@ -215,9 +333,9 @@ describe('Breadcrumb', () => { it('renders dropdown items on click', () => { const items = [ - { label: 'Electronics' }, - { label: 'Clothing' }, - { label: 'Books' } + { children: 'Electronics' }, + { children: 'Clothing' }, + { children: 'Books' } ]; render( @@ -234,6 +352,41 @@ describe('Breadcrumb', () => { expect(screen.getByText('Clothing')).toBeInTheDocument(); expect(screen.getByText('Books')).toBeInTheDocument(); }); + + it('renders dropdown items with href as links', () => { + render( + + + ) + }, + { + children: 'Same tab', + render: + } + ]} + > + Categories + + + ); + + fireEvent.click(screen.getByText('Categories')); + + const newTabLink = screen.getByText('New tab'); + expect(newTabLink.tagName).toBe('A'); + expect(newTabLink).toHaveAttribute('href', '/page'); + expect(newTabLink).toHaveAttribute('target', '_blank'); + expect(newTabLink).toHaveAttribute('rel', 'noopener noreferrer'); + + const sameTabLink = screen.getByText('Same tab'); + expect(sameTabLink.tagName).toBe('A'); + expect(sameTabLink).toHaveAttribute('href', '/other'); + }); }); describe('BreadcrumbSeparator', () => { @@ -282,6 +435,22 @@ describe('Breadcrumb', () => { ); expect(ref).toHaveBeenCalled(); }); + + it('has role="presentation" and aria-hidden="true" for screen readers', () => { + const { container } = render( + + Home + + Products + + ); + + const separator = container.querySelector( + `.${styles['breadcrumb-separator']}` + ); + expect(separator).toHaveAttribute('role', 'presentation'); + expect(separator).toHaveAttribute('aria-hidden', 'true'); + }); }); describe('BreadcrumbEllipsis', () => { @@ -388,9 +557,9 @@ describe('Breadcrumb', () => { it('renders breadcrumb with icons and dropdown', () => { const categories = [ - { label: 'Electronics' }, - { label: 'Clothing' }, - { label: 'Books' } + { children: 'Electronics' }, + { children: 'Clothing' }, + { children: 'Books' } ]; render( diff --git a/packages/raystack/components/breadcrumb/breadcrumb-item.tsx b/packages/raystack/components/breadcrumb/breadcrumb-item.tsx index 18c3d82eb..b3bfad30e 100644 --- a/packages/raystack/components/breadcrumb/breadcrumb-item.tsx +++ b/packages/raystack/components/breadcrumb/breadcrumb-item.tsx @@ -11,15 +11,21 @@ import React, { import { Menu } from '../menu'; import styles from './breadcrumb.module.css'; -export interface BreadcrumbDropdownItem { +/** + * Each entry maps to ``. Use `children`, `render`, `onClick`, + * `disabled`, etc. — whatever `Menu.Item` supports. + */ +export type BreadcrumbDropdownItem = ComponentProps & { + /** Optional stable key for React list reconciliation (not passed to `Menu.Item`). */ key?: string; - label: string; - onClick?: React.MouseEventHandler; -} +}; export interface BreadcrumbItemProps extends ComponentProps<'a'> { leadingIcon?: ReactNode; + trailingIcon?: ReactNode; current?: boolean; + /** When true, the item is non-clickable and visually muted (e.g. loading or no access). */ + disabled?: boolean; dropdownItems?: BreadcrumbDropdownItem[]; as?: ReactElement; } @@ -30,7 +36,9 @@ export const BreadcrumbItem = ({ children, className, leadingIcon, + trailingIcon, current, + disabled, href, dropdownItems, ...props @@ -42,42 +50,76 @@ export const BreadcrumbItem = ({ {leadingIcon} )} {children && {children}} + {trailingIcon && ( + {trailingIcon} + )} ); - if (dropdownItems) { + if (dropdownItems && !disabled) { return ( - + } + className={cx(styles['breadcrumb-dropdown-trigger'], className)} + {...(props as React.ButtonHTMLAttributes)} + > {label} - {dropdownItems.map((dropdownItem, dropdownIndex) => ( - - {dropdownItem.label} - - ))} + {dropdownItems.map((dropdownItem, dropdownIndex) => { + const { + key, + className: itemClassName, + ...menuItemProps + } = dropdownItem; + return ( + + ); + })} ); } + if (disabled || current) { + return ( +
  • + } + className={cx( + styles['breadcrumb-link'], + disabled && styles['breadcrumb-link-disabled'], + current && styles['breadcrumb-link-active'] + )} + {...(disabled && { + 'aria-disabled': 'true', + 'data-disabled': 'true' + })} + {...(current && { 'aria-current': 'page', 'data-current': 'true' })} + > + {label} + +
  • + ); + } return (
  • {cloneElement( renderedElement, { - className: cx( - styles['breadcrumb-link'], - current && styles['breadcrumb-link-active'] - ), + className: styles['breadcrumb-link'], href, ...props, - ...renderedElement.props + ...renderedElement.props, + ref }, label )} diff --git a/packages/raystack/components/breadcrumb/breadcrumb-misc.tsx b/packages/raystack/components/breadcrumb/breadcrumb-misc.tsx index 83e580d88..dc89344cf 100644 --- a/packages/raystack/components/breadcrumb/breadcrumb-misc.tsx +++ b/packages/raystack/components/breadcrumb/breadcrumb-misc.tsx @@ -24,12 +24,19 @@ BreadcrumbEllipsis.displayName = 'Breadcrumb.Ellipsis'; export interface BreadcrumbSeparatorProps extends ComponentProps<'span'> {} export const BreadcrumbSeparator = ({ + ref, children = , className, ...props }: BreadcrumbSeparatorProps) => { return ( - + ); diff --git a/packages/raystack/components/breadcrumb/breadcrumb.module.css b/packages/raystack/components/breadcrumb/breadcrumb.module.css index fa93de192..47b21edc1 100644 --- a/packages/raystack/components/breadcrumb/breadcrumb.module.css +++ b/packages/raystack/components/breadcrumb/breadcrumb.module.css @@ -45,6 +45,17 @@ .breadcrumb-link-active { color: var(--rs-color-foreground-base-primary); font-weight: var(--rs-font-weight-medium); + cursor: default; +} + +.breadcrumb-link-active:hover { + color: var(--rs-color-foreground-base-primary); +} + +.breadcrumb-link-disabled { + color: var(--rs-color-foreground-base-tertiary); + opacity: 0.5; + cursor: not-allowed; pointer-events: none; } @@ -76,10 +87,23 @@ } .breadcrumb-dropdown-item { + display: block; + width: 100%; + padding: var(--rs-space-3); cursor: pointer; color: var(--rs-color-foreground-base-primary); + font-weight: var(--rs-font-weight-regular); + font-size: var(--rs-font-size-small); + line-height: var(--rs-line-height-small); + letter-spacing: var(--rs-letter-spacing-small); + text-decoration: none; + border: none; + background: none; + text-align: left; + box-sizing: border-box; } .breadcrumb-dropdown-item:hover { background-color: var(--rs-color-background-base-primary-hover); + border-radius: var(--rs-radius-2); }