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
4 changes: 2 additions & 2 deletions apps/www/src/content/docs/components/breadcrumb/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,9 @@ export const asDemo = {
type: 'code',
code: `
<Breadcrumb>
<Breadcrumb.Item href="/home" as={<NextLink href="/" />}>Home</Breadcrumb.Item>
<Breadcrumb.Item href="/" as={NextLink}>Home</Breadcrumb.Item>
<Breadcrumb.Separator/>
<Breadcrumb.Item href="/playground" as={<NextLink />}>Playground</Breadcrumb.Item>
<Breadcrumb.Item href="/playground" as={NextLink}>Playground</Breadcrumb.Item>
<Breadcrumb.Separator/>
<Breadcrumb.Item href="/docs" current>Docs</Breadcrumb.Item>
</Breadcrumb>`
Expand Down
9 changes: 6 additions & 3 deletions apps/www/src/content/docs/components/breadcrumb/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Groups all parts of the breadcrumb navigation.

### Item

Renders an individual breadcrumb link.
Renders an individual breadcrumb link. Ref is forwarded to the link or custom component when using `as` (not when using `dropdownItems`).

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

Expand Down Expand Up @@ -94,9 +94,12 @@ Breadcrumb items can include dropdown menus for additional navigation options. S

### As

Use the `as` prop to render the breadcrumb item as a custom component. By default, breadcrumb items are rendered as `a` tags.
Use the `as` prop to render the breadcrumb item as a custom component. Pass the **component reference** (e.g. `as={NextLink}`), not a JSX element. Put `href` and other props on `Breadcrumb.Item`; they are passed through to the component. By default, items render as `a` tags.

When a custom component is provided, the props are merged, with the custom component's props taking precedence over the breadcrumb item's props.
```tsx
// Correct: pass the component, set href on the item
<Breadcrumb.Item href="/" as={NextLink}>Home</Breadcrumb.Item>
```

<Demo data={asDemo} />

Expand Down
11 changes: 5 additions & 6 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 { ElementType, ReactEventHandler, ReactNode } from 'react';

export interface BreadcrumbItem {
/** Text to display for the item */
Expand Down Expand Up @@ -29,13 +29,12 @@ export interface BreadcrumbItem {
}[];

/**
* Custom element used to render the Item.
* Component to render as (e.g. NextLink). Pass the component reference, not a JSX element.
* Receives breadcrumb item props (href, className, ref, children, etc.).
*
* All props are merged, with the custom component's props taking precedence over the breadcrumb item's props.
*
* @default "<a />"
* @default "a"
*/
as?: ReactElement;
as?: ElementType;
}

export interface BreadcrumbProps {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react';
import React, { forwardRef } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { Breadcrumb } from '../breadcrumb';
import styles from '../breadcrumb.module.css';
Expand Down Expand Up @@ -135,24 +136,32 @@ describe('Breadcrumb', () => {
</Breadcrumb>
);

const link = container.querySelector('a');
expect(link).toHaveClass(styles['breadcrumb-link-active']);
// Current page renders as <span> (non-link), not <a>
const currentEl = container.querySelector(
`span.${styles['breadcrumb-link-active']}`
);
expect(currentEl).toBeInTheDocument();
expect(currentEl).toHaveAttribute('aria-current', 'page');
});

it('renders with custom element using as prop', () => {
const CustomLink = ({ children, ...props }: any) => (
<button {...props}>{children}</button>
const CustomLink: React.ComponentType<
React.AnchorHTMLAttributes<HTMLAnchorElement>
> = ({ children, ...props }) => (
<a data-custom-link {...props}>
{children}
</a>
);

const { container } = render(
<Breadcrumb>
<Breadcrumb.Item as={<CustomLink />}>Custom</Breadcrumb.Item>
<Breadcrumb.Item as={CustomLink}>Custom</Breadcrumb.Item>
</Breadcrumb>
);

const button = container.querySelector('button');
expect(button).toBeInTheDocument();
expect(button).toHaveClass(styles['breadcrumb-link']);
const link = container.querySelector('[data-custom-link]');
expect(link).toBeInTheDocument();
expect(link).toHaveClass(styles['breadcrumb-link']);
expect(screen.getByText('Custom')).toBeInTheDocument();
});

Expand All @@ -166,6 +175,26 @@ describe('Breadcrumb', () => {
expect(ref).toHaveBeenCalled();
});

it('forwards ref when using as prop (component)', () => {
const CustomLink = forwardRef<
HTMLAnchorElement,
React.AnchorHTMLAttributes<HTMLAnchorElement>
>(({ children, ...props }, ref) => (
<a ref={ref} data-custom-link {...props}>
{children}
</a>
));
const ref = vi.fn();
render(
<Breadcrumb>
<Breadcrumb.Item ref={ref} as={CustomLink}>
Custom
</Breadcrumb.Item>
</Breadcrumb>
);
expect(ref).toHaveBeenCalled();
});

it('applies custom className', () => {
const { container } = render(
<Breadcrumb>
Expand Down
64 changes: 37 additions & 27 deletions packages/raystack/components/breadcrumb/breadcrumb-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,7 @@

import { ChevronDownIcon } from '@radix-ui/react-icons';
import { cx } from 'class-variance-authority';
import React, {
cloneElement,
forwardRef,
HTMLAttributes,
ReactElement,
ReactNode
} from 'react';
import React, { forwardRef, HTMLAttributes, ReactNode } from 'react';
import { Menu } from '../menu';
import styles from './breadcrumb.module.css';

Expand All @@ -22,7 +16,8 @@ export interface BreadcrumbItemProps extends HTMLAttributes<HTMLAnchorElement> {
current?: boolean;
dropdownItems?: BreadcrumbDropdownItem[];
href?: string;
as?: ReactElement;
/** Component to render as (e.g. NextLink). Receives our props (href, className, ref, etc.). Defaults to "a". */
as?: React.ElementType;
}

export const BreadcrumbItem = forwardRef<
Expand All @@ -42,14 +37,15 @@ export const BreadcrumbItem = forwardRef<
},
ref
) => {
const renderedElement = as ?? <a ref={ref} />;
const label = (
const Component = as ?? 'a';
// Only wrap in spans when needed for layout (leading icon); otherwise keep link content as plain text so DOM shows <a>Home</a> not <a><span>Home</span></a>
const label = leadingIcon ? (
<>
{leadingIcon && (
<span className={styles['breadcrumb-icon']}>{leadingIcon}</span>
)}
{children && <span>{children}</span>}
<span className={styles['breadcrumb-icon']}>{leadingIcon}</span>
{children != null && <span>{children}</span>}
</>
) : (
children
);

if (dropdownItems) {
Expand All @@ -73,21 +69,35 @@ export const BreadcrumbItem = forwardRef<
</Menu>
);
}
// Current page: render as non-link <span> (semantic; no href)
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'
{...props}
>
{label}
</span>
</li>
);
}
// Link: render as <a> or custom Component (e.g. NextLink → <a> in DOM)
return (
<li className={cx(styles['breadcrumb-item'], className)}>
{cloneElement(
renderedElement,
{
className: cx(
styles['breadcrumb-link'],
current && styles['breadcrumb-link-active']
),
href,
...props,
...renderedElement.props
},
label
)}
<Component
ref={ref}
className={cx(styles['breadcrumb-link'])}
href={href}
{...props}
>
{label}
</Component>
</li>
);
}
Expand Down
10 changes: 7 additions & 3 deletions packages/raystack/components/breadcrumb/breadcrumb-root.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import { type VariantProps, cva } from 'class-variance-authority';
import { HTMLAttributes, forwardRef } from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { forwardRef, HTMLAttributes } from 'react';
import styles from './breadcrumb.module.css';

const breadcrumbVariants = cva(styles['breadcrumb'], {
Expand All @@ -21,11 +21,15 @@ export interface BreadcrumbProps
HTMLAttributes<HTMLDivElement> {}

export const BreadcrumbRoot = forwardRef<HTMLDivElement, BreadcrumbProps>(
({ className, children, size = 'medium', ...props }, ref) => {
(
{ className, children, size = 'medium', 'aria-label': ariaLabel, ...props },
ref
) => {
return (
<nav
className={breadcrumbVariants({ size, className })}
ref={ref}
aria-label={ariaLabel ?? 'Breadcrumb'}
{...props}
>
<ol className={styles['breadcrumb-list']}>{children}</ol>
Expand Down
Loading