Skip to content
Merged
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
4 changes: 2 additions & 2 deletions apps/www/src/components/playground/breadcrumb-examples.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ export function BreadcrumbExamples() {
<Breadcrumb.Item
dropdownItems={[
{
label: 'Clothes',
children: 'Clothes',
onClick: () => {
console.log('Clothes');
}
},
{
label: 'Electronics',
children: 'Electronics',
onClick: () => {
console.log('Electronics');
}
Expand Down
21 changes: 19 additions & 2 deletions apps/www/src/content/docs/components/breadcrumb/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,13 +94,30 @@ export const dropdownDemo = {
<Breadcrumb.Item href="/category">Category</Breadcrumb.Item>
<Breadcrumb.Separator/>
<Breadcrumb.Item dropdownItems={[
{ label: 'Option 1', onClick: () => {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</Breadcrumb.Item>
<Breadcrumb.Separator/>
<Breadcrumb.Item href="/category/subcategory/current">Current Page</Breadcrumb.Item>
</Breadcrumb>`
};

export const dropdownLinksDemo = {
type: 'code',
code: `
<Breadcrumb>
<Breadcrumb.Item href="/">Home</Breadcrumb.Item>
<Breadcrumb.Separator/>
<Breadcrumb.Item dropdownItems={[
{ children: 'Electronics', render: <a href="/electronics" target="_blank" rel="noopener noreferrer" /> },
{ children: 'Clothing', render: <a href="/clothing" /> },
{ children: 'Books', onClick: () => {console.log('Books')}}
]}>Categories</Breadcrumb.Item>
<Breadcrumb.Separator/>
<Breadcrumb.Item href="/current" current>Current</Breadcrumb.Item>
</Breadcrumb>`
};

export const asDemo = {
type: 'code',
code: `
Expand Down
25 changes: 23 additions & 2 deletions apps/www/src/content/docs/components/breadcrumb/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
iconsDemo,
ellipsisDemo,
dropdownDemo,
dropdownLinksDemo,
asDemo,
disabledDemo,
} from "./demo.ts";
Expand Down Expand Up @@ -45,6 +46,24 @@ Groups all parts of the breadcrumb navigation.

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;
}
```

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

### Separator
Expand Down Expand Up @@ -87,12 +106,14 @@ Breadcrumb items can include icons via `leadingIcon` (before the label) or `trai

### 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 `<Menu.Item>` (e.g. `children` for the label, `onClick`, `disabled`, `render` for a link such as `<a href="…" />`, 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.

<Demo data={dropdownDemo} />

<Demo data={dropdownLinksDemo} />

### As

Use the `as` prop to render the breadcrumb item as a custom component. By default, breadcrumb items are rendered as `a` tags.
Expand Down
16 changes: 7 additions & 9 deletions apps/www/src/content/docs/components/breadcrumb/props.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ReactElement, ReactEventHandler, ReactNode } from 'react';
import { ReactElement, ReactNode } from 'react';

export interface BreadcrumbItem {
/** Text to display for the item */
Expand Down Expand Up @@ -26,18 +26,16 @@ export interface BreadcrumbItem {
disabled?: boolean;

/**
* Optional array of dropdown items
* Optional array of dropdown entries; each object is passed to `<Menu.Item>`
* (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<string, unknown> & {
key?: string;
/** Text to display for the dropdown item */
label: string;
/** Callback function when a dropdown item is clicked */
onClick?: ReactEventHandler<HTMLDivElement>;
}[];
children?: ReactNode;
})[];

/**
* Custom element used to render the Item.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ describe('Breadcrumb', () => {
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');
});

Expand Down Expand Up @@ -267,6 +268,7 @@ describe('Breadcrumb', () => {
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…');
});

Expand All @@ -288,8 +290,8 @@ describe('Breadcrumb', () => {

it('disabled with dropdownItems renders as disabled span not dropdown', () => {
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() }
];
const { container } = render(
<Breadcrumb>
Expand All @@ -312,8 +314,8 @@ describe('Breadcrumb', () => {
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(
Expand All @@ -331,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(
Expand All @@ -350,6 +352,41 @@ describe('Breadcrumb', () => {
expect(screen.getByText('Clothing')).toBeInTheDocument();
expect(screen.getByText('Books')).toBeInTheDocument();
});

it('renders dropdown items with href as links', () => {
render(
<Breadcrumb>
<Breadcrumb.Item
dropdownItems={[
{
children: 'New tab',
render: (
<a href='/page' target='_blank' rel='noopener noreferrer' />
)
},
{
children: 'Same tab',
render: <a href='/other' />
}
]}
>
Categories
</Breadcrumb.Item>
</Breadcrumb>
);

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', () => {
Expand Down Expand Up @@ -520,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(
Expand Down
48 changes: 31 additions & 17 deletions packages/raystack/components/breadcrumb/breadcrumb-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,14 @@ import React, {
import { Menu } from '../menu';
import styles from './breadcrumb.module.css';

export interface BreadcrumbDropdownItem {
/**
* Each entry maps to `<Menu.Item>`. Use `children`, `render`, `onClick`,
* `disabled`, etc. — whatever `Menu.Item` supports.
*/
export type BreadcrumbDropdownItem = ComponentProps<typeof Menu.Item> & {
/** Optional stable key for React list reconciliation (not passed to `Menu.Item`). */
key?: string;
label: string;
onClick?: React.MouseEventHandler<HTMLElement>;
}
};

export interface BreadcrumbItemProps extends ComponentProps<'a'> {
leadingIcon?: ReactNode;
Expand Down Expand Up @@ -65,15 +68,23 @@ export const BreadcrumbItem = ({
<ChevronDownIcon className={styles['breadcrumb-dropdown-icon']} />
</Menu.Trigger>
<Menu.Content className={styles['breadcrumb-dropdown-content']}>
{dropdownItems.map((dropdownItem, dropdownIndex) => (
<Menu.Item
key={dropdownItem.key ?? dropdownIndex}
className={styles['breadcrumb-dropdown-item']}
onClick={dropdownItem?.onClick}
>
{dropdownItem.label}
</Menu.Item>
))}
{dropdownItems.map((dropdownItem, dropdownIndex) => {
const {
key,
className: itemClassName,
...menuItemProps
} = dropdownItem;
return (
<Menu.Item
key={key ?? dropdownIndex}
className={cx(
styles['breadcrumb-dropdown-item'],
itemClassName
)}
{...menuItemProps}
/>
);
})}
</Menu.Content>
</Menu>
);
Expand All @@ -88,8 +99,11 @@ export const BreadcrumbItem = ({
disabled && styles['breadcrumb-link-disabled'],
current && styles['breadcrumb-link-active']
)}
{...(disabled && { 'aria-disabled': 'true' })}
{...(current && { 'aria-current': 'page' })}
{...(disabled && {
'aria-disabled': 'true',
'data-disabled': 'true'
})}
{...(current && { 'aria-current': 'page', 'data-current': 'true' })}
>
{label}
</span>
Expand All @@ -101,11 +115,11 @@ export const BreadcrumbItem = ({
{cloneElement(
renderedElement,
{
ref,
className: styles['breadcrumb-link'],
href,
...props,
...renderedElement.props
...renderedElement.props,
ref
},
label
)}
Expand Down
13 changes: 13 additions & 0 deletions packages/raystack/components/breadcrumb/breadcrumb.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -87,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);
}