UNPKG

@ngneat/overview

Version:

The Template for Success in Template Management

444 lines (431 loc) 21.7 kB
import * as i0 from '@angular/core'; import { Injectable, model, inject, TemplateRef, effect, untracked, Directive, input, ViewContainerRef, DestroyRef, createComponent, InjectionToken, Injector, ApplicationRef, EnvironmentInjector, signal, Component } from '@angular/core'; import { BehaviorSubject } from 'rxjs'; import { startWith, filter, map, distinctUntilChanged } from 'rxjs/operators'; class TeleportService { constructor() { this.outlets = new BehaviorSubject(''); this.ports = new Map(); } outlet$(name) { return this.outlets.pipe( // Immediately check current ports on subscription so that a teleportTo // registered after its outlet doesn't miss the already-emitted newOutlet event. // Without this, BehaviorSubject only replays one name, so only the last // registered outlet is visible to new subscribers. startWith(name), filter((current) => current === name), map((name) => this.ports.get(name)), distinctUntilChanged()); } newOutlet(name) { this.outlets.next(name); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.6", ngImport: i0, type: TeleportService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.6", ngImport: i0, type: TeleportService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.6", ngImport: i0, type: TeleportService, decorators: [{ type: Injectable, args: [{ providedIn: 'root', }] }] }); class TeleportDirective { constructor() { this.teleportTo = model(...(ngDevMode ? [undefined, { debugName: "teleportTo" }] : /* istanbul ignore next */ [])); this.viewRef = null; this.tpl = inject(TemplateRef); this.service = inject(TeleportService); effect((onCleanup) => { const teleportTo = this.teleportTo(); if (!teleportTo) return; // outlet$() emits immediately if the outlet is already registered, or // waits for it to appear — covering both sync and async outlet creation. const subscription = this.service.outlet$(teleportTo).subscribe((outlet) => { if (!outlet) return; // untracked() prevents signals read during view creation (e.g. in the // teleported template's directives) from being tracked as dependencies // of this effect, which would cause spurious re-runs and view recreation. this.viewRef = untracked(() => { return outlet.createEmbeddedView(this.tpl); }); }); // onCleanup runs before the next effect execution and on directive destroy, // ensuring the subscription and the remote view are always released together. onCleanup(() => { subscription.unsubscribe(); this.viewRef?.destroy(); this.viewRef = null; }); }); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.6", ngImport: i0, type: TeleportDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); } static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.6", type: TeleportDirective, isStandalone: true, selector: "[teleportTo]", inputs: { teleportTo: { classPropertyName: "teleportTo", publicName: "teleportTo", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { teleportTo: "teleportToChange" }, ngImport: i0 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.6", ngImport: i0, type: TeleportDirective, decorators: [{ type: Directive, args: [{ selector: '[teleportTo]', }] }], ctorParameters: () => [], propDecorators: { teleportTo: [{ type: i0.Input, args: [{ isSignal: true, alias: "teleportTo", required: false }] }, { type: i0.Output, args: ["teleportToChange"] }] } }); class TeleportOutletDirective { constructor() { this.teleportOutlet = input(...(ngDevMode ? [undefined, { debugName: "teleportOutlet" }] : /* istanbul ignore next */ [])); this.vcr = inject(ViewContainerRef); this.service = inject(TeleportService); effect(() => { const teleportOutlet = this.teleportOutlet(); if (teleportOutlet) { this.service.ports.set(teleportOutlet, this.vcr); this.service.newOutlet(teleportOutlet); } }); inject(DestroyRef).onDestroy(() => { this.service.ports.delete(this.teleportOutlet()); }); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.6", ngImport: i0, type: TeleportOutletDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); } static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.6", type: TeleportOutletDirective, isStandalone: true, selector: "[teleportOutlet]", inputs: { teleportOutlet: { classPropertyName: "teleportOutlet", publicName: "teleportOutlet", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.6", ngImport: i0, type: TeleportOutletDirective, decorators: [{ type: Directive, args: [{ selector: '[teleportOutlet]', }] }], ctorParameters: () => [], propDecorators: { teleportOutlet: [{ type: i0.Input, args: [{ isSignal: true, alias: "teleportOutlet", required: false }] }] } }); class CompRef { constructor(options) { this.options = options; if (options.vcr) { // Creating through a ViewContainerRef inserts the component into the existing // Angular view tree, so it participates in the host's change detection naturally. this.ref = options.vcr.createComponent(options.component, { index: options.vcr.length, injector: options.injector || options.vcr.injector, bindings: options.bindings, directives: options.directives, }); } else { // Without a ViewContainerRef the component is created detached from any view tree. // Attaching to ApplicationRef keeps it inside Angular's change detection cycle so // it still updates — otherwise the view would be permanently stale. this.ref = createComponent(options.component, { elementInjector: options.injector, environmentInjector: options.environmentInjector, bindings: options.bindings, directives: options.directives, }); options.appRef.attachView(this.ref.hostView); } } setInput(input, value) { this.ref.setInput(input, value); return this; } setInputs(inputs) { Object.keys(inputs).forEach((input) => { this.ref.setInput(input, inputs[input]); }); return this; } detectChanges() { this.ref.hostView.detectChanges(); return this; } updateContext(context) { // Context is held in a signal so the component can react to updates reactively // without requiring an explicit change detection call from the outside. this.options.contextSignal?.set(context); return this; } appendTo(container) { container.appendChild(this.getElement()); return this; } removeFrom(container) { container.removeChild(this.getElement()); return this; } getRawContent() { return this.getElement().outerHTML; } getElement() { return this.ref.location.nativeElement; } destroy() { this.ref.destroy(); // When there's no ViewContainerRef we manually attached to ApplicationRef, // so we must also manually detach — otherwise Angular keeps running change // detection on a destroyed view, causing errors. !this.options.vcr && this.options.appRef.detachView(this.ref.hostView); this.ref = null; } } // Runtime type guards are needed because Content is a union that TypeScript can't // narrow automatically at runtime — TemplateRef, component classes, and strings are // structurally indistinguishable without these checks. function isTemplateRef(value) { return value instanceof TemplateRef; } function isComponent(value) { return typeof value === 'function'; } function isString(value) { return typeof value === 'string'; } // CompRef and TplRef store their inner Angular view refs differently, so this // normalises access when the raw Angular view (e.g. for change detection) is needed. function getViewRef(value) { return value instanceof CompRef ? value.ref.hostView : value.ref; } class TplRef { constructor(args) { this.args = args; if (this.args.vcr) { // Creating through a ViewContainerRef inserts the view into the Angular view tree, // giving it automatic change detection via its host component's cycle. this.ref = this.args.vcr.createEmbeddedView(this.args.tpl, this.args.context || {}, { injector: args.injector }); this.ref.detectChanges(); } else { // Without a ViewContainerRef the view sits outside the component tree, so we attach // it to ApplicationRef to keep it inside Angular's change detection — then trigger // an initial check since no parent cycle will do it for us. this.ref = this.args.tpl.createEmbeddedView(this.args.context || {}, args.injector); this.ref.detectChanges(); this.args.appRef.attachView(this.ref); } } detectChanges() { this.ref.detectChanges(); return this; } getElement() { const rootNodes = this.ref.rootNodes; if (rootNodes.length === 1 && rootNodes[0] === Node.ELEMENT_NODE) { this.element = rootNodes[0]; } else { // Templates can project multiple root nodes (e.g. siblings or ng-container contents). // Wrapping them ensures callers always receive a single element they can append, // remove, or measure without handling multi-root edge cases themselves. this.element = document.createElement('div'); this.element.append(...rootNodes); } return this.element; } destroy() { if (this.ref.rootNodes[0] !== 1) { // Only remove the wrapper element from the DOM if it was actually inserted there. // Skipping this for VCR-managed views avoids double-removal, since Angular tears // those down through the view tree itself. this.element?.parentNode.removeChild(this.element); this.element = null; } if (!this.args.vcr) { // Mirror the manual attachView from construction — Angular won't clean this up // on its own for views that live outside the component tree. this.args.appRef.detachView(this.ref); } this.ref.destroy(); this.ref = null; } updateContext(context) { // Mutating the existing context object (rather than replacing it) preserves // any template variable bindings that already hold a reference to the context. Object.assign(this.ref.context, context); return this; } } // Plain strings have no Angular lifecycle, so this adapter exists purely to satisfy // the ViewRef contract — letting callers treat all content types uniformly without // special-casing strings throughout the codebase. class StringRef { constructor(value) { this.value = value; } getElement() { return this.value; } detectChanges() { return this; } updateContext() { return this; } destroy() { } } class ViewUnsupportedContentTypeError extends Error { constructor() { super(typeof ngDevMode !== 'undefined' && ngDevMode ? 'Type of content is not supported' : ''); } } // Components rendered dynamically often need external data but can't receive it via // @Input() because they're instantiated programmatically, not by a template. An // injection token lets the component pull context from its injector without coupling // to the caller's API or requiring a shared base class. const VIEW_CONTEXT = new InjectionToken(typeof ngDevMode !== 'undefined' && ngDevMode ? 'Component context' : ''); class ViewService { constructor() { this.injector = inject(Injector); this.appRef = inject(ApplicationRef); this.environmentInjector = inject(EnvironmentInjector); } createComponent(component, options = {}) { return untracked(() => { let injector = options.injector ?? this.injector; let contextSignal; if (options.context) { contextSignal = signal(options.context); // Wrap the context in a child injector so the VIEW_CONTEXT token is only visible // to this component and its descendants — not leaked into the broader injector tree. injector = Injector.create({ providers: [ { provide: VIEW_CONTEXT, useValue: contextSignal.asReadonly(), }, ], parent: injector, }); } return new CompRef({ component, vcr: options.vcr, injector, appRef: this.appRef, environmentInjector: options.environmentInjector || this.environmentInjector, contextSignal, bindings: options.bindings, directives: options.directives, }); }); } createTemplate(tpl, options = {}) { return untracked(() => { return new TplRef({ vcr: options.vcr, appRef: this.appRef, tpl, context: options.context, injector: options.injector, }); }); } createView(content, viewOptions = {}) { return untracked(() => { if (isTemplateRef(content)) { return this.createTemplate(content, viewOptions); } else if (isComponent(content)) { return this.createComponent(content, viewOptions); } else if (isString(content)) { return new StringRef(content); } else { throw new ViewUnsupportedContentTypeError(); } }); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.6", ngImport: i0, type: ViewService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.6", ngImport: i0, type: ViewService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.6", ngImport: i0, type: ViewService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }] }); // Convenience wrapper so dynamically-rendered components don't need to know about // the internal VIEW_CONTEXT token — they just call injectViewContext<MyContext>(). function injectViewContext() { return inject(VIEW_CONTEXT); } class DynamicViewComponent { constructor() { this.content = input(...(ngDevMode ? [undefined, { debugName: "content" }] : /* istanbul ignore next */ [])); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.6", ngImport: i0, type: DynamicViewComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.2.6", type: DynamicViewComponent, isStandalone: true, selector: "dynamic-view", inputs: { content: { classPropertyName: "content", publicName: "content", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: ` <div [innerHTML]="content()"></div> `, isInline: true }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.6", ngImport: i0, type: DynamicViewComponent, decorators: [{ type: Component, args: [{ selector: 'dynamic-view', template: ` <div [innerHTML]="content()"></div> `, }] }], propDecorators: { content: [{ type: i0.Input, args: [{ isSignal: true, alias: "content", required: false }] }] } }); class DynamicViewDirective { constructor() { this.view = input(undefined, { ...(ngDevMode ? { debugName: "view" } : /* istanbul ignore next */ {}), alias: 'dynamicView' }); this.injector = input(undefined, { ...(ngDevMode ? { debugName: "injector" } : /* istanbul ignore next */ {}), alias: 'dynamicViewInjector' }); this.context = input(undefined, { ...(ngDevMode ? { debugName: "context" } : /* istanbul ignore next */ {}), alias: 'dynamicViewContext' }); this.inputs = input(undefined, { ...(ngDevMode ? { debugName: "inputs" } : /* istanbul ignore next */ {}), alias: 'dynamicViewInputs' }); this.defaultTpl = inject(TemplateRef); this.vcr = inject(ViewContainerRef); this.viewService = inject(ViewService); inject(DestroyRef).onDestroy(() => { this.viewRef?.destroy(); }); } ngOnInit() { this.resolveContentType(); } ngOnChanges(changes) { const viewChanged = changes.view && !changes.view.isFirstChange(); const contextChanged = changes.context && !changes.context.isFirstChange(); const inputsChanged = changes.inputs && !changes.inputs.isFirstChange(); if (viewChanged) { this.resolveContentType(); } else if (contextChanged) { this.viewRef.updateContext(this.context()); } else if (isComponent(this.view()) && inputsChanged) { this.viewRef.setInputs(this.inputs() || {}); } } resolveContentType() { this.viewRef?.destroy(); const view = this.view(); const injector = this.injector(); const context = this.context(); if (isString(view)) { const viewRef = (this.viewRef = this.viewService.createComponent(DynamicViewComponent, { vcr: this.vcr, injector, })); viewRef.setInput('content', view).detectChanges(); } else if (isComponent(view)) { this.viewRef = this.viewService.createComponent(view, { vcr: this.vcr, injector: injector ?? this.vcr.injector, context, }); const inputs = this.inputs(); if (inputs) { this.viewRef.setInputs(inputs); } } else { this.viewRef = this.viewService.createView(view || this.defaultTpl, { vcr: this.vcr, injector: injector ?? this.vcr.injector, context, }); } } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.6", ngImport: i0, type: DynamicViewDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); } static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.6", type: DynamicViewDirective, isStandalone: true, selector: "[dynamicView]", inputs: { view: { classPropertyName: "view", publicName: "dynamicView", isSignal: true, isRequired: false, transformFunction: null }, injector: { classPropertyName: "injector", publicName: "dynamicViewInjector", isSignal: true, isRequired: false, transformFunction: null }, context: { classPropertyName: "context", publicName: "dynamicViewContext", isSignal: true, isRequired: false, transformFunction: null }, inputs: { classPropertyName: "inputs", publicName: "dynamicViewInputs", isSignal: true, isRequired: false, transformFunction: null } }, usesOnChanges: true, ngImport: i0 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.6", ngImport: i0, type: DynamicViewDirective, decorators: [{ type: Directive, args: [{ selector: '[dynamicView]', }] }], ctorParameters: () => [], propDecorators: { view: [{ type: i0.Input, args: [{ isSignal: true, alias: "dynamicView", required: false }] }], injector: [{ type: i0.Input, args: [{ isSignal: true, alias: "dynamicViewInjector", required: false }] }], context: [{ type: i0.Input, args: [{ isSignal: true, alias: "dynamicViewContext", required: false }] }], inputs: [{ type: i0.Input, args: [{ isSignal: true, alias: "dynamicViewInputs", required: false }] }] } }); /** * Generated bundle index. Do not edit. */ export { CompRef, DynamicViewDirective, StringRef, TeleportDirective, TeleportOutletDirective, TplRef, ViewService, ViewUnsupportedContentTypeError, getViewRef, injectViewContext, isComponent, isString, isTemplateRef }; //# sourceMappingURL=ngneat-overview.mjs.map