Skip to content

feat(arc): implement breadcrumb feature #128

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jul 15, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import {TitleDetails, UserDetails} from './user-title.interface';

export const USERS: UserDetails[] = [
{id: '123', name: 'John Doe', email: 'john.doe123@example.com'},
{id: '124', name: 'Jane Smith', email: 'jane.smith124@example.com'},
];
export const TITLES: TitleDetails[] = [
{id: '1', title: 'Contract.pdf'},
{id: '2', title: 'Appointment.pdf'},
];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use types here

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done mam

Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export interface UserDetails {
id: string;
name: string;
email: string;
}
export interface TitleDetails {
id: string;
title: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<div class="container mt-4">
<h2>Documentation</h2>
<p><strong>ID:</strong>{{ title?.id }}</p>
<p><strong>Title:</strong>{{ title?.title }}</p>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {Component} from '@angular/core';
import {ActivatedRoute} from '@angular/router';
import {TitleDetails} from '../user-title.interface';
import {TitleService} from './user-title.service';

@Component({
selector: 'lib-user-title',
templateUrl: './user-title.component.html',
})
export class UserTitleComponent {
title: TitleDetails;
constructor(
private readonly route: ActivatedRoute,
private readonly titleService: TitleService,
) {
const id = this.route.snapshot.paramMap.get('id');
if (id) {
this.titleService.getTitleById(id).subscribe(title => {
this.title = title;
});
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import {catchError, delay, map, Observable, of} from 'rxjs';
import {TITLES} from '../mock-data.constants';
import {TitleDetails} from '../user-title.interface';

export class TitleService {
private readonly titles = TITLES;

getTitleById(id: string): Observable<TitleDetails> {
const title = this.titles.find(u => u.id === id);
return of(title);
}
getTitleNameForBreadcrumb(id: string): Observable<string> {
return this.getTitleById(id).pipe(
map(titles => titles?.title || `Document #${id}`),
catchError(() => of(`Document #${id}`)),
delay(2000), // Simulating network delay
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<div class="container mt-4">
<h2>User Details</h2>
<p><strong>ID:</strong> {{ user?.id }}</p>
<p><strong>Name:</strong> {{ user?.name }}</p>
<p><strong>Email:</strong> {{ user?.email }}</p>

<router-outlet></router-outlet>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {CommonModule} from '@angular/common';
import {Component} from '@angular/core';
import {ActivatedRoute, RouterModule} from '@angular/router';
import {UserDetails} from '../user-title.interface';
import {UserService} from './user.service';

@Component({
selector: 'lib-user',
standalone: true,
templateUrl: './user.component.html',
imports: [CommonModule, RouterModule],
})
export class UserComponent {
user: UserDetails;

constructor(
private readonly route: ActivatedRoute,
private readonly userService: UserService,
) {
const id = this.route.snapshot.paramMap.get('id');
if (id) {
this.userService.getUserById(id).subscribe(user => {
this.user = user;
});
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import {catchError, delay, map, Observable, of} from 'rxjs';
import {USERS} from '../mock-data.constants';
import {UserDetails} from '../user-title.interface';

export class UserService {
private readonly users = USERS;

getUserById(id: string): Observable<UserDetails> {
const user = this.users.find(u => u.id === id);
return of(user);
}
getUserNameForBreadcrumb(id: string): Observable<string> {
return this.getUserById(id).pipe(
map(user => user?.name || `User #${id}`),
catchError(() => of(`User #${id}`)),
delay(4000),
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<nav aria-label="breadcrumb" *ngIf="breadcrumbs$ | async as breadcrumbs">
<ul class="breadcrumb">
<ng-container
*ngIf="breadcrumbs.length > maxItems && !expanded; else fullBreadcrumb"
>
<li class="breadcrumb-item">
<ng-container *ngIf="!breadcrumbs[0].skipLink; else noLinkFirst">
<a [routerLink]="breadcrumbs[0].url">{{ breadcrumbs[0].label }}</a>
</ng-container>
<ng-template #noLinkFirst>
<span>{{ breadcrumbs[0].label }}</span>
</ng-template>
</li>
<span class="separator">{{ separator }}</span>

<li class="breadcrumb-item clickable" (click)="toggleExpand()">...</li>
<span class="separator">{{ separator }}</span>

<li class="breadcrumb-item" [class.active]="true">
<ng-container
*ngIf="!breadcrumbs[breadcrumbs.length - 1].skipLink; else noLinkLast"
>
<a [routerLink]="breadcrumbs[breadcrumbs.length - 1].url">
{{ breadcrumbs[breadcrumbs.length - 1].label }}
</a>
</ng-container>
<ng-template #noLinkLast>
<span>{{ breadcrumbs[breadcrumbs.length - 1].label }}</span>
</ng-template>
</li>
</ng-container>

<ng-template #fullBreadcrumb>
<ng-container *ngFor="let breadcrumb of breadcrumbs; let last = last">
<li class="breadcrumb-item" [class.active]="last">
<ng-container *ngIf="!breadcrumb.skipLink && !last; else noLink">
<a [routerLink]="breadcrumb.url">{{ breadcrumb.label }}</a>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what if a breacrumb label is too long? Shouldn't we trim it ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done!

</ng-container>
<ng-template #noLink>
<span>{{ breadcrumb.label }}</span>
</ng-template>
</li>
<span *ngIf="!last" class="separator">{{ separator }}</span>
</ng-container>
</ng-template>
</ul>
</nav>
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
.breadcrumb {
display: flex;
flex-wrap: wrap;
list-style: none;
padding: 0;
margin: 0;
background-color: transparent;

.breadcrumb-item {
display: flex;
align-items: center;
font-size: 14px;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we use rem for responsive design?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done!

color: #555;

a {
color: #007bff;
text-decoration: none;

&:hover {
text-decoration: underline;
}
}

&.active {
font-weight: 600;
color: #333;
}
&.clickable {
cursor: pointer;
color: #007bff;
text-decoration: underline;

&:hover {
color: #0056b3;
}
}
}

.separator {
margin: 0 6px;
color: #aaa;
font-size: 14px;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import {Component, Input, isDevMode, OnInit} from '@angular/core';
import {CommonModule} from '@angular/common';
import {Breadcrumb} from './breadcrumb.interface';
import {BreadcrumbService} from './breadcrumb.service';
import {Observable, Subject, takeUntil} from 'rxjs';
import {RouterModule} from '@angular/router';

@Component({
selector: 'app-breadcrumb',
templateUrl: './breadcrumb.component.html',
standalone: true,
imports: [CommonModule, RouterModule],
styleUrls: ['./breadcrumb.component.scss'],
})
export class BreadcrumbComponent implements OnInit {
breadcrumbs$: Observable<Breadcrumb[]> = this.breadcrumbService.breadcrumbs;

@Input() staticBreadcrumbs = [];

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

where is this being used in component?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as of now, this input is not being used anywhere inside the component.
but it uses as like below.
<app-breadcrumb
[staticBreadcrumbs]="[
{ label: 'Home', url: '/home' },
{ label: 'Settings', url: '/settings' }
]"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but they don't seem to be used

@Input() separator = '>';
@Input() maxItems = 8;
@Input() separatorClass = 'separator';
@Input() itemClass = 'breadcrumb-item';

expanded = false;
private destroy$ = new Subject<void>();
constructor(private readonly breadcrumbService: BreadcrumbService) {}
ngOnInit(): void {
this.breadcrumbs$.pipe(takeUntil(this.destroy$)).subscribe(breadcrumbs => {
if (isDevMode()) {
console.log('Breadcrumbs:', breadcrumbs);
}
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
toggleExpand() {
this.expanded = !this.expanded;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export interface Breadcrumb {
label: string;
url: string;
skipLink?: boolean;
}
109 changes: 109 additions & 0 deletions projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import {Injectable, Injector} from '@angular/core';
import {ActivatedRouteSnapshot, NavigationEnd, Router} from '@angular/router';
import {BehaviorSubject} from 'rxjs';
import {filter} from 'rxjs/operators';
import {Breadcrumb} from './breadcrumb.interface';

@Injectable({providedIn: 'root'})
export class BreadcrumbService {
private readonly breadcrumbs$ = new BehaviorSubject<Breadcrumb[]>([]);

constructor(
private readonly router: Router,
private readonly injector: Injector,
) {
this.router.events
.pipe(filter(event => event instanceof NavigationEnd))
.subscribe(async () => {
const root = this.router.routerState.snapshot.root;
const breadcrumbs = this.buildBreadcrumbs(root);
this.breadcrumbs$.next(breadcrumbs);
});
}

private buildBreadcrumbs(
route: ActivatedRouteSnapshot,
url = '',
breadcrumbs: Breadcrumb[] = [],
): Breadcrumb[] {
if (!route.routeConfig) {
return route.firstChild
? this.buildBreadcrumbs(route.firstChild, url, breadcrumbs)
: breadcrumbs;
}

let path = route.routeConfig.path || '';

// Replace route params like :id with actual values
Object.keys(route.params).forEach(key => {
path = path.replace(`:${key}`, route.params[key]);
});
const nextUrl = path ? `${url}/${path}` : url;
const label = this._resolveLabel(route, path, nextUrl);
const skipLink = route.routeConfig.data?.['skipLink'] ?? false;

if (label && label.trim() !== '') {
breadcrumbs.push({label, url: nextUrl, skipLink});
}

return route.firstChild
? this.buildBreadcrumbs(route.firstChild, nextUrl, breadcrumbs)
: breadcrumbs;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

modify this function to keep it clean, readable and remove if else if

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done mam


private _toTitleCase(str: string): string {
return str.replace(/-/g, ' ').replace(/\b\w/g, char => char.toUpperCase());
}

private _resolveLabel(
route: ActivatedRouteSnapshot,
path: string,
currentUrl: string,
): string {
const data = route.routeConfig?.data;

//async breadcrumb logic
const asyncConfig = data?.asyncBreadcrumb;
if (asyncConfig?.service && asyncConfig?.method) {
const params = route.paramMap;
const paramValue = params.get('id');

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

id shouldn't be hardcoded.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done !

const fallback =
asyncConfig.fallbackLabel?.(params) || this._toTitleCase(path);
const loadingLabel = asyncConfig.loadingLabel || fallback;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have a reusable loader service? If yes, then I think it will be better to show loader instead of a text

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done!

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

need to discuss

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

discussed


setTimeout(async () => {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why setTimeout?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With setTimeout, the breadcrumb shows loadingLabel immediately, and updates asynchronously when the result arrives.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i removed seTimeout.Now, The updated implementation now uses RxJS operators like tap, catchError, and finalize inside the observable pipeline.

try {
const serviceInstance = this.injector.get(asyncConfig.service);
const result$ = serviceInstance[asyncConfig.method](paramValue);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems a little restrictive as the method can only have 1 required argument of string type.

const result = await result$.toPromise();
this.updateBreadcrumbLabel(currentUrl, result);
} catch (error) {
console.warn('Async breadcrumb load failed:', error);
}
}, 0);

return loadingLabel;
}
const breadcrumbData = data?.['breadcrumb'];

if (typeof breadcrumbData === 'string') {
return breadcrumbData;
}

return this._toTitleCase(path);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove so many ifs, use array instead

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done!

}
updateBreadcrumbLabel(url: string, newLabel: string): void {
const currentBreadcrumbs = this.breadcrumbs$.getValue();
const index = currentBreadcrumbs.findIndex(bc => bc.url === url);
if (index !== -1) {
currentBreadcrumbs[index] = {
...currentBreadcrumbs[index],
label: newLabel,
};
this.breadcrumbs$.next([...currentBreadcrumbs]);
}
}
get breadcrumbs() {
return this.breadcrumbs$.asObservable();
}
}
Loading