UNPKG

ngx-suspense

Version:

This library is an experimetnal implementation of React Suspense for Angular.

394 lines (385 loc) 14.4 kB
import { __decorate, __param } from 'tslib'; import { InjectionToken, Optional, Inject, Injectable, Input, Component, ContentChildren, NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { BehaviorSubject, Subject, timer, combineLatest, merge, Observable, of } from 'rxjs'; import { switchMap, takeUntil, mapTo, startWith, skip, filter, tap, concatMap, shareReplay, pairwise } from 'rxjs/operators'; import { trigger, transition, style, animate } from '@angular/animations'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { CommonModule } from '@angular/common'; const LOADING_DEFUALT_CONFIG = { busyMinDurationMs: 0, busyDelayMs: 0, }; const LOADING_CONFIG_TOKEN = new InjectionToken("LOADING_CONFIG_TOKEN"); let NgxSuspenseService = class NgxSuspenseService { constructor(userConfig = LOADING_DEFUALT_CONFIG) { this.userConfig = userConfig; this.loadingSubject = new BehaviorSubject(false); this.loading$ = this.loadingSubject.asObservable(); this.taskStartSubject = new Subject(); this.taskStart$ = this.taskStartSubject.asObservable(); this.taskEndSubject = new Subject(); this.taskEnd$ = this.taskEndSubject.asObservable(); } ngOnDestroy() { if (this.sub && typeof this.sub.unsubscribe === "function") { this.sub.unsubscribe(); } } set busyTimer({ busyDelayMs, busyMinDurationMs }) { if (typeof busyDelayMs === "number") { this.userConfig.busyDelayMs = busyDelayMs; } if (typeof busyMinDurationMs === "number") { this.userConfig.busyMinDurationMs = busyMinDurationMs; } } get config() { return this.userConfig; } show() { this.loadingSubject.next(true); } hide() { this.loadingSubject.next(false); } controller() { this.busyMinDurationTimer = timer(this.config.busyMinDurationMs + this.config.busyDelayMs); this.busyDelayTimer = timer(this.config.busyDelayMs); const busyDelayTimerStart = this.taskStart$.pipe(switchMap(() => this.busyDelayTimer)); const busyDelayTimerEnd = busyDelayTimerStart.pipe(takeUntil(this.taskEnd$)); const emitOnTaskEnd = this.taskEnd$.pipe(mapTo(1)); const emitOnDelayTimerEnd = busyDelayTimerEnd.pipe(mapTo(-1)); const emitOnMinDurationEnd = this.busyMinDurationTimer.pipe(mapTo(-1)); // Start loading skeleton const raceBetweenTaskAndDelay = combineLatest([ emitOnTaskEnd.pipe(startWith(null)), emitOnDelayTimerEnd.pipe(startWith(null)), ]).pipe(skip(1)); const taskEndBeforeDelay = raceBetweenTaskAndDelay.pipe(filter(([taskEndFirst, timerEndFirst]) => { return taskEndFirst === 1 && timerEndFirst === null; })); const shouldNotShowSpinner = taskEndBeforeDelay.pipe(mapTo(false)); const taskEndAfterTimeout = raceBetweenTaskAndDelay.pipe(filter(([taskEndFirst, timerEndFirst]) => { return taskEndFirst === null && timerEndFirst === -1; })); const shouldShowSpinner = taskEndAfterTimeout.pipe(mapTo(true)); const showSpinner = shouldShowSpinner.pipe(tap(() => { this.show(); })); // hide loading skeleton const raceBetweenTaskAndMinDuration = combineLatest([ emitOnTaskEnd.pipe(startWith(null)), emitOnMinDurationEnd.pipe(startWith(null)), ]).pipe(skip(1)); const hideSpinnerUntilMinDurationEnd = raceBetweenTaskAndMinDuration.pipe(filter(([taskEndFirst, timerEndFirst]) => { return taskEndFirst === 1 && timerEndFirst === null; })); const hideSpinnerAfterTimerAndTaskEnd = raceBetweenTaskAndMinDuration.pipe(filter(([taskEndFirst, timerEndFirst]) => { return taskEndFirst === 1 && timerEndFirst === -1; })); const hideSpinner = merge( // case 1: should not show spinner at all shouldNotShowSpinner, // case 2: task end, but wait until min duration timer ends combineLatest([hideSpinnerUntilMinDurationEnd, emitOnMinDurationEnd]), // case 3: task takes a long time, wait unitl its end hideSpinnerAfterTimerAndTaskEnd).pipe(tap(() => { this.hide(); })); return showSpinner.pipe(takeUntil(hideSpinner)); } showLoadingStatus() { if (this.sub && typeof this.sub.unsubscribe === "function") { this.sub.unsubscribe(); } this.sub = this.controller().subscribe(); return (source) => { return new Observable((subscriber) => { const emitOnObsEnd = source.pipe(tap(() => { this.taskEndSubject.next(); })); const sub = of(null) .pipe(tap(() => { this.taskStartSubject.next(); }), concatMap(() => emitOnObsEnd), shareReplay(1)) .subscribe(subscriber); return () => { sub.unsubscribe(); }; }); }; } showingFor(obs$) { if (this.sub && typeof this.sub.unsubscribe === "function") { this.sub.unsubscribe(); } this.sub = this.controller().subscribe(); const emitOnObsEnd = obs$.pipe(tap(() => { this.taskEndSubject.next(); })); return of(null).pipe(tap(() => { this.taskStartSubject.next(); }), concatMap(() => emitOnObsEnd), shareReplay(1)); } }; NgxSuspenseService.ctorParameters = () => [ { type: undefined, decorators: [{ type: Optional }, { type: Inject, args: [LOADING_CONFIG_TOKEN,] }] } ]; NgxSuspenseService = __decorate([ Injectable(), __param(0, Optional()), __param(0, Inject(LOADING_CONFIG_TOKEN)) ], NgxSuspenseService); let NgxSuspenseComponent = class NgxSuspenseComponent { constructor(loadingService) { this.loadingService = loadingService; this.ariaLabel = "Loading..."; this.isVisible = false; } ngOnInit() { this.service = this.getService(); this.loading$ = this.service.loading$; } getService() { return this.bind || this.loadingService; } show() { this.service.show(); this.isVisible = true; } hide() { this.service.hide(); this.isVisible = false; } }; NgxSuspenseComponent.ctorParameters = () => [ { type: NgxSuspenseService } ]; __decorate([ Input() ], NgxSuspenseComponent.prototype, "fallback", void 0); __decorate([ Input() ], NgxSuspenseComponent.prototype, "ariaLabel", void 0); __decorate([ Input() ], NgxSuspenseComponent.prototype, "bind", void 0); __decorate([ Input() ], NgxSuspenseComponent.prototype, "isVisible", void 0); NgxSuspenseComponent = __decorate([ Component({ selector: "Suspense", template: "<ng-template #content>\n <!-- animation doesn't work with ng-template, ng-content or ng-container, have to add extra div for workaround-->\n <div [@fadeIn]>\n <ng-content></ng-content>\n </div>\n</ng-template>\n\n<div\n *ngIf=\"(loading$ | async) || isVisible; else content\"\n role=\"alert\"\n aria-busy=\"true\"\n aria-hidden=\"false\"\n [attr.aria-label]=\"ariaLabel\"\n>\n <ng-container *ngTemplateOutlet=\"fallback\"></ng-container>\n</div>\n", animations: [ trigger("fadeIn", [ transition(":enter", [ style({ opacity: 0 }), animate("300ms ease-in", style({ opacity: 1, })), ]), ]), ], styles: [` :host { display: block; } `] }) ], NgxSuspenseComponent); var NgxSuspenseListComponent_1; let NgxSuspenseListComponent = NgxSuspenseListComponent_1 = class NgxSuspenseListComponent { constructor() { this.revealOrder = "*"; this.subs = []; this.allBroadcasters = []; this.allListeners = []; this.hasParentControlSubject = new BehaviorSubject(undefined); this.parentControl$ = this.hasParentControlSubject.asObservable(); } ngOnInit() { this.parentControl$ .pipe(tap((val) => { console.log("release", val); })) .subscribe(); } ngAfterContentInit() { this.allBroadcasters = this.skeletons.map((s) => s.loading$); this.allListeners = this.skeletons.map((s) => this.hideSkeletonListener(s)); this.revealOrderOperator(this.revealOrder); } ngOnChanges(changes) { if (changes.revealOrder) { this.revealOrderOperator(changes.revealOrder.currentValue); } } ngOnDestroy() { if (this.subs.length) { this.subs.forEach((sub) => sub.unsubscribe()); this.subs.length = 0; } } reload(order) { this.revealOrderOperator(order || this.revealOrder); } show() { } hide() { } revealOrderOperator(order) { if (this.allBroadcasters.length === 0 || this.allListeners.length === 0) { return; } if (this.subs.length) { this.subs.forEach((sub) => sub.unsubscribe()); this.subs.length = 0; } switch (order) { case "together": { const sub = this.togetherOperator(this.allBroadcasters)(this.allListeners); this.subs.push(sub); break; } case "forwards": { const subs = this.domOrderOperator(this.allBroadcasters)(this.allListeners); this.subs = subs; break; } case "backwards": { const broadcasters = [...this.allBroadcasters].reverse(); const listeners = [...this.allListeners].reverse(); const subs = this.domOrderOperator(broadcasters)(listeners); this.subs = subs; break; } default: // nothing should happen } } hideSkeletonListener(skeleton) { return () => { skeleton.hide(); }; } showSkeletonListener(skeleton) { return () => { skeleton.show(); }; } togetherOperator(broadcasters) { this.skeletons.forEach((s) => this.showSkeletonListener(s)()); return (listeners) => { return combineLatest([...broadcasters]) .pipe( // skip default skip(1), // keep tracking previous value pairwise(), filter(([ary1, ary2]) => { // any loading state is ture const cond1 = ary1.some((b) => b); // all loading state is false const cond2 = !ary2.some((b) => b); // pass only when prev loading state is ture, current is false return cond1 && cond2; })) .subscribe(() => { listeners.forEach((hide) => hide()); }); }; } domOrderOperator(broadcasters) { this.skeletons.forEach((s) => this.showSkeletonListener(s)()); return (listeners) => { let subs = []; let checks = [...new Array(broadcasters.length)].fill(null); broadcasters.forEach((boradcaster, index) => { subs.push(boradcaster .pipe( // skip default skip(1), // keep tracking previous value pairwise(), filter(([b1, b2]) => { // pass only when prev loading state is ture, current is false return b1 && !b2; })) .subscribe(() => { checks[index] = true; const indexesToFlush = this.getFlushIndexes(checks); for (let i of indexesToFlush) { listeners[i](); } })); }); return subs; }; } getFlushIndexes(checks) { const indexesToFlush = []; for (let i = 0; i < checks.length; i++) { if (indexesToFlush.indexOf(i) > -1) { continue; } const check = checks[i]; if (check !== null) { indexesToFlush.push(i); } else { break; } } return indexesToFlush; } }; __decorate([ Input() ], NgxSuspenseListComponent.prototype, "revealOrder", void 0); __decorate([ ContentChildren(NgxSuspenseComponent) ], NgxSuspenseListComponent.prototype, "skeletons", void 0); __decorate([ ContentChildren(NgxSuspenseListComponent_1) ], NgxSuspenseListComponent.prototype, "list", void 0); NgxSuspenseListComponent = NgxSuspenseListComponent_1 = __decorate([ Component({ selector: "SuspenseList", template: "<ng-content></ng-content>\n", exportAs: "list" }) ], NgxSuspenseListComponent); var NgxSuspenseModule_1; const ɵ0 = LOADING_DEFUALT_CONFIG; let NgxSuspenseModule = NgxSuspenseModule_1 = class NgxSuspenseModule { static forRoot(config) { return { ngModule: NgxSuspenseModule_1, providers: [ { provide: LOADING_CONFIG_TOKEN, useValue: Object.assign(Object.assign({}, LOADING_DEFUALT_CONFIG), config), }, ], }; } }; NgxSuspenseModule = NgxSuspenseModule_1 = __decorate([ NgModule({ declarations: [NgxSuspenseListComponent, NgxSuspenseComponent], imports: [BrowserAnimationsModule, CommonModule], providers: [ { provide: LOADING_CONFIG_TOKEN, useValue: ɵ0, }, ], schemas: [CUSTOM_ELEMENTS_SCHEMA], exports: [NgxSuspenseComponent, NgxSuspenseListComponent], }) ], NgxSuspenseModule); /* * Public API Surface of ngx-suspense */ /** * Generated bundle index. Do not edit. */ export { LOADING_CONFIG_TOKEN, LOADING_DEFUALT_CONFIG, NgxSuspenseComponent, NgxSuspenseListComponent, NgxSuspenseModule, NgxSuspenseService, ɵ0 }; //# sourceMappingURL=ngx-suspense.js.map