Adding a Component to a TemplateRef using a structural Directive
Asked Answered
S

2

13

We are building an angular 4 component library and one of the components is a Busy component. The purpose of the component is to allow a developer to create an overlay on any given HTML element which contains a spinner graphic.

<div *xuiBusy="isBusy">...</div>

When the value of isBusy is true we want to append to the inner content of the div so that we can present the overlay elements on top of the content.

We have been able to append the component to the ViewContainerRef however this inserts the busy element as a sibling to the div rather than within the div as desired.

    ngOnInit(): void {
      const compFactory = this._componentFactory.resolveComponentFactory(XuiBusyComponent);
      const comp = this._viewContainer.createComponent(compFactory);
      

What the consumer does:

<div *xuiBusy="isBusy">
  <span>This is the content</span>
</div>

When isBusy is set to true we want to alter the markup to look something like this. Notice that <spinner> has been added to the div element.

<div *xuiBusy="isBusy">
  <span>This is the content</span>
  <spinner>Please wait...</spinner> <-- inserted by directive
</div>

Any advice is appreciated!

Sheathing answered 12/5, 2017 at 18:36 Comment(7)
If that is the case, why don't you wrap it with <div [hidden]="!isBusy"><div *xuiBusy="isBusy">...</div></div>Fibriform
Good question. We want to allow developer consumers to not have to provide special structure to their markup to get the component to work. So in the simplest form we want to allow them to decorate their element with this attribute and the rest is handled by the directive.Sheathing
Then you should not use div. You can use <ng-template> and <ng-container>. Output will be what ever it is in template. No HTML tag element dependency. angular.io/docs/ts/latest/api/common/index/…Fibriform
The div is what the consumer developer uses and is only an example. It could also be form, article, body, ect. The idea is that the complexity of adding the busy elements are handled by the directive. This was easy to achieve in ng1 since we could just append/insert during the link phase.Sheathing
Check this #42598669Coarse
Thanks @yurzui! That is what I was looking for but my searchfoo failed me.Sheathing
@Sheathing Check this answer if it helps you answer if not can you create a plunker?Armallas
M
18

Demo

I've set up a demo on StackBlitz. The Spinner component, Busy directive and the consumer are all in app.component.ts for brevity.

Setup

The structural directive needs to inject the following:

  • TemplateRef, a reference to the template that the structural directive lies on (in desugared syntax);
  • ViewContainerRef, a reference to the container of views which can be rendered inside the view that the structural directive encapsulates;
  • ComponentFactoryResolver, a class which knows how to dynamically create instances of component from code.

Injecting in Angular is done via a constructor.

constructor(private templateRef: TemplateRef<void>,
            private vcr: ViewContainerRef,
            private cfr: ComponentFactoryResolver) { }

Passing the boolean

We need an input in order to pass data from the outside. In order to make the syntax pretty and obvious (especially when a directive expects a single input such as in this case), we can name the input exactly the same as our selector for the directive.

To rename the input, we can pass a bindingPropertyName to the Input decorator.

@Input('xuiBusy') isBusy: boolean;

Creating consumer's content

The content which consumer can be dynamically created by using the createEmbeddedView method defined on the ViewContainerRef class. The first parameter is the only mandatory one, and it accepts a template reference that the inserted view will be based on: this is the templateRef which we injected in our case.

this.vcr.createEmbeddedView(this.templateRef)

Creating the component

Creating a component dynamically requires a bit more ceremony, because you first need to resolve a factory which knows how to span a component instance.

For this, we use the resolveComponentFactory method on the instance of the injected ComponentFactoryResolver.

const cmpFactory = this.cfr.resolveComponentFactory(SpinnerComponent)

Now we can use the resulting factory in order to createComponent in a similar fashion we created the embedded view.

this.vcr.createComponent(cmpFactory)

Of course, this should happen only if the isBusy flag is set to true, so we wrap this in a branch.

if (this.isBusy) {
  const cmpFactory = this.cfr.resolveComponentFactory(SpinnerComponent)
  this.vcr.createComponent(cmpFactory)
}

Entry components

Angular needs to compile our component before they can be used in the application. If the component is never referenced in the template, Angular won't know it needs to compile it. This is the case with our Spinner component, as we're only adding it dynamically from code.

To tell explicitly Angular to compile a component, add it to NgModule.entryComponents.

@NgModule({
  ...
  entryComponents: [SpinnerComponent],
  ...
})

Full code

@Directive({selector: '[xuiBusy]'})
export class BusyDirective implements OnInit {

  @Input('xuiBusy') isBusy: boolean;

  constructor(private templateRef: TemplateRef<void>,
              private vcr: ViewContainerRef,
              private cfr: ComponentFactoryResolver) { }

  ngOnInit() {
    this.vcr.createEmbeddedView(this.templateRef)
    if (this.isBusy) {
      const cmpFactory = this.cfr.resolveComponentFactory(SpinnerComponent)
      this.vcr.createComponent(cmpFactory)
    }
  }

}

Usage examples (check out the demo as well)

<div *xuiBusy="true">
  <span>This is some content</span>
</div>

<div *xuiBusy="false">
  <span>This is some content</span>
</div>
Mod answered 1/9, 2017 at 21:59 Comment(9)
No provider for TemplateRef! what if there is nothing in your div, and the component is precisely what you want to addDepressive
thanks for your really useful answer, and do you know how to paste the component into child <div *xuiBusy="false"> ?Kirima
Very interesting. What if the created component (e.g the spinner) needs data from outside? Can I use @Input, where do I set and how do I pass those data?Vaenfila
@Vaenfila Just place it on the selector, it will understand. I suggest checking out the source code for the way *ngFor works without using *. It's basically just a bunch of inputs.Galenical
@Lazar Ljubenović sorry, I didn't get it. Could you extend your example, eg. taking a property (e.g a string) from structural directive and display that in the spinner component?Vaenfila
@Lazar Ljubenović I've read I can use bindings (like I would use in the spinner) only with static components and templates. I have a solution in this example: stackblitz.com/edit/so-43944487-pdvgnq . Another way could be a shared service.Vaenfila
Please ask a new question if you have issues.Galenical
The demo does not answer the original question @LazarLjubenović... it seems that the SpinnerComponent span is added beneath the div containing the directive attribute, not inside it. ?Huihuie
@AdamMarshall It's just how Angular's outlets work. You should be able to easily change your markup.Galenical
M
0

I think the key to get elemenRef AFTER the directive done the job. Because it obviously easy to add a 'div' in non-structural directive.

  const div = this.renderer.createElement('div');
  const text = this.renderer.createText('Hello world!');
  this.elementRef = this.elementRef;

  this.renderer.appendChild(div, text);
  const nativeElement = this.elementRef.nativeElement;
  this.renderer.appendChild(nativeElement, div);

also you can add component in simular way: full-code-is-there

 this.spinner = this._viewContainerRef.createComponent(MatProgressSpinner);
      this.spinner.instance.color = this.color;
      this.spinner.instance.diameter = 24;
      this.spinner.instance.mode = 'indeterminate';
      this._renderer.appendChild(
        this._elementRef.nativeElement,
        this.spinner.instance._elementRef.nativeElement
      );

but however you can get elementRef in structure directive (by templateRef.elementRef for example) it will be point to comment element.

I do not know how get elementRef with correct html(i.e. after rendering) or to the template html-data to change the template before rendering.

any ideas?

Madigan answered 1/2, 2023 at 9:27 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.