From 7d8b7e95476ee66d6161f0d6d6ab87269d738ba4 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 28 Apr 2026 17:17:40 +0300 Subject: [PATCH] feat: Added Breadcrumbs component --- src/components/breadcrumb/breadcrumb.ts | 113 ++++++++++ src/components/breadcrumb/breadcrumbs.spec.ts | 209 ++++++++++++++++++ src/components/breadcrumb/breadcrumbs.ts | 111 ++++++++++ .../breadcrumb/themes/breadcrumb.base.scss | 55 +++++ .../breadcrumb/themes/breadcrumbs.base.scss | 11 + src/components/breadcrumb/themes/themes.ts | 6 + src/components/common/context.ts | 3 + src/index.ts | 2 + stories/breadcrumbs.stories.ts | 167 ++++++++++++++ 9 files changed, 677 insertions(+) create mode 100644 src/components/breadcrumb/breadcrumb.ts create mode 100644 src/components/breadcrumb/breadcrumbs.spec.ts create mode 100644 src/components/breadcrumb/breadcrumbs.ts create mode 100644 src/components/breadcrumb/themes/breadcrumb.base.scss create mode 100644 src/components/breadcrumb/themes/breadcrumbs.base.scss create mode 100644 src/components/breadcrumb/themes/themes.ts create mode 100644 stories/breadcrumbs.stories.ts diff --git a/src/components/breadcrumb/breadcrumb.ts b/src/components/breadcrumb/breadcrumb.ts new file mode 100644 index 000000000..16610c4fa --- /dev/null +++ b/src/components/breadcrumb/breadcrumb.ts @@ -0,0 +1,113 @@ +import { html, LitElement, type PropertyValues } from 'lit'; +import { property } from 'lit/decorators.js'; +import { breadcrumbsContext } from '../common/context.js'; +import { createAsyncContext } from '../common/controllers/async-consumer.js'; +import { addInternalsController } from '../common/controllers/internals.js'; +import { registerComponent } from '../common/definitions/register.js'; +import IgcIconComponent from '../icon/icon.js'; +import { styles } from './themes/breadcrumb.base.css.js'; + +/** + * A single breadcrumb item within an `igc-breadcrumbs` list. + * + * @element igc-breadcrumb + * + * @slot - The main content of the breadcrumb, typically an anchor (``) element. + * @slot prefix - Renders content before the main breadcrumb content. + * @slot suffix - Renders content after the main breadcrumb content. + * @slot separator - Overrides the default separator icon rendered after the breadcrumb item. + * + * @csspart label - The container wrapping the prefix, default, and suffix slots. + * @csspart separator - The container wrapping the separator slot content. + * + * @cssproperty --ig-breadcrumb-link-color - The color of the breadcrumb link. Defaults to `--ig-primary-500`. + * @cssproperty --ig-breadcrumb-link-color-hover - The hover color of the breadcrumb link. Defaults to `--ig-primary-700`. + * @cssproperty --ig-breadcrumb-current-color - The color of the active (current) breadcrumb link. Defaults to `--ig-gray-900`. + * @cssproperty --ig-breadcrumb-separator-color - The color of the separator. Defaults to `--ig-gray-500`. + * + * @example + * ```html + * + * + * Home + * + * + * Products + * + * + * Laptop + * + * + * ``` + */ +export default class IgcBreadcrumbComponent extends LitElement { + public static readonly tagName = 'igc-breadcrumb'; + public static override styles = [styles]; + + /* blazorSuppress */ + public static register(): void { + registerComponent(IgcBreadcrumbComponent, IgcIconComponent); + } + + //#region Internal state + + private readonly _internals = addInternalsController(this, { + initialARIA: { role: 'listitem' }, + }); + + private readonly _separatorConsumer = createAsyncContext( + this, + breadcrumbsContext + ); + + private get _separator(): string { + return this._separatorConsumer.value ?? 'tree_expand'; + } + + //#endregion + + //#region Public properties + + /** + * Marks this breadcrumb as representing the current page. + * Sets `aria-current="page"` on the element when active. + * + * @attr + * @default false + */ + @property({ type: Boolean, reflect: true }) + public current = false; + + //#endregion + + //#region Lit lifecycle + + protected override updated(changedProperties: PropertyValues): void { + if (changedProperties.has('current')) { + this._internals.setARIA({ ariaCurrent: this.current ? 'page' : null }); + } + } + + protected override render() { + return html` + + + + + + + + + + + `; + } + + //#endregion +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-breadcrumb': IgcBreadcrumbComponent; + } +} diff --git a/src/components/breadcrumb/breadcrumbs.spec.ts b/src/components/breadcrumb/breadcrumbs.spec.ts new file mode 100644 index 000000000..29ad358cd --- /dev/null +++ b/src/components/breadcrumb/breadcrumbs.spec.ts @@ -0,0 +1,209 @@ +import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; + +import { defineComponents } from '../common/definitions/defineComponents.js'; +import { first, last } from '../common/util.js'; +import IgcBreadcrumbComponent from './breadcrumb.js'; +import IgcBreadcrumbsComponent from './breadcrumbs.js'; + +describe('Breadcrumbs', () => { + before(() => { + defineComponents(IgcBreadcrumbsComponent); + }); + + const createDefaultBreadcrumbs = () => html` + + Home + Products + Laptop + + `; + + describe('Initialization', () => { + it('passes the a11y audit', async () => { + const el = await fixture( + createDefaultBreadcrumbs() + ); + await expect(el).to.be.accessible(); + await expect(el).shadowDom.to.be.accessible(); + }); + + it('initializes igc-breadcrumb with current=false by default', async () => { + const item = await fixture( + html`Home` + ); + expect(item.current).to.be.false; + }); + }); + + describe('current property', () => { + it('reflects the current attribute', async () => { + const item = await fixture( + html`Page` + ); + expect(item.current).to.be.true; + expect(item).dom.to.equal( + 'Page' + ); + }); + + it('toggles current state programmatically', async () => { + const el = await fixture( + createDefaultBreadcrumbs() + ); + + const lastBreadcrumb = last( + Array.from(el.querySelectorAll(IgcBreadcrumbComponent.tagName)) + ); + + expect(lastBreadcrumb.current).to.be.true; + + lastBreadcrumb.current = false; + await elementUpdated(lastBreadcrumb); + + expect(lastBreadcrumb.current).to.be.false; + expect(lastBreadcrumb.hasAttribute('current')).to.be.false; + }); + + it('sets current attribute when property changes to true', async () => { + const item = await fixture( + html`Page` + ); + + expect(item.current).to.be.false; + expect(item.hasAttribute('current')).to.be.false; + + item.current = true; + await elementUpdated(item); + + expect(item.current).to.be.true; + expect(item.hasAttribute('current')).to.be.true; + }); + }); + + describe('Separator', () => { + it('hides the separator on the last breadcrumb item', async () => { + const el = await fixture( + createDefaultBreadcrumbs() + ); + const lastBreadcrumb = last( + Array.from(el.querySelectorAll(IgcBreadcrumbComponent.tagName)) + ); + const separator = + lastBreadcrumb.renderRoot.querySelector( + '[part="separator"]' + )!; + + expect(getComputedStyle(separator).display).to.equal('none'); + }); + + it('renders a custom separator via the separator slot', async () => { + const el = await fixture(html` + + Home + / + + `); + + const slot = el.renderRoot.querySelector( + 'slot[name="separator"]' + )!; + const assigned = slot.assignedNodes(); + + expect(assigned).to.have.lengthOf(1); + expect(first(assigned).textContent).to.equal('/'); + }); + }); + + describe('Prefix and Suffix slots', () => { + it('renders content in the prefix slot', async () => { + const el = await fixture(html` + + + Home + + `); + + const slot = el.renderRoot.querySelector( + 'slot[name="prefix"]' + )!; + const assigned = slot.assignedNodes(); + + expect(assigned).to.have.lengthOf(1); + expect(first(assigned).textContent).to.equal('★'); + }); + + it('renders content in the suffix slot', async () => { + const el = await fixture(html` + + Home + + + `); + + const slot = el.renderRoot.querySelector( + 'slot[name="suffix"]' + )!; + const assigned = slot.assignedNodes(); + + expect(assigned).to.have.lengthOf(1); + expect(first(assigned).textContent).to.equal('▸'); + }); + }); + + describe('Separator property', () => { + it('defaults to tree_expand separator icon', async () => { + const el = await fixture( + createDefaultBreadcrumbs() + ); + expect(el.separator).to.equal('tree_expand'); + }); + + it('reflects the separator attribute', async () => { + const el = await fixture( + html` + Home + ` + ); + expect(el.separator).to.equal('chevron_right'); + expect(el.getAttribute('separator')).to.equal('chevron_right'); + }); + + it('propagates separator to child breadcrumb items', async () => { + const el = await fixture( + html` + Home + Products + Item + ` + ); + + const items = Array.from( + el.querySelectorAll( + IgcBreadcrumbComponent.tagName + ) + ); + for (const item of items) { + await elementUpdated(item); + const icon = item.renderRoot.querySelector('igc-icon'); + expect(icon?.getAttribute('name')).to.equal('chevron_right'); + } + }); + + it('updates separator icon when property changes', async () => { + const el = await fixture( + createDefaultBreadcrumbs() + ); + + el.separator = 'chevron_right'; + await elementUpdated(el); + + const firstItem = el.querySelector( + IgcBreadcrumbComponent.tagName + )!; + await elementUpdated(firstItem); + + const icon = firstItem.renderRoot.querySelector('igc-icon'); + expect(icon?.getAttribute('name')).to.equal('chevron_right'); + }); + }); +}); diff --git a/src/components/breadcrumb/breadcrumbs.ts b/src/components/breadcrumb/breadcrumbs.ts new file mode 100644 index 000000000..7b6e59844 --- /dev/null +++ b/src/components/breadcrumb/breadcrumbs.ts @@ -0,0 +1,111 @@ +import { ContextProvider } from '@lit/context'; +import { html, LitElement, type PropertyValues } from 'lit'; +import { property } from 'lit/decorators.js'; +import { breadcrumbsContext } from '../common/context.js'; +import { addInternalsController } from '../common/controllers/internals.js'; +import { registerComponent } from '../common/definitions/register.js'; +import IgcBreadcrumbComponent from './breadcrumb.js'; +import { styles } from './themes/breadcrumbs.base.css.js'; + +/** + * A breadcrumb navigation component that renders an ordered list of breadcrumb items. + * + * @remarks + * Wrap `igc-breadcrumb` elements inside this component to build a navigable breadcrumb + * trail. The component sets the ARIA `list` role on the host element. Wrap it in a + * `