Search code examples
angulartypescriptangular-directiveangular-dependency-injection

Angular inject a component into an attribute directive


Tl;dr: How do I provide a visible component as a dependency for a directive? Naturally the component has to get initialized before the directive, but it has to be the same instance that gets displayed when the app later runs across the selector of the component.


Details:

My app.component.html has a structure like so:

app.component.html

<app-navigation></app-navigation>
<router-outlet></router-outlet>

There is a navigation bar at the top which is always visible. The <router-outlet> always displays the currently active component.

I'd now like to allow the components that are rendered in the <router-outlet> to modify the contents of the navigation bar, for example to display additional buttons that fit the currently active component. This should work with a directive, like so:

some.component.html

<div *appTopBar>
  <button>Additional Button</button>
</div>

The additional button should now appear in the navigation bar at the top.

The appTopBar directive looks like this:

top-bar.directive.ts

import {AfterViewInit, Directive, OnDestroy, TemplateRef} from '@angular/core';
import {AppComponent} from '../navigation/navigation.component';

@Directive({
  selector: '[appTopBar]'
})
export class TopBarDirective implements AfterViewInit, OnDestroy {

  constructor(private tmpl: TemplateRef<any>,
              private nav: NavigationComponent) {
  }

  ngAfterViewInit(): void {
    this.nav.setTopBarContent(this.tmpl);
  }

  ngOnDestroy(): void {
    this.nav.setTopBarContent(null);
  }
}

The directive has a dependency on the NavigationComponent and can pass content to the navigation bar via the publicly provided methods setTopBarContent():

navigation.component.ts

import {Component, EmbeddedViewRef, TemplateRef, ViewChild, ViewContainerRef} from '@angular/core';

@Component({
  selector: 'app-navigation',
  templateUrl: './navigation.component.html',
  styleUrls: ['./navigation.component.scss']
})
export class NavigationComponent {

  @ViewChild('topBarContainer',{static: false})
  topBar: ViewContainerRef;
  topBarContent: EmbeddedViewRef<any>;

  constructor() {}

  /**
   * Set the template to be shown in the top bar
   * @param tmpl template, null clears the content
   */
  public setTopBarContent(tmpl: TemplateRef<any>) {
    if (this.topBarContent) {
      this.topBarContent.destroy();
    }
    if (tmpl) {
      this.topBarContent = this.topBar.createEmbeddedView(tmpl);
    }
  }
}

The first issue I ran into was that the NavigationComponent dependency was not available yet, when the TopBarDirective was initialized. I got the following error:

ERROR Error: Uncaught (in promise): NullInjectorError:

StaticInjectorError(AppModule)[TopBarDirective -> NavigationComponent]: StaticInjectorError(Platform: core)[TopBarDirective -> NavigationComponent]:

NullInjectorError: No provider for NavigationComponent!

So obviously the component got initialized after the directive and wasn't available yet.

I tried adding the NavigationComponent to the providers array of the AppComponent and the dependency injection now worked:

@NgModule({
  declarations: [
    NavigationComponent,
    SomeComponent,
    TopBarDirective
  ],
  imports: [
    BrowserModule,
    CommonModule
  ],
  providers: [NavigationComponent]
})
export class AppModule { }

However, it seems there are now two instances of the NavigationComponent. I checked this by generating a random number in the constructor of the NavigationComponent and logging it. The directive definitely has an instance other from the one displayed at the <app-navigation> selector.

Now I know this pattern works somehow. I found it some time ago where it was introduced by some Angular developer, but I unfortunately don't have the source anymore. The working version, however, displays the contents in the AppComponent, so the directive only has a dependency to AppComponent, which seems to get initialized first. Therefore the whole dependency issue does not occur.

How can I make sure the instance of NavigationComponent provided to the TopBarDirective is the same instance that is displayed at the <app-navigation> selector?


Solution

  • I propose you to create a service say TopbarService for this which would be like this.There we will use a BehaviorSubjectto set the template and emit it's latest value.

    @Injectable()
    export class TopbarService {
    
      private currentState = new BehaviorSubject<TemplateRef<any> | null>(null);
      readonly contents = this.currentState.asObservable();
    
      setContents(ref: TemplateRef<any>): void {
        this.currentState.next(ref);
      }
    
      clearContents(): void {
        this.currentState.next(null);
      }
    }
    

    Now in directive inject this service and invoke the service method.

    @Directive({
      selector: '[appTopbar]',
    })
    export class TopbarDirective implements OnInit {
    
      constructor(private topbarService: TopbarService,
                  private templateRef: TemplateRef<any>) {
      }
    
      ngOnInit(): void {
        this.topbarService.setContents(this.templateRef);
      }
    }
    

    In the NavigationComponent component subscribe on the contents behaviorsubject to get the latest value and set the template.

    export class NavigationComponent implements OnInit, AfterViewInit {
      _current: EmbeddedViewRef<any> | null = null;
    
      @ViewChild('vcr', { read: ViewContainerRef })
      vcr: ViewContainerRef;
    
      constructor(private topbarService: TopbarService,
                  private cdRef: ChangeDetectorRef) {
      }
    
      ngOnInit() {
      }
    
      ngAfterViewInit() {
        this.topbarService
          .contents
          .subscribe(ref => {
            if (this._current !== null) {
              this._current.destroy();
              this._current = null;
            }
            if (!ref) {
              return;
            }
            this._current = this.vcr.createEmbeddedView(ref);
            this.cdRef.detectChanges();
          });
      }
    }
    

    Html of this component would be like this where you place the template.

    template: `
        <div class="full-container topbar">
          <ng-container #vcr></ng-container>
          <h1>Navbar</h1>
        </div>
    `,