From 3f9dda559bf692032c54bf100d24c4f771a3d10e Mon Sep 17 00:00:00 2001 From: vaibhav bhalla Date: Mon, 2 Jun 2025 22:20:21 +0530 Subject: [PATCH 1/3] feat(arc): implement breadcrumb feature GH-126 --- .../breadcrumb-demo/mock-data.constants.ts | 8 + .../breadcrumb-demo/user-title.interface.ts | 9 ++ .../user-title/user-title.component.html | 5 + .../user-title/user-title.component.ts | 14 ++ .../user-title/user-title.resolver.ts | 15 ++ .../user-title/user-title.service.ts | 13 ++ .../breadcrumb-demo/user/user.component.html | 8 + .../breadcrumb-demo/user/user.component.ts | 19 +++ .../breadcrumb-demo/user/user.resolver.ts | 15 ++ .../breadcrumb-demo/user/user.service.ts | 13 ++ .../breadcrumb/breadcrumb.component.html | 47 ++++++ .../breadcrumb/breadcrumb.component.scss | 44 ++++++ .../breadcrumb/breadcrumb.component.ts | 36 +++++ .../breadcrumb/breadcrumb.interface.ts | 5 + .../breadcrumb/breadcrumb.service.ts | 72 +++++++++ projects/arc-lib/src/lib/components/index.ts | 1 + projects/arc/src/app/app-routing.module.ts | 1 + projects/arc/src/app/app.module.ts | 3 +- .../bread-crumb-introduction.component.html | 119 ++++++++++++++ .../bread-crumb-introduction.component.scss | 149 ++++++++++++++++++ .../bread-crumb-introduction.component.ts | 65 ++++++++ .../app/main/constants/components.constant.ts | 4 + .../introduction-routing.module.ts | 5 + .../arc/src/app/main/main-routing.module.ts | 27 +++- projects/arc/src/app/main/main.component.html | 6 + projects/arc/src/app/main/main.module.ts | 7 + .../components/add-plan/add-plan.component.ts | 2 +- .../add-tenant/add-tenant.component.ts | 2 +- 28 files changed, 708 insertions(+), 6 deletions(-) create mode 100644 projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/mock-data.constants.ts create mode 100644 projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title.interface.ts create mode 100644 projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.component.html create mode 100644 projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.component.ts create mode 100644 projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.resolver.ts create mode 100644 projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.service.ts create mode 100644 projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user/user.component.html create mode 100644 projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user/user.component.ts create mode 100644 projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user/user.resolver.ts create mode 100644 projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user/user.service.ts create mode 100644 projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.component.html create mode 100644 projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.component.scss create mode 100644 projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.component.ts create mode 100644 projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.interface.ts create mode 100644 projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.service.ts create mode 100644 projects/arc/src/app/main/bread-crumb-introduction/bread-crumb-introduction.component.html create mode 100644 projects/arc/src/app/main/bread-crumb-introduction/bread-crumb-introduction.component.scss create mode 100644 projects/arc/src/app/main/bread-crumb-introduction/bread-crumb-introduction.component.ts diff --git a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/mock-data.constants.ts b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/mock-data.constants.ts new file mode 100644 index 00000000..99486c83 --- /dev/null +++ b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/mock-data.constants.ts @@ -0,0 +1,8 @@ +export const USERS = [ + {id: '123', name: 'John Doe', email: 'john.doe123@example.com'}, + {id: '124', name: 'Jane Smith', email: 'jane.smith124@example.com'}, +]; +export const TITLES = [ + {id: '1', title: 'Contract.pdf'}, + {id: '2', title: 'Appointment.pdf'}, +]; diff --git a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title.interface.ts b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title.interface.ts new file mode 100644 index 00000000..d5f24328 --- /dev/null +++ b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title.interface.ts @@ -0,0 +1,9 @@ +export interface User { + id: string; + name: string; + email: string; +} +export interface Title { + id: string; + title: string; +} diff --git a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.component.html b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.component.html new file mode 100644 index 00000000..0740b3a3 --- /dev/null +++ b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.component.html @@ -0,0 +1,5 @@ +
+

Documentation

+

ID:{{ title?.id }}

+

Title:{{ title?.title }}

+
diff --git a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.component.ts b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.component.ts new file mode 100644 index 00000000..72b497e5 --- /dev/null +++ b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.component.ts @@ -0,0 +1,14 @@ +import {Component} from '@angular/core'; +import {ActivatedRoute} from '@angular/router'; +import {Title} from '../user-title.interface'; + +@Component({ + selector: 'lib-user-title', + templateUrl: './user-title.component.html', +}) +export class UserTitleComponent { + title: Title; + constructor(private readonly route: ActivatedRoute) { + this.title = this.route.snapshot.data['document']; + } +} diff --git a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.resolver.ts b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.resolver.ts new file mode 100644 index 00000000..8f0cafcd --- /dev/null +++ b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.resolver.ts @@ -0,0 +1,15 @@ +import {Injectable} from '@angular/core'; +import {ActivatedRouteSnapshot} from '@angular/router'; +import {Observable} from 'rxjs'; +import {TitleService} from './user-title.service'; +import {Title} from '../user-title.interface'; + +@Injectable() +export class TitleResolver { + constructor(private readonly titleService: TitleService) {} + + resolve(route: ActivatedRouteSnapshot): Observable { + const id = route.paramMap.get('id'); + return this.titleService.getTitleById(id); + } +} diff --git a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.service.ts b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.service.ts new file mode 100644 index 00000000..3ffe5232 --- /dev/null +++ b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.service.ts @@ -0,0 +1,13 @@ +import {Injectable} from '@angular/core'; +import {Observable, of} from 'rxjs'; +import {TITLES} from '../mock-data.constants'; + +@Injectable() +export class TitleService { + private readonly titles = TITLES; + + getTitleById(id: string): Observable<any> { + const title = this.titles.find(u => u.id === id); + return of(title); + } +} diff --git a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user/user.component.html b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user/user.component.html new file mode 100644 index 00000000..e2b69192 --- /dev/null +++ b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user/user.component.html @@ -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> diff --git a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user/user.component.ts b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user/user.component.ts new file mode 100644 index 00000000..a9fb9172 --- /dev/null +++ b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user/user.component.ts @@ -0,0 +1,19 @@ +import {CommonModule} from '@angular/common'; +import {Component} from '@angular/core'; +import {ActivatedRoute, RouterModule} from '@angular/router'; +import {User} from '../user-title.interface'; +import {UserResolver} from './user.resolver'; + +@Component({ + selector: 'lib-user', + standalone: true, + templateUrl: './user.component.html', + imports: [CommonModule, RouterModule], +}) +export class UserComponent { + user: User; + + constructor(private readonly route: ActivatedRoute) { + this.user = this.route.snapshot.data['user']; + } +} diff --git a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user/user.resolver.ts b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user/user.resolver.ts new file mode 100644 index 00000000..0755fd9a --- /dev/null +++ b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user/user.resolver.ts @@ -0,0 +1,15 @@ +import {Injectable} from '@angular/core'; +import {ActivatedRouteSnapshot} from '@angular/router'; +import {Observable} from 'rxjs'; +import {UserService} from './user.service'; +import {User} from '../user-title.interface'; + +@Injectable() +export class UserResolver { + constructor(private readonly userService: UserService) {} + + resolve(route: ActivatedRouteSnapshot): Observable<User> { + const id = route.paramMap.get('id'); + return this.userService.getUserById(id); + } +} diff --git a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user/user.service.ts b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user/user.service.ts new file mode 100644 index 00000000..6fd2d486 --- /dev/null +++ b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user/user.service.ts @@ -0,0 +1,13 @@ +import {Injectable} from '@angular/core'; +import {Observable, of} from 'rxjs'; +import {USERS} from '../mock-data.constants'; + +@Injectable() +export class UserService { + private readonly users = USERS; + + getUserById(id: string): Observable<any> { + const user = this.users.find(u => u.id === id); + return of(user); + } +} diff --git a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.component.html b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.component.html new file mode 100644 index 00000000..f3c93e5e --- /dev/null +++ b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.component.html @@ -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> + </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> diff --git a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.component.scss b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.component.scss new file mode 100644 index 00000000..7611bbbb --- /dev/null +++ b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.component.scss @@ -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; + 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; + } +} diff --git a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.component.ts b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.component.ts new file mode 100644 index 00000000..b11eebe9 --- /dev/null +++ b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.component.ts @@ -0,0 +1,36 @@ +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} 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 = []; + @Input() separator = '>'; + @Input() maxItems = 8; + @Input() separatorClass = 'separator'; + @Input() itemClass = 'breadcrumb-item'; + + expanded = false; + constructor(private readonly breadcrumbService: BreadcrumbService) {} + ngOnInit(): void { + this.breadcrumbs$.subscribe(breadcrumbs => { + if (isDevMode()) { + console.log('Breadcrumbs:', breadcrumbs); + } + }); + } + toggleExpand() { + this.expanded = true; + } +} diff --git a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.interface.ts b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.interface.ts new file mode 100644 index 00000000..121c88ae --- /dev/null +++ b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.interface.ts @@ -0,0 +1,5 @@ +export interface Breadcrumb { + label: string; + url: string; + skipLink?: boolean; +} diff --git a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.service.ts b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.service.ts new file mode 100644 index 00000000..ce18475a --- /dev/null +++ b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.service.ts @@ -0,0 +1,72 @@ +import {Injectable} 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) { + this.router.events + .pipe(filter(event => event instanceof NavigationEnd)) + .subscribe(() => { + 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 || ''; + 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); + const skipLink = route.routeConfig.data?.['skipLink'] ?? false; + + if (label) { + breadcrumbs.push({label, url: nextUrl, skipLink}); + } + + return route.firstChild + ? this.buildBreadcrumbs(route.firstChild, nextUrl, breadcrumbs) + : breadcrumbs; + } + + private _toTitleCase(str: string): string { + return str.replace(/-/g, ' ').replace(/\b\w/g, char => char.toUpperCase()); + } + + private _resolveLabel(route: ActivatedRouteSnapshot, path: string): string { + const breadcrumbData = route.routeConfig.data?.['breadcrumb']; + + if (typeof breadcrumbData === 'function') { + return breadcrumbData(route.data, route.paramMap, route); + } + if (typeof breadcrumbData === 'string') { + return breadcrumbData; + } + if (route.routeConfig.path?.startsWith(':')) { + const paramName = route.routeConfig.path.slice(1); + return route.params[paramName] ?? paramName; + } + return this._toTitleCase(path); + } + + get breadcrumbs() { + return this.breadcrumbs$.asObservable(); + } +} diff --git a/projects/arc-lib/src/lib/components/index.ts b/projects/arc-lib/src/lib/components/index.ts index 6f21206f..5e46f739 100644 --- a/projects/arc-lib/src/lib/components/index.ts +++ b/projects/arc-lib/src/lib/components/index.ts @@ -4,3 +4,4 @@ export * from './gantt/gantt.module'; export * from './selector/select.module'; export * from './resize/resize.module'; export * from './list/list.component'; +export * from './breadcrumb/breadcrumb.component'; diff --git a/projects/arc/src/app/app-routing.module.ts b/projects/arc/src/app/app-routing.module.ts index 583bf642..b14ce947 100644 --- a/projects/arc/src/app/app-routing.module.ts +++ b/projects/arc/src/app/app-routing.module.ts @@ -15,6 +15,7 @@ const routes: Routes = [ { path: 'main', loadChildren: () => import('./main/main.module').then(m => m.MainModule), + data: {skipLink: true}, canActivate: [AuthGuard], }, { diff --git a/projects/arc/src/app/app.module.ts b/projects/arc/src/app/app.module.ts index 24ba47bb..8a19f7f1 100644 --- a/projects/arc/src/app/app.module.ts +++ b/projects/arc/src/app/app.module.ts @@ -19,7 +19,7 @@ import {environment} from '../environments/environment'; import {ThemeModule} from '@project-lib/theme/theme.module'; import {OverlayModule} from '@angular/cdk/overlay'; import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; -import {GanttModule} from '@project-lib/components/index'; +import {BreadcrumbComponent, GanttModule} from '@project-lib/components/index'; import {SelectModule} from '@project-lib/components/selector'; import {HeaderComponent} from '@project-lib/components/header/header.component'; import {SidebarComponent} from '@project-lib/components/sidebar/sidebar.component'; @@ -40,6 +40,7 @@ import {SidebarComponent} from '@project-lib/components/sidebar/sidebar.componen BrowserAnimationsModule, HeaderComponent, SidebarComponent, + BreadcrumbComponent, ], providers: [ TranslationService, diff --git a/projects/arc/src/app/main/bread-crumb-introduction/bread-crumb-introduction.component.html b/projects/arc/src/app/main/bread-crumb-introduction/bread-crumb-introduction.component.html new file mode 100644 index 00000000..0c63eb11 --- /dev/null +++ b/projects/arc/src/app/main/bread-crumb-introduction/bread-crumb-introduction.component.html @@ -0,0 +1,119 @@ +<div class="breadcrumb-section"> + <h3>1.Basic Breadcrumb</h3> + <ul class="breadcrumb"> + <li>Home</li> + <span class="separator">›</span> + <li>Users</li> + <span class="separator">›</span> + <li>John Doe</li> + <span class="separator">›</span> + <li>Document.pdf</li> + </ul> + <div class="code-block"> + <span + class="copy-icon" + (click)="copyCode(basicCode)" + title="Copy Routing Code" + >📋</span + > + <pre>{{ basicCode }}</pre> + </div> + + <h3>2.Dynamic Handling Breadcrumb</h3> + + <ul class="breadcrumb"> + <li>Main</li> + <span class="separator">›</span> + <li class="clickable" (click)="expanded = !expanded">...</li> + <span class="separator">›</span> + <li>Document.pdf</li> + </ul> + + <div *ngIf="expanded" class="expanded-breadcrumb"> + <ul class="breadcrumb"> + <li>Main</li> + <span class="separator">›</span> + <li>Users</li> + <span class="separator">›</span> + <li>John Doe</li> + <span class="separator">›</span> + <li>Document.pdf</li> + </ul> + </div> + <div class="button-group"> + <button class="colors" (click)="goToUser(123)">User/123</button> + <button class="colors" (click)="goToDocument(124, 1)"> + User/124/document/1 + </button> + </div> + <div class="code-container"> + <div class="code-block"> + <h3>Routing Code</h3> + <span + class="copy-icon" + (click)="copyCode(routingCode)" + title="Copy Routing Code" + >📋</span + > + <pre>{{ routingCode }}</pre> + </div> + + <div class="code-block"> + <h3>Resolver Code</h3> + <span + class="copy-icon" + (click)="copyCode(resolverCode)" + title="Copy Resolver Code" + >📋</span + > + <pre>{{ resolverCode }}</pre> + </div> + <div class="code-block"> + <h3>Service Code</h3> + <span + class="copy-icon" + (click)="copyCode(serviceCode)" + title="Copy Service Code" + >📋</span + > + <pre>{{ serviceCode }}</pre> + </div> + </div> + <h3>3. Breadcrumb Inputs</h3> + <p>See all available inputs for customization:</p> + + <table class="breadcrumb-api-table"> + <thead> + <tr> + <th>Input</th> + <th>Description</th> + </tr> + </thead> + <tbody> + <tr> + <td>staticBreadcrumbs</td> + <td>Static breadcrumb items</td> + </tr> + <tr> + <td>separator</td> + <td>Separator character/string</td> + </tr> + <tr> + <td>skipLink</td> + <td>A flag to disable click navigation on a breadcrumb</td> + </tr> + <tr> + <td>resolver</td> + <td>Angular service that pre-fetches data before routing</td> + </tr> + <tr> + <td>separatorClass</td> + <td>Custom class applied to the separator between breadcrumb items</td> + </tr> + <tr> + <td>itemClass</td> + <td>Custom class applied to each breadcrumb element.</td> + </tr> + </tbody> + </table> +</div> diff --git a/projects/arc/src/app/main/bread-crumb-introduction/bread-crumb-introduction.component.scss b/projects/arc/src/app/main/bread-crumb-introduction/bread-crumb-introduction.component.scss new file mode 100644 index 00000000..bd1fce85 --- /dev/null +++ b/projects/arc/src/app/main/bread-crumb-introduction/bread-crumb-introduction.component.scss @@ -0,0 +1,149 @@ +.breadcrumb-section { + padding: 1rem; + font-family: Arial, sans-serif; + background: #fff; + + h3 { + margin-top: 2rem; + margin-bottom: 0.5rem; + color: #333; + } + + .breadcrumb { + display: flex; + align-items: center; + flex-wrap: wrap; + list-style: none; + padding: 0; + margin-bottom: 1rem; + + li { + color: #555; + font-size: 14px; + + a { + color: #007bff; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + &.clickable { + cursor: pointer; + color: #007bff; + font-weight: bold; + } + + select { + padding: 2px 6px; + font-size: 14px; + } + } + + .separator { + margin: 0 8px; + color: #999; + } + } + + .expanded-breadcrumb { + margin-top: 0.5rem; + } +} +// example.component.scss + +.code-container { + display: flex; + gap: 2rem; + flex-wrap: wrap; + + // For smaller screens stack vertically + @media (max-width: 768px) { + flex-direction: column; + } +} + +.code-block { + position: relative; // needed for absolute positioning of icon + flex: 1 1 45%; + background-color: #f9f9f9; + padding: 1.5rem 1rem 1rem 1rem; // top padding extra for icon space + border-radius: 8px; + box-shadow: 0 0 6px rgba(0, 0, 0, 0.1); + + pre { + background-color: #eee; + padding: 1rem; + border-radius: 5px; + overflow-x: auto; + font-family: monospace; + white-space: pre-wrap; + word-break: break-word; + } + + .copy-icon { + position: absolute; + top: 0.5rem; + right: 0.5rem; + cursor: pointer; + font-size: 1.25rem; + color: #3f51b5; + user-select: none; + transition: color 0.3s ease; + + &:hover { + color: #303f9f; + } + + &:active { + color: #283593; + } + } +} +.button-group { + display: flex; + gap: 2rem; // adjust spacing as needed + margin-bottom: 1rem; +} +.colors { + background-color: #1976d2; // blue color + color: white; + padding: 0.5rem 1rem; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; + transition: background-color 0.3s ease; + + &:hover { + background-color: #1565c0; // darker on hover + } + + &:active { + background-color: #0d47a1; // even darker when clicked + } +} +.breadcrumb-api-table { + width: 100%; + border-collapse: collapse; + margin-top: 1rem; + font-size: 16px; /* Increase font size */ + font-weight: bold; /* Make all text bold */ + + th, + td { + border: 1px solid #ccc; + padding: 10px 14px; + text-align: left; + } + + th { + background-color: #f0f0f0; + } + + tr:nth-child(even) { + background-color: #f9f9f9; + } +} diff --git a/projects/arc/src/app/main/bread-crumb-introduction/bread-crumb-introduction.component.ts b/projects/arc/src/app/main/bread-crumb-introduction/bread-crumb-introduction.component.ts new file mode 100644 index 00000000..d5271ee7 --- /dev/null +++ b/projects/arc/src/app/main/bread-crumb-introduction/bread-crumb-introduction.component.ts @@ -0,0 +1,65 @@ +import {Component} from '@angular/core'; +import {Router} from '@angular/router'; +@Component({ + selector: 'arc-bread-crumb-introduction', + templateUrl: './bread-crumb-introduction.component.html', + styleUrls: ['./bread-crumb-introduction.component.scss'], +}) +export class BreadCrumbIntroductionComponent { + expanded = false; + constructor(private readonly router: Router) {} + basicCode = ` + <app-breadcrumb + [staticBreadcrumbs]="[ + {label: 'Home', url: '/home'}, + {label: 'Components', url: '/components'}, + {label: 'Arc Components', url: '/components/arc-comp'}]"> + </app-breadcrumb>`; + routingCode = ` +{ + path: 'user/:id', + component: UserComponent, + resolve: { user: UserResolver }, + data: { + breadcrumb: (data: any, params: any) => + data.user?.name ?? \`User #\${params.get('id')}\` + }, + children: [ + { + path: 'document/:id', + component: UserTitleComponent, + resolve: { document: TitleResolver }, + data: { + breadcrumb: (data: any, params: any) => + data.document?.title ?? \`Document #\${params.get('id')}\` + } + } + ] +} + `; + resolverCode = ` +resolve(route: ActivatedRouteSnapshot): Observable<any> { + const id = route.paramMap.get('id'); + return this.userService.getUserById(id); +} + `; + serviceCode = `private readonly users = [ + { id: '123', name: 'John Doe', email: 'john.doe123@example.com' }, + { id: '124', name: 'Jane Smith', email: 'jane.smith124@example.com' } + ]; + + getUserById(id: string): Observable<any> { + const user = this.users.find(u => u.id === id); + return of(user); + }`; + + copyCode(text: string) { + navigator.clipboard.writeText(text); + } + goToUser(userId: number) { + this.router.navigate(['/main/user', userId]); + } + goToDocument(userId: number, documentId: number) { + this.router.navigate(['/main/user', userId, 'document', documentId]); + } +} diff --git a/projects/arc/src/app/main/constants/components.constant.ts b/projects/arc/src/app/main/constants/components.constant.ts index ddf3eb1d..57fc8e0e 100644 --- a/projects/arc/src/app/main/constants/components.constant.ts +++ b/projects/arc/src/app/main/constants/components.constant.ts @@ -34,6 +34,10 @@ export const COMPONENTS_ITEMS = [ pathMatch: 'prefix', image: '../../../assets/images/components/Rectangle 37.svg', }, + { + title: 'Breadcrumb', + link: '/main/components/arc-comp/breadcrumb', + }, { title: 'List', link: '/main/components/arc-comp', diff --git a/projects/arc/src/app/main/introduction/introduction-routing.module.ts b/projects/arc/src/app/main/introduction/introduction-routing.module.ts index cc3dc0f4..ca0e3f54 100644 --- a/projects/arc/src/app/main/introduction/introduction-routing.module.ts +++ b/projects/arc/src/app/main/introduction/introduction-routing.module.ts @@ -1,12 +1,17 @@ import {NgModule} from '@angular/core'; import {RouterModule, Routes} from '@angular/router'; import {IntroductionComponent} from './introduction.component'; +import {BreadCrumbIntroductionComponent} from '../bread-crumb-introduction/bread-crumb-introduction.component'; const routes: Routes = [ { path: '', component: IntroductionComponent, }, + { + path: 'breadcrumb', + component: BreadCrumbIntroductionComponent, + }, ]; @NgModule({ imports: [RouterModule.forChild(routes)], diff --git a/projects/arc/src/app/main/main-routing.module.ts b/projects/arc/src/app/main/main-routing.module.ts index 92baaa87..5b438395 100644 --- a/projects/arc/src/app/main/main-routing.module.ts +++ b/projects/arc/src/app/main/main-routing.module.ts @@ -1,7 +1,10 @@ import {NgModule} from '@angular/core'; import {RouterModule, Routes} from '@angular/router'; import {MainComponent} from './main.component'; -import {IntroductionComponent} from './introduction/introduction.component'; +import {UserComponent} from '@project-lib/components/breadcrumb/breadcrumb-demo/user/user.component'; +import {UserResolver} from '@project-lib/components/breadcrumb/breadcrumb-demo/user/user.resolver'; +import {UserTitleComponent} from '@project-lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.component'; +import {TitleResolver} from '@project-lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.resolver'; const routes: Routes = [ { @@ -15,7 +18,6 @@ const routes: Routes = [ }, { path: 'components', - component: IntroductionComponent, children: [ { path: 'nebular-comp', @@ -33,10 +35,29 @@ const routes: Routes = [ }, ], }, + { + path: 'user/:id', + component: UserComponent, + resolve: {user: UserResolver}, + data: { + breadcrumb: (data: any, params: any) => + data.user?.name ?? `User #${params.get('id')}`, + }, + children: [ + { + path: 'document/:id', + component: UserTitleComponent, + resolve: {document: TitleResolver}, + data: { + breadcrumb: (data: any, params: any) => + data.document?.title ?? `Document #${params.get('id')}`, + }, + }, + ], + }, ], }, ]; - @NgModule({ imports: [RouterModule.forChild(routes)], exports: [RouterModule], diff --git a/projects/arc/src/app/main/main.component.html b/projects/arc/src/app/main/main.component.html index 89a6b3f1..18cd6009 100644 --- a/projects/arc/src/app/main/main.component.html +++ b/projects/arc/src/app/main/main.component.html @@ -26,6 +26,12 @@ </nb-sidebar> <nb-layout-column> + <app-breadcrumb + [separator]="'/'" + [maxItems]="2" + [itemClass]="'breadcrumb-item'" + [separatorClass]="'separator'" + ></app-breadcrumb> <router-outlet class="main-router"></router-outlet> </nb-layout-column> </nb-layout> diff --git a/projects/arc/src/app/main/main.module.ts b/projects/arc/src/app/main/main.module.ts index e87733c2..c439b9f0 100644 --- a/projects/arc/src/app/main/main.module.ts +++ b/projects/arc/src/app/main/main.module.ts @@ -5,6 +5,11 @@ import {MainComponent} from './main.component'; import {ThemeModule} from '@project-lib/theme/theme.module'; import {HeaderComponent} from '@project-lib/components/header/header.component'; import {SidebarComponent} from '@project-lib/components/sidebar/sidebar.component'; +import {BreadcrumbComponent} from '@project-lib/components/index'; +import {UserResolver} from '@project-lib/components/breadcrumb/breadcrumb-demo/user/user.resolver'; +import {TitleResolver} from '@project-lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.resolver'; +import {UserService} from '@project-lib/components/breadcrumb/breadcrumb-demo/user/user.service'; +import {TitleService} from '@project-lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.service'; @NgModule({ declarations: [MainComponent], @@ -14,6 +19,8 @@ import {SidebarComponent} from '@project-lib/components/sidebar/sidebar.componen ThemeModule, HeaderComponent, SidebarComponent, + BreadcrumbComponent, ], + providers: [UserResolver, TitleResolver, UserService, TitleService], }) export class MainModule {} diff --git a/projects/saas-ui/src/app/main/components/add-plan/add-plan.component.ts b/projects/saas-ui/src/app/main/components/add-plan/add-plan.component.ts index b9267394..83d382e0 100644 --- a/projects/saas-ui/src/app/main/components/add-plan/add-plan.component.ts +++ b/projects/saas-ui/src/app/main/components/add-plan/add-plan.component.ts @@ -309,7 +309,7 @@ export class AddPlanComponent implements OnInit { updateFeatureDetails, this.activateRoute.snapshot.params.id, ) - .subscribe(respFeature => {}); + .subscribe(); } else { // Handle form validation errors if necessary console.error('Form is invalid'); diff --git a/projects/saas-ui/src/app/on-boarding/components/add-tenant/add-tenant.component.ts b/projects/saas-ui/src/app/on-boarding/components/add-tenant/add-tenant.component.ts index c17723e5..35f82923 100644 --- a/projects/saas-ui/src/app/on-boarding/components/add-tenant/add-tenant.component.ts +++ b/projects/saas-ui/src/app/on-boarding/components/add-tenant/add-tenant.component.ts @@ -9,7 +9,7 @@ import { Lead } from '../../../shared/models'; import { BillingPlanService } from '../../../shared/services/billing-plan-service'; import { OnBoardingService } from '../../../shared/services/on-boarding-service'; -declare var Stripe: any; +declare let Stripe: any; @Component({ selector: 'app-add-tenant', From c57160d6d77d7e5183776f50a4014b96b3d39586 Mon Sep 17 00:00:00 2001 From: Vaibhav Bhalla <vaibhav.bhalla@SFSupports-MacBook-Air.local> Date: Mon, 16 Jun 2025 16:08:18 +0530 Subject: [PATCH 2/3] feat(arc): implement breadcrumb feature GH-126 --- package-lock.json | 3 +- .../breadcrumb-demo/mock-data.constants.ts | 6 +- .../breadcrumb-demo/user-title.interface.ts | 4 +- .../user-title/user-title.component.ts | 17 +++- .../user-title/user-title.resolver.ts | 15 --- .../user-title/user-title.service.ts | 14 ++- .../breadcrumb-demo/user/user.component.ts | 18 +++- .../breadcrumb-demo/user/user.resolver.ts | 15 --- .../breadcrumb-demo/user/user.service.ts | 14 ++- .../breadcrumb/breadcrumb.component.ts | 11 ++- .../breadcrumb/breadcrumb.service.ts | 65 ++++++++++--- projects/arc/src/app/app.module.ts | 4 + .../bread-crumb-introduction.component.html | 33 ++++--- .../bread-crumb-introduction.component.ts | 93 ++++++++++++------- .../arc/src/app/main/main-routing.module.ts | 25 +++-- projects/arc/src/app/main/main.module.ts | 5 - .../add-tenant/add-tenant.component.ts | 67 +++++++------ 17 files changed, 255 insertions(+), 154 deletions(-) delete mode 100644 projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.resolver.ts delete mode 100644 projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user/user.resolver.ts diff --git a/package-lock.json b/package-lock.json index 805dc9d7..3fdfd55c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "@nebular/theme": "^11.0.0", "@ngx-translate/core": "^14.0.0", "@ngx-translate/http-loader": "^7.0.0", - "@stripe/stripe-js": "^4.9.0", + "@stripe/stripe-js": "^4.10.0", "@types/lodash": "^4.14.194", "ag-grid-angular": "^31.3.1", "ag-grid-community": "^31.3.1", @@ -5036,6 +5036,7 @@ "version": "4.10.0", "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-4.10.0.tgz", "integrity": "sha512-KrMOL+sH69htCIXCaZ4JluJ35bchuCCznyPyrbN8JXSGQfwBI1SuIEMZNwvy8L8ykj29t6sa5BAAiL7fNoLZ8A==", + "license": "MIT", "engines": { "node": ">=12.16" } diff --git a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/mock-data.constants.ts b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/mock-data.constants.ts index 99486c83..e6d2acc8 100644 --- a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/mock-data.constants.ts +++ b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/mock-data.constants.ts @@ -1,8 +1,10 @@ -export const USERS = [ +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 = [ +export const TITLES: TitleDetails[] = [ {id: '1', title: 'Contract.pdf'}, {id: '2', title: 'Appointment.pdf'}, ]; diff --git a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title.interface.ts b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title.interface.ts index d5f24328..683c23c1 100644 --- a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title.interface.ts +++ b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title.interface.ts @@ -1,9 +1,9 @@ -export interface User { +export interface UserDetails { id: string; name: string; email: string; } -export interface Title { +export interface TitleDetails { id: string; title: string; } diff --git a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.component.ts b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.component.ts index 72b497e5..b3165168 100644 --- a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.component.ts +++ b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.component.ts @@ -1,14 +1,23 @@ import {Component} from '@angular/core'; import {ActivatedRoute} from '@angular/router'; -import {Title} from '../user-title.interface'; +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: Title; - constructor(private readonly route: ActivatedRoute) { - this.title = this.route.snapshot.data['document']; + 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; + }); + } } } diff --git a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.resolver.ts b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.resolver.ts deleted file mode 100644 index 8f0cafcd..00000000 --- a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.resolver.ts +++ /dev/null @@ -1,15 +0,0 @@ -import {Injectable} from '@angular/core'; -import {ActivatedRouteSnapshot} from '@angular/router'; -import {Observable} from 'rxjs'; -import {TitleService} from './user-title.service'; -import {Title} from '../user-title.interface'; - -@Injectable() -export class TitleResolver { - constructor(private readonly titleService: TitleService) {} - - resolve(route: ActivatedRouteSnapshot): Observable<Title> { - const id = route.paramMap.get('id'); - return this.titleService.getTitleById(id); - } -} diff --git a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.service.ts b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.service.ts index 3ffe5232..1ad4de5b 100644 --- a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.service.ts +++ b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.service.ts @@ -1,13 +1,19 @@ -import {Injectable} from '@angular/core'; -import {Observable, of} from 'rxjs'; +import {catchError, delay, map, Observable, of} from 'rxjs'; import {TITLES} from '../mock-data.constants'; +import {TitleDetails} from '../user-title.interface'; -@Injectable() export class TitleService { private readonly titles = TITLES; - getTitleById(id: string): Observable<any> { + 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 + ); + } } diff --git a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user/user.component.ts b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user/user.component.ts index a9fb9172..0b6f83ac 100644 --- a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user/user.component.ts +++ b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user/user.component.ts @@ -1,8 +1,8 @@ import {CommonModule} from '@angular/common'; import {Component} from '@angular/core'; import {ActivatedRoute, RouterModule} from '@angular/router'; -import {User} from '../user-title.interface'; -import {UserResolver} from './user.resolver'; +import {UserDetails} from '../user-title.interface'; +import {UserService} from './user.service'; @Component({ selector: 'lib-user', @@ -11,9 +11,17 @@ import {UserResolver} from './user.resolver'; imports: [CommonModule, RouterModule], }) export class UserComponent { - user: User; + user: UserDetails; - constructor(private readonly route: ActivatedRoute) { - this.user = this.route.snapshot.data['user']; + 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; + }); + } } } diff --git a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user/user.resolver.ts b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user/user.resolver.ts deleted file mode 100644 index 0755fd9a..00000000 --- a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user/user.resolver.ts +++ /dev/null @@ -1,15 +0,0 @@ -import {Injectable} from '@angular/core'; -import {ActivatedRouteSnapshot} from '@angular/router'; -import {Observable} from 'rxjs'; -import {UserService} from './user.service'; -import {User} from '../user-title.interface'; - -@Injectable() -export class UserResolver { - constructor(private readonly userService: UserService) {} - - resolve(route: ActivatedRouteSnapshot): Observable<User> { - const id = route.paramMap.get('id'); - return this.userService.getUserById(id); - } -} diff --git a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user/user.service.ts b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user/user.service.ts index 6fd2d486..4dd22776 100644 --- a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user/user.service.ts +++ b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user/user.service.ts @@ -1,13 +1,19 @@ -import {Injectable} from '@angular/core'; -import {Observable, of} from 'rxjs'; +import {catchError, delay, map, Observable, of} from 'rxjs'; import {USERS} from '../mock-data.constants'; +import {UserDetails} from '../user-title.interface'; -@Injectable() export class UserService { private readonly users = USERS; - getUserById(id: string): Observable<any> { + 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), + ); + } } diff --git a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.component.ts b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.component.ts index b11eebe9..58b50808 100644 --- a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.component.ts +++ b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.component.ts @@ -2,7 +2,7 @@ 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} from 'rxjs'; +import {Observable, Subject, takeUntil} from 'rxjs'; import {RouterModule} from '@angular/router'; @Component({ @@ -22,15 +22,20 @@ export class BreadcrumbComponent implements OnInit { @Input() itemClass = 'breadcrumb-item'; expanded = false; + private destroy$ = new Subject<void>(); constructor(private readonly breadcrumbService: BreadcrumbService) {} ngOnInit(): void { - this.breadcrumbs$.subscribe(breadcrumbs => { + 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 = true; + this.expanded = !this.expanded; } } diff --git a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.service.ts b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.service.ts index ce18475a..bab368a0 100644 --- a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.service.ts +++ b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.service.ts @@ -1,4 +1,4 @@ -import {Injectable} from '@angular/core'; +import {Injectable, Injector} from '@angular/core'; import {ActivatedRouteSnapshot, NavigationEnd, Router} from '@angular/router'; import {BehaviorSubject} from 'rxjs'; import {filter} from 'rxjs/operators'; @@ -8,10 +8,13 @@ import {Breadcrumb} from './breadcrumb.interface'; export class BreadcrumbService { private readonly breadcrumbs$ = new BehaviorSubject<Breadcrumb[]>([]); - constructor(private readonly router: Router) { + constructor( + private readonly router: Router, + private readonly injector: Injector, + ) { this.router.events .pipe(filter(event => event instanceof NavigationEnd)) - .subscribe(() => { + .subscribe(async () => { const root = this.router.routerState.snapshot.root; const breadcrumbs = this.buildBreadcrumbs(root); this.breadcrumbs$.next(breadcrumbs); @@ -30,14 +33,16 @@ export class BreadcrumbService { } 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); + const label = this._resolveLabel(route, path, nextUrl); const skipLink = route.routeConfig.data?.['skipLink'] ?? false; - if (label) { + if (label && label.trim() !== '') { breadcrumbs.push({label, url: nextUrl, skipLink}); } @@ -50,22 +55,54 @@ export class BreadcrumbService { return str.replace(/-/g, ' ').replace(/\b\w/g, char => char.toUpperCase()); } - private _resolveLabel(route: ActivatedRouteSnapshot, path: string): string { - const breadcrumbData = route.routeConfig.data?.['breadcrumb']; + 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'); + const fallback = + asyncConfig.fallbackLabel?.(params) || this._toTitleCase(path); + const loadingLabel = asyncConfig.loadingLabel || fallback; - if (typeof breadcrumbData === 'function') { - return breadcrumbData(route.data, route.paramMap, route); + setTimeout(async () => { + try { + const serviceInstance = this.injector.get(asyncConfig.service); + const result$ = serviceInstance[asyncConfig.method](paramValue); + 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; } - if (route.routeConfig.path?.startsWith(':')) { - const paramName = route.routeConfig.path.slice(1); - return route.params[paramName] ?? paramName; - } + return this._toTitleCase(path); } - + 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(); } diff --git a/projects/arc/src/app/app.module.ts b/projects/arc/src/app/app.module.ts index 8a19f7f1..ef317d56 100644 --- a/projects/arc/src/app/app.module.ts +++ b/projects/arc/src/app/app.module.ts @@ -23,6 +23,8 @@ import {BreadcrumbComponent, GanttModule} from '@project-lib/components/index'; import {SelectModule} from '@project-lib/components/selector'; import {HeaderComponent} from '@project-lib/components/header/header.component'; import {SidebarComponent} from '@project-lib/components/sidebar/sidebar.component'; +import {UserService} from '@project-lib/components/breadcrumb/breadcrumb-demo/user/user.service'; +import {TitleService} from '@project-lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.service'; @NgModule({ declarations: [AppComponent], @@ -49,6 +51,8 @@ import {SidebarComponent} from '@project-lib/components/sidebar/sidebar.componen TranslateStore, SystemStoreFacadeService, EnvAdapterService, + UserService, + TitleService, ApiService, { provide: APP_CONFIG, diff --git a/projects/arc/src/app/main/bread-crumb-introduction/bread-crumb-introduction.component.html b/projects/arc/src/app/main/bread-crumb-introduction/bread-crumb-introduction.component.html index 0c63eb11..04f96af9 100644 --- a/projects/arc/src/app/main/bread-crumb-introduction/bread-crumb-introduction.component.html +++ b/projects/arc/src/app/main/bread-crumb-introduction/bread-crumb-introduction.component.html @@ -19,7 +19,17 @@ <h3>1.Basic Breadcrumb</h3> <pre>{{ basicCode }}</pre> </div> - <h3>2.Dynamic Handling Breadcrumb</h3> + <h3>2. Dynamic Handling Breadcrumb</h3> + <p> + This example demonstrates how breadcrumbs can be dynamically generated and + updated based on route parameters and asynchronous data fetching from + services. Instead of relying on blocking resolvers, the breadcrumb labels + are initially displayed using fallback or loading placeholders (e.g., + "Loading user...") and are automatically updated when the actual data + becomes available from the API or service. This approach ensures smooth + navigation and improves user experience by preventing full page loading + delays while keeping the breadcrumb context accurate and dynamic. + </p> <ul class="breadcrumb"> <li>Main</li> @@ -59,24 +69,25 @@ <h3>Routing Code</h3> </div> <div class="code-block"> - <h3>Resolver Code</h3> + <h3>Service Code</h3> <span class="copy-icon" - (click)="copyCode(resolverCode)" - title="Copy Resolver Code" + (click)="copyCode(serviceCode)" + title="Copy Service Code" >📋</span > - <pre>{{ resolverCode }}</pre> + <pre>{{ serviceCode }}</pre> </div> + <div class="code-block"> - <h3>Service Code</h3> + <h3>Async Breadcrumb Logic</h3> <span class="copy-icon" - (click)="copyCode(serviceCode)" - title="Copy Service Code" + (click)="copyCode(asyncLogicCode)" + title="Copy Async Logic Code" >📋</span > - <pre>{{ serviceCode }}</pre> + <pre>{{ asyncLogicCode }}</pre> </div> </div> <h3>3. Breadcrumb Inputs</h3> @@ -102,10 +113,6 @@ <h3>3. Breadcrumb Inputs</h3> <td>skipLink</td> <td>A flag to disable click navigation on a breadcrumb</td> </tr> - <tr> - <td>resolver</td> - <td>Angular service that pre-fetches data before routing</td> - </tr> <tr> <td>separatorClass</td> <td>Custom class applied to the separator between breadcrumb items</td> diff --git a/projects/arc/src/app/main/bread-crumb-introduction/bread-crumb-introduction.component.ts b/projects/arc/src/app/main/bread-crumb-introduction/bread-crumb-introduction.component.ts index d5271ee7..4607c123 100644 --- a/projects/arc/src/app/main/bread-crumb-introduction/bread-crumb-introduction.component.ts +++ b/projects/arc/src/app/main/bread-crumb-introduction/bread-crumb-introduction.component.ts @@ -1,9 +1,12 @@ -import {Component} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {Component, NgModule} from '@angular/core'; import {Router} from '@angular/router'; @Component({ selector: 'arc-bread-crumb-introduction', templateUrl: './bread-crumb-introduction.component.html', styleUrls: ['./bread-crumb-introduction.component.scss'], + standalone: true, + imports: [CommonModule], }) export class BreadCrumbIntroductionComponent { expanded = false; @@ -16,41 +19,67 @@ export class BreadCrumbIntroductionComponent { {label: 'Arc Components', url: '/components/arc-comp'}]"> </app-breadcrumb>`; routingCode = ` -{ - path: 'user/:id', - component: UserComponent, - resolve: { user: UserResolver }, - data: { - breadcrumb: (data: any, params: any) => - data.user?.name ?? \`User #\${params.get('id')}\` - }, - children: [ - { - path: 'document/:id', - component: UserTitleComponent, - resolve: { document: TitleResolver }, - data: { - breadcrumb: (data: any, params: any) => - data.document?.title ?? \`Document #\${params.get('id')}\` - } - } - ] -} + { + path: 'user/:id', + component: UserComponent, + data: { + asyncBreadcrumb: { + service: UserService, + method: 'getUserNameForBreadcrumb', + fallbackLabel: (params: ParamMap) => \`User #\${params.get('id')}\`, + loadingLabel: 'Loading user...', + }, + }, + children: [ + { + path: 'document/:id', + component: UserTitleComponent, + data: { + asyncBreadcrumb: { + service: TitleService, + method: 'getTitleNameForBreadcrumb', + fallbackLabel: (params: ParamMap) => + \`Document #\${params.get('id')}\`, + loadingLabel: 'Loading document...', + }, + }, + }, + ], + }, `; - resolverCode = ` -resolve(route: ActivatedRouteSnapshot): Observable<any> { - const id = route.paramMap.get('id'); - return this.userService.getUserById(id); -} + asyncLogicCode = ` + const asyncConfig = data?.asyncBreadcrumb; + if (asyncConfig?.service && asyncConfig?.method) { + const params = route.paramMap; + const paramValue = params.get('id'); + const fallback = + asyncConfig.fallbackLabel?.(params) || this._toTitleCase(path); + const loadingLabel = asyncConfig.loadingLabel || fallback; + + setTimeout(async () => { + try { + const serviceInstance = this.injector.get(asyncConfig.service); + const result$ = serviceInstance[asyncConfig.method](paramValue); + const result = await result$.toPromise(); + this.updateBreadcrumbLabel(currentUrl, result); + } catch (error) { + console.warn('Async breadcrumb load failed:', error); + } + }, 0); + + return loadingLabel; + } `; - serviceCode = `private readonly users = [ - { id: '123', name: 'John Doe', email: 'john.doe123@example.com' }, - { id: '124', name: 'Jane Smith', email: 'jane.smith124@example.com' } - ]; - - getUserById(id: string): Observable<any> { + serviceCode = ` + 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}\`)), + ); }`; copyCode(text: string) { diff --git a/projects/arc/src/app/main/main-routing.module.ts b/projects/arc/src/app/main/main-routing.module.ts index 5b438395..cb8a4cfc 100644 --- a/projects/arc/src/app/main/main-routing.module.ts +++ b/projects/arc/src/app/main/main-routing.module.ts @@ -1,10 +1,10 @@ import {NgModule} from '@angular/core'; -import {RouterModule, Routes} from '@angular/router'; +import {ParamMap, RouterModule, Routes} from '@angular/router'; import {MainComponent} from './main.component'; import {UserComponent} from '@project-lib/components/breadcrumb/breadcrumb-demo/user/user.component'; -import {UserResolver} from '@project-lib/components/breadcrumb/breadcrumb-demo/user/user.resolver'; import {UserTitleComponent} from '@project-lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.component'; -import {TitleResolver} from '@project-lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.resolver'; +import {UserService} from '@project-lib/components/breadcrumb/breadcrumb-demo/user/user.service'; +import {TitleService} from '@project-lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.service'; const routes: Routes = [ { @@ -38,19 +38,26 @@ const routes: Routes = [ { path: 'user/:id', component: UserComponent, - resolve: {user: UserResolver}, data: { - breadcrumb: (data: any, params: any) => - data.user?.name ?? `User #${params.get('id')}`, + asyncBreadcrumb: { + service: UserService, + method: 'getUserNameForBreadcrumb', + fallbackLabel: (params: ParamMap) => `User #${params.get('id')}`, + loadingLabel: 'Loading user...', + }, }, children: [ { path: 'document/:id', component: UserTitleComponent, - resolve: {document: TitleResolver}, data: { - breadcrumb: (data: any, params: any) => - data.document?.title ?? `Document #${params.get('id')}`, + asyncBreadcrumb: { + service: TitleService, + method: 'getTitleNameForBreadcrumb', + fallbackLabel: (params: ParamMap) => + `Document #${params.get('id')}`, + loadingLabel: 'Loading document...', + }, }, }, ], diff --git a/projects/arc/src/app/main/main.module.ts b/projects/arc/src/app/main/main.module.ts index c439b9f0..fadab0ef 100644 --- a/projects/arc/src/app/main/main.module.ts +++ b/projects/arc/src/app/main/main.module.ts @@ -6,10 +6,6 @@ import {ThemeModule} from '@project-lib/theme/theme.module'; import {HeaderComponent} from '@project-lib/components/header/header.component'; import {SidebarComponent} from '@project-lib/components/sidebar/sidebar.component'; import {BreadcrumbComponent} from '@project-lib/components/index'; -import {UserResolver} from '@project-lib/components/breadcrumb/breadcrumb-demo/user/user.resolver'; -import {TitleResolver} from '@project-lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.resolver'; -import {UserService} from '@project-lib/components/breadcrumb/breadcrumb-demo/user/user.service'; -import {TitleService} from '@project-lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.service'; @NgModule({ declarations: [MainComponent], @@ -21,6 +17,5 @@ import {TitleService} from '@project-lib/components/breadcrumb/breadcrumb-demo/u SidebarComponent, BreadcrumbComponent, ], - providers: [UserResolver, TitleResolver, UserService, TitleService], }) export class MainModule {} diff --git a/projects/saas-ui/src/app/on-boarding/components/add-tenant/add-tenant.component.ts b/projects/saas-ui/src/app/on-boarding/components/add-tenant/add-tenant.component.ts index 35f82923..581ffdc5 100644 --- a/projects/saas-ui/src/app/on-boarding/components/add-tenant/add-tenant.component.ts +++ b/projects/saas-ui/src/app/on-boarding/components/add-tenant/add-tenant.component.ts @@ -1,15 +1,21 @@ -import { Location } from '@angular/common'; -import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core'; -import { FormBuilder, FormGroup, Validators } from '@angular/forms'; -import { ActivatedRoute, Router } from '@angular/router'; -import { NbToastrService } from '@nebular/theme'; -import { AnyObject } from '@project-lib/core/api'; -import { environment } from 'projects/saas-ui/src/environment'; -import { Lead } from '../../../shared/models'; -import { BillingPlanService } from '../../../shared/services/billing-plan-service'; -import { OnBoardingService } from '../../../shared/services/on-boarding-service'; - -declare let Stripe: any; +import {Location} from '@angular/common'; +import { + AfterViewInit, + Component, + ElementRef, + OnInit, + ViewChild, +} from '@angular/core'; +import {FormBuilder, FormGroup, Validators} from '@angular/forms'; +import {ActivatedRoute, Router} from '@angular/router'; +import {NbToastrService} from '@nebular/theme'; +import {AnyObject} from '@project-lib/core/api'; +import {environment} from 'projects/saas-ui/src/environment'; +import {Lead} from '../../../shared/models'; +import {BillingPlanService} from '../../../shared/services/billing-plan-service'; +import {OnBoardingService} from '../../../shared/services/on-boarding-service'; +import {Stripe} from '@stripe/stripe-js'; +declare let Stripe: (key: string) => Stripe; @Component({ selector: 'app-add-tenant', @@ -36,11 +42,18 @@ export class AddTenantComponent implements OnInit, AfterViewInit { private billingPlanService: BillingPlanService, ) { this.addTenantForm = this.fb.group({ - key: ['', [Validators.required, Validators.maxLength(10), Validators.pattern('^[a-zA-Z][a-zA-Z0-9]*$')]], + key: [ + '', + [ + Validators.required, + Validators.maxLength(10), + Validators.pattern('^[a-zA-Z][a-zA-Z0-9]*$'), + ], + ], domains: [''], - planId: [null, Validators.required], // Mark planId as required - paymentMethod: ['payment_source'], // Specify Stripe as payment method - paymentToken: ['', Validators.required] // New FormControl for Stripe token + planId: [null, Validators.required], // Mark planId as required + paymentMethod: ['payment_source'], // Specify Stripe as payment method + paymentToken: ['', Validators.required], // New FormControl for Stripe token }); } @@ -67,14 +80,14 @@ export class AddTenantComponent implements OnInit, AfterViewInit { fontSmoothing: 'antialiased', fontSize: '16px', '::placeholder': { - color: '#aab7c4' - } + color: '#aab7c4', + }, }, invalid: { color: '#fa755a', - iconColor: '#fa755a' - } - } + iconColor: '#fa755a', + }, + }, }); this.cardElement.mount(this.cardNumberElement.nativeElement); @@ -83,7 +96,7 @@ export class AddTenantComponent implements OnInit, AfterViewInit { this.cardElement.on('change', (event: any) => { if (event.error) { this.toastrService.danger(event.error.message, 'Error'); - this.addTenantForm.get('paymentToken')?.setValue(''); // Clear paymentToken on error + this.addTenantForm.get('paymentToken')?.setValue(''); // Clear paymentToken on error } else if (event.complete) { this.generateStripeToken(); } @@ -92,7 +105,7 @@ export class AddTenantComponent implements OnInit, AfterViewInit { // Generate token and set it to the form when payment details are complete async generateStripeToken() { - const { token, error } = await this.stripe.createToken(this.cardElement); + const {token, error} = await this.stripe.createToken(this.cardElement); if (error) { this.toastrService.danger(error.message, 'Error'); } else { @@ -115,7 +128,7 @@ export class AddTenantComponent implements OnInit, AfterViewInit { this.onboardingService.addTenant(domainData, this.leadId).subscribe( () => this.router.navigate(['/tenant/registration/complete']), - error => this.toastrService.danger('Registration failed', 'Error') + error => this.toastrService.danger('Registration failed', 'Error'), ); } } @@ -128,13 +141,15 @@ export class AddTenantComponent implements OnInit, AfterViewInit { }, error => { this.toastrService.danger('Failed to fetch lead data', 'Error'); - } + }, ); } updateDomainFromEmail() { if (this.leadData && this.leadData.email) { - const emailDomain = this.leadData.email?.substring(this.leadData.email.lastIndexOf('@') + 1); + const emailDomain = this.leadData.email?.substring( + this.leadData.email.lastIndexOf('@') + 1, + ); if (emailDomain) { this.addTenantForm.get('domains').setValue(emailDomain); } From f6ba77b7e1fafbae63756b28a4e304e258a2fde7 Mon Sep 17 00:00:00 2001 From: Vaibhav Bhalla <vaibhav.bhalla@SFSupports-MacBook-Air.local> Date: Wed, 2 Jul 2025 14:40:08 +0530 Subject: [PATCH 3/3] feat(arc): implement breadcrumb feature GH-126 --- .../user-title/user-title.service.ts | 7 +- .../breadcrumb-demo/user/user.service.ts | 3 +- .../breadcrumb/breadcrumb.component.html | 152 +++++++++++++----- .../breadcrumb/breadcrumb.component.scss | 48 +++++- .../breadcrumb/breadcrumb.component.ts | 15 +- .../breadcrumb/breadcrumb.interface.ts | 1 + .../breadcrumb/breadcrumb.service.ts | 48 ++++-- projects/arc/src/app/app.module.ts | 4 + .../bread-crumb-introduction.component.html | 73 ++++++++- .../bread-crumb-introduction.component.ts | 76 ++++++--- .../arc/src/app/main/main-routing.module.ts | 2 + projects/arc/src/app/main/main.component.html | 2 + 12 files changed, 343 insertions(+), 88 deletions(-) diff --git a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.service.ts b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.service.ts index 1ad4de5b..e2e8f717 100644 --- a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.service.ts +++ b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.service.ts @@ -9,11 +9,14 @@ export class TitleService { const title = this.titles.find(u => u.id === id); return of(title); } - getTitleNameForBreadcrumb(id: string): Observable<string> { + getTitleNameForBreadcrumb( + params: Record<string, string>, + ): Observable<string> { + const id = params['id']; return this.getTitleById(id).pipe( map(titles => titles?.title || `Document #${id}`), catchError(() => of(`Document #${id}`)), - delay(2000), // Simulating network delay + delay(4000), // Simulating network delay ); } } diff --git a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user/user.service.ts b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user/user.service.ts index 4dd22776..15ea3176 100644 --- a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user/user.service.ts +++ b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb-demo/user/user.service.ts @@ -9,7 +9,8 @@ export class UserService { const user = this.users.find(u => u.id === id); return of(user); } - getUserNameForBreadcrumb(id: string): Observable<string> { + getUserNameForBreadcrumb(params: Record<string, string>): Observable<string> { + const id = params['id']; // Access any param key dynamically return this.getUserById(id).pipe( map(user => user?.name || `User #${id}`), catchError(() => of(`User #${id}`)), diff --git a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.component.html b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.component.html index f3c93e5e..06018cba 100644 --- a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.component.html +++ b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.component.html @@ -1,47 +1,121 @@ -<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> +<nav aria-label="breadcrumb"> + <div + *ngIf="(loading$ | async) && showLoadingSkeleton" + class="breadcrumb-skeleton" + > + <div class="skeleton-item"></div> + <span [class]="separatorClass">{{ separator }}</span> + <div class="skeleton-item"></div> + <span [class]="separatorClass">{{ separator }}</span> + <div class="skeleton-item"></div> + </div> + + <ng-container *ngIf="!(loading$ | async)"> + <ul class="breadcrumb" *ngIf="breadcrumbs$ | async as breadcrumbs"> + <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" + [title]="breadcrumbs[0].label" + class="breadcrumb-label" + > + <nb-icon + *ngIf="breadcrumbs[0].icon" + [icon]="breadcrumbs[0].icon" + class="breadcrumb-icon" + ></nb-icon> + {{ getTrimmedLabel(breadcrumbs[0].label) }} + </a> + </ng-container> + <ng-template #noLinkFirst> + <span class="breadcrumb-label" [title]="breadcrumbs[0].label"> + <nb-icon + *ngIf="breadcrumbs[0].icon" + [icon]="breadcrumbs[0].icon" + class="breadcrumb-icon" + ></nb-icon> + {{ getTrimmedLabel(breadcrumbs[0].label) }} + </span> + </ng-template> + </li> - <li class="breadcrumb-item clickable" (click)="toggleExpand()">...</li> - <span class="separator">{{ separator }}</span> + <span class="{{ separatorClass }}">{{ 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> + <li class="breadcrumb-item clickable" (click)="toggleExpand()">...</li> + <span class="{{ separatorClass }}">{{ separator }}</span> - <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> + <li class="breadcrumb-item active"> + <ng-container + *ngIf=" + !breadcrumbs[breadcrumbs.length - 1].skipLink; + else noLinkLast + " + > + <a + [routerLink]="breadcrumbs[breadcrumbs.length - 1].url" + [title]="breadcrumbs[breadcrumbs.length - 1].label" + class="breadcrumb-label" + > + <nb-icon + *ngIf="breadcrumbs[breadcrumbs.length - 1].icon" + [icon]="breadcrumbs[breadcrumbs.length - 1].icon" + class="breadcrumb-icon" + ></nb-icon> + {{ getTrimmedLabel(breadcrumbs[breadcrumbs.length - 1].label) }} + </a> </ng-container> - <ng-template #noLink> - <span>{{ breadcrumb.label }}</span> + <ng-template #noLinkLast> + <span + class="breadcrumb-label" + [title]="breadcrumbs[breadcrumbs.length - 1].label" + > + <nb-icon + *ngIf="breadcrumbs[breadcrumbs.length - 1].icon" + [icon]="breadcrumbs[breadcrumbs.length - 1].icon" + class="breadcrumb-icon" + ></nb-icon> + {{ getTrimmedLabel(breadcrumbs[breadcrumbs.length - 1].label) }} + </span> </ng-template> </li> - <span *ngIf="!last" class="separator">{{ separator }}</span> </ng-container> - </ng-template> - </ul> + + <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" + [title]="breadcrumb.label" + class="breadcrumb-label" + > + <nb-icon + *ngIf="breadcrumb.icon" + [icon]="breadcrumb.icon" + class="breadcrumb-icon" + ></nb-icon> + {{ getTrimmedLabel(breadcrumb.label) }} + </a> + </ng-container> + <ng-template #noLink> + <span class="breadcrumb-label" [title]="breadcrumb.label"> + <nb-icon + *ngIf="breadcrumb.icon" + [icon]="breadcrumb.icon" + class="breadcrumb-icon" + ></nb-icon> + {{ getTrimmedLabel(breadcrumb.label) }} + </span> + </ng-template> + </li> + <span *ngIf="!last" class="{{ separatorClass }}">{{ + separator + }}</span> + </ng-container> + </ng-template> + </ul> + </ng-container> </nav> diff --git a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.component.scss b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.component.scss index 7611bbbb..31c73db6 100644 --- a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.component.scss +++ b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.component.scss @@ -9,7 +9,7 @@ .breadcrumb-item { display: flex; align-items: center; - font-size: 14px; + font-size: 0.875rem; color: #555; a { @@ -25,6 +25,7 @@ font-weight: 600; color: #333; } + &.clickable { cursor: pointer; color: #007bff; @@ -34,11 +35,52 @@ color: #0056b3; } } + + .breadcrumb-label { + max-width: 12rem; // Restrict label width + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: inline-block; + vertical-align: middle; + } } .separator { - margin: 0 6px; + margin: 0 0.375rem; color: #aaa; - font-size: 14px; + font-size: 0.875rem; + } +} + +.breadcrumb-skeleton { + display: flex; + align-items: center; + padding: 0.5rem 0; + + .skeleton-item { + height: 1rem; + background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); + background-size: 200% 100%; + animation: loading 1.5s infinite; + border-radius: 4px; + min-width: 3.75rem; + max-width: 7.5rem; + width: 5rem; + } + + .separator { + margin: 0 0.375rem; + color: #ccc; + font-size: 0.875rem; + } +} + +@keyframes loading { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; } } diff --git a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.component.ts b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.component.ts index 58b50808..4c1ed979 100644 --- a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.component.ts +++ b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.component.ts @@ -2,24 +2,28 @@ 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 {Observable, of, Subject, takeUntil} from 'rxjs'; import {RouterModule} from '@angular/router'; - +import {NbIconModule} from '@nebular/theme'; @Component({ selector: 'app-breadcrumb', templateUrl: './breadcrumb.component.html', standalone: true, - imports: [CommonModule, RouterModule], + imports: [CommonModule, RouterModule, NbIconModule], styleUrls: ['./breadcrumb.component.scss'], }) export class BreadcrumbComponent implements OnInit { breadcrumbs$: Observable<Breadcrumb[]> = this.breadcrumbService.breadcrumbs; + loading$: Observable<boolean> = this.breadcrumbService.loading; @Input() staticBreadcrumbs = []; @Input() separator = '>'; @Input() maxItems = 8; @Input() separatorClass = 'separator'; @Input() itemClass = 'breadcrumb-item'; + @Input() maxLabelLength = 30; + @Input() showLoadingSkeleton = true; + @Input() showIcons = false; expanded = false; private destroy$ = new Subject<void>(); @@ -31,6 +35,11 @@ export class BreadcrumbComponent implements OnInit { } }); } + getTrimmedLabel(label: string): string { + return label.length > this.maxLabelLength + ? label.slice(0, this.maxLabelLength).trimEnd() + '…' + : label; + } ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); diff --git a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.interface.ts b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.interface.ts index 121c88ae..69bdc099 100644 --- a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.interface.ts +++ b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.interface.ts @@ -2,4 +2,5 @@ export interface Breadcrumb { label: string; url: string; skipLink?: boolean; + icon?: string; } diff --git a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.service.ts b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.service.ts index bab368a0..26d21486 100644 --- a/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.service.ts +++ b/projects/arc-lib/src/lib/components/breadcrumb/breadcrumb.service.ts @@ -1,12 +1,13 @@ import {Injectable, Injector} from '@angular/core'; import {ActivatedRouteSnapshot, NavigationEnd, Router} from '@angular/router'; -import {BehaviorSubject} from 'rxjs'; -import {filter} from 'rxjs/operators'; +import {BehaviorSubject, EMPTY, Observable} from 'rxjs'; +import {catchError, filter, finalize, tap} from 'rxjs/operators'; import {Breadcrumb} from './breadcrumb.interface'; @Injectable({providedIn: 'root'}) export class BreadcrumbService { private readonly breadcrumbs$ = new BehaviorSubject<Breadcrumb[]>([]); + private readonly loading$ = new BehaviorSubject<boolean>(false); constructor( private readonly router: Router, @@ -41,9 +42,10 @@ export class BreadcrumbService { const nextUrl = path ? `${url}/${path}` : url; const label = this._resolveLabel(route, path, nextUrl); const skipLink = route.routeConfig.data?.['skipLink'] ?? false; + const icon = route.routeConfig.data?.['icon']; if (label && label.trim() !== '') { - breadcrumbs.push({label, url: nextUrl, skipLink}); + breadcrumbs.push({label, url: nextUrl, skipLink, icon}); } return route.firstChild @@ -66,21 +68,36 @@ export class BreadcrumbService { const asyncConfig = data?.asyncBreadcrumb; if (asyncConfig?.service && asyncConfig?.method) { const params = route.paramMap; - const paramValue = params.get('id'); + const paramValue = Object.fromEntries( + params.keys.map(k => [k, params.get(k)]), + ); const fallback = asyncConfig.fallbackLabel?.(params) || this._toTitleCase(path); const loadingLabel = asyncConfig.loadingLabel || fallback; - setTimeout(async () => { - try { - const serviceInstance = this.injector.get(asyncConfig.service); - const result$ = serviceInstance[asyncConfig.method](paramValue); - const result = await result$.toPromise(); - this.updateBreadcrumbLabel(currentUrl, result); - } catch (error) { - console.warn('Async breadcrumb load failed:', error); - } - }, 0); + this.loading$.next(true); + try { + const serviceInstance = this.injector.get(asyncConfig.service); + const result$ = serviceInstance[asyncConfig.method](paramValue); + + this.loading$.next(true); + + result$ + .pipe( + tap(result => + this.updateBreadcrumbLabel(currentUrl, String(result)), + ), + catchError(error => { + console.warn('Async breadcrumb load failed:', error); + return EMPTY; + }), + finalize(() => this.loading$.next(false)), + ) + .subscribe(); + } catch (error) { + console.warn('Async breadcrumb load failed:', error); + this.loading$.next(false); + } return loadingLabel; } @@ -106,4 +123,7 @@ export class BreadcrumbService { get breadcrumbs() { return this.breadcrumbs$.asObservable(); } + get loading(): Observable<boolean> { + return this.loading$.asObservable(); + } } diff --git a/projects/arc/src/app/app.module.ts b/projects/arc/src/app/app.module.ts index ef317d56..1652bde6 100644 --- a/projects/arc/src/app/app.module.ts +++ b/projects/arc/src/app/app.module.ts @@ -25,6 +25,8 @@ import {HeaderComponent} from '@project-lib/components/header/header.component'; import {SidebarComponent} from '@project-lib/components/sidebar/sidebar.component'; import {UserService} from '@project-lib/components/breadcrumb/breadcrumb-demo/user/user.service'; import {TitleService} from '@project-lib/components/breadcrumb/breadcrumb-demo/user-title/user-title.service'; +import {NbIconModule} from '@nebular/theme'; +import {NbEvaIconsModule} from '@nebular/eva-icons'; @NgModule({ declarations: [AppComponent], @@ -43,6 +45,8 @@ import {TitleService} from '@project-lib/components/breadcrumb/breadcrumb-demo/u HeaderComponent, SidebarComponent, BreadcrumbComponent, + NbIconModule, + NbEvaIconsModule, ], providers: [ TranslationService, diff --git a/projects/arc/src/app/main/bread-crumb-introduction/bread-crumb-introduction.component.html b/projects/arc/src/app/main/bread-crumb-introduction/bread-crumb-introduction.component.html index 04f96af9..00f91ef9 100644 --- a/projects/arc/src/app/main/bread-crumb-introduction/bread-crumb-introduction.component.html +++ b/projects/arc/src/app/main/bread-crumb-introduction/bread-crumb-introduction.component.html @@ -90,7 +90,57 @@ <h3>Async Breadcrumb Logic</h3> <pre>{{ asyncLogicCode }}</pre> </div> </div> - <h3>3. Breadcrumb Inputs</h3> + <h3>3. Breadcrumb with Icons</h3> + <p> + This example shows how to add icons to breadcrumb items using icon property + in route data. + </p> + + <ul class="breadcrumb"> + <li> + <nb-icon icon="home-outline" pack="eva" class="breadcrumb-icon"></nb-icon> + Home + </li> + <span class="separator">›</span> + <li> + <nb-icon + icon="people-outline" + pack="eva" + class="breadcrumb-icon" + ></nb-icon> + Users + </li> + <span class="separator">›</span> + <li> + <nb-icon + icon="person-outline" + pack="eva" + class="breadcrumb-icon" + ></nb-icon> + Jane Smith + </li> + <span class="separator">›</span> + <li> + <nb-icon + icon="file-text-outline" + pack="eva" + class="breadcrumb-icon" + ></nb-icon> + Contract.pdf + </li> + </ul> + + <div class="code-block"> + <span + class="copy-icon" + (click)="copyCode(iconBreadcrumbCode)" + title="Copy Icon Example Code" + >📋</span + > + <pre>{{ iconBreadcrumbCode }}</pre> + </div> + + <h3>4. Breadcrumb Inputs</h3> <p>See all available inputs for customization:</p> <table class="breadcrumb-api-table"> @@ -105,6 +155,13 @@ <h3>3. Breadcrumb Inputs</h3> <td>staticBreadcrumbs</td> <td>Static breadcrumb items</td> </tr> + <tr> + <td>showIcons</td> + <td> + Icons will appear before each breadcrumb item that has an associated + icon + </td> + </tr> <tr> <td>separator</td> <td>Separator character/string</td> @@ -121,6 +178,20 @@ <h3>3. Breadcrumb Inputs</h3> <td>itemClass</td> <td>Custom class applied to each breadcrumb element.</td> </tr> + <tr> + <td>maxLabelLength</td> + <td> + Maximum number of characters shown in each breadcrumb label before it + gets trimmed with an ellipsis (…) + </td> + </tr> + <tr> + <td>maxItems</td> + <td> + Maximum number of breadcrumb items to display before collapsing the + middle items into an expandable “...” element + </td> + </tr> </tbody> </table> </div> diff --git a/projects/arc/src/app/main/bread-crumb-introduction/bread-crumb-introduction.component.ts b/projects/arc/src/app/main/bread-crumb-introduction/bread-crumb-introduction.component.ts index 4607c123..4462bc55 100644 --- a/projects/arc/src/app/main/bread-crumb-introduction/bread-crumb-introduction.component.ts +++ b/projects/arc/src/app/main/bread-crumb-introduction/bread-crumb-introduction.component.ts @@ -1,12 +1,13 @@ import {CommonModule} from '@angular/common'; import {Component, NgModule} from '@angular/core'; import {Router} from '@angular/router'; +import {NbIconModule} from '@nebular/theme'; @Component({ selector: 'arc-bread-crumb-introduction', templateUrl: './bread-crumb-introduction.component.html', styleUrls: ['./bread-crumb-introduction.component.scss'], standalone: true, - imports: [CommonModule], + imports: [CommonModule, NbIconModule], }) export class BreadCrumbIntroductionComponent { expanded = false; @@ -20,32 +21,34 @@ export class BreadCrumbIntroductionComponent { </app-breadcrumb>`; routingCode = ` { - path: 'user/:id', - component: UserComponent, - data: { - asyncBreadcrumb: { - service: UserService, - method: 'getUserNameForBreadcrumb', - fallbackLabel: (params: ParamMap) => \`User #\${params.get('id')}\`, - loadingLabel: 'Loading user...', - }, - }, - children: [ - { - path: 'document/:id', - component: UserTitleComponent, - data: { - asyncBreadcrumb: { - service: TitleService, - method: 'getTitleNameForBreadcrumb', - fallbackLabel: (params: ParamMap) => - \`Document #\${params.get('id')}\`, - loadingLabel: 'Loading document...', + path: 'user/:id', + component: UserComponent, + data: { + asyncBreadcrumb: { + service: UserService, + method: 'getUserNameForBreadcrumb', + fallbackLabel: (params: ParamMap) => \`User #\${params.get('id')}\`, + loadingLabel: 'Loading user...', + }, + icon: 'person-outline', }, + children: [ + { + path: 'document/:id', + component: UserTitleComponent, + data: { + asyncBreadcrumb: { + service: TitleService, + method: 'getTitleNameForBreadcrumb', + fallbackLabel: (params: ParamMap) => + \`Document #\${params.get('id')}\`, + loadingLabel: 'Loading document...', + }, + icon: 'file-text-outline', + }, + }, + ], }, - }, - ], - }, `; asyncLogicCode = ` const asyncConfig = data?.asyncBreadcrumb; @@ -81,6 +84,29 @@ export class BreadCrumbIntroductionComponent { catchError(() => of(\`User #\${id}\`)), ); }`; + iconBreadcrumbCode = ` +<ul class="breadcrumb"> + <li> + <nb-icon icon="home-outline" class="breadcrumb-icon"></nb-icon> + Home + </li> + <span class="separator">›</span> + <li> + <nb-icon icon="people-outline" class="breadcrumb-icon"></nb-icon> + Users + </li> + <span class="separator">›</span> + <li> + <nb-icon icon="person-outline" class="breadcrumb-icon"></nb-icon> + Jane Smith + </li> + <span class="separator">›</span> + <li> + <nb-icon icon="file-text-outline" class="breadcrumb-icon"></nb-icon> + Contract.pdf + </li> +</ul> +`; copyCode(text: string) { navigator.clipboard.writeText(text); diff --git a/projects/arc/src/app/main/main-routing.module.ts b/projects/arc/src/app/main/main-routing.module.ts index cb8a4cfc..ffeef2e9 100644 --- a/projects/arc/src/app/main/main-routing.module.ts +++ b/projects/arc/src/app/main/main-routing.module.ts @@ -45,6 +45,7 @@ const routes: Routes = [ fallbackLabel: (params: ParamMap) => `User #${params.get('id')}`, loadingLabel: 'Loading user...', }, + icon: 'person-outline', }, children: [ { @@ -58,6 +59,7 @@ const routes: Routes = [ `Document #${params.get('id')}`, loadingLabel: 'Loading document...', }, + icon: 'file-text-outline', }, }, ], diff --git a/projects/arc/src/app/main/main.component.html b/projects/arc/src/app/main/main.component.html index 18cd6009..9617819d 100644 --- a/projects/arc/src/app/main/main.component.html +++ b/projects/arc/src/app/main/main.component.html @@ -31,6 +31,8 @@ [maxItems]="2" [itemClass]="'breadcrumb-item'" [separatorClass]="'separator'" + [maxLabelLength]="15" + [showIcons]="true" ></app-breadcrumb> <router-outlet class="main-router"></router-outlet> </nb-layout-column>