ngx-suspense
Version:
This library is an experimetnal implementation of React Suspense for Angular.
394 lines (385 loc) • 14.4 kB
JavaScript
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