Skip to content

Commit 7202695

Browse files
authored
feat(Breadcrumb): new component (#1331)
* feat(Breadcrumb): new component * docs(Breadcrumb): add jsdocs * refactor: export BreadcrumbPath from Breadcrumb component
1 parent 2d6e619 commit 7202695

File tree

7 files changed

+350
-0
lines changed

7 files changed

+350
-0
lines changed
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
.Breadcrumb__list {
2+
display: flex;
3+
gap: var(--space-50);
4+
padding: 0;
5+
margin: 0;
6+
}
7+
8+
.Breadcrumb__listItem {
9+
list-style-type: none;
10+
}
11+
12+
.Breadcrumb__link {
13+
display: flex;
14+
gap: var(--space-50);
15+
align-items: center;
16+
composes: OneUI-caption-text from global;
17+
color: var(--color-text-subtlest, #808080);
18+
19+
&:hover {
20+
color: var(--color-text-bold, #000000);
21+
}
22+
23+
&--active {
24+
composes: OneUI-caption-text-bold from global;
25+
color: var(--color-text-selected-default, #004999);
26+
27+
&:hover {
28+
color: var(--color-text-bold, #000000);
29+
}
30+
}
31+
32+
&:focus-visible {
33+
outline: 2px solid var(--color-border-brand-subtle-brand, #66c1e3);
34+
}
35+
}
36+
37+
.Breadcrumb__icon {
38+
height: var(--space-200);
39+
width: var(--space-200);
40+
}
41+
42+
a {
43+
color: inherit;
44+
text-decoration: none;
45+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import React from 'react';
2+
import { bem } from '../../utils';
3+
import styles from './Breadcrumb.scss';
4+
5+
export interface Path {
6+
href: string;
7+
label?: string;
8+
icon?: React.ReactElement;
9+
}
10+
11+
export interface Props extends React.ComponentPropsWithoutRef<'nav'> {
12+
/** Paths used for creation of different breadcrumb links */
13+
paths: Path[];
14+
/** Custom link renderer to allow any router implementation */
15+
linkRenderer?: (path: Path) => React.ReactElement;
16+
}
17+
18+
const { block, elem } = bem('Breadcrumb', styles);
19+
20+
const Breadcrumb = React.forwardRef<HTMLElement, Props>(
21+
(
22+
{ paths, linkRenderer = (path: Path) => <a href={path.href}>{path.label}</a>, ...rest },
23+
ref
24+
) => (
25+
<nav {...block(rest)} {...rest} ref={ref}>
26+
<ol {...elem('list')}>
27+
{paths.map((path, index) => {
28+
const isLast = index === paths.length - 1;
29+
return (
30+
<li
31+
key={path.href}
32+
aria-current={isLast ? 'page' : undefined}
33+
{...elem('listItem')}
34+
>
35+
{React.cloneElement(
36+
linkRenderer(path),
37+
{ ...elem('link', { active: isLast }) },
38+
<>
39+
{index > 0 && <span {...elem('separator')}>/</span>}
40+
{path.icon &&
41+
React.cloneElement(path.icon, { ...elem('icon') })}
42+
{path.label}
43+
</>
44+
)}
45+
</li>
46+
);
47+
})}
48+
</ol>
49+
</nav>
50+
)
51+
);
52+
53+
export { Breadcrumb };
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import React from 'react';
2+
import { MemoryRouter } from 'react-router-dom';
3+
import { MdHome } from 'react-icons/md';
4+
import { render, RenderResult, screen } from '@testing-library/react';
5+
import { Breadcrumb } from '..';
6+
import '@testing-library/jest-dom';
7+
8+
describe('Breadcrumb', () => {
9+
let view: RenderResult;
10+
11+
it('should render correctly', () => {
12+
view = render(
13+
<MemoryRouter>
14+
<Breadcrumb
15+
paths={[
16+
{ href: '/', icon: <MdHome aria-label="home-icon" /> },
17+
{ href: '/compare' },
18+
{ label: 'Details', href: '/compare/details' },
19+
]}
20+
/>
21+
</MemoryRouter>
22+
);
23+
24+
expect(screen.getAllByRole('link')).toHaveLength(3);
25+
expect(screen.queryByText('Compare')).not.toBeInTheDocument();
26+
expect(screen.getByText('Details')).toBeInTheDocument();
27+
expect(screen.getByLabelText('home-icon')).toBeInTheDocument();
28+
expect(screen.getAllByText('/')).toHaveLength(2);
29+
expect(screen.queryByLabelText('Details')).not.toBeInTheDocument();
30+
expect(view.container).toMatchSnapshot();
31+
});
32+
33+
it('should render with a custom linkRenderer', () => {
34+
view = render(
35+
<MemoryRouter>
36+
<Breadcrumb
37+
paths={[
38+
{ href: '/', icon: <MdHome aria-label="home-icon" /> },
39+
{ href: '/compare' },
40+
{ label: 'Details', href: '/compare/details' },
41+
]}
42+
linkRenderer={(path) => <div aria-label={path.label}>{path.label}</div>}
43+
/>
44+
</MemoryRouter>
45+
);
46+
47+
expect(screen.getByLabelText('Details')).toBeInTheDocument();
48+
expect(view.container).toMatchSnapshot();
49+
});
50+
});
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`Breadcrumb should render correctly 1`] = `
4+
<div>
5+
<nav>
6+
<ol
7+
class="Breadcrumb__list"
8+
>
9+
<li
10+
class="Breadcrumb__listItem"
11+
>
12+
<a
13+
class="Breadcrumb__link"
14+
href="/"
15+
>
16+
<svg
17+
aria-label="home-icon"
18+
class="Breadcrumb__icon"
19+
fill="currentColor"
20+
height="1em"
21+
stroke="currentColor"
22+
stroke-width="0"
23+
viewBox="0 0 24 24"
24+
width="1em"
25+
xmlns="http://www.w3.org/2000/svg"
26+
>
27+
<path
28+
d="M0 0h24v24H0z"
29+
fill="none"
30+
/>
31+
<path
32+
d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"
33+
/>
34+
</svg>
35+
</a>
36+
</li>
37+
<li
38+
class="Breadcrumb__listItem"
39+
>
40+
<a
41+
class="Breadcrumb__link"
42+
href="/compare"
43+
>
44+
<span>
45+
/
46+
</span>
47+
</a>
48+
</li>
49+
<li
50+
aria-current="page"
51+
class="Breadcrumb__listItem"
52+
>
53+
<a
54+
class="Breadcrumb__link Breadcrumb__link--active"
55+
href="/compare/details"
56+
>
57+
<span>
58+
/
59+
</span>
60+
Details
61+
</a>
62+
</li>
63+
</ol>
64+
</nav>
65+
</div>
66+
`;
67+
68+
exports[`Breadcrumb should render with a custom linkRenderer 1`] = `
69+
<div>
70+
<nav>
71+
<ol
72+
class="Breadcrumb__list"
73+
>
74+
<li
75+
class="Breadcrumb__listItem"
76+
>
77+
<div
78+
class="Breadcrumb__link"
79+
>
80+
<svg
81+
aria-label="home-icon"
82+
class="Breadcrumb__icon"
83+
fill="currentColor"
84+
height="1em"
85+
stroke="currentColor"
86+
stroke-width="0"
87+
viewBox="0 0 24 24"
88+
width="1em"
89+
xmlns="http://www.w3.org/2000/svg"
90+
>
91+
<path
92+
d="M0 0h24v24H0z"
93+
fill="none"
94+
/>
95+
<path
96+
d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"
97+
/>
98+
</svg>
99+
</div>
100+
</li>
101+
<li
102+
class="Breadcrumb__listItem"
103+
>
104+
<div
105+
class="Breadcrumb__link"
106+
>
107+
<span>
108+
/
109+
</span>
110+
</div>
111+
</li>
112+
<li
113+
aria-current="page"
114+
class="Breadcrumb__listItem"
115+
>
116+
<div
117+
aria-label="Details"
118+
class="Breadcrumb__link Breadcrumb__link--active"
119+
>
120+
<span>
121+
/
122+
</span>
123+
Details
124+
</div>
125+
</li>
126+
</ol>
127+
</nav>
128+
</div>
129+
`;

src/components/Breadcrumb/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { Breadcrumb, Props as BreadcrumbProps, Path as BreadcrumbPath } from './Breadcrumb';

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export * from './components/Badges';
4646
// Molecules
4747
export * from './components/BulkActionsToolbar';
4848
export * from './components/ButtonGroup';
49+
export * from './components/Breadcrumb';
4950
export * from './components/Checkbox';
5051
export * from './components/DatePicker';
5152
export * from './components/Dropdown';
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import * as React from 'react';
2+
import type { Meta, StoryObj } from '@storybook/react';
3+
import { Breadcrumb, BreadcrumbPath, Button, Heading } from '@textkernel/oneui';
4+
import { MemoryRouter, Navigate, NavLink, Route, Routes, useLocation } from 'react-router-dom';
5+
import { MdHome } from 'react-icons/md';
6+
7+
const meta: Meta<typeof Breadcrumb> = {
8+
title: 'Atoms/Breadcrumb',
9+
component: Breadcrumb,
10+
};
11+
12+
export default meta;
13+
14+
type Story = StoryObj<typeof Breadcrumb>;
15+
16+
export const _Breadcrumb: Story = {
17+
name: 'Breadcrumb',
18+
decorators: [
19+
(Story) => (
20+
<MemoryRouter initialIndex={0}>
21+
<Story />
22+
</MemoryRouter>
23+
),
24+
],
25+
render: () => {
26+
const location = useLocation();
27+
const pathnames = location.pathname.split('/').filter((p) => p);
28+
const paths: BreadcrumbPath[] = [
29+
{ href: '/', label: 'Home', icon: <MdHome /> },
30+
...pathnames.map((name, index) => ({
31+
href: `/${pathnames.slice(0, index + 1).join('/')}`,
32+
label: name.charAt(0).toUpperCase() + name.slice(1),
33+
})),
34+
];
35+
36+
return (
37+
<div>
38+
<Breadcrumb
39+
paths={paths}
40+
linkRenderer={(path) => <NavLink to={path.href}>{path.label}</NavLink>}
41+
/>
42+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 48 }}>
43+
<Heading level="h3">Navigation helpers</Heading>
44+
<div style={{ display: 'flex', gap: 8 }}>
45+
<NavLink to="/">
46+
<Button>Go to Home</Button>
47+
</NavLink>
48+
<NavLink to="/compare">
49+
<Button>Go to Compare</Button>
50+
</NavLink>
51+
<NavLink to="/compare/details">
52+
<Button>Go to Compare Details</Button>
53+
</NavLink>
54+
</div>
55+
<div style={{ display: 'flex', gap: 8 }}>
56+
<span>Current route:</span>
57+
<Routes>
58+
<Route
59+
path="/compare/details"
60+
element={<span>/compare/details</span>}
61+
/>
62+
<Route path="/compare" element={<span>/compare</span>} />
63+
<Route path="/" element={<span>/</span>} />
64+
<Route path="*" element={<Navigate to="/" replace />} />
65+
</Routes>
66+
</div>
67+
</div>
68+
</div>
69+
);
70+
},
71+
};

0 commit comments

Comments
 (0)