Skip to content

Commit 7d8b7e9

Browse files
committed
feat: Added Breadcrumbs component
1 parent b2a46d6 commit 7d8b7e9

9 files changed

Lines changed: 677 additions & 0 deletions

File tree

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { html, LitElement, type PropertyValues } from 'lit';
2+
import { property } from 'lit/decorators.js';
3+
import { breadcrumbsContext } from '../common/context.js';
4+
import { createAsyncContext } from '../common/controllers/async-consumer.js';
5+
import { addInternalsController } from '../common/controllers/internals.js';
6+
import { registerComponent } from '../common/definitions/register.js';
7+
import IgcIconComponent from '../icon/icon.js';
8+
import { styles } from './themes/breadcrumb.base.css.js';
9+
10+
/**
11+
* A single breadcrumb item within an `igc-breadcrumbs` list.
12+
*
13+
* @element igc-breadcrumb
14+
*
15+
* @slot - The main content of the breadcrumb, typically an anchor (`<a>`) element.
16+
* @slot prefix - Renders content before the main breadcrumb content.
17+
* @slot suffix - Renders content after the main breadcrumb content.
18+
* @slot separator - Overrides the default separator icon rendered after the breadcrumb item.
19+
*
20+
* @csspart label - The container wrapping the prefix, default, and suffix slots.
21+
* @csspart separator - The container wrapping the separator slot content.
22+
*
23+
* @cssproperty --ig-breadcrumb-link-color - The color of the breadcrumb link. Defaults to `--ig-primary-500`.
24+
* @cssproperty --ig-breadcrumb-link-color-hover - The hover color of the breadcrumb link. Defaults to `--ig-primary-700`.
25+
* @cssproperty --ig-breadcrumb-current-color - The color of the active (current) breadcrumb link. Defaults to `--ig-gray-900`.
26+
* @cssproperty --ig-breadcrumb-separator-color - The color of the separator. Defaults to `--ig-gray-500`.
27+
*
28+
* @example
29+
* ```html
30+
* <igc-breadcrumbs>
31+
* <igc-breadcrumb>
32+
* <a href="/home">Home</a>
33+
* </igc-breadcrumb>
34+
* <igc-breadcrumb>
35+
* <a href="/products">Products</a>
36+
* </igc-breadcrumb>
37+
* <igc-breadcrumb current>
38+
* <a href="/products/laptop">Laptop</a>
39+
* </igc-breadcrumb>
40+
* </igc-breadcrumbs>
41+
* ```
42+
*/
43+
export default class IgcBreadcrumbComponent extends LitElement {
44+
public static readonly tagName = 'igc-breadcrumb';
45+
public static override styles = [styles];
46+
47+
/* blazorSuppress */
48+
public static register(): void {
49+
registerComponent(IgcBreadcrumbComponent, IgcIconComponent);
50+
}
51+
52+
//#region Internal state
53+
54+
private readonly _internals = addInternalsController(this, {
55+
initialARIA: { role: 'listitem' },
56+
});
57+
58+
private readonly _separatorConsumer = createAsyncContext(
59+
this,
60+
breadcrumbsContext
61+
);
62+
63+
private get _separator(): string {
64+
return this._separatorConsumer.value ?? 'tree_expand';
65+
}
66+
67+
//#endregion
68+
69+
//#region Public properties
70+
71+
/**
72+
* Marks this breadcrumb as representing the current page.
73+
* Sets `aria-current="page"` on the element when active.
74+
*
75+
* @attr
76+
* @default false
77+
*/
78+
@property({ type: Boolean, reflect: true })
79+
public current = false;
80+
81+
//#endregion
82+
83+
//#region Lit lifecycle
84+
85+
protected override updated(changedProperties: PropertyValues<this>): void {
86+
if (changedProperties.has('current')) {
87+
this._internals.setARIA({ ariaCurrent: this.current ? 'page' : null });
88+
}
89+
}
90+
91+
protected override render() {
92+
return html`
93+
<span part="label">
94+
<slot name="prefix"></slot>
95+
<slot></slot>
96+
<slot name="suffix"></slot>
97+
</span>
98+
<span part="separator">
99+
<slot name="separator">
100+
<igc-icon name="${this._separator}" collection="default"></igc-icon>
101+
</slot>
102+
</span>
103+
`;
104+
}
105+
106+
//#endregion
107+
}
108+
109+
declare global {
110+
interface HTMLElementTagNameMap {
111+
'igc-breadcrumb': IgcBreadcrumbComponent;
112+
}
113+
}
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import { elementUpdated, expect, fixture, html } from '@open-wc/testing';
2+
3+
import { defineComponents } from '../common/definitions/defineComponents.js';
4+
import { first, last } from '../common/util.js';
5+
import IgcBreadcrumbComponent from './breadcrumb.js';
6+
import IgcBreadcrumbsComponent from './breadcrumbs.js';
7+
8+
describe('Breadcrumbs', () => {
9+
before(() => {
10+
defineComponents(IgcBreadcrumbsComponent);
11+
});
12+
13+
const createDefaultBreadcrumbs = () => html`
14+
<igc-breadcrumbs>
15+
<igc-breadcrumb><a href="#">Home</a></igc-breadcrumb>
16+
<igc-breadcrumb><a href="#">Products</a></igc-breadcrumb>
17+
<igc-breadcrumb current><a href="#">Laptop</a></igc-breadcrumb>
18+
</igc-breadcrumbs>
19+
`;
20+
21+
describe('Initialization', () => {
22+
it('passes the a11y audit', async () => {
23+
const el = await fixture<IgcBreadcrumbsComponent>(
24+
createDefaultBreadcrumbs()
25+
);
26+
await expect(el).to.be.accessible();
27+
await expect(el).shadowDom.to.be.accessible();
28+
});
29+
30+
it('initializes igc-breadcrumb with current=false by default', async () => {
31+
const item = await fixture<IgcBreadcrumbComponent>(
32+
html`<igc-breadcrumb><a href="#">Home</a></igc-breadcrumb>`
33+
);
34+
expect(item.current).to.be.false;
35+
});
36+
});
37+
38+
describe('current property', () => {
39+
it('reflects the current attribute', async () => {
40+
const item = await fixture<IgcBreadcrumbComponent>(
41+
html`<igc-breadcrumb current><a href="#">Page</a></igc-breadcrumb>`
42+
);
43+
expect(item.current).to.be.true;
44+
expect(item).dom.to.equal(
45+
'<igc-breadcrumb current><a href="#">Page</a></igc-breadcrumb>'
46+
);
47+
});
48+
49+
it('toggles current state programmatically', async () => {
50+
const el = await fixture<IgcBreadcrumbsComponent>(
51+
createDefaultBreadcrumbs()
52+
);
53+
54+
const lastBreadcrumb = last(
55+
Array.from(el.querySelectorAll(IgcBreadcrumbComponent.tagName))
56+
);
57+
58+
expect(lastBreadcrumb.current).to.be.true;
59+
60+
lastBreadcrumb.current = false;
61+
await elementUpdated(lastBreadcrumb);
62+
63+
expect(lastBreadcrumb.current).to.be.false;
64+
expect(lastBreadcrumb.hasAttribute('current')).to.be.false;
65+
});
66+
67+
it('sets current attribute when property changes to true', async () => {
68+
const item = await fixture<IgcBreadcrumbComponent>(
69+
html`<igc-breadcrumb><a href="#">Page</a></igc-breadcrumb>`
70+
);
71+
72+
expect(item.current).to.be.false;
73+
expect(item.hasAttribute('current')).to.be.false;
74+
75+
item.current = true;
76+
await elementUpdated(item);
77+
78+
expect(item.current).to.be.true;
79+
expect(item.hasAttribute('current')).to.be.true;
80+
});
81+
});
82+
83+
describe('Separator', () => {
84+
it('hides the separator on the last breadcrumb item', async () => {
85+
const el = await fixture<IgcBreadcrumbsComponent>(
86+
createDefaultBreadcrumbs()
87+
);
88+
const lastBreadcrumb = last(
89+
Array.from(el.querySelectorAll(IgcBreadcrumbComponent.tagName))
90+
);
91+
const separator =
92+
lastBreadcrumb.renderRoot.querySelector<HTMLElement>(
93+
'[part="separator"]'
94+
)!;
95+
96+
expect(getComputedStyle(separator).display).to.equal('none');
97+
});
98+
99+
it('renders a custom separator via the separator slot', async () => {
100+
const el = await fixture<IgcBreadcrumbComponent>(html`
101+
<igc-breadcrumb>
102+
<a href="#">Home</a>
103+
<span slot="separator">/</span>
104+
</igc-breadcrumb>
105+
`);
106+
107+
const slot = el.renderRoot.querySelector<HTMLSlotElement>(
108+
'slot[name="separator"]'
109+
)!;
110+
const assigned = slot.assignedNodes();
111+
112+
expect(assigned).to.have.lengthOf(1);
113+
expect(first(assigned).textContent).to.equal('/');
114+
});
115+
});
116+
117+
describe('Prefix and Suffix slots', () => {
118+
it('renders content in the prefix slot', async () => {
119+
const el = await fixture<IgcBreadcrumbComponent>(html`
120+
<igc-breadcrumb>
121+
<span slot="prefix"></span>
122+
<a href="#">Home</a>
123+
</igc-breadcrumb>
124+
`);
125+
126+
const slot = el.renderRoot.querySelector<HTMLSlotElement>(
127+
'slot[name="prefix"]'
128+
)!;
129+
const assigned = slot.assignedNodes();
130+
131+
expect(assigned).to.have.lengthOf(1);
132+
expect(first(assigned).textContent).to.equal('★');
133+
});
134+
135+
it('renders content in the suffix slot', async () => {
136+
const el = await fixture<IgcBreadcrumbComponent>(html`
137+
<igc-breadcrumb>
138+
<a href="#">Home</a>
139+
<span slot="suffix"></span>
140+
</igc-breadcrumb>
141+
`);
142+
143+
const slot = el.renderRoot.querySelector<HTMLSlotElement>(
144+
'slot[name="suffix"]'
145+
)!;
146+
const assigned = slot.assignedNodes();
147+
148+
expect(assigned).to.have.lengthOf(1);
149+
expect(first(assigned).textContent).to.equal('▸');
150+
});
151+
});
152+
153+
describe('Separator property', () => {
154+
it('defaults to tree_expand separator icon', async () => {
155+
const el = await fixture<IgcBreadcrumbsComponent>(
156+
createDefaultBreadcrumbs()
157+
);
158+
expect(el.separator).to.equal('tree_expand');
159+
});
160+
161+
it('reflects the separator attribute', async () => {
162+
const el = await fixture<IgcBreadcrumbsComponent>(
163+
html`<igc-breadcrumbs separator="chevron_right">
164+
<igc-breadcrumb><a href="#">Home</a></igc-breadcrumb>
165+
</igc-breadcrumbs>`
166+
);
167+
expect(el.separator).to.equal('chevron_right');
168+
expect(el.getAttribute('separator')).to.equal('chevron_right');
169+
});
170+
171+
it('propagates separator to child breadcrumb items', async () => {
172+
const el = await fixture<IgcBreadcrumbsComponent>(
173+
html`<igc-breadcrumbs separator="chevron_right">
174+
<igc-breadcrumb><a href="#">Home</a></igc-breadcrumb>
175+
<igc-breadcrumb><a href="#">Products</a></igc-breadcrumb>
176+
<igc-breadcrumb current><a href="#">Item</a></igc-breadcrumb>
177+
</igc-breadcrumbs>`
178+
);
179+
180+
const items = Array.from(
181+
el.querySelectorAll<IgcBreadcrumbComponent>(
182+
IgcBreadcrumbComponent.tagName
183+
)
184+
);
185+
for (const item of items) {
186+
await elementUpdated(item);
187+
const icon = item.renderRoot.querySelector('igc-icon');
188+
expect(icon?.getAttribute('name')).to.equal('chevron_right');
189+
}
190+
});
191+
192+
it('updates separator icon when property changes', async () => {
193+
const el = await fixture<IgcBreadcrumbsComponent>(
194+
createDefaultBreadcrumbs()
195+
);
196+
197+
el.separator = 'chevron_right';
198+
await elementUpdated(el);
199+
200+
const firstItem = el.querySelector<IgcBreadcrumbComponent>(
201+
IgcBreadcrumbComponent.tagName
202+
)!;
203+
await elementUpdated(firstItem);
204+
205+
const icon = firstItem.renderRoot.querySelector('igc-icon');
206+
expect(icon?.getAttribute('name')).to.equal('chevron_right');
207+
});
208+
});
209+
});

0 commit comments

Comments
 (0)