Skip to content

Commit b10cd1c

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

26 files changed

+690
-6
lines changed
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: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
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+
})
8+
export class UserTitleComponent {
9+
title: any;
10+
constructor(private readonly route: ActivatedRoute) {
11+
this.title = this.route.snapshot.data['document'];
12+
}
13+
}
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 {ActivatedRouteSnapshot} from '@angular/router';
3+
import {Observable} from 'rxjs';
4+
import {TitleService} from './user-title.service';
5+
6+
export interface Title {
7+
id: string;
8+
title: string;
9+
}
10+
@Injectable({providedIn: 'root'})
11+
export class TitleResolver {
12+
constructor(private readonly titleService: TitleService) {}
13+
14+
resolve(route: ActivatedRouteSnapshot): Observable<Title> {
15+
const id = route.paramMap.get('id');
16+
return this.titleService.getTitleById(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+
getTitleById(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: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import {Injectable} from '@angular/core';
2+
import {ActivatedRouteSnapshot} from '@angular/router';
3+
import {Observable} from 'rxjs';
4+
import {UserService} from './user.service';
5+
6+
export interface User {
7+
id: string;
8+
name: string;
9+
email: string;
10+
}
11+
@Injectable({providedIn: 'root'})
12+
export class UserResolver {
13+
constructor(private readonly userService: UserService) {}
14+
15+
resolve(route: ActivatedRouteSnapshot): Observable<User> {
16+
const id = route.paramMap.get('id');
17+
return this.userService.getUserById(id);
18+
}
19+
}
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+
}
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: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import {Component, Input, isDevMode, OnInit} from '@angular/core';
2+
import {CommonModule} from '@angular/common';
3+
import {Breadcrumb} from './breadcrumb.interface';
4+
import {BreadcrumbService} from './breadcrumb.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+
@Input() separatorClass = 'separator';
22+
@Input() itemClass = 'breadcrumb-item';
23+
24+
expanded = false;
25+
constructor(private readonly breadcrumbService: BreadcrumbService) {}
26+
ngOnInit(): void {
27+
this.breadcrumbs$.subscribe(breadcrumbs => {
28+
if (isDevMode()) {
29+
console.log('Breadcrumbs:', breadcrumbs);
30+
}
31+
});
32+
}
33+
toggleExpand() {
34+
this.expanded = true;
35+
}
36+
}
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 './breadcrumb.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/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: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ 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';
@@ -40,6 +40,7 @@ import {SidebarComponent} from '@project-lib/components/sidebar/sidebar.componen
4040
BrowserAnimationsModule,
4141
HeaderComponent,
4242
SidebarComponent,
43+
BreadcrumbComponent,
4344
],
4445
providers: [
4546
TranslationService,

0 commit comments

Comments
 (0)