Angular 2 transclusion: Can I pass slot content upward to a parent Component
Asked Answered
S

4

9

I have an outer component (blue-green) featuring some flexbox toolbars with a bunch of my own ui-button buttons. And an inner component, mostly doing its thing in the brown area (as you would expect).

However, depending on the inner component (there are several ones what are switched forth and back), a few more contextual buttons must be inserted in that top/bottom bar.

(CSS tricks with absolute positioning stuff on the outside are not an option, depending on size and convenience the outer toolbars can vary pretty much in position, size and so on...)


Now my question is:

Can I somehow pass in a reference to some placeholder (black square brackets) (just like regular content projection/transclusion), and have them filled by content coming from the child component?

With something like ngTemplate[Outlet] perhaps? And/or using @Output?

I want to “pass upwards” more than plain text or simple <b>rich</b> <i>html</i> but ideally true angular template code, including custom components like

    <ui-button icon="up.svg" (click)='...'></ui-button>
    <ui-button icon="down.svg" (click)='...'></ui-button>

...leading in the outer component's top bar to:

<section class='some-flexbox'>
    <ui-button icon="home.svg" (click)='...'></ui-button>

    <ui-button icon="up.svg" (click)='...'></ui-button>
    <ui-button icon="down.svg" (click)='...'></ui-button>

    <ui-button icon="help.svg" (click)='...'></ui-button>
    <ui-button icon="account.svg" (click)='...'></ui-button>
</section>

Thinking about it, the click events of those buttons added should find their way back home into the child, sigh...

update

ngTemplateOutlet sounds pretty interesting

We can also take the template itself and instantiate it anywhere on the page, using the ngTemplateOutlet directive:

Examining, still unsure how...

Shipload answered 26/2, 2020 at 16:51 Comment(4)
Have you tried using a common service for this?Unbalanced
@Unbalanced Can you provide an example, how to do that?Shipload
hey @FrankNocke you've got a few answers here, can you try and provide some feedback?Carry
— yes, apologies for the delay! Today or tomorrow it will happen!Shipload
M
7

There are two possible ways you can achieve the desired behavior.

  1. Using angular CDK's PortalModule
  2. By Creating a custom directive which uses javascript dom apis to move an element from child component to the parent component.

Here is a quick explanation for both the solutions.

1. Using PortalModule Demo Link

You can define a template inside child component's template file.

<ng-template #templateForParent>
    <button (click)="onTemplateBtnClicked('btn from child')">
    Button from Child
  </button>
</ng-template>

then grab the reference of that template inside your child component file using @viewChid decorator.

@ViewChild("templateForParent", { static: true }) templateForParent: TemplateRef<any>;

then you can create a TemplatePortal using that template ref and pass that portal to the parent whenever you want.

ngAfterViewInit(): void {
    const templatePortal = new TemplatePortal(this.templateForParent, this.viewContainerRef);
    setTimeout(() => {
      this.renderToParent.emit(templatePortal);
    });
  }

And the parent component's template file may look like this.

<div fxLayout="row" fxLayoutAlign="space-between center">
  <button (click)="onBtnClick('one')">one</button>
  <button (click)="onBtnClick('two')">two</button>
  <ng-container [cdkPortalOutlet]="selectedPortal"></ng-container> <!-- pass the portal to the portalOutlet -->
  <app-button-group (renderToParent)="selectedPortal = $event"></app-button-group>
  <button (click)="onBtnClick('five')">five</button>
</div>

that's it. Now you have rendered a template to the parent component and the component still has bindings for click event. And the click event handler is defined inside the child component.

The same way you can use ComponentPortal or DomPortal

2. Using custom directive Demo Link

You can create one angular directive as follows which will move the elements from its host component to the parent of the host component.

import {Directive,TemplateRef,ElementRef,OnChanges,SimpleChanges,OnInit,Renderer2,DoCheck} from "@angular/core";

@Directive({
  selector: "[appRenderToParent]"
})
export class RenderToParentDirective implements OnInit {
  constructor(private elementRef: ElementRef<HTMLElement>) {}

  ngOnInit(): void {
    const childNodes = [];
    this.elementRef.nativeElement.childNodes.forEach(node =>
      childNodes.push(node)
    );
    childNodes.forEach(node => {
      this.elementRef.nativeElement.parentElement.insertBefore(
        node,
        this.elementRef.nativeElement
      );
    });

    this.elementRef.nativeElement.style.display = "none";
  }
}

And then you can use this directive on any component.

<div fxLayout="row" fxLayoutAlign="space-between center">
  <button (click)="onBtnClick('one')">one</button>
  <button (click)="onBtnClick('two')">two</button>
  <app-button-group appRenderToParent></app-button-group>
  <button (click)="onBtnClick('five')">five</button>
</div>

here <app-button-group> has following template file.

<button (click)="onBtnClick('three')">three</button>
<button (click)="onBtnClick('four')">four</button>

So our directive will move both the button element to the parent component of its host. hare we are just moving DOM nodes in the DOM tree so all the events bound with those elements will still work.

We can modify the directive to accept a class name or an id and to move only those elements which with that class name or the id.

I hope this will help. I suggest reading docs for more info on PortalModule.

Mure answered 21/3, 2020 at 20:15 Comment(1)
(Apologies, I only realized now that „accepting answer“ and „awarding bounty“ are two clicks, not just one. But now it happened :)Shipload
R
1

If each inner component defines a template for the header content and another template for the footer content, and associates these templates to public properties having a specific name (e.g. header and footer), the outer component can access these properties and embed the content of each template in an ngTemplateOutlet.

Define the templates in the inner component:

<ng-template #header1>
  <button (click)="onChild1HeaderClick('A')">Command A</button>
  <button (click)="onChild1HeaderClick('B')">Command B</button>
</ng-template>
<ng-template #footer1>
  <button (click)="onChild1FooterClick('C')">Command C</button>
</ng-template>

and associate them to the properties header and footer:

@ViewChild("header1", { static: true }) header: TemplateRef<any>;
@ViewChild("footer1", { static: true }) footer: TemplateRef<any>;

Assuming that the inner component is inserted in the parent component with a router outlet, associate a template reference variable #inner to the outlet:

<router-outlet #inner="outlet"></router-outlet>

and use that variable to access the header and footer properties of the inner component, and to insert their content in the outer component:

<section>
  ...
  <ng-container *ngTemplateOutlet="inner.isActivated ? inner.component.header : null">
  </ng-container>
</section>
...
<section>
  ...
  <ng-container *ngTemplateOutlet="inner.isActivated ? inner.component.footer : null">
  </ng-container>
</section>

You can take a look this stackblitz for a demo. A simpler case is shown in this other stackblitz, where the inner component is declared directly in the outer component template.

Rodrigorodrigue answered 18/3, 2020 at 0:21 Comment(0)
C
1

You can't use output here as you can't get output from transcluded content, but there are a few ways to accomplish this...

this all relies on the TemplateRef type and ViewChild or ContentChildren, all of which can be imported from angular core:

import { TemplateRef, ViewChild, ContentChildren } from '@angular/core'

Using Shared Service

You can actually use a service to pass templates around pretty effectively.

consider a simple service to pass templates via a Subject like so:

@Injectable()
export class TemplateService {
  // allow undefined for resetting
  private templateSource = new Subject<TemplateRef<any> | undefined>()
  template$ = this.templateSource.asObservable()
  setTemplate(template?: TemplateRef<any>) {
    this.templateSource.next(template)
  }
}

and a host component that provides and subscribes to it:

@Component({
  selector: 'host',
  templateUrl: './host.component.html',
  // providing the service here makes sure the children of the component (and ONLY this children of this component) will have access to the same service instance
  providers: [TemplateService]
})
export class HostComponent  {
  childTemplate?: TemplateRef<any>

  constructor(private templateService: TemplateService) {
    // receive templates from the service and assign
    this.templateService.template$.subscribe(t => this.childTemplate = t)
  }
}

with a template that defines an ngTemplateOutlet:

<div>
  <h1>Host Content</h1>
  <ng-container *ngTemplateOutlet="childTemplate"></ng-container>
</div>

<ng-content></ng-content>

*NOTE: you said you switch the content inside your component quite often, I'm asssuming you use ng-content to do that, but this method would work equally well with a router outlet.

then a child component that sends the template through the service:

@Component({
  selector: 'child1',
  templateUrl: './child1.component.html',
})
export class Child1Component  {
  // access template with ViewChild
  @ViewChild('childTemplate')
  childTemplate?: TemplateRef<any>

  constructor(private templateService: TemplateService) {
  }

  ngAfterViewInit() {
    // set here in afterViewInit hook when ViewChild is available
    this.templateService.setTemplate(this.childTemplate)
  }

  ngOnDestroy() {
    // need to clean up
    this.templateService.setTemplate()
  }

  childFunc() {
    console.log('child func called')
  }
}

with template that defines the template to pass with ng-template:

<h2>Child 1 Content</h2>

<ng-template #childTemplate><button (click)="childFunc()">Run Child Func</button></ng-template>

use it like so:

<host>
  <child1></child1>
</host>

this will effectively pass the child template from the child to the host, and the functions defined in that template will still run in the child. The only real gotcha here is that you need to make sure you clean up after yourself in the onDestroy hook. If a given child doesn't have a special template, that's fine too. Just nothing will be in the template outlet. If the child components need to be available in other contexts, simply mark the templateService as optional and only set the template if it's provided.

blitz (expanded with header / footer): https://stackblitz.com/edit/angular-7-master-v81afu

Using Content Children

An alternative is to use ContentChildren, if you defined your host like this (same template and children as before but without the service stuff):

@Component({
  selector: 'host',
  templateUrl: './host.component.html',
})
export class HostComponent  {
  childTemplate?: TemplateRef<any>

  // use content children to access projected content
  @ContentChildren('child')
  children: QueryList<any>  

  ngAfterContentInit() {
    // set from initial child
    this.childTemplate = (this.children.first || {}).childTemplate
    // listen for changes and reset
    this.children.changes.subscribe(child => {
      this.childTemplate = (child.first || {}).childTemplate
    })
  }
}

then you'd just need to mark the possible children when you use your host:

<host>
  <child1 #child></child1>
</host>

Here the gotcha is you need to make sure you have at most one marked content child available at a time. Otherwise it'll just take the first one (or you could define whatever logic you want to find the template you want). The null checks make a child without a template perfectly acceptable.

blitz (expanded with header / footer): https://stackblitz.com/edit/angular-7-master-8gc4yb

IMPORTANT: In either case you MUST clean up. holding a reference to the child's template in the host after the child is destroyed creates the potential for a memory leak or buggy behavior as the children won't be able to be destroyed or garbage collected correctly.

The service method is a bit more general / flexible, such as you can pass template from children of children easily, or you can switch the template from the child quite easily, or you can do it with a router outlet instead of transclusion. But ContentChildren is a bit more straight forward, though it only works with ng-content. Both are equally valid but depend on your use case.

Carry answered 18/3, 2020 at 0:56 Comment(0)
C
0

I see couple ways of doing that, they all depend on application complexity. The most simple one is to have a host component containing blue-green and brown components.

So structure code as follows:

  • host.component
    • blue-green.component
    • brown.component

Blue-green and brown components should have be stateless, just @Input() arguments that tell what to display and @Output() events (potentially with payload) emitting the fact that the component has clicked.

Host component would be stateless and would subscribe to these @Output() and control how blue-green and brown components are rendered.

I've done that in some projects on very small features - it worked flawlessly. If you need a example showcasing that - let me know.

Other options would be creating a singleton service that would contain state and all components would subscribe to this state, if things are even more complicated, you could adopt state management libraries.

Coworker answered 16/3, 2020 at 17:24 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.