Skip to content

Commit f6ba77b

Browse files
Vaibhav  BhallaVaibhav  Bhalla
authored andcommitted
feat(arc): implement breadcrumb feature
GH-126
1 parent c57160d commit f6ba77b

File tree

12 files changed

+343
-88
lines changed

12 files changed

+343
-88
lines changed

projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.service.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,14 @@ export class TitleService {
99
const title = this.titles.find(u => u.id === id);
1010
return of(title);
1111
}
12-
getTitleNameForBreadcrumb(id: string): Observable<string> {
12+
getTitleNameForBreadcrumb(
13+
params: Record<string, string>,
14+
): Observable<string> {
15+
const id = params['id'];
1316
return this.getTitleById(id).pipe(
1417
map(titles => titles?.title || `Document #${id}`),
1518
catchError(() => of(`Document #${id}`)),
16-
delay(2000), // Simulating network delay
19+
delay(4000), // Simulating network delay
1720
);
1821
}
1922
}

projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user/user.service.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ export class UserService {
99
const user = this.users.find(u => u.id === id);
1010
return of(user);
1111
}
12-
getUserNameForBreadcrumb(id: string): Observable<string> {
12+
getUserNameForBreadcrumb(params: Record<string, string>): Observable<string> {
13+
const id = params['id']; // Access any param key dynamically
1314
return this.getUserById(id).pipe(
1415
map(user => user?.name || `User #${id}`),
1516
catchError(() => of(`User #${id}`)),
Lines changed: 113 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,121 @@
1-
<nav aria-label="breadcrumb" *ngIf="breadcrumbs$ | async as breadcrumbs">
2-
<ul class="breadcrumb">
3-
<ng-container
4-
*ngIf="breadcrumbs.length > maxItems && !expanded; else fullBreadcrumb"
5-
>
6-
<li class="breadcrumb-item">
7-
<ng-container *ngIf="!breadcrumbs[0].skipLink; else noLinkFirst">
8-
<a [routerLink]="breadcrumbs[0].url">{{ breadcrumbs[0].label }}</a>
9-
</ng-container>
10-
<ng-template #noLinkFirst>
11-
<span>{{ breadcrumbs[0].label }}</span>
12-
</ng-template>
13-
</li>
14-
<span class="separator">{{ separator }}</span>
1+
<nav aria-label="breadcrumb">
2+
<div
3+
*ngIf="(loading$ | async) && showLoadingSkeleton"
4+
class="breadcrumb-skeleton"
5+
>
6+
<div class="skeleton-item"></div>
7+
<span [class]="separatorClass">{{ separator }}</span>
8+
<div class="skeleton-item"></div>
9+
<span [class]="separatorClass">{{ separator }}</span>
10+
<div class="skeleton-item"></div>
11+
</div>
12+
13+
<ng-container *ngIf="!(loading$ | async)">
14+
<ul class="breadcrumb" *ngIf="breadcrumbs$ | async as breadcrumbs">
15+
<ng-container
16+
*ngIf="breadcrumbs.length > maxItems && !expanded; else fullBreadcrumb"
17+
>
18+
<li class="breadcrumb-item">
19+
<ng-container *ngIf="!breadcrumbs[0].skipLink; else noLinkFirst">
20+
<a
21+
[routerLink]="breadcrumbs[0].url"
22+
[title]="breadcrumbs[0].label"
23+
class="breadcrumb-label"
24+
>
25+
<nb-icon
26+
*ngIf="breadcrumbs[0].icon"
27+
[icon]="breadcrumbs[0].icon"
28+
class="breadcrumb-icon"
29+
></nb-icon>
30+
{{ getTrimmedLabel(breadcrumbs[0].label) }}
31+
</a>
32+
</ng-container>
33+
<ng-template #noLinkFirst>
34+
<span class="breadcrumb-label" [title]="breadcrumbs[0].label">
35+
<nb-icon
36+
*ngIf="breadcrumbs[0].icon"
37+
[icon]="breadcrumbs[0].icon"
38+
class="breadcrumb-icon"
39+
></nb-icon>
40+
{{ getTrimmedLabel(breadcrumbs[0].label) }}
41+
</span>
42+
</ng-template>
43+
</li>
1544

16-
<li class="breadcrumb-item clickable" (click)="toggleExpand()">...</li>
17-
<span class="separator">{{ separator }}</span>
45+
<span class="{{ separatorClass }}">{{ separator }}</span>
1846

19-
<li class="breadcrumb-item" [class.active]="true">
20-
<ng-container
21-
*ngIf="!breadcrumbs[breadcrumbs.length - 1].skipLink; else noLinkLast"
22-
>
23-
<a [routerLink]="breadcrumbs[breadcrumbs.length - 1].url">
24-
{{ breadcrumbs[breadcrumbs.length - 1].label }}
25-
</a>
26-
</ng-container>
27-
<ng-template #noLinkLast>
28-
<span>{{ breadcrumbs[breadcrumbs.length - 1].label }}</span>
29-
</ng-template>
30-
</li>
31-
</ng-container>
47+
<li class="breadcrumb-item clickable" (click)="toggleExpand()">...</li>
48+
<span class="{{ separatorClass }}">{{ separator }}</span>
3249

33-
<ng-template #fullBreadcrumb>
34-
<ng-container *ngFor="let breadcrumb of breadcrumbs; let last = last">
35-
<li class="breadcrumb-item" [class.active]="last">
36-
<ng-container *ngIf="!breadcrumb.skipLink && !last; else noLink">
37-
<a [routerLink]="breadcrumb.url">{{ breadcrumb.label }}</a>
50+
<li class="breadcrumb-item active">
51+
<ng-container
52+
*ngIf="
53+
!breadcrumbs[breadcrumbs.length - 1].skipLink;
54+
else noLinkLast
55+
"
56+
>
57+
<a
58+
[routerLink]="breadcrumbs[breadcrumbs.length - 1].url"
59+
[title]="breadcrumbs[breadcrumbs.length - 1].label"
60+
class="breadcrumb-label"
61+
>
62+
<nb-icon
63+
*ngIf="breadcrumbs[breadcrumbs.length - 1].icon"
64+
[icon]="breadcrumbs[breadcrumbs.length - 1].icon"
65+
class="breadcrumb-icon"
66+
></nb-icon>
67+
{{ getTrimmedLabel(breadcrumbs[breadcrumbs.length - 1].label) }}
68+
</a>
3869
</ng-container>
39-
<ng-template #noLink>
40-
<span>{{ breadcrumb.label }}</span>
70+
<ng-template #noLinkLast>
71+
<span
72+
class="breadcrumb-label"
73+
[title]="breadcrumbs[breadcrumbs.length - 1].label"
74+
>
75+
<nb-icon
76+
*ngIf="breadcrumbs[breadcrumbs.length - 1].icon"
77+
[icon]="breadcrumbs[breadcrumbs.length - 1].icon"
78+
class="breadcrumb-icon"
79+
></nb-icon>
80+
{{ getTrimmedLabel(breadcrumbs[breadcrumbs.length - 1].label) }}
81+
</span>
4182
</ng-template>
4283
</li>
43-
<span *ngIf="!last" class="separator">{{ separator }}</span>
4484
</ng-container>
45-
</ng-template>
46-
</ul>
85+
86+
<ng-template #fullBreadcrumb>
87+
<ng-container *ngFor="let breadcrumb of breadcrumbs; let last = last">
88+
<li class="breadcrumb-item" [class.active]="last">
89+
<ng-container *ngIf="!breadcrumb.skipLink && !last; else noLink">
90+
<a
91+
[routerLink]="breadcrumb.url"
92+
[title]="breadcrumb.label"
93+
class="breadcrumb-label"
94+
>
95+
<nb-icon
96+
*ngIf="breadcrumb.icon"
97+
[icon]="breadcrumb.icon"
98+
class="breadcrumb-icon"
99+
></nb-icon>
100+
{{ getTrimmedLabel(breadcrumb.label) }}
101+
</a>
102+
</ng-container>
103+
<ng-template #noLink>
104+
<span class="breadcrumb-label" [title]="breadcrumb.label">
105+
<nb-icon
106+
*ngIf="breadcrumb.icon"
107+
[icon]="breadcrumb.icon"
108+
class="breadcrumb-icon"
109+
></nb-icon>
110+
{{ getTrimmedLabel(breadcrumb.label) }}
111+
</span>
112+
</ng-template>
113+
</li>
114+
<span *ngIf="!last" class="{{ separatorClass }}">{{
115+
separator
116+
}}</span>
117+
</ng-container>
118+
</ng-template>
119+
</ul>
120+
</ng-container>
47121
</nav>

projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.component.scss

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
.breadcrumb-item {
1010
display: flex;
1111
align-items: center;
12-
font-size: 14px;
12+
font-size: 0.875rem;
1313
color: #555;
1414

1515
a {
@@ -25,6 +25,7 @@
2525
font-weight: 600;
2626
color: #333;
2727
}
28+
2829
&.clickable {
2930
cursor: pointer;
3031
color: #007bff;
@@ -34,11 +35,52 @@
3435
color: #0056b3;
3536
}
3637
}
38+
39+
.breadcrumb-label {
40+
max-width: 12rem; // Restrict label width
41+
overflow: hidden;
42+
text-overflow: ellipsis;
43+
white-space: nowrap;
44+
display: inline-block;
45+
vertical-align: middle;
46+
}
3747
}
3848

3949
.separator {
40-
margin: 0 6px;
50+
margin: 0 0.375rem;
4151
color: #aaa;
42-
font-size: 14px;
52+
font-size: 0.875rem;
53+
}
54+
}
55+
56+
.breadcrumb-skeleton {
57+
display: flex;
58+
align-items: center;
59+
padding: 0.5rem 0;
60+
61+
.skeleton-item {
62+
height: 1rem;
63+
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
64+
background-size: 200% 100%;
65+
animation: loading 1.5s infinite;
66+
border-radius: 4px;
67+
min-width: 3.75rem;
68+
max-width: 7.5rem;
69+
width: 5rem;
70+
}
71+
72+
.separator {
73+
margin: 0 0.375rem;
74+
color: #ccc;
75+
font-size: 0.875rem;
76+
}
77+
}
78+
79+
@keyframes loading {
80+
0% {
81+
background-position: 200% 0;
82+
}
83+
100% {
84+
background-position: -200% 0;
4385
}
4486
}

projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.component.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,28 @@ import {Component, Input, isDevMode, OnInit} from '@angular/core';
22
import {CommonModule} from '@angular/common';
33
import {Breadcrumb} from './breadcrumb.interface';
44
import {BreadcrumbService} from './breadcrumb.service';
5-
import {Observable, Subject, takeUntil} from 'rxjs';
5+
import {Observable, of, Subject, takeUntil} from 'rxjs';
66
import {RouterModule} from '@angular/router';
7-
7+
import {NbIconModule} from '@nebular/theme';
88
@Component({
99
selector: 'app-breadcrumb',
1010
templateUrl: './breadcrumb.component.html',
1111
standalone: true,
12-
imports: [CommonModule, RouterModule],
12+
imports: [CommonModule, RouterModule, NbIconModule],
1313
styleUrls: ['./breadcrumb.component.scss'],
1414
})
1515
export class BreadcrumbComponent implements OnInit {
1616
breadcrumbs$: Observable<Breadcrumb[]> = this.breadcrumbService.breadcrumbs;
17+
loading$: Observable<boolean> = this.breadcrumbService.loading;
1718

1819
@Input() staticBreadcrumbs = [];
1920
@Input() separator = '>';
2021
@Input() maxItems = 8;
2122
@Input() separatorClass = 'separator';
2223
@Input() itemClass = 'breadcrumb-item';
24+
@Input() maxLabelLength = 30;
25+
@Input() showLoadingSkeleton = true;
26+
@Input() showIcons = false;
2327

2428
expanded = false;
2529
private destroy$ = new Subject<void>();
@@ -31,6 +35,11 @@ export class BreadcrumbComponent implements OnInit {
3135
}
3236
});
3337
}
38+
getTrimmedLabel(label: string): string {
39+
return label.length > this.maxLabelLength
40+
? label.slice(0, this.maxLabelLength).trimEnd() + '…'
41+
: label;
42+
}
3443
ngOnDestroy(): void {
3544
this.destroy$.next();
3645
this.destroy$.complete();

projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.interface.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ export interface Breadcrumb {
22
label: string;
33
url: string;
44
skipLink?: boolean;
5+
icon?: string;
56
}

projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.service.ts

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import {Injectable, Injector} from '@angular/core';
22
import {ActivatedRouteSnapshot, NavigationEnd, Router} from '@angular/router';
3-
import {BehaviorSubject} from 'rxjs';
4-
import {filter} from 'rxjs/operators';
3+
import {BehaviorSubject, EMPTY, Observable} from 'rxjs';
4+
import {catchError, filter, finalize, tap} from 'rxjs/operators';
55
import {Breadcrumb} from './breadcrumb.interface';
66

77
@Injectable({providedIn: 'root'})
88
export class BreadcrumbService {
99
private readonly breadcrumbs$ = new BehaviorSubject<Breadcrumb[]>([]);
10+
private readonly loading$ = new BehaviorSubject<boolean>(false);
1011

1112
constructor(
1213
private readonly router: Router,
@@ -41,9 +42,10 @@ export class BreadcrumbService {
4142
const nextUrl = path ? `${url}/${path}` : url;
4243
const label = this._resolveLabel(route, path, nextUrl);
4344
const skipLink = route.routeConfig.data?.['skipLink'] ?? false;
45+
const icon = route.routeConfig.data?.['icon'];
4446

4547
if (label && label.trim() !== '') {
46-
breadcrumbs.push({label, url: nextUrl, skipLink});
48+
breadcrumbs.push({label, url: nextUrl, skipLink, icon});
4749
}
4850

4951
return route.firstChild
@@ -66,21 +68,36 @@ export class BreadcrumbService {
6668
const asyncConfig = data?.asyncBreadcrumb;
6769
if (asyncConfig?.service && asyncConfig?.method) {
6870
const params = route.paramMap;
69-
const paramValue = params.get('id');
71+
const paramValue = Object.fromEntries(
72+
params.keys.map(k => [k, params.get(k)]),
73+
);
7074
const fallback =
7175
asyncConfig.fallbackLabel?.(params) || this._toTitleCase(path);
7276
const loadingLabel = asyncConfig.loadingLabel || fallback;
7377

74-
setTimeout(async () => {
75-
try {
76-
const serviceInstance = this.injector.get(asyncConfig.service);
77-
const result$ = serviceInstance[asyncConfig.method](paramValue);
78-
const result = await result$.toPromise();
79-
this.updateBreadcrumbLabel(currentUrl, result);
80-
} catch (error) {
81-
console.warn('Async breadcrumb load failed:', error);
82-
}
83-
}, 0);
78+
this.loading$.next(true);
79+
try {
80+
const serviceInstance = this.injector.get(asyncConfig.service);
81+
const result$ = serviceInstance[asyncConfig.method](paramValue);
82+
83+
this.loading$.next(true);
84+
85+
result$
86+
.pipe(
87+
tap(result =>
88+
this.updateBreadcrumbLabel(currentUrl, String(result)),
89+
),
90+
catchError(error => {
91+
console.warn('Async breadcrumb load failed:', error);
92+
return EMPTY;
93+
}),
94+
finalize(() => this.loading$.next(false)),
95+
)
96+
.subscribe();
97+
} catch (error) {
98+
console.warn('Async breadcrumb load failed:', error);
99+
this.loading$.next(false);
100+
}
84101

85102
return loadingLabel;
86103
}
@@ -106,4 +123,7 @@ export class BreadcrumbService {
106123
get breadcrumbs() {
107124
return this.breadcrumbs$.asObservable();
108125
}
126+
get loading(): Observable<boolean> {
127+
return this.loading$.asObservable();
128+
}
109129
}

0 commit comments

Comments
 (0)