From 7ffad0ed662ef42d47d9396f2d6f75eab7eb2032 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Nov 2025 12:51:30 +0000 Subject: [PATCH 1/4] Initial plan From ccfbbc01b0a414d11d34b50090c7e51982c96115 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Nov 2025 13:14:52 +0000 Subject: [PATCH 2/4] feat: Add IgxBreadcrumb component with router integration and accessibility support Co-authored-by: zdrawku <11193764+zdrawku@users.noreply.github.com> --- .../igniteui-angular/breadcrumb/README.md | 143 +++++++++ projects/igniteui-angular/breadcrumb/index.ts | 1 + .../breadcrumb/ng-package.json | 2 + .../breadcrumb/breadcrumb-item.component.html | 29 ++ .../breadcrumb/breadcrumb-item.component.scss | 50 ++++ .../breadcrumb/breadcrumb-item.component.ts | 125 ++++++++ .../src/breadcrumb/breadcrumb.common.ts | 23 ++ .../src/breadcrumb/breadcrumb.component.html | 4 + .../src/breadcrumb/breadcrumb.component.scss | 84 ++++++ .../breadcrumb/breadcrumb.component.spec.ts | 283 ++++++++++++++++++ .../src/breadcrumb/breadcrumb.component.ts | 229 ++++++++++++++ .../src/breadcrumb/breadcrumb.directives.ts | 43 +++ .../src/breadcrumb/breadcrumb.module.ts | 16 + .../src/breadcrumb/breadcrumb.service.ts | 156 ++++++++++ .../breadcrumb/src/breadcrumb/public_api.ts | 17 ++ .../breadcrumb/src/public_api.ts | 2 + projects/igniteui-angular/src/public_api.ts | 1 + 17 files changed, 1208 insertions(+) create mode 100644 projects/igniteui-angular/breadcrumb/README.md create mode 100644 projects/igniteui-angular/breadcrumb/index.ts create mode 100644 projects/igniteui-angular/breadcrumb/ng-package.json create mode 100644 projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb-item.component.html create mode 100644 projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb-item.component.scss create mode 100644 projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb-item.component.ts create mode 100644 projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb.common.ts create mode 100644 projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb.component.html create mode 100644 projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb.component.scss create mode 100644 projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb.component.spec.ts create mode 100644 projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb.component.ts create mode 100644 projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb.directives.ts create mode 100644 projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb.module.ts create mode 100644 projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb.service.ts create mode 100644 projects/igniteui-angular/breadcrumb/src/breadcrumb/public_api.ts create mode 100644 projects/igniteui-angular/breadcrumb/src/public_api.ts diff --git a/projects/igniteui-angular/breadcrumb/README.md b/projects/igniteui-angular/breadcrumb/README.md new file mode 100644 index 00000000000..0e871129ee9 --- /dev/null +++ b/projects/igniteui-angular/breadcrumb/README.md @@ -0,0 +1,143 @@ +# IgxBreadcrumb + +The `IgxBreadcrumb` component provides a navigation trail showing the user's current location within a site hierarchy. + +## Usage + +### Basic Usage + +```html + + Home + Products + Electronics + Laptops + +``` + +### Standalone Components + +```typescript +import { IGX_BREADCRUMB_DIRECTIVES } from 'igniteui-angular/breadcrumb'; + +@Component({ + imports: [...IGX_BREADCRUMB_DIRECTIVES] +}) +export class MyComponent {} +``` + +### NgModule + +```typescript +import { IgxBreadcrumbModule } from 'igniteui-angular/breadcrumb'; + +@NgModule({ + imports: [IgxBreadcrumbModule] +}) +export class MyModule {} +``` + +## Features + +### Custom Separator + +```html + + + +``` + +Or use a custom template: + +```html + + + arrow_forward + + + +``` + +### Overflow/Collapse Behavior + +When you have many breadcrumb items, you can limit the visible items: + +```html + + + +``` + +This will show the first item, an ellipsis (...), and the last 2 items. + +### Router Integration + +Use the `IgxBreadcrumbService` to automatically generate breadcrumbs from route configuration: + +```typescript +// Route configuration +const routes: Routes = [ + { path: '', data: { breadcrumb: 'Home' }, component: HomeComponent }, + { + path: 'products', + data: { breadcrumb: 'Products' }, + children: [ + { path: ':id', data: { breadcrumb: 'Product Details' }, component: ProductComponent } + ] + } +]; +``` + +```typescript +// Component +@Component({...}) +export class AppComponent { + breadcrumbs$ = this.breadcrumbService.breadcrumbs$; + + constructor(private breadcrumbService: IgxBreadcrumbService) {} +} +``` + +```html + + @for (item of breadcrumbs$ | async; track item.label) { + + {{ item.label }} + + } + +``` + +## Inputs + +### IgxBreadcrumbComponent + +| Input | Type | Default | Description | +|-------|------|---------|-------------| +| `separator` | `string` | `'›'` | Custom separator character between crumbs | +| `maxItems` | `number` | - | Maximum number of visible items before overflow | +| `itemsBeforeCollapse` | `number` | `1` | Number of items visible before the collapsed section | +| `itemsAfterCollapse` | `number` | `1` | Number of items visible after the collapsed section | +| `type` | `BreadcrumbType` | `'location'` | Breadcrumb type: 'location', 'attribute', or 'dynamic' | + +### IgxBreadcrumbItemComponent + +| Input | Type | Default | Description | +|-------|------|---------|-------------| +| `link` | `string` | - | Navigation URL (standard href) | +| `routerLink` | `string \| any[]` | - | Angular Router link | +| `disabled` | `boolean` | `false` | Whether the item is non-clickable | +| `icon` | `string` | - | Icon name to display | + +## Accessibility + +The component follows WAI-ARIA best practices: + +- Uses `role="navigation"` on the component +- Uses `aria-label="breadcrumb"` for screen readers +- Uses semantic `
    ` and `
  1. ` elements +- Adds `aria-current="page"` to the current/last item +- Supports keyboard navigation with Tab/Shift+Tab diff --git a/projects/igniteui-angular/breadcrumb/index.ts b/projects/igniteui-angular/breadcrumb/index.ts new file mode 100644 index 00000000000..decc72d85bc --- /dev/null +++ b/projects/igniteui-angular/breadcrumb/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/igniteui-angular/breadcrumb/ng-package.json b/projects/igniteui-angular/breadcrumb/ng-package.json new file mode 100644 index 00000000000..2c63c085104 --- /dev/null +++ b/projects/igniteui-angular/breadcrumb/ng-package.json @@ -0,0 +1,2 @@ +{ +} diff --git a/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb-item.component.html b/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb-item.component.html new file mode 100644 index 00000000000..d934be85967 --- /dev/null +++ b/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb-item.component.html @@ -0,0 +1,29 @@ +@if (disabled) { + + @if (icon) { + {{ icon }} + } + + +} @else if (routerLink) { + + @if (icon) { + {{ icon }} + } + + +} @else if (link) { + + @if (icon) { + {{ icon }} + } + + +} @else { + + @if (icon) { + {{ icon }} + } + + +} diff --git a/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb-item.component.scss b/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb-item.component.scss new file mode 100644 index 00000000000..5ff23208924 --- /dev/null +++ b/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb-item.component.scss @@ -0,0 +1,50 @@ +@use 'sass:map'; + +:host { + display: flex; + align-items: center; +} + +.igx-breadcrumb-item__link, +.igx-breadcrumb-item__text { + display: inline-flex; + align-items: center; + gap: 0.25rem; + text-decoration: none; + color: inherit; + white-space: nowrap; +} + +.igx-breadcrumb-item__link { + cursor: pointer; + + &:hover { + text-decoration: underline; + } + + &:focus { + outline: none; + } + + &:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; + border-radius: 2px; + } +} + +:host(.igx-breadcrumb-item--disabled) { + .igx-breadcrumb-item__text { + cursor: default; + } +} + +:host(.igx-breadcrumb-item--current) { + .igx-breadcrumb-item__text { + font-weight: 600; + } +} + +:host(.igx-breadcrumb-item--hidden) { + display: none; +} diff --git a/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb-item.component.ts b/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb-item.component.ts new file mode 100644 index 00000000000..413b8a5c2cc --- /dev/null +++ b/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb-item.component.ts @@ -0,0 +1,125 @@ +import { + Component, + ElementRef, + HostBinding, + Input, + booleanAttribute +} from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { IgxIconComponent } from 'igniteui-angular/icon'; + +let NEXT_ID = 0; + +/** + * IgxBreadcrumbItem represents a single item in the breadcrumb trail. + * + * @igxModule IgxBreadcrumbModule + * + * @igxKeywords breadcrumb item, crumb + * + * @igxGroup Navigation + * + * @remarks + * The breadcrumb item component can be used with standard href links, Angular router links, + * or as a disabled/non-clickable element for the current location. + * + * @example + * ```html + * Home + * Current Page + * ``` + */ +@Component({ + selector: 'igx-breadcrumb-item', + templateUrl: 'breadcrumb-item.component.html', + styleUrl: 'breadcrumb-item.component.scss', + imports: [RouterLink, IgxIconComponent], + host: { + 'role': 'listitem' + } +}) +export class IgxBreadcrumbItemComponent { + /** + * Sets the value of the `id` attribute. If not provided it will be automatically generated. + * + * @example + * ```html + * Home + * ``` + */ + @HostBinding('attr.id') + @Input() + public id = `igx-breadcrumb-item-${NEXT_ID++}`; + + /** + * Navigation URL (standard href). + * + * @example + * ```html + * Products + * ``` + */ + @Input() + public link: string; + + /** + * Angular Router integration for navigation. + * + * @example + * ```html + * Electronics + * ``` + */ + @Input() + public routerLink: string | any[]; + + /** + * Whether the item is disabled/non-clickable. + * Typically used for the current location (last item). + * + * @example + * ```html + * Current Page + * ``` + */ + @Input({ transform: booleanAttribute }) + public disabled = false; + + /** + * Optional icon name to display before the item label. + * + * @example + * ```html + * Home + * ``` + */ + @Input() + public icon: string; + + /** @hidden */ + @HostBinding('class.igx-breadcrumb-item') + public cssClass = true; + + /** @hidden */ + @HostBinding('class.igx-breadcrumb-item--disabled') + public get disabledClass(): boolean { + return this.disabled; + } + + /** @hidden */ + @HostBinding('class.igx-breadcrumb-item--current') + public isCurrent = false; + + /** @hidden */ + @HostBinding('class.igx-breadcrumb-item--hidden') + public isHidden = false; + + constructor(public elementRef: ElementRef) {} + + /** + * Returns the display label of the breadcrumb item. + */ + public get label(): string { + return this.elementRef.nativeElement.textContent?.trim() || ''; + } +} diff --git a/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb.common.ts b/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb.common.ts new file mode 100644 index 00000000000..34772880338 --- /dev/null +++ b/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb.common.ts @@ -0,0 +1,23 @@ +/** + * The type of breadcrumb display behavior. + * - `location`: Used for navigation schemes with multiple levels of hierarchy + * - `attribute`: Displays the full crumb items trail + * - `dynamic`: Path-based breadcrumbs showing the path taken to arrive at a page + */ +export type BreadcrumbType = 'location' | 'attribute' | 'dynamic'; + +/** + * Interface representing a breadcrumb item. + */ +export interface IBreadcrumbItem { + /** The display label for the breadcrumb item */ + label: string; + /** The URL for standard navigation */ + link?: string; + /** Angular router link for navigation */ + routerLink?: string | any[]; + /** Whether the item is disabled/non-clickable */ + disabled?: boolean; + /** Optional icon name to display */ + icon?: string; +} diff --git a/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb.component.html b/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb.component.html new file mode 100644 index 00000000000..97a28ea6858 --- /dev/null +++ b/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb.component.html @@ -0,0 +1,4 @@ +
      + + +
    diff --git a/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb.component.scss b/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb.component.scss new file mode 100644 index 00000000000..d2ee971c4d1 --- /dev/null +++ b/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb.component.scss @@ -0,0 +1,84 @@ +@use 'sass:map'; + +:host { + display: block; +} + +.igx-breadcrumb__list { + display: flex; + flex-wrap: wrap; + align-items: center; + list-style: none; + padding: 0; + margin: 0; + gap: 0.5rem; +} + +.igx-breadcrumb__item-wrapper { + display: flex; + align-items: center; +} + +.igx-breadcrumb__separator { + display: flex; + align-items: center; + color: var(--igx-breadcrumb-separator-color, currentColor); + opacity: 0.7; + user-select: none; +} + +.igx-breadcrumb__separator-text { + font-size: 1rem; +} + +.igx-breadcrumb__ellipsis { + display: flex; + align-items: center; + cursor: default; +} + +.igx-breadcrumb__ellipsis-text { + font-weight: bold; + letter-spacing: 0.1em; +} + +.igx-breadcrumb-item__link, +.igx-breadcrumb-item__text { + display: inline-flex; + align-items: center; + gap: 0.25rem; + text-decoration: none; + color: var(--igx-breadcrumb-item-color, inherit); + white-space: nowrap; + font-size: var(--igx-breadcrumb-item-font-size, inherit); +} + +.igx-breadcrumb-item__link { + cursor: pointer; + color: var(--igx-breadcrumb-link-color, #0066cc); + + &:hover { + text-decoration: underline; + color: var(--igx-breadcrumb-link-hover-color, #004499); + } + + &:focus { + outline: none; + } + + &:focus-visible { + outline: 2px solid var(--igx-breadcrumb-focus-color, currentColor); + outline-offset: 2px; + border-radius: 2px; + } +} + +.igx-breadcrumb-item--disabled { + cursor: default; + color: var(--igx-breadcrumb-disabled-color, #666); +} + +.igx-breadcrumb-item--current { + font-weight: 600; + color: var(--igx-breadcrumb-current-color, inherit); +} diff --git a/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb.component.spec.ts b/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb.component.spec.ts new file mode 100644 index 00000000000..bcc01cff376 --- /dev/null +++ b/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb.component.spec.ts @@ -0,0 +1,283 @@ +import { Component, QueryList, ViewChild, ViewChildren } from '@angular/core'; +import { TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { IgxBreadcrumbComponent } from './breadcrumb.component'; +import { IgxBreadcrumbItemComponent } from './breadcrumb-item.component'; +import { IgxBreadcrumbSeparatorDirective } from './breadcrumb.directives'; + +@Component({ + template: ` + + Home + Products + Laptops + + `, + imports: [IgxBreadcrumbComponent, IgxBreadcrumbItemComponent] +}) +class BasicBreadcrumbComponent { + @ViewChild(IgxBreadcrumbComponent, { static: true }) public breadcrumb: IgxBreadcrumbComponent; + @ViewChildren(IgxBreadcrumbItemComponent) public items: QueryList; +} + +@Component({ + template: ` + + Home + Products + Laptops + + `, + imports: [IgxBreadcrumbComponent, IgxBreadcrumbItemComponent] +}) +class CustomSeparatorBreadcrumbComponent { + @ViewChild(IgxBreadcrumbComponent, { static: true }) public breadcrumb: IgxBreadcrumbComponent; +} + +@Component({ + template: ` + + + + + Home + Products + + `, + imports: [IgxBreadcrumbComponent, IgxBreadcrumbItemComponent, IgxBreadcrumbSeparatorDirective] +}) +class TemplateSeparatorBreadcrumbComponent { + @ViewChild(IgxBreadcrumbComponent, { static: true }) public breadcrumb: IgxBreadcrumbComponent; +} + +@Component({ + template: ` + + Home + Level 1 + Level 2 + Level 3 + Level 4 + Current + + `, + imports: [IgxBreadcrumbComponent, IgxBreadcrumbItemComponent] +}) +class OverflowBreadcrumbComponent { + @ViewChild(IgxBreadcrumbComponent, { static: true }) public breadcrumb: IgxBreadcrumbComponent; +} + +@Component({ + template: ` + + Home + Products + + `, + imports: [IgxBreadcrumbComponent, IgxBreadcrumbItemComponent] +}) +class LinkBreadcrumbComponent { + @ViewChild(IgxBreadcrumbComponent, { static: true }) public breadcrumb: IgxBreadcrumbComponent; +} + +describe('IgxBreadcrumb', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + RouterTestingModule, + BasicBreadcrumbComponent, + CustomSeparatorBreadcrumbComponent, + TemplateSeparatorBreadcrumbComponent, + OverflowBreadcrumbComponent, + LinkBreadcrumbComponent + ] + }).compileComponents(); + })); + + describe('Basic functionality', () => { + let fixture; + let breadcrumb: IgxBreadcrumbComponent; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(BasicBreadcrumbComponent); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + breadcrumb = fixture.componentInstance.breadcrumb; + })); + + it('should initialize igx-breadcrumb', () => { + expect(breadcrumb).toBeDefined(); + expect(breadcrumb instanceof IgxBreadcrumbComponent).toBeTruthy(); + }); + + it('should have correct number of items', () => { + expect(breadcrumb.items.length).toBe(3); + }); + + it('should have navigation role', () => { + const element = fixture.nativeElement.querySelector('igx-breadcrumb'); + expect(element.getAttribute('role')).toBe('navigation'); + }); + + it('should have aria-label', () => { + const element = fixture.nativeElement.querySelector('igx-breadcrumb'); + expect(element.getAttribute('aria-label')).toBe('breadcrumb'); + }); + + it('should mark last item as current', () => { + const items = breadcrumb.items.toArray(); + expect(items[0].isCurrent).toBeFalsy(); + expect(items[1].isCurrent).toBeFalsy(); + expect(items[2].isCurrent).toBeTruthy(); + }); + + it('should have default separator', () => { + expect(breadcrumb.separator).toBe('›'); + }); + }); + + describe('Custom separator', () => { + it('should use custom separator string', fakeAsync(() => { + const fixture = TestBed.createComponent(CustomSeparatorBreadcrumbComponent); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + const breadcrumb = fixture.componentInstance.breadcrumb; + expect(breadcrumb.separator).toBe('/'); + })); + + it('should use custom separator template', fakeAsync(() => { + const fixture = TestBed.createComponent(TemplateSeparatorBreadcrumbComponent); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + const breadcrumb = fixture.componentInstance.breadcrumb; + expect(breadcrumb.separatorTemplate).toBeDefined(); + })); + }); + + describe('Overflow behavior', () => { + let fixture; + let breadcrumb: IgxBreadcrumbComponent; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(OverflowBreadcrumbComponent); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + breadcrumb = fixture.componentInstance.breadcrumb; + })); + + it('should have maxItems set', () => { + expect(breadcrumb.maxItems).toBe(4); + }); + + it('should have itemsBeforeCollapse set', () => { + expect(breadcrumb.itemsBeforeCollapse).toBe(1); + }); + + it('should have itemsAfterCollapse set', () => { + expect(breadcrumb.itemsAfterCollapse).toBe(2); + }); + + it('should calculate visible items correctly', () => { + // With 6 items, maxItems=4, before=1, after=2 + // Should show: first item, last 2 items = 3 visible + expect(breadcrumb.visibleItems.length).toBe(3); + }); + + it('should have collapsed items', () => { + expect(breadcrumb.hasCollapsedItems).toBeTruthy(); + // Hidden items should be: Level 1, Level 2, Level 3 = 3 items + expect(breadcrumb.hiddenItems.length).toBe(3); + }); + + it('should generate tooltip for collapsed items', () => { + const tooltip = breadcrumb.getCollapsedItemsTooltip(); + expect(tooltip).toContain('Level 1'); + expect(tooltip).toContain('Level 2'); + expect(tooltip).toContain('Level 3'); + }); + + it('should mark hidden items', () => { + const items = breadcrumb.items.toArray(); + expect(items[0].isHidden).toBeFalsy(); // Home - visible + expect(items[1].isHidden).toBeTruthy(); // Level 1 - hidden + expect(items[2].isHidden).toBeTruthy(); // Level 2 - hidden + expect(items[3].isHidden).toBeTruthy(); // Level 3 - hidden + expect(items[4].isHidden).toBeFalsy(); // Level 4 - visible + expect(items[5].isHidden).toBeFalsy(); // Current - visible + }); + }); + + describe('Item properties', () => { + it('should handle disabled items', fakeAsync(() => { + const fixture = TestBed.createComponent(BasicBreadcrumbComponent); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + const items = fixture.componentInstance.items.toArray(); + expect(items[2].disabled).toBeTruthy(); + })); + + it('should handle icon property', fakeAsync(() => { + const fixture = TestBed.createComponent(BasicBreadcrumbComponent); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + const items = fixture.componentInstance.items.toArray(); + expect(items[0].icon).toBe('home'); + })); + + it('should handle link property', fakeAsync(() => { + const fixture = TestBed.createComponent(LinkBreadcrumbComponent); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + const breadcrumb = fixture.componentInstance.breadcrumb; + const items = breadcrumb.items.toArray(); + expect(items[0].link).toBe('/home'); + })); + + it('should handle routerLink property', fakeAsync(() => { + const fixture = TestBed.createComponent(LinkBreadcrumbComponent); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + const breadcrumb = fixture.componentInstance.breadcrumb; + const items = breadcrumb.items.toArray(); + expect(items[1].routerLink).toEqual(['/products']); + })); + }); + + describe('Accessibility', () => { + let fixture; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(BasicBreadcrumbComponent); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it('should have ordered list structure', () => { + const list = fixture.nativeElement.querySelector('ol.igx-breadcrumb__list'); + expect(list).toBeTruthy(); + }); + + it('should have breadcrumb items', () => { + const breadcrumbItems = fixture.nativeElement.querySelectorAll('igx-breadcrumb-item'); + expect(breadcrumbItems.length).toBe(3); + }); + }); +}); diff --git a/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb.component.ts b/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb.component.ts new file mode 100644 index 00000000000..760bfa20e42 --- /dev/null +++ b/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb.component.ts @@ -0,0 +1,229 @@ +import { + AfterContentInit, + ChangeDetectorRef, + Component, + ContentChild, + ContentChildren, + ElementRef, + HostBinding, + Input, + OnDestroy, + QueryList, + numberAttribute +} from '@angular/core'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +import { IgxBreadcrumbItemComponent } from './breadcrumb-item.component'; +import { IgxBreadcrumbSeparatorDirective } from './breadcrumb.directives'; +import { BreadcrumbType } from './breadcrumb.common'; + +let NEXT_ID = 0; + +/** + * IgxBreadcrumb provides a navigation aid showing the user's location in a website hierarchy. + * + * @igxModule IgxBreadcrumbModule + * + * @igxTheme igx-breadcrumb-theme + * + * @igxKeywords breadcrumb, navigation, trail + * + * @igxGroup Navigation + * + * @remarks + * The Ignite UI for Angular Breadcrumb component provides a navigation trail showing + * the user's current location within a site hierarchy. It supports collapsing items + * when there are too many, custom separators, and accessibility features. + * + * @example + * ```html + * + * Home + * Products + * Current Page + * + * ``` + */ +@Component({ + selector: 'igx-breadcrumb', + templateUrl: 'breadcrumb.component.html', + styleUrl: 'breadcrumb.component.scss', + host: { + 'role': 'navigation', + '[attr.aria-label]': '"breadcrumb"' + } +}) +export class IgxBreadcrumbComponent implements AfterContentInit, OnDestroy { + /** + * Sets the value of the `id` attribute. If not provided it will be automatically generated. + * + * @example + * ```html + * ... + * ``` + */ + @HostBinding('attr.id') + @Input() + public id = `igx-breadcrumb-${NEXT_ID++}`; + + /** + * Custom separator between crumbs. + * Default is a chevron icon (›). + * + * @example + * ```html + * ... + * ``` + */ + @Input() + public separator = '›'; + + /** + * Maximum number of visible items before overflow/collapsing. + * If not set, all items are visible. + * + * @example + * ```html + * ... + * ``` + */ + @Input({ transform: numberAttribute }) + public maxItems: number; + + /** + * Number of items visible before the collapsed section. + * Default is 1. + * + * @example + * ```html + * ... + * ``` + */ + @Input({ transform: numberAttribute }) + public itemsBeforeCollapse = 1; + + /** + * Number of items visible after the collapsed section. + * Default is 1. + * + * @example + * ```html + * ... + * ``` + */ + @Input({ transform: numberAttribute }) + public itemsAfterCollapse = 1; + + /** + * Breadcrumb type determining the display behavior. + * - `location`: Used for navigation schemes with multiple levels of hierarchy + * - `attribute`: Displays the full crumb items trail + * - `dynamic`: Path-based breadcrumbs showing the path taken to arrive at a page + * + * @example + * ```html + * ... + * ``` + */ + @Input() + public type: BreadcrumbType = 'location'; + + /** @hidden */ + @HostBinding('class.igx-breadcrumb') + public cssClass = true; + + /** @hidden */ + @ContentChildren(IgxBreadcrumbItemComponent) + public items: QueryList; + + /** @hidden */ + @ContentChild(IgxBreadcrumbSeparatorDirective) + public separatorTemplate: IgxBreadcrumbSeparatorDirective; + + private destroy$ = new Subject(); + + constructor( + public elementRef: ElementRef, + private cdr: ChangeDetectorRef + ) {} + + /** @hidden */ + public ngAfterContentInit(): void { + this.updateItems(); + this.items.changes.pipe(takeUntil(this.destroy$)).subscribe(() => { + this.updateItems(); + }); + } + + /** @hidden */ + public ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + /** + * Returns the visible items based on maxItems and collapse settings. + */ + public get visibleItems(): IgxBreadcrumbItemComponent[] { + if (!this.items) { + return []; + } + + const allItems = this.items.toArray(); + + if (!this.maxItems || allItems.length <= this.maxItems) { + return allItems; + } + + const before = allItems.slice(0, this.itemsBeforeCollapse); + const after = allItems.slice(allItems.length - this.itemsAfterCollapse); + + return [...before, ...after]; + } + + /** + * Returns the items that are collapsed (hidden in ellipsis). + */ + public get hiddenItems(): IgxBreadcrumbItemComponent[] { + if (!this.items) { + return []; + } + + const allItems = this.items.toArray(); + + if (!this.maxItems || allItems.length <= this.maxItems) { + return []; + } + + return allItems.slice(this.itemsBeforeCollapse, allItems.length - this.itemsAfterCollapse); + } + + /** + * Returns whether there are collapsed items. + */ + public get hasCollapsedItems(): boolean { + return this.hiddenItems.length > 0; + } + + /** @hidden */ + public getCollapsedItemsTooltip(): string { + return this.hiddenItems.map(item => item.label).join(' > '); + } + + private updateItems(): void { + if (!this.items) { + return; + } + + const allItems = this.items.toArray(); + + // Mark the last item as current and determine visibility + allItems.forEach((item, index) => { + item.isCurrent = index === allItems.length - 1; + item.isHidden = !this.visibleItems.includes(item); + }); + + this.cdr.markForCheck(); + } +} diff --git a/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb.directives.ts b/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb.directives.ts new file mode 100644 index 00000000000..9f1073b2f97 --- /dev/null +++ b/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb.directives.ts @@ -0,0 +1,43 @@ +import { Directive, TemplateRef } from '@angular/core'; + +/** + * Directive to provide a custom separator template for the breadcrumb. + * + * @example + * ```html + * + * + * arrow_forward + * + * + * + * ``` + */ +@Directive({ + selector: '[igxBreadcrumbSeparator]', + standalone: true +}) +export class IgxBreadcrumbSeparatorDirective { + constructor(public template: TemplateRef) {} +} + +/** + * Directive to provide a custom item template for the breadcrumb. + * + * @example + * ```html + * + * + * {{ item.label }} + * + * + * + * ``` + */ +@Directive({ + selector: '[igxBreadcrumbItemTemplate]', + standalone: true +}) +export class IgxBreadcrumbItemTemplateDirective { + constructor(public template: TemplateRef) {} +} diff --git a/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb.module.ts b/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb.module.ts new file mode 100644 index 00000000000..534b19d6f20 --- /dev/null +++ b/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core'; +import { IGX_BREADCRUMB_DIRECTIVES } from './public_api'; + +/** + * @hidden + * IMPORTANT: The following is NgModule exported for backwards-compatibility before standalone components + */ +@NgModule({ + imports: [ + ...IGX_BREADCRUMB_DIRECTIVES + ], + exports: [ + ...IGX_BREADCRUMB_DIRECTIVES + ] +}) +export class IgxBreadcrumbModule { } diff --git a/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb.service.ts b/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb.service.ts new file mode 100644 index 00000000000..b7eaf62c9d6 --- /dev/null +++ b/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb.service.ts @@ -0,0 +1,156 @@ +import { Injectable, Optional } from '@angular/core'; +import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { filter } from 'rxjs/operators'; +import { IBreadcrumbItem } from './breadcrumb.common'; + +/** + * Service for generating breadcrumbs from Angular Router configuration. + * + * @remarks + * This service listens to router events and builds a breadcrumb trail + * from route data. Routes should define breadcrumb labels using + * `data: { breadcrumb: 'Label' }` in their configuration. + * + * @example + * ```typescript + * // Route configuration + * const routes: Routes = [ + * { path: '', data: { breadcrumb: 'Home' }, component: HomeComponent }, + * { + * path: 'products', + * data: { breadcrumb: 'Products' }, + * children: [ + * { path: ':id', data: { breadcrumb: 'Product Details' }, component: ProductComponent } + * ] + * } + * ]; + * + * // Component usage + * @Component({...}) + * export class AppComponent { + * breadcrumbs$ = this.breadcrumbService.breadcrumbs$; + * constructor(private breadcrumbService: IgxBreadcrumbService) {} + * } + * ``` + */ +@Injectable({ + providedIn: 'root' +}) +export class IgxBreadcrumbService { + private breadcrumbsSubject = new BehaviorSubject([]); + + /** + * Observable of the current breadcrumb items. + */ + public breadcrumbs$: Observable = this.breadcrumbsSubject.asObservable(); + + constructor( + @Optional() private router: Router, + @Optional() private activatedRoute: ActivatedRoute + ) { + if (this.router && this.activatedRoute) { + this.router.events + .pipe(filter(event => event instanceof NavigationEnd)) + .subscribe(() => { + this.updateBreadcrumbs(); + }); + + // Initial breadcrumb build + this.updateBreadcrumbs(); + } + } + + /** + * Returns the current breadcrumb items. + */ + public get breadcrumbs(): IBreadcrumbItem[] { + return this.breadcrumbsSubject.value; + } + + /** + * Manually sets the breadcrumb items. + * This overrides any auto-generated breadcrumbs. + * + * @param items The breadcrumb items to set + */ + public setBreadcrumbs(items: IBreadcrumbItem[]): void { + this.breadcrumbsSubject.next(items); + } + + /** + * Adds a breadcrumb item to the end of the trail. + * + * @param item The breadcrumb item to add + */ + public addBreadcrumb(item: IBreadcrumbItem): void { + const current = this.breadcrumbsSubject.value; + this.breadcrumbsSubject.next([...current, item]); + } + + /** + * Clears all breadcrumb items. + */ + public clearBreadcrumbs(): void { + this.breadcrumbsSubject.next([]); + } + + /** + * Refreshes the breadcrumbs from the current route. + */ + public refresh(): void { + this.updateBreadcrumbs(); + } + + private updateBreadcrumbs(): void { + if (!this.activatedRoute) { + return; + } + + const breadcrumbs = this.buildBreadcrumbs(this.activatedRoute.root); + this.breadcrumbsSubject.next(breadcrumbs); + } + + private buildBreadcrumbs( + route: ActivatedRoute, + url: string = '', + breadcrumbs: IBreadcrumbItem[] = [] + ): IBreadcrumbItem[] { + const children: ActivatedRoute[] = route.children; + + if (children.length === 0) { + return breadcrumbs; + } + + for (const child of children) { + const routeURL: string = child.snapshot.url.map(segment => segment.path).join('/'); + + if (routeURL !== '') { + url += `/${routeURL}`; + } + + const label = child.snapshot.data['breadcrumb']; + if (label) { + const isLast = child.children.length === 0 || + !child.children.some(c => c.snapshot.data['breadcrumb']); + + breadcrumbs.push({ + label: this.resolveLabel(label, child), + routerLink: url, + disabled: isLast + }); + } + + return this.buildBreadcrumbs(child, url, breadcrumbs); + } + + return breadcrumbs; + } + + private resolveLabel(label: string | ((data: any) => string), route: ActivatedRoute): string { + if (typeof label === 'function') { + return label(route.snapshot.data); + } + return label; + } +} diff --git a/projects/igniteui-angular/breadcrumb/src/breadcrumb/public_api.ts b/projects/igniteui-angular/breadcrumb/src/breadcrumb/public_api.ts new file mode 100644 index 00000000000..076b9295919 --- /dev/null +++ b/projects/igniteui-angular/breadcrumb/src/breadcrumb/public_api.ts @@ -0,0 +1,17 @@ +import { IgxBreadcrumbComponent } from './breadcrumb.component'; +import { IgxBreadcrumbItemComponent } from './breadcrumb-item.component'; +import { IgxBreadcrumbSeparatorDirective, IgxBreadcrumbItemTemplateDirective } from './breadcrumb.directives'; + +export * from './breadcrumb.component'; +export * from './breadcrumb-item.component'; +export * from './breadcrumb.directives'; +export * from './breadcrumb.service'; +export * from './breadcrumb.common'; + +/* NOTE: Breadcrumb directives collection for ease-of-use import in standalone components scenario */ +export const IGX_BREADCRUMB_DIRECTIVES = [ + IgxBreadcrumbComponent, + IgxBreadcrumbItemComponent, + IgxBreadcrumbSeparatorDirective, + IgxBreadcrumbItemTemplateDirective +] as const; diff --git a/projects/igniteui-angular/breadcrumb/src/public_api.ts b/projects/igniteui-angular/breadcrumb/src/public_api.ts new file mode 100644 index 00000000000..76286b89b5a --- /dev/null +++ b/projects/igniteui-angular/breadcrumb/src/public_api.ts @@ -0,0 +1,2 @@ +export * from './breadcrumb/public_api'; +export * from './breadcrumb/breadcrumb.module'; diff --git a/projects/igniteui-angular/src/public_api.ts b/projects/igniteui-angular/src/public_api.ts index 5352b064fef..1b5e8e58ff7 100644 --- a/projects/igniteui-angular/src/public_api.ts +++ b/projects/igniteui-angular/src/public_api.ts @@ -30,6 +30,7 @@ export * from 'igniteui-angular/avatar'; export * from 'igniteui-angular/badge'; export * from 'igniteui-angular/banner'; export * from 'igniteui-angular/bottom-nav'; +export * from 'igniteui-angular/breadcrumb'; export * from 'igniteui-angular/button-group'; export * from 'igniteui-angular/calendar'; export * from 'igniteui-angular/card'; From dfe695c4773c2c5e609f5eb1475c1e52e014f3d6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Nov 2025 13:19:26 +0000 Subject: [PATCH 3/4] fix: Add standalone: true to breadcrumb components per code review feedback Co-authored-by: zdrawku <11193764+zdrawku@users.noreply.github.com> --- .../breadcrumb/src/breadcrumb/breadcrumb-item.component.ts | 1 + .../breadcrumb/src/breadcrumb/breadcrumb.component.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb-item.component.ts b/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb-item.component.ts index 413b8a5c2cc..1d6ba1aeb9c 100644 --- a/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb-item.component.ts +++ b/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb-item.component.ts @@ -33,6 +33,7 @@ let NEXT_ID = 0; selector: 'igx-breadcrumb-item', templateUrl: 'breadcrumb-item.component.html', styleUrl: 'breadcrumb-item.component.scss', + standalone: true, imports: [RouterLink, IgxIconComponent], host: { 'role': 'listitem' diff --git a/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb.component.ts b/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb.component.ts index 760bfa20e42..e265f8b9f3f 100644 --- a/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb.component.ts +++ b/projects/igniteui-angular/breadcrumb/src/breadcrumb/breadcrumb.component.ts @@ -49,6 +49,7 @@ let NEXT_ID = 0; selector: 'igx-breadcrumb', templateUrl: 'breadcrumb.component.html', styleUrl: 'breadcrumb.component.scss', + standalone: true, host: { 'role': 'navigation', '[attr.aria-label]': '"breadcrumb"' From 3b096554d226153ddb0dfc16ca9f8dc4433ad6e9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 30 Nov 2025 14:31:07 +0000 Subject: [PATCH 4/4] feat: Add breadcrumb sample apps demonstrating usage, customization and styling Co-authored-by: zdrawku <11193764+zdrawku@users.noreply.github.com> --- src/app/app.routes.ts | 5 + src/app/breadcrumb/breadcrumb.sample.html | 109 ++++++++++++++++++++++ src/app/breadcrumb/breadcrumb.sample.scss | 53 +++++++++++ src/app/breadcrumb/breadcrumb.sample.ts | 39 ++++++++ 4 files changed, 206 insertions(+) create mode 100644 src/app/breadcrumb/breadcrumb.sample.html create mode 100644 src/app/breadcrumb/breadcrumb.sample.scss create mode 100644 src/app/breadcrumb/breadcrumb.sample.ts diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 62c98189e5c..fe0ce96b5be 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -87,6 +87,7 @@ import { HierarchicalGridRemoteSampleComponent } from './hierarchical-grid-remot import { HierarchicalGridUpdatingSampleComponent } from './hierarchical-grid-updating/hierarchical-grid-updating.sample'; import { GridColumnPercentageWidthsSampleComponent } from './grid-percentage-columns/grid-percantge-widths.sample'; import { BannerSampleComponent } from './banner/banner.sample'; +import { BreadcrumbSampleComponent } from './breadcrumb/breadcrumb.sample'; import { CalendarViewsSampleComponent } from './calendar-views/calendar-views.sample'; import { GridSearchComponent } from './grid-search/grid-search.sample'; import { AutocompleteSampleComponent } from './autocomplete/autocomplete.sample'; @@ -181,6 +182,10 @@ export const appRoutes: Routes = [ path: 'banner', component: BannerSampleComponent }, + { + path: 'breadcrumb', + component: BreadcrumbSampleComponent + }, { path: 'buttons', component: ButtonSampleComponent diff --git a/src/app/breadcrumb/breadcrumb.sample.html b/src/app/breadcrumb/breadcrumb.sample.html new file mode 100644 index 00000000000..296ad4eb52c --- /dev/null +++ b/src/app/breadcrumb/breadcrumb.sample.html @@ -0,0 +1,109 @@ +
    + + +
    +

    Basic Breadcrumb

    +

    Simple breadcrumb navigation with icons and disabled current page.

    + +
    + + +
    +

    Custom Separator

    +

    Breadcrumb with a custom separator character.

    + +
    + + +
    +

    Icon Separator Template

    +

    Breadcrumb with a custom icon as separator using ng-template.

    + +
    + + +
    +

    Collapsed Items (Overflow)

    +

    Long breadcrumb trail with collapsed middle items. Shows first item and last 2 items.

    + +
    + + +
    +

    Standard Links (href)

    +

    Breadcrumb using standard href links instead of router links.

    + +
    + + +
    +

    Dynamic Breadcrumb (Data-driven)

    +

    Breadcrumb items generated from data array.

    + +
    + + +
    +

    Custom Styled Breadcrumb

    +

    Breadcrumb with custom CSS styling applied.

    + +
    + +
    diff --git a/src/app/breadcrumb/breadcrumb.sample.scss b/src/app/breadcrumb/breadcrumb.sample.scss new file mode 100644 index 00000000000..98c022b7ec5 --- /dev/null +++ b/src/app/breadcrumb/breadcrumb.sample.scss @@ -0,0 +1,53 @@ +.breadcrumb-sample { + padding: 16px 24px; + background: #f5f5f5; + border-radius: 8px; + margin-bottom: 8px; +} + +.sample-description { + color: #666; + font-size: 14px; + margin-bottom: 12px; +} + +.sample-column.full-width { + min-width: 100%; +} + +// Custom styled breadcrumb +.styled-breadcrumb { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + + igx-breadcrumb { + --igx-breadcrumb-item-color: rgba(255, 255, 255, 0.9); + --igx-breadcrumb-link-color: #fff; + --igx-breadcrumb-link-hover-color: #e0e0ff; + --igx-breadcrumb-separator-color: rgba(255, 255, 255, 0.6); + --igx-breadcrumb-current-color: #fff; + --igx-breadcrumb-disabled-color: rgba(255, 255, 255, 0.7); + } + + .igx-breadcrumb-item__link { + color: #fff; + font-weight: 500; + + &:hover { + color: #e0e0ff; + } + } + + .igx-breadcrumb-item__text { + color: rgba(255, 255, 255, 0.9); + } + + .igx-breadcrumb__separator-text { + color: rgba(255, 255, 255, 0.6); + } +} + +#igniteui-demo-app .sample-title { + font-size: 16px; + margin-bottom: 8px; + color: #333; +} diff --git a/src/app/breadcrumb/breadcrumb.sample.ts b/src/app/breadcrumb/breadcrumb.sample.ts new file mode 100644 index 00000000000..6a053dba328 --- /dev/null +++ b/src/app/breadcrumb/breadcrumb.sample.ts @@ -0,0 +1,39 @@ +import { Component, ViewEncapsulation } from '@angular/core'; +import { IGX_BREADCRUMB_DIRECTIVES, IgxBreadcrumbSeparatorDirective } from 'igniteui-angular/breadcrumb'; +import { IgxIconComponent } from 'igniteui-angular/icon'; + +@Component({ + encapsulation: ViewEncapsulation.None, + selector: 'app-breadcrumb-sample', + styleUrls: ['breadcrumb.sample.scss'], + templateUrl: 'breadcrumb.sample.html', + imports: [IGX_BREADCRUMB_DIRECTIVES, IgxBreadcrumbSeparatorDirective, IgxIconComponent] +}) +export class BreadcrumbSampleComponent { + // Sample data for dynamic breadcrumbs + public breadcrumbItems = [ + { label: 'Home', link: '/home', icon: 'home' }, + { label: 'Products', link: '/products' }, + { label: 'Electronics', link: '/products/electronics' }, + { label: 'Laptops', link: '/products/electronics/laptops' }, + { label: 'Gaming Laptops', link: '/products/electronics/laptops/gaming' }, + { label: 'ASUS ROG', disabled: true } + ]; + + // Toggle for custom separator demo + public useCustomSeparator = false; + + // Toggle for collapsed items demo + public showCollapsed = true; + public maxItems = 4; + public itemsBeforeCollapse = 1; + public itemsAfterCollapse = 2; + + public toggleCustomSeparator(): void { + this.useCustomSeparator = !this.useCustomSeparator; + } + + public toggleCollapsed(): void { + this.showCollapsed = !this.showCollapsed; + } +}