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
JavaScript
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