Skip to content

Commit 297d628

Browse files
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
1 parent 6d2bbb1 commit 297d628

7 files changed

Lines changed: 142 additions & 42 deletions

File tree

apps/www/src/components/playground/breadcrumb-examples.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,13 @@ export function BreadcrumbExamples() {
1313
<Breadcrumb.Item
1414
dropdownItems={[
1515
{
16-
label: 'Clothes',
16+
children: 'Clothes',
1717
onClick: () => {
1818
console.log('Clothes');
1919
}
2020
},
2121
{
22-
label: 'Electronics',
22+
children: 'Electronics',
2323
onClick: () => {
2424
console.log('Electronics');
2525
}

apps/www/src/content/docs/components/breadcrumb/demo.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,13 +94,30 @@ export const dropdownDemo = {
9494
<Breadcrumb.Item href="/category">Category</Breadcrumb.Item>
9595
<Breadcrumb.Separator/>
9696
<Breadcrumb.Item dropdownItems={[
97-
{ label: 'Option 1', onClick: () => {console.log('Option 1')}},
98-
{ label: 'Option 2', onClick: () => {console.log('Option 2')}}
97+
{ children: 'Option 1', onClick: () => {console.log('Option 1')}},
98+
{ children: 'Option 2', onClick: () => {console.log('Option 2')}}
9999
]}>Subcategory</Breadcrumb.Item>
100100
<Breadcrumb.Separator/>
101101
<Breadcrumb.Item href="/category/subcategory/current">Current Page</Breadcrumb.Item>
102102
</Breadcrumb>`
103103
};
104+
105+
export const dropdownLinksDemo = {
106+
type: 'code',
107+
code: `
108+
<Breadcrumb>
109+
<Breadcrumb.Item href="/">Home</Breadcrumb.Item>
110+
<Breadcrumb.Separator/>
111+
<Breadcrumb.Item dropdownItems={[
112+
{ children: 'Electronics', render: <a href="/electronics" target="_blank" rel="noopener noreferrer" /> },
113+
{ children: 'Clothing', render: <a href="/clothing" /> },
114+
{ children: 'Books', onClick: () => {console.log('Books')}}
115+
]}>Categories</Breadcrumb.Item>
116+
<Breadcrumb.Separator/>
117+
<Breadcrumb.Item href="/current" current>Current</Breadcrumb.Item>
118+
</Breadcrumb>`
119+
};
120+
104121
export const asDemo = {
105122
type: 'code',
106123
code: `

apps/www/src/content/docs/components/breadcrumb/index.mdx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
iconsDemo,
1212
ellipsisDemo,
1313
dropdownDemo,
14+
dropdownLinksDemo,
1415
asDemo,
1516
disabledDemo,
1617
} from "./demo.ts";
@@ -45,6 +46,24 @@ Groups all parts of the breadcrumb navigation.
4546

4647
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).
4748

49+
Item elements expose data attributes for CSS state targeting so you can style current and disabled states without relying on internal class names:
50+
51+
| Attribute | When present |
52+
|-----------|----------------|
53+
| `data-current="true"` | Item is the current page (`current` prop) |
54+
| `data-disabled="true"` | Item is disabled (`disabled` prop) |
55+
56+
Example:
57+
58+
```css
59+
[data-current="true"] {
60+
color: var(--my-brand);
61+
}
62+
[data-disabled="true"] {
63+
opacity: 0.6;
64+
}
65+
```
66+
4867
<auto-type-table path="./props.ts" name="BreadcrumbItem" />
4968

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

88107
### Dropdown
89108

90-
Breadcrumb items can include dropdown menus for additional navigation options. Specify the dropdown items using the `dropdownItems` prop.
109+
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.
91110

92-
**Note:** When `dropdownItems` is provided, the `as` and `href` props are ignored.
111+
**Note:** When `dropdownItems` is provided, the `as` and `href` props on the breadcrumb item are ignored.
93112

94113
<Demo data={dropdownDemo} />
95114

115+
<Demo data={dropdownLinksDemo} />
116+
96117
### As
97118

98119
Use the `as` prop to render the breadcrumb item as a custom component. By default, breadcrumb items are rendered as `a` tags.

apps/www/src/content/docs/components/breadcrumb/props.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ReactElement, ReactEventHandler, ReactNode } from 'react';
1+
import { ReactElement, ReactNode } from 'react';
22

33
export interface BreadcrumbItem {
44
/** Text to display for the item */
@@ -26,18 +26,16 @@ export interface BreadcrumbItem {
2626
disabled?: boolean;
2727

2828
/**
29-
* Optional array of dropdown items
29+
* Optional array of dropdown entries; each object is passed to `<Menu.Item>`
30+
* (e.g. `children`, `onClick`, `render` for a link, etc.), plus optional `key`
31+
* for React list reconciliation (not forwarded to `Menu.Item`).
3032
*
3133
* When `dropdownItems` is provided, the `as` and `href` props are ignored.
3234
*/
33-
dropdownItems?: {
34-
/** Optional stable key for list reconciliation. Falls back to index if omitted. */
35+
dropdownItems?: (Record<string, unknown> & {
3536
key?: string;
36-
/** Text to display for the dropdown item */
37-
label: string;
38-
/** Callback function when a dropdown item is clicked */
39-
onClick?: ReactEventHandler<HTMLDivElement>;
40-
}[];
37+
children?: ReactNode;
38+
})[];
4139

4240
/**
4341
* Custom element used to render the Item.

packages/raystack/components/breadcrumb/__tests__/breadcrumb.test.tsx

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ describe('Breadcrumb', () => {
194194
expect(span).toHaveClass(styles['breadcrumb-link']);
195195
expect(span).toHaveClass(styles['breadcrumb-link-active']);
196196
expect(span).toHaveAttribute('aria-current', 'page');
197+
expect(span).toHaveAttribute('data-current', 'true');
197198
expect(span).toHaveTextContent('Current Page');
198199
});
199200

@@ -267,6 +268,7 @@ describe('Breadcrumb', () => {
267268
expect(span).toHaveClass(styles['breadcrumb-link']);
268269
expect(span).toHaveClass(styles['breadcrumb-link-disabled']);
269270
expect(span).toHaveAttribute('aria-disabled', 'true');
271+
expect(span).toHaveAttribute('data-disabled', 'true');
270272
expect(span).toHaveTextContent('Loading…');
271273
});
272274

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

289291
it('disabled with dropdownItems renders as disabled span not dropdown', () => {
290292
const items = [
291-
{ label: 'Option 1', onClick: vi.fn() },
292-
{ label: 'Option 2', onClick: vi.fn() }
293+
{ children: 'Option 1', onClick: vi.fn() },
294+
{ children: 'Option 2', onClick: vi.fn() }
293295
];
294296
const { container } = render(
295297
<Breadcrumb>
@@ -312,8 +314,8 @@ describe('Breadcrumb', () => {
312314
describe('BreadcrumbItem with Dropdown', () => {
313315
it('renders dropdown trigger when dropdownItems provided', () => {
314316
const items = [
315-
{ label: 'Option 1', onClick: vi.fn() },
316-
{ label: 'Option 2', onClick: vi.fn() }
317+
{ children: 'Option 1', onClick: vi.fn() },
318+
{ children: 'Option 2', onClick: vi.fn() }
317319
];
318320

319321
render(
@@ -331,9 +333,9 @@ describe('Breadcrumb', () => {
331333

332334
it('renders dropdown items on click', () => {
333335
const items = [
334-
{ label: 'Electronics' },
335-
{ label: 'Clothing' },
336-
{ label: 'Books' }
336+
{ children: 'Electronics' },
337+
{ children: 'Clothing' },
338+
{ children: 'Books' }
337339
];
338340

339341
render(
@@ -350,6 +352,41 @@ describe('Breadcrumb', () => {
350352
expect(screen.getByText('Clothing')).toBeInTheDocument();
351353
expect(screen.getByText('Books')).toBeInTheDocument();
352354
});
355+
356+
it('renders dropdown items with href as links', () => {
357+
render(
358+
<Breadcrumb>
359+
<Breadcrumb.Item
360+
dropdownItems={[
361+
{
362+
children: 'New tab',
363+
render: (
364+
<a href='/page' target='_blank' rel='noopener noreferrer' />
365+
)
366+
},
367+
{
368+
children: 'Same tab',
369+
render: <a href='/other' />
370+
}
371+
]}
372+
>
373+
Categories
374+
</Breadcrumb.Item>
375+
</Breadcrumb>
376+
);
377+
378+
fireEvent.click(screen.getByText('Categories'));
379+
380+
const newTabLink = screen.getByText('New tab');
381+
expect(newTabLink.tagName).toBe('A');
382+
expect(newTabLink).toHaveAttribute('href', '/page');
383+
expect(newTabLink).toHaveAttribute('target', '_blank');
384+
expect(newTabLink).toHaveAttribute('rel', 'noopener noreferrer');
385+
386+
const sameTabLink = screen.getByText('Same tab');
387+
expect(sameTabLink.tagName).toBe('A');
388+
expect(sameTabLink).toHaveAttribute('href', '/other');
389+
});
353390
});
354391

355392
describe('BreadcrumbSeparator', () => {
@@ -520,9 +557,9 @@ describe('Breadcrumb', () => {
520557

521558
it('renders breadcrumb with icons and dropdown', () => {
522559
const categories = [
523-
{ label: 'Electronics' },
524-
{ label: 'Clothing' },
525-
{ label: 'Books' }
560+
{ children: 'Electronics' },
561+
{ children: 'Clothing' },
562+
{ children: 'Books' }
526563
];
527564

528565
render(

packages/raystack/components/breadcrumb/breadcrumb-item.tsx

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,14 @@ import React, {
1111
import { Menu } from '../menu';
1212
import styles from './breadcrumb.module.css';
1313

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

2023
export interface BreadcrumbItemProps extends ComponentProps<'a'> {
2124
leadingIcon?: ReactNode;
@@ -65,15 +68,23 @@ export const BreadcrumbItem = ({
6568
<ChevronDownIcon className={styles['breadcrumb-dropdown-icon']} />
6669
</Menu.Trigger>
6770
<Menu.Content className={styles['breadcrumb-dropdown-content']}>
68-
{dropdownItems.map((dropdownItem, dropdownIndex) => (
69-
<Menu.Item
70-
key={dropdownItem.key ?? dropdownIndex}
71-
className={styles['breadcrumb-dropdown-item']}
72-
onClick={dropdownItem?.onClick}
73-
>
74-
{dropdownItem.label}
75-
</Menu.Item>
76-
))}
71+
{dropdownItems.map((dropdownItem, dropdownIndex) => {
72+
const {
73+
key,
74+
className: itemClassName,
75+
...menuItemProps
76+
} = dropdownItem;
77+
return (
78+
<Menu.Item
79+
key={key ?? dropdownIndex}
80+
className={cx(
81+
styles['breadcrumb-dropdown-item'],
82+
itemClassName
83+
)}
84+
{...menuItemProps}
85+
/>
86+
);
87+
})}
7788
</Menu.Content>
7889
</Menu>
7990
);
@@ -88,8 +99,11 @@ export const BreadcrumbItem = ({
8899
disabled && styles['breadcrumb-link-disabled'],
89100
current && styles['breadcrumb-link-active']
90101
)}
91-
{...(disabled && { 'aria-disabled': 'true' })}
92-
{...(current && { 'aria-current': 'page' })}
102+
{...(disabled && {
103+
'aria-disabled': 'true',
104+
'data-disabled': 'true'
105+
})}
106+
{...(current && { 'aria-current': 'page', 'data-current': 'true' })}
93107
>
94108
{label}
95109
</span>
@@ -101,11 +115,11 @@ export const BreadcrumbItem = ({
101115
{cloneElement(
102116
renderedElement,
103117
{
104-
ref,
105118
className: styles['breadcrumb-link'],
106119
href,
107120
...props,
108-
...renderedElement.props
121+
...renderedElement.props,
122+
ref
109123
},
110124
label
111125
)}

packages/raystack/components/breadcrumb/breadcrumb.module.css

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,23 @@
8787
}
8888

8989
.breadcrumb-dropdown-item {
90+
display: block;
91+
width: 100%;
92+
padding: var(--rs-space-3);
9093
cursor: pointer;
9194
color: var(--rs-color-foreground-base-primary);
95+
font-weight: var(--rs-font-weight-regular);
96+
font-size: var(--rs-font-size-small);
97+
line-height: var(--rs-line-height-small);
98+
letter-spacing: var(--rs-letter-spacing-small);
99+
text-decoration: none;
100+
border: none;
101+
background: none;
102+
text-align: left;
103+
box-sizing: border-box;
92104
}
93105

94106
.breadcrumb-dropdown-item:hover {
95107
background-color: var(--rs-color-background-base-primary-hover);
108+
border-radius: var(--rs-radius-2);
96109
}

0 commit comments

Comments
 (0)