UNPKG

xng-breadcrumb

Version:

A declarative and reactive breadcrumb approach for Angular 6 and beyond https://www.npmjs.com/package/xng-breadcrumb

578 lines (569 loc) 22.6 kB
import { BehaviorSubject } from 'rxjs'; import { distinctUntilChanged, filter } from 'rxjs/operators'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, NavigationEnd, Router, RouterModule } from '@angular/router'; import { Directive, Injectable, Pipe, Component, ContentChild, Input, TemplateRef, ViewEncapsulation, NgModule, ɵɵdefineInjectable, ɵɵinject } from '@angular/core'; /** * @fileoverview added by tsickle * @suppress {checkTypes,constantProperty,extraRequire,missingOverride,missingReturn,unusedPrivateMembers,uselessCode} checked by tsc */ class BreadcrumbItemDirective { constructor() { } } BreadcrumbItemDirective.decorators = [ { type: Directive, args: [{ selector: '[xngBreadcrumbItem]' },] } ]; /** @nocollapse */ BreadcrumbItemDirective.ctorParameters = () => []; /** * @fileoverview added by tsickle * @suppress {checkTypes,constantProperty,extraRequire,missingOverride,missingReturn,unusedPrivateMembers,uselessCode} checked by tsc */ class BreadcrumbService { /** * @param {?} activatedRoute * @param {?} router */ constructor(activatedRoute, router) { this.activatedRoute = activatedRoute; this.router = router; this.baseHref = '/'; /** * dynamicBreadcrumbStore holds information about dynamically updated breadcrumbs. * Breadcrumbs can be set from anywhere (component, service) in the app. * On every breadcrumb update check this store and use the info if available. */ this.dynamicBreadcrumbStore = []; /** * breadcrumbList for the current route * When breadcrumb info is changed dynamically, check if the currentBreadcrumbs is effected * If effected, update the change and emit a new stream */ this.currentBreadcrumbs = []; /** * Breadcrumbs observable to be subscribed by BreadcrumbComponent * Emits on every route change OR dynamic update of breadcrumb */ this.breadcrumbs = new BehaviorSubject([]); this.breadcrumbs$ = this.breadcrumbs.asObservable(); this.pathParamPrefix = ':'; this.pathParamRegexIdentifier = '/:[^/]+'; this.pathParamRegexReplacer = '/[^/]+'; this.setBaseBreadcrumb(); this.detectRouteChanges(); } /** * Update breadcrumb label or options for - * * route (complete route path). route can be passed the same way you define angular routes * 1) update label Ex: set('/mentor', 'Mentor'), set('/mentor/:id', 'Mentor Details') * 2) change visibility Ex: set('/mentor/:id/edit', { skip: true }) * 3) add info Ex: set('/mentor/:id/edit', { info: { icon: 'edit', iconColor: 'blue' } }) * ------------------------ OR ------------------------- * * alias (prefixed with '\@'). breadcrumb alias is unique for a route * 1) update label Ex: set('\@mentor', 'Enabler') * 2) change visibility Ex: set('\@mentorEdit', { skip: true }) * 3) add info Ex: set('\@mentorEdit', { info: { icon: 'edit', iconColor: 'blue' } }) * @param {?} pathOrAlias * @param {?} breadcrumb * @return {?} */ set(pathOrAlias, breadcrumb) { if (!this.validateArguments(pathOrAlias, breadcrumb)) { return; } if (typeof breadcrumb === 'string') { breadcrumb = { label: breadcrumb }; } if (pathOrAlias.startsWith('@')) { this.updateStore(Object.assign({}, breadcrumb, { alias: pathOrAlias.slice(1) })); } else { /** @type {?} */ const breadcrumbExtraProps = this.buildRouteRegExp(pathOrAlias); this.updateStore(Object.assign({}, breadcrumb, breadcrumbExtraProps)); } } /** * @private * @return {?} */ setBaseBreadcrumb() { /** @type {?} */ const baseConfig = this.router.config.find((/** * @param {?} pathConfig * @return {?} */ pathConfig => pathConfig.path === '')); if (baseConfig && baseConfig.data) { let { label, alias, skip = false, info } = this.getBreadcrumbOptions(baseConfig.data); /** @type {?} */ let isAutoGeneratedLabel = false; if (typeof label !== 'string' && !label) { label = ''; isAutoGeneratedLabel = true; } this.baseBreadcrumb = { label, alias, skip, info, routeLink: this.baseHref, isAutoGeneratedLabel }; } } /** * Whenever route changes build breadcrumb list again * @private * @return {?} */ detectRouteChanges() { this.router.events .pipe(filter((/** * @param {?} event * @return {?} */ event => event instanceof NavigationEnd)), distinctUntilChanged()) .subscribe((/** * @param {?} event * @return {?} */ event => { this.currentBreadcrumbs = this.baseBreadcrumb ? [this.baseBreadcrumb] : []; this.prepareBreadcrumbList(this.activatedRoute.root, this.baseHref); })); } /** * @private * @param {?} activatedRoute * @param {?} routeLinkPrefix * @return {?} */ prepareBreadcrumbList(activatedRoute, routeLinkPrefix) { if (activatedRoute.routeConfig && activatedRoute.routeConfig.path) { /** @type {?} */ const breadcrumbItem = this.prepareBreadcrumbItem(activatedRoute, routeLinkPrefix); this.currentBreadcrumbs.push(breadcrumbItem); if (activatedRoute.firstChild) { return this.prepareBreadcrumbList(activatedRoute.firstChild, breadcrumbItem.routeLink + '/'); } } else if (activatedRoute.firstChild) { return this.prepareBreadcrumbList(activatedRoute.firstChild, routeLinkPrefix); } // remove breadcrumb items that needs to be hidden or don't have a label /** @type {?} */ const breacrumbsToShow = this.currentBreadcrumbs.filter((/** * @param {?} item * @return {?} */ item => !item.skip)); this.breadcrumbs.next(breacrumbsToShow); } /** * @private * @param {?} activatedRoute * @param {?} routeLinkPrefix * @return {?} */ prepareBreadcrumbItem(activatedRoute, routeLinkPrefix) { const { path, breadcrumb } = this.parseRouteData(activatedRoute.routeConfig); // in case of path param get the resolved for param /** @type {?} */ const resolvedPath = this.resolvePathParam(path, activatedRoute); /** @type {?} */ const routeLink = `${routeLinkPrefix}${resolvedPath}`; let { label, alias, skip, info } = this.getFromStore(breadcrumb.alias, routeLink); /** @type {?} */ let isAutoGeneratedLabel = false; if (typeof label !== 'string') { if (typeof breadcrumb.label === 'string') { label = breadcrumb.label; } else { label = resolvedPath; isAutoGeneratedLabel = true; } } return { label, alias: alias || breadcrumb.alias, skip: skip || breadcrumb.skip, info: info || breadcrumb.info, routeLink, isAutoGeneratedLabel }; } /** * For a specific route, breadcrumb can be defined either on parent data OR it's child(which has empty path) data * When both are defined, child takes precedence * * Ex: Below we are setting breadcrumb on both parent and child. * So, child takes precedence and "Defined On Child" is displayed for the route 'home' * { path: 'home', loadChildren: './home/home.module#HomeModule' , data: {breadcrumb: "Defined On Module"}} * AND * children: [ * { path: '', component: ShowUserComponent, data: {breadcrumb: "Defined On Child" } * ] * @private * @param {?} routeConfig * @return {?} */ parseRouteData(routeConfig) { const { path, data = {} } = routeConfig; /** @type {?} */ const breadcrumb = this.mergeWithBaseChildData(routeConfig, Object.assign({}, data)); return { path, breadcrumb }; } /** * @private * @param {?} breadcrumbAlias * @param {?} routeLink * @return {?} */ getFromStore(breadcrumbAlias, routeLink) { /** @type {?} */ let matchingItem; if (breadcrumbAlias) { matchingItem = this.dynamicBreadcrumbStore.find((/** * @param {?} item * @return {?} */ item => item.alias === breadcrumbAlias)); } if (!matchingItem && routeLink) { matchingItem = this.dynamicBreadcrumbStore.find((/** * @param {?} item * @return {?} */ item => { return (item.routeLink && item.routeLink === routeLink) || (item.routeRegex && new RegExp(item.routeRegex).test(routeLink + '/')); })); } return matchingItem || {}; } /** * To update breadcrumb label for a route with path param, we need regex that matches route. * Instead of user providing regex, we help in preparing regex dynamically * * Ex: route declaration - path: '/mentor/:id' * breadcrumbService.set('/mentor/:id', 'Uday'); * '/mentor/2' OR 'mentor/adasd' we should use 'Uday' as label * * regex string is built, if route has path params(contains with ':') * @private * @param {?} path * @return {?} */ buildRouteRegExp(path) { // ensure leading slash is provided in the path if (!path.startsWith('/')) { path = '/' + path; } if (path.includes(this.pathParamPrefix)) { // replace mathing path param with a regex // '/mentor/:id' becomes '/mentor/[^/]', which further will be matched in updateStore /** @type {?} */ const routeRegex = path.replace(new RegExp(this.pathParamRegexIdentifier, 'g'), this.pathParamRegexReplacer); return { routeRegex }; } else { return { routeLink: path }; } } /** * Update current breadcrumb definition and emit a new stream of breadcrumbs * Also update the store to reuse dynamic declarations * @private * @param {?} breadcrumb * @return {?} */ updateStore(breadcrumb) { const { breadcrumbItemIndex, storeItemIndex } = this.getBreadcrumbIndexes(breadcrumb); // if breadcrumb is present in current breadcrumbs update it and emit new stream if (breadcrumbItemIndex > -1) { this.currentBreadcrumbs[breadcrumbItemIndex] = Object.assign({}, this.currentBreadcrumbs[breadcrumbItemIndex], breadcrumb); /** @type {?} */ const breacrumbsToShow = this.currentBreadcrumbs.filter((/** * @param {?} item * @return {?} */ item => !item.skip)); this.breadcrumbs.next([...breacrumbsToShow]); } // If the store already has this route definition update it, else add if (storeItemIndex > -1) { this.dynamicBreadcrumbStore[storeItemIndex] = Object.assign({}, this.dynamicBreadcrumbStore[storeItemIndex], breadcrumb); } else { this.dynamicBreadcrumbStore.push(breadcrumb); } } /** * @private * @param {?} breadcrumb * @return {?} */ getBreadcrumbIndexes(breadcrumb) { const { alias, routeLink, routeRegex } = breadcrumb; /** @type {?} */ let indexMap = {}; // identify macthing breadcrumb and store item if (alias) { indexMap = this.getBreadcrumbIndexesByType('alias', alias); } else if (routeLink) { indexMap = this.getBreadcrumbIndexesByType('routeLink', routeLink); } else if (routeRegex) { indexMap = this.getBreadcrumbIndexesByType('routeRegex', routeRegex); } return indexMap; } /** * @private * @param {?} key * @param {?} value * @return {?} */ getBreadcrumbIndexesByType(key, value) { /** @type {?} */ const breadcrumbItemIndex = this.currentBreadcrumbs.findIndex((/** * @param {?} item * @return {?} */ item => value === item[key])); /** @type {?} */ const storeItemIndex = this.dynamicBreadcrumbStore.findIndex((/** * @param {?} item * @return {?} */ item => value === item[key])); return { breadcrumbItemIndex, storeItemIndex }; } /** * @private * @param {?} path * @param {?} activatedRoute * @return {?} */ resolvePathParam(path, activatedRoute) { // if the path segment is a route param, read the param value from url if (path.startsWith(this.pathParamPrefix)) { return activatedRoute.snapshot.params[path.slice(1)]; } return path; } /** * get empty children of a module or Component. Empty child is the one with path: '' * When parent and it's children (that has empty route path) define data * merge them both with child taking precedence * @private * @param {?} routeConfig * @param {?} data * @return {?} */ mergeWithBaseChildData(routeConfig, data) { if (!routeConfig) { return this.getBreadcrumbOptions(data); } /** @type {?} */ let baseChild; if (routeConfig.loadChildren) { // To handle a module with empty child route baseChild = routeConfig._loadedConfig.routes.find((/** * @param {?} route * @return {?} */ route => route.path === '')); } else if (routeConfig.children) { // To handle a component with empty child route baseChild = routeConfig.children.find((/** * @param {?} route * @return {?} */ route => route.path === '')); } return baseChild && baseChild.data ? this.mergeWithBaseChildData(baseChild, Object.assign({}, this.getBreadcrumbOptions(data), this.getBreadcrumbOptions(baseChild.data))) : this.getBreadcrumbOptions(data); } /** * @private * @param {?} pathOrAlias * @param {?} breadcrumb * @return {?} */ validateArguments(pathOrAlias, breadcrumb) { if (pathOrAlias === null || pathOrAlias === undefined) { console.error('Invalid first argument. Please pass a route path or a breadcrumb alias.'); return false; } else if (breadcrumb === null || breadcrumb === undefined) { console.error('Invalid second argument. Please pass a string or an Object with breadcrumb options.'); return false; } return true; } /** * breadcrumb can be passed a label or an options object * If passed as a string convert to breadcrumb options object * @private * @param {?} data * @return {?} */ getBreadcrumbOptions(data) { let { breadcrumb } = data; if (typeof breadcrumb === 'string' || !breadcrumb) { breadcrumb = { label: breadcrumb }; } return breadcrumb; } } BreadcrumbService.decorators = [ { type: Injectable, args: [{ providedIn: 'root' },] } ]; /** @nocollapse */ BreadcrumbService.ctorParameters = () => [ { type: ActivatedRoute }, { type: Router } ]; /** @nocollapse */ BreadcrumbService.ngInjectableDef = ɵɵdefineInjectable({ factory: function BreadcrumbService_Factory() { return new BreadcrumbService(ɵɵinject(ActivatedRoute), ɵɵinject(Router)); }, token: BreadcrumbService, providedIn: "root" }); /** * @fileoverview added by tsickle * @suppress {checkTypes,constantProperty,extraRequire,missingOverride,missingReturn,unusedPrivateMembers,uselessCode} checked by tsc */ class BreadcrumbComponent { /** * @param {?} breadcrumbService */ constructor(breadcrumbService) { this.breadcrumbService = breadcrumbService; this._separator = '/'; /** * If true, breacrumb is auto generated even without any mapping label * Default label is same as route segment */ this.autoGenerate = true; /** * custom class provided by consumer to increase specificity * This will benefit to override styles that are conflicting */ this.class = ''; } /** * separator between breadcrumbs, defaults to '/'. * User can customize separator either by passing a String or Template * * String --> Ex: <xng-breadcrumb separator="-"> </xng-breadcrumb> * * Template --> Ex: <xng-breadcrumb [separator]="separatorTemplate"> </xng-breadcrumb> * <ng-template #separatorTemplate><mat-icon>arrow_right</mat-icon></ng-template> * @param {?} value * @return {?} */ set separator(value) { if (value instanceof TemplateRef) { this.separatorTemplate = value; this._separator = undefined; } else { this.separatorTemplate = undefined; this._separator = value || '/'; } } /** * @return {?} */ get separator() { return this._separator; } /** * @return {?} */ ngOnInit() { this.breadcrumbs$ = this.breadcrumbService.breadcrumbs$; } } BreadcrumbComponent.decorators = [ { type: Component, args: [{ selector: 'xng-breadcrumb', template: "<nav aria-label=\"breadcrumb\" class=\"xng-breadcrumb-root\" [ngClass]=\"class\">\n <ol class=\"xng-breadcrumb-list\">\n <ng-container *ngFor=\"let breadcrumb of breadcrumbs$ | async | autoLabel: autoGenerate; last as isLast; first as isFirst\">\n <li class=\"xng-breadcrumb-item\">\n <a *ngIf=\"!isLast\" [routerLink]=\"[breadcrumb.routeLink]\" class=\"xng-breadcrumb-link\">\n <ng-container\n *ngTemplateOutlet=\"itemTemplate; context: { $implicit: breadcrumb.label, info: breadcrumb.info, last: isLast, first: isFirst }\"\n ></ng-container>\n <ng-container *ngIf=\"!itemTemplate\">{{ breadcrumb.label }}</ng-container>\n </a>\n\n <label *ngIf=\"isLast\" class=\"xng-breadcrumb-trail\">\n <ng-container\n *ngTemplateOutlet=\"itemTemplate; context: { $implicit: breadcrumb.label, info: breadcrumb.info, last: isLast, first: isFirst }\"\n ></ng-container>\n <ng-container *ngIf=\"!itemTemplate\">{{ breadcrumb.label }}</ng-container>\n </label>\n </li>\n\n <li *ngIf=\"!isLast\" class=\"xng-breadcrumb-separator\" aria-hidden=\"true\" role=\"separator\">\n <ng-container *ngTemplateOutlet=\"separatorTemplate\"></ng-container>\n <ng-container *ngIf=\"!separatorTemplate\">{{ separator }}</ng-container>\n </li>\n </ng-container>\n </ol>\n</nav>\n", encapsulation: ViewEncapsulation.None, styles: [".xng-breadcrumb-root{margin:0;color:rgba(0,0,0,.6)}.xng-breadcrumb-list{display:flex;align-items:center;flex-wrap:wrap;margin:0;padding:0}.xng-breadcrumb-item{list-style:none}.xng-breadcrumb-trail{display:flex;align-items:center;color:rgba(0,0,0,.9)}.xng-breadcrumb-link{display:flex;align-items:center;white-space:nowrap;color:inherit;text-decoration:none;transition:text-decoration .3s;transition:text-decoration .3s,-webkit-text-decoration .3s}.xng-breadcrumb-link:hover{text-decoration:underline}.xng-breadcrumb-separator{display:flex;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;margin-left:8px;margin-right:8px}"] }] } ]; /** @nocollapse */ BreadcrumbComponent.ctorParameters = () => [ { type: BreadcrumbService } ]; BreadcrumbComponent.propDecorators = { itemTemplate: [{ type: ContentChild, args: [BreadcrumbItemDirective, { static: false, read: TemplateRef },] }], autoGenerate: [{ type: Input }], class: [{ type: Input }], separator: [{ type: Input, args: ['separator',] }] }; /** * @fileoverview added by tsickle * @suppress {checkTypes,constantProperty,extraRequire,missingOverride,missingReturn,unusedPrivateMembers,uselessCode} checked by tsc */ class AutoLabelPipe { /** * @param {?} breadcrumbList * @param {?} shouldautoGenerate * @param {...?} args * @return {?} */ transform(breadcrumbList, shouldautoGenerate, ...args) { if (shouldautoGenerate) { return breadcrumbList; } else { return breadcrumbList.filter((/** * @param {?} breadcrumb * @return {?} */ breadcrumb => !breadcrumb.isAutoGeneratedLabel)); } return null; } } AutoLabelPipe.decorators = [ { type: Pipe, args: [{ name: 'autoLabel' },] } ]; /** * @fileoverview added by tsickle * @suppress {checkTypes,constantProperty,extraRequire,missingOverride,missingReturn,unusedPrivateMembers,uselessCode} checked by tsc */ class BreadcrumbModule { } BreadcrumbModule.decorators = [ { type: NgModule, args: [{ declarations: [BreadcrumbComponent, BreadcrumbItemDirective, AutoLabelPipe], imports: [CommonModule, RouterModule], exports: [BreadcrumbComponent, BreadcrumbItemDirective] },] } ]; /** * @fileoverview added by tsickle * @suppress {checkTypes,constantProperty,extraRequire,missingOverride,missingReturn,unusedPrivateMembers,uselessCode} checked by tsc */ /** * @fileoverview added by tsickle * @suppress {checkTypes,constantProperty,extraRequire,missingOverride,missingReturn,unusedPrivateMembers,uselessCode} checked by tsc */ export { BreadcrumbItemDirective, BreadcrumbComponent, BreadcrumbModule, BreadcrumbService, AutoLabelPipe as ɵa }; //# sourceMappingURL=xng-breadcrumb.js.map