diff --git a/apps/www/src/content/docs/components/breadcrumb/demo.ts b/apps/www/src/content/docs/components/breadcrumb/demo.ts index a7fa20e61..0050e847d 100644 --- a/apps/www/src/content/docs/components/breadcrumb/demo.ts +++ b/apps/www/src/content/docs/components/breadcrumb/demo.ts @@ -113,11 +113,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 +139,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..1d7db91ec 100644 --- a/apps/www/src/content/docs/components/breadcrumb/index.mdx +++ b/apps/www/src/content/docs/components/breadcrumb/index.mdx @@ -12,6 +12,7 @@ import { ellipsisDemo, dropdownDemo, asDemo, + disabledDemo, } from "./demo.ts"; @@ -42,7 +43,7 @@ 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). @@ -80,7 +81,7 @@ 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. @@ -100,8 +101,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 6862d0c81..7b9aa8bb7 100644 --- a/apps/www/src/content/docs/components/breadcrumb/props.ts +++ b/apps/www/src/content/docs/components/breadcrumb/props.ts @@ -7,15 +7,24 @@ 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 */ current?: boolean; + /** + * When true, the item is non-clickable and visually muted (e.g. loading or no access). + * @defaultValue false + */ + disabled?: boolean; + /** * Optional array of dropdown items * diff --git a/packages/raystack/components/breadcrumb/__tests__/breadcrumb.test.tsx b/packages/raystack/components/breadcrumb/__tests__/breadcrumb.test.tsx index 95b9587d7..647d66098 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,16 @@ 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).toHaveTextContent('Current Page'); }); it('renders with custom element using as prop', () => { @@ -191,6 +249,64 @@ 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).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 = [ + { label: 'Option 1', onClick: vi.fn() }, + { label: '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', () => { @@ -282,6 +398,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', () => { diff --git a/packages/raystack/components/breadcrumb/breadcrumb-item.tsx b/packages/raystack/components/breadcrumb/breadcrumb-item.tsx index 0b3471ac2..3d6668d35 100644 --- a/packages/raystack/components/breadcrumb/breadcrumb-item.tsx +++ b/packages/raystack/components/breadcrumb/breadcrumb-item.tsx @@ -19,7 +19,10 @@ export interface BreadcrumbDropdownItem { export interface BreadcrumbItemProps extends HTMLAttributes { 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[]; href?: string; as?: ReactElement; @@ -35,7 +38,9 @@ export const BreadcrumbItem = forwardRef< children, className, leadingIcon, + trailingIcon, current, + disabled, href, dropdownItems, ...props @@ -49,10 +54,13 @@ export const BreadcrumbItem = forwardRef< {leadingIcon} )} {children && {children}} + {trailingIcon && ( + {trailingIcon} + )} ); - if (dropdownItems) { + if (dropdownItems && !disabled) { return ( @@ -73,15 +81,44 @@ export const BreadcrumbItem = forwardRef< ); } + if (disabled) { + return ( +
  • + } + className={cx( + styles['breadcrumb-link'], + styles['breadcrumb-link-disabled'] + )} + aria-disabled='true' + > + {label} + +
  • + ); + } + if (current) { + return ( +
  • + } + className={cx( + styles['breadcrumb-link'], + styles['breadcrumb-link-active'] + )} + aria-current='page' + > + {label} + +
  • + ); + } return (
  • {cloneElement( renderedElement, { - className: cx( - styles['breadcrumb-link'], - current && styles['breadcrumb-link-active'] - ), + className: styles['breadcrumb-link'], href, ...props, ...renderedElement.props diff --git a/packages/raystack/components/breadcrumb/breadcrumb-misc.tsx b/packages/raystack/components/breadcrumb/breadcrumb-misc.tsx index 52bc07c90..5901e36c3 100644 --- a/packages/raystack/components/breadcrumb/breadcrumb-misc.tsx +++ b/packages/raystack/components/breadcrumb/breadcrumb-misc.tsx @@ -2,7 +2,7 @@ import { ChevronRightIcon, DotsHorizontalIcon } from '@radix-ui/react-icons'; import { cx } from 'class-variance-authority'; -import { HTMLAttributes, forwardRef } from 'react'; +import { forwardRef, HTMLAttributes } from 'react'; import styles from './breadcrumb.module.css'; export interface BreadcrumbEllipsisProps @@ -53,6 +53,8 @@ export const BreadcrumbSeparator = forwardRef<