Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion apps/www/src/content/docs/components/breadcrumb/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,23 @@ export const asDemo = {
</Breadcrumb>`
};

export const disabledDemo = {
type: 'code',
code: `
<Breadcrumb>
<Breadcrumb.Item href="/">Home</Breadcrumb.Item>
<Breadcrumb.Separator/>
<Breadcrumb.Item disabled>Loading…</Breadcrumb.Item>
<Breadcrumb.Separator/>
<Breadcrumb.Item href="/products" current>Products</Breadcrumb.Item>
</Breadcrumb>`
};

export const iconsDemo = {
type: 'code',
tabs: [
{
name: 'Text with Icon',
name: 'Leading Icon',
code: `
<Breadcrumb>
<Breadcrumb.Item href="/" leadingIcon={<BellIcon />}>Home</Breadcrumb.Item>
Expand All @@ -127,6 +139,28 @@ export const iconsDemo = {
<Breadcrumb.Item href="/settings" leadingIcon={<ShoppingBagFilledIcon />}>Settings</Breadcrumb.Item>
</Breadcrumb>`
},
{
name: 'Trailing Icon',
code: `
<Breadcrumb>
<Breadcrumb.Item href="/" trailingIcon={<BellIcon />}>Home</Breadcrumb.Item>
<Breadcrumb.Separator/>
<Breadcrumb.Item href="/documents" trailingIcon={<FilterIcon />}>Documents</Breadcrumb.Item>
<Breadcrumb.Separator/>
<Breadcrumb.Item href="/settings" trailingIcon={<ShoppingBagFilledIcon />}>Settings</Breadcrumb.Item>
</Breadcrumb>`
},
{
name: 'Both Icons',
code: `
<Breadcrumb>
<Breadcrumb.Item href="/" leadingIcon={<BellIcon />} trailingIcon={<FilterIcon />}>Home</Breadcrumb.Item>
<Breadcrumb.Separator/>
<Breadcrumb.Item href="/documents" leadingIcon={<FilterIcon />} trailingIcon={<ShoppingBagFilledIcon />}>Documents</Breadcrumb.Item>
<Breadcrumb.Separator/>
<Breadcrumb.Item href="/settings" leadingIcon={<ShoppingBagFilledIcon />} trailingIcon={<BellIcon />}>Settings</Breadcrumb.Item>
</Breadcrumb>`
},
{
name: 'Only Icon',
code: `
Expand Down
14 changes: 11 additions & 3 deletions apps/www/src/content/docs/components/breadcrumb/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
ellipsisDemo,
dropdownDemo,
asDemo,
disabledDemo,
} from "./demo.ts";

<Demo data={playground} />
Expand Down Expand Up @@ -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).

<auto-type-table path="./props.ts" name="BreadcrumbItem" />

Expand Down Expand Up @@ -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.

<Demo data={iconsDemo} />

Expand All @@ -100,8 +101,15 @@ When a custom component is provided, the props are merged, with the custom compo

<Demo data={asDemo} />

### 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.

<Demo data={disabledDemo} />

## 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
11 changes: 10 additions & 1 deletion apps/www/src/content/docs/components/breadcrumb/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
136 changes: 134 additions & 2 deletions packages/raystack/components/breadcrumb/__tests__/breadcrumb.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,15 +128,73 @@ describe('Breadcrumb', () => {
expect(screen.getByText('Home')).toBeInTheDocument();
});

it('applies current/active state', () => {
it('renders with trailing icon', () => {
render(
<Breadcrumb>
<Breadcrumb.Item
trailingIcon={<span data-testid='trailing-icon'>▶</span>}
>
Next
</Breadcrumb.Item>
</Breadcrumb>
);

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(
<Breadcrumb>
<Breadcrumb.Item
leadingIcon={<span data-testid='leading'>L</span>}
trailingIcon={<span data-testid='trailing'>T</span>}
>
Label
</Breadcrumb.Item>
</Breadcrumb>
);

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(
<Breadcrumb>
<Breadcrumb.Item current>Current Page</Breadcrumb.Item>
</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', () => {
Expand Down Expand Up @@ -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(
<Breadcrumb>
<Breadcrumb.Item disabled>Loading…</Breadcrumb.Item>
</Breadcrumb>
);

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(
<Breadcrumb>
<Breadcrumb.Item disabled href='/skipped'>
No access
</Breadcrumb.Item>
</Breadcrumb>
);

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(
<Breadcrumb>
<Breadcrumb.Item disabled dropdownItems={items}>
Categories
</Breadcrumb.Item>
</Breadcrumb>
);

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', () => {
Expand Down Expand Up @@ -282,6 +398,22 @@ describe('Breadcrumb', () => {
);
expect(ref).toHaveBeenCalled();
});

it('has role="presentation" and aria-hidden="true" for screen readers', () => {
const { container } = render(
<Breadcrumb>
<Breadcrumb.Item>Home</Breadcrumb.Item>
<Breadcrumb.Separator />
<Breadcrumb.Item>Products</Breadcrumb.Item>
</Breadcrumb>
);

const separator = container.querySelector(
`.${styles['breadcrumb-separator']}`
);
expect(separator).toHaveAttribute('role', 'presentation');
expect(separator).toHaveAttribute('aria-hidden', 'true');
});
});

describe('BreadcrumbEllipsis', () => {
Expand Down
47 changes: 42 additions & 5 deletions packages/raystack/components/breadcrumb/breadcrumb-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ export interface BreadcrumbDropdownItem {

export interface BreadcrumbItemProps extends HTMLAttributes<HTMLAnchorElement> {
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;
Expand All @@ -35,7 +38,9 @@ export const BreadcrumbItem = forwardRef<
children,
className,
leadingIcon,
trailingIcon,
current,
disabled,
href,
dropdownItems,
...props
Expand All @@ -49,10 +54,13 @@ export const BreadcrumbItem = forwardRef<
<span className={styles['breadcrumb-icon']}>{leadingIcon}</span>
)}
{children && <span>{children}</span>}
{trailingIcon && (
<span className={styles['breadcrumb-icon']}>{trailingIcon}</span>
)}
</>
);

if (dropdownItems) {
if (dropdownItems && !disabled) {
return (
<Menu>
<Menu.Trigger className={styles['breadcrumb-dropdown-trigger']}>
Expand All @@ -73,15 +81,44 @@ export const BreadcrumbItem = forwardRef<
</Menu>
);
}
if (disabled) {
return (
<li className={cx(styles['breadcrumb-item'], className)}>
<span
ref={ref as React.RefObject<HTMLSpanElement>}
className={cx(
styles['breadcrumb-link'],
styles['breadcrumb-link-disabled']
)}
aria-disabled='true'
>
{label}
</span>
</li>
);
}
if (current) {
return (
<li className={cx(styles['breadcrumb-item'], className)}>
<span
ref={ref as React.RefObject<HTMLSpanElement>}
className={cx(
styles['breadcrumb-link'],
styles['breadcrumb-link-active']
)}
aria-current='page'
>
{label}
</span>
</li>
);
}
return (
<li className={cx(styles['breadcrumb-item'], className)}>
{cloneElement(
renderedElement,
{
className: cx(
styles['breadcrumb-link'],
current && styles['breadcrumb-link-active']
),
className: styles['breadcrumb-link'],
href,
...props,
...renderedElement.props
Expand Down
4 changes: 3 additions & 1 deletion packages/raystack/components/breadcrumb/breadcrumb-misc.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -53,6 +53,8 @@ export const BreadcrumbSeparator = forwardRef<
<span
className={cx(styles['breadcrumb-separator'], className)}
ref={ref}
role='presentation'
aria-hidden='true'
{...props}
>
{children}
Expand Down
Loading