Skip to content

Commit 5c4bd27

Browse files
feat(arc): implement breadcrumb feature
GH-126
1 parent 62b36aa commit 5c4bd27

28 files changed

+715
-6
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// import { Injectable } from "@angular/core";
2+
// import { ActivatedRouteSnapshot } from "@angular/router";
3+
4+
// @Injectable({ providedIn: 'root' })
5+
// export class BreadcrumbLabelResolver {
6+
// resolveLabel(snapshot: ActivatedRouteSnapshot): string {
7+
// const path = snapshot.routeConfig?.path ?? '';
8+
9+
// const result = path
10+
// .split('/')
11+
// .map(segment => {
12+
// if (segment.startsWith(':')) {
13+
// const param = segment.slice(1);
14+
// return snapshot.params[param] || `{${param}}`;
15+
// }
16+
// return segment;
17+
// })
18+
// .join(' ');
19+
20+
// return this.toTitleCase(result);
21+
// }
22+
23+
// private toTitleCase(str: string): string {
24+
// return str
25+
// .replace(/[-_]/g, ' ')
26+
// .replace(/\b\w/g, char => char.toUpperCase());
27+
// }
28+
// }
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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>
15+
16+
<li class="breadcrumb-item clickable" (click)="toggleExpand()">...</li>
17+
<span class="separator">{{separator}}</span>
18+
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>
32+
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>
38+
</ng-container>
39+
<ng-template #noLink>
40+
<span>{{ breadcrumb.label }}</span>
41+
</ng-template>
42+
</li>
43+
<span *ngIf="!last" class="separator">{{separator}}</span>
44+
</ng-container>
45+
</ng-template>
46+
</ul>
47+
</nav>
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
.breadcrumb {
2+
display: flex;
3+
flex-wrap: wrap;
4+
list-style: none;
5+
padding: 0;
6+
margin: 0;
7+
background-color: transparent;
8+
9+
10+
.breadcrumb-item {
11+
display: flex;
12+
align-items: center;
13+
font-size: 14px;
14+
color: #555;
15+
16+
a {
17+
color: #007bff;
18+
text-decoration: none;
19+
20+
&:hover {
21+
text-decoration: underline;
22+
}
23+
}
24+
25+
&.active {
26+
font-weight: 600;
27+
color: #333;
28+
}
29+
&.clickable {
30+
cursor: pointer;
31+
color: #007bff;
32+
text-decoration: underline;
33+
34+
&:hover {
35+
color: #0056b3;
36+
}
37+
}
38+
}
39+
40+
.separator {
41+
margin: 0 6px;
42+
color: #aaa;
43+
font-size: 14px;
44+
}
45+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import {Component, Input, OnInit} from '@angular/core';
2+
import {CommonModule} from '@angular/common';
3+
import {Breadcrumb} from './breadcrump.interface';
4+
import {BreadcrumbService} from './breadcrump.service';
5+
import {Observable} from 'rxjs';
6+
import {RouterModule} from '@angular/router';
7+
8+
@Component({
9+
selector: 'app-breadcrumb',
10+
templateUrl: './breadcrumb.component.html',
11+
standalone: true,
12+
imports: [CommonModule, RouterModule],
13+
styleUrls: ['./breadcrumb.component.scss'],
14+
})
15+
export class BreadcrumbComponent implements OnInit {
16+
breadcrumbs$: Observable<Breadcrumb[]> = this.breadcrumbService.breadcrumbs;
17+
18+
@Input() staticBreadcrumbs = [];
19+
@Input() separator = '>';
20+
@Input() maxItems = 8;
21+
22+
expanded = false;
23+
constructor(private readonly breadcrumbService: BreadcrumbService) {}
24+
ngOnInit(): void {
25+
this.breadcrumbs$.subscribe(breadcrumbs => {
26+
console.log('Breadcrumbs:', breadcrumbs);
27+
});
28+
}
29+
toggleExpand() {
30+
this.expanded = true;
31+
}
32+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export interface Breadcrumb {
2+
label: string;
3+
url: string;
4+
skipLink?: boolean;
5+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import {Injectable} from '@angular/core';
2+
import {ActivatedRouteSnapshot, NavigationEnd, Router} from '@angular/router';
3+
import {BehaviorSubject} from 'rxjs';
4+
import {filter} from 'rxjs/operators';
5+
import {Breadcrumb} from './breadcrump.interface';
6+
7+
@Injectable({providedIn: 'root'})
8+
export class BreadcrumbService {
9+
private readonly breadcrumbs$ = new BehaviorSubject<Breadcrumb[]>([]);
10+
11+
constructor(private readonly router: Router) {
12+
this.router.events
13+
.pipe(filter(event => event instanceof NavigationEnd))
14+
.subscribe(() => {
15+
const root = this.router.routerState.snapshot.root;
16+
const breadcrumbs = this.buildBreadcrumbs(root);
17+
this.breadcrumbs$.next(breadcrumbs);
18+
});
19+
}
20+
21+
private buildBreadcrumbs(
22+
route: ActivatedRouteSnapshot,
23+
url='',
24+
breadcrumbs: Breadcrumb[] = [],
25+
): Breadcrumb[] {
26+
if (!route.routeConfig) {
27+
if (route.firstChild) {
28+
return this.buildBreadcrumbs(route.firstChild, url, breadcrumbs);
29+
}
30+
return breadcrumbs;
31+
}
32+
33+
let path = route.routeConfig.path || '';
34+
Object.keys(route.params).forEach(key => {
35+
path = path.replace(`:${key}`, route.params[key]);
36+
});
37+
const nextUrl = path ? `${url}/${path}` : url;
38+
39+
const breadcrumbData = route.routeConfig.data?.['breadcrumb'];
40+
let label = '';
41+
42+
if (typeof breadcrumbData === 'function') {
43+
label = breadcrumbData(route.data, route.paramMap, route);
44+
} else if (typeof breadcrumbData === 'string') {
45+
label = breadcrumbData;
46+
} else if (route.routeConfig.path?.startsWith(':')) {
47+
const paramName = route.routeConfig.path.slice(1);
48+
label = route.params[paramName] ?? paramName;
49+
} else {
50+
label = this.toTitleCase(path);
51+
}
52+
const skipLink = route.routeConfig.data?.['skipLink'] ?? false;
53+
54+
if (label) {
55+
breadcrumbs.push({label, url: nextUrl, skipLink});
56+
}
57+
58+
if (route.firstChild) {
59+
return this.buildBreadcrumbs(route.firstChild, nextUrl, breadcrumbs);
60+
}
61+
62+
return breadcrumbs;
63+
}
64+
65+
private toTitleCase(str: string): string {
66+
return str.replace(/-/g, ' ').replace(/\b\w/g, char => char.toUpperCase());
67+
}
68+
69+
get breadcrumbs() {
70+
return this.breadcrumbs$.asObservable();
71+
}
72+
}

projects/arc-lib/src/lib/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export * from './gantt/gantt.module';
44
export * from './selector/select.module';
55
export * from './resize/resize.module';
66
export * from './list/list.component';
7+
export * from './breadcrumb/breadcrumb.component';

projects/arc-lib/src/lib/components/user-title/user-title.component.css

Whitespace-only changes.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<div class="container mt-4">
2+
<h2>Documentation</h2>
3+
<p><strong>ID:</strong>{{ title?.id }}</p>
4+
<p><strong>Title:</strong>{{ title?.title }}</p>
5+
</div>
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import {Component} from '@angular/core';
2+
import {ActivatedRoute} from '@angular/router';
3+
4+
@Component({
5+
selector: 'lib-user-title',
6+
templateUrl: './user-title.component.html',
7+
styleUrls: ['./user-title.component.css'],
8+
})
9+
export class UserTitleComponent {
10+
title: any;
11+
constructor(private readonly route: ActivatedRoute) {
12+
this.title = this.route.snapshot.data['document'];
13+
}
14+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import {Injectable} from '@angular/core';
2+
import {Resolve, ActivatedRouteSnapshot} from '@angular/router';
3+
import {HttpClient} from '@angular/common/http';
4+
import {Observable} from 'rxjs';
5+
import {TitleService} from './user-title.service';
6+
7+
@Injectable({providedIn: 'root'})
8+
export class TitleResolver implements Resolve<any> {
9+
constructor(
10+
private readonly http: HttpClient,
11+
private readonly titleService: TitleService,
12+
) {}
13+
14+
resolve(route: ActivatedRouteSnapshot): Observable<any> {
15+
const id = route.paramMap.get('id');
16+
return this.titleService.getTileById(id);
17+
}
18+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import {Injectable} from '@angular/core';
2+
import {Observable, of} from 'rxjs';
3+
4+
@Injectable({providedIn: 'root'})
5+
export class TitleService {
6+
private readonly titles = [
7+
{id: '1', title: 'Contract.pdf'},
8+
{id: '2', title: 'Appointment.pdf'},
9+
];
10+
11+
getTileById(id: string): Observable<any> {
12+
const title = this.titles.find(u => u.id === id);
13+
return of(title);
14+
}
15+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<div class="container mt-4">
2+
<h2>User Details</h2>
3+
<p><strong>ID:</strong> {{ user?.id }}</p>
4+
<p><strong>Name:</strong> {{ user?.name }}</p>
5+
<p><strong>Email:</strong> {{ user?.email }}</p>
6+
7+
<router-outlet></router-outlet>
8+
</div>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import {CommonModule} from '@angular/common';
2+
import {Component} from '@angular/core';
3+
import {ActivatedRoute, RouterModule} from '@angular/router';
4+
5+
@Component({
6+
selector: 'lib-user',
7+
standalone: true,
8+
templateUrl: './user.component.html',
9+
imports: [CommonModule, RouterModule],
10+
})
11+
export class UserComponent {
12+
user: any;
13+
14+
constructor(private readonly route: ActivatedRoute) {
15+
this.user = this.route.snapshot.data['user'];
16+
}
17+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import {Injectable} from '@angular/core';
2+
import {Resolve, ActivatedRouteSnapshot} from '@angular/router';
3+
import {HttpClient} from '@angular/common/http';
4+
import {Observable} from 'rxjs';
5+
import {UserService} from './user.service';
6+
7+
@Injectable({providedIn: 'root'})
8+
export class UserResolver implements Resolve<any> {
9+
constructor(
10+
private readonly http: HttpClient,
11+
private readonly userService: UserService,
12+
) {}
13+
14+
resolve(route: ActivatedRouteSnapshot): Observable<any> {
15+
const id = route.paramMap.get('id');
16+
return this.userService.getUserById(id);
17+
}
18+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import {Injectable} from '@angular/core';
2+
import {Observable, of} from 'rxjs';
3+
4+
@Injectable({providedIn: 'root'})
5+
export class UserService {
6+
private readonly users = [
7+
{id: '123', name: 'John Doe', email: 'john.doe123@example.com'},
8+
{id: '124', name: 'Jane Smith', email: 'jane.smith124@example.com'},
9+
];
10+
11+
getUserById(id: string): Observable<any> {
12+
const user = this.users.find(u => u.id === id);
13+
return of(user);
14+
}
15+
}

projects/arc/src/app/app-routing.module.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const routes: Routes = [
1515
{
1616
path: 'main',
1717
loadChildren: () => import('./main/main.module').then(m => m.MainModule),
18+
data: {skipLink: true},
1819
canActivate: [AuthGuard],
1920
},
2021
{

projects/arc/src/app/app.module.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@ import {environment} from '../environments/environment';
1919
import {ThemeModule} from '@project-lib/theme/theme.module';
2020
import {OverlayModule} from '@angular/cdk/overlay';
2121
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
22-
import {GanttModule} from '@project-lib/components/index';
22+
import {BreadcrumbComponent, GanttModule} from '@project-lib/components/index';
2323
import {SelectModule} from '@project-lib/components/selector';
2424
import {HeaderComponent} from '@project-lib/components/header/header.component';
2525
import {SidebarComponent} from '@project-lib/components/sidebar/sidebar.component';
26+
import { UserComponent } from '@project-lib/components/user/user.component';
2627

2728
@NgModule({
2829
declarations: [AppComponent],
@@ -40,6 +41,8 @@ import {SidebarComponent} from '@project-lib/components/sidebar/sidebar.componen
4041
BrowserAnimationsModule,
4142
HeaderComponent,
4243
SidebarComponent,
44+
BreadcrumbComponent,
45+
UserComponent
4346
],
4447
providers: [
4548
TranslationService,

0 commit comments

Comments
 (0)