DEV Community

Mathias Remshardt
Mathias Remshardt

Posted on

Optional content projection/injection in Angular

Optional content projection/injection in Angular

Recently I had the requirement to make part of a component (the header of a custom table) replaceable with custom content. In case nothing is provided the implementation was supposed to render "default" content. A simple property was not sufficient as the injected/projected content could be anything ranging from simple text to a slider/toggle...

The requirements could be summarized as follows:

  1. Render custom content in case it is provided
  2. Render default content otherwise

I was torn between using ng-content or ng-template to solve the problem. To make an informed decision I created a POC implementing both options to see if one is superior to the other. In contrast to the requirements, the created POC allows for replacing multiple contents (like a header and a footer) to verify that the solution could be extended in the future (if needs arise). The next sections describe the alternatives I could come up with.

ng-content

This is usually the first option as it is simple to implement and use. The custom contents are provided as children using ng-content. By using a select attribute multiple contents can be projected as well:

<ng-content select="[slot='header']"></ng-content>
<ng-content select="[slot='footer']"></ng-content>
Enter fullscreen mode Exit fullscreen mode

This covers the first requirement. The second is more difficult to realize using ng-content alone. The figure out whether to render the custom or default content requires some means to determine if something has been passed as ng-content or not. I was not able to find any build-in feature to query/get that information from the component or template so a custom solution is required.

One option is to create a directive which is put on the content to be projected (appSlot in the example below):

<app-render-slot>
  <div appSlot slot="header">Custom Header</div>
  <div appSlot slot="footer">Custom Footer</div>
</app-render-slot>
Enter fullscreen mode Exit fullscreen mode

The component can search for the directive(s) using a @ContentChildren query. In case something is found for the placeholder the custom content is used, otherwise it falls back to the default content:

@Component({
  selector: 'app-render-slot',
  templateUrl: './component.html',
  styleUrls: ['./component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class RenderSlotComponent {
  @ContentChildren(SlotDirective, { read: ElementRef }) set slotDirectives(
    value: QueryList<ElementRef>
  ) {
    this.nativeSlots.next(Array.from(value));
  }

  private nativeSlots: BehaviorSubject<Array<ElementRef>>;
  readonly slotNames$: Observable<SlotNames>;

  constructor() {
    this.nativeSlots = new BehaviorSubject<Array<ElementRef>>([]);

    this.slotNames$ = this.setSlotsByName(this.nativeSlots.asObservable());
  }

  isSlotSet(slotName: SlotName): Observable<boolean> {
    return this.slotNames$.pipe(
      map((slotNames) => slotNames.includes(slotName))
    );
  }

  private setSlotsByName(
    slots$: Observable<Array<ElementRef>>
  ): Observable<SlotNames> {
    return slots$.pipe(
      map((slots) =>
        slots.map((slot) => slot.nativeElement.getAttribute('slot'))
      )
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

For the example, the "name" of the slot ("header" or "footer") is extracted based on what has been set for the custom "slot" attribute on the projected content. The ElementRef to look for is marked/identified by the SlotDirective and extracted with the @ContentChildren query. The other part of the implementation just maps the list of ElementRefs to the slot names.

With help of the isSlotSet method, the template can either render the custom content (in case the slot is found) or fall back to a default content.

For the sake of the example, the template of the component is kept simple containing only the ng-content placeholders:

<ng-content
  select="[slot='header']"
  *ngIf="isSlotSet('header') | async; else defaultHeader"
></ng-content>
<ng-content
  select="[slot='footer']"
  *ngIf="isSlotSet('footer') | async; else defaultFooter"
></ng-content>

<ng-template #defaultHeader> Default Header </ng-template>
<ng-template #defaultFooter> Default Footer </ng-template>
Enter fullscreen mode Exit fullscreen mode

The alternative described here can be found in the ng-content/render-slot folder in the example repository. When removing either the "Custom Header" or "Custom Footer" div in the AppComponent template for app-render-slot the default fallback will be rendered.

with SlotRenderer

Heads up: This solution does not work, so please skip ahead in case not of interest.

The approach described above has the disadvantage that each component with optional content projection does have to implement the mechanism to find/determine the rendered content.

My idea was to improve the solution by creating a "helper" component called SlotRendererComponent, which would be responsible for rendering the content passed by the using component:

<app-slot-renderer [defaultSlotContent]="defaultHeader"
  ><ng-content select="[slot='header']"></ng-content
></app-slot-renderer>
<app-slot-renderer [defaultSlotContent]="defaultFooter"
  ><ng-content select="[slot='footer']"></ng-content
></app-slot-renderer>

<ng-template #defaultHeader> <div>Default Header</div> </ng-template>
<ng-template #defaultFooter> <div>Default Footer</div> </ng-template>
Enter fullscreen mode Exit fullscreen mode

The custom content gets provided using ng-content and the select attribute (the latter could be omitted in case there is only a single ng-content to project). The default content is passed as TemplateRef using an Input property.

The SlotRendererComponent also uses ng-content to render what has been projected from the using component which would be

<ng-content *ngIf="isSlotSet$ | async; else defaultSlotContent"></ng-content>
Enter fullscreen mode Exit fullscreen mode

The custom content originally passed is therefore projected twice:

  • First to the outer component (RenderSlotSlotRendererComponent in the example)
  • Second to the SlotRendererComponent

The flattened hierarchy looks something like this (not the real DOM structure):

<!-- From SlotRendererComponent  -->
<ng-content *ngIf="isSlotSet$ | async; else defaultSlotContent">
  <!-- From RenderSlotSlotRendererComponent  -->
  <ng-content select="[slot='header']">
    <!-- Projected custom content   -->
    <div appSlot slot="header">Custom Header</div>
  </ng-content>
</ng-content>
<!-- Same for the footer -->
Enter fullscreen mode Exit fullscreen mode

By the same mechanism as in the first approach, the custom or default content will be rendered by SlotRendererComponent.
The reason why this solution is not working is due to @ContentChildren not being able to query nested ng-contents. Setting { descendants: true } also did not work for me. I found an issue describing the problem for the AngularDart repository so maybe it is related (or I am doing something wrong here ;) ).

ng-template

with template properties

One option for the ng-template based solutions is to directly pass the custom contents in a property as TemplateRefs.

<app-template-render-props
  [templates]="{ 'header': header, 'footer': footer }"
></app-template-render-props>

<ng-template #header><div>Custom Header</div></ng-template>
<ng-template #footer><div>Custom Footer</div></ng-template>
Enter fullscreen mode Exit fullscreen mode

The provided TemplateRef for each slot is rendered using *ngTemplateOutlet. Same as for the ng-content approach the component falls back to a default content in case nothing has been defined (done by the RenderTemplateComponent helper in the example).

<app-render-template
  [template]="{ customTemplate: templates.header, defaultTemplate: defaultHeader }"
></app-render-template>
<app-render-template
  [template]="{ customTemplate: templates.footer, defaultTemplate: defaultHeader }"
></app-render-template>

<ng-template #defaultHeader> <div>Default Header</div> </ng-template>
<ng-template #defaultFooter> <div>Default Footer</div> </ng-template>
Enter fullscreen mode Exit fullscreen mode

with directive

Having to define a dedicated ng-template wrapper for each custom content is inconvenient to use and clutters the template of the using component. This can be avoided by using a structural directive storing the TemplateRef as well as the slot name:

@Directive({
  selector: '[appTemplateSlot]'
})
export class TemplateSlotDirective {
  @Input() appTemplateSlot: SlotName | null = null;

  constructor(public template: TemplateRef<unknown>) {}
}
Enter fullscreen mode Exit fullscreen mode

The directive takes the slot name ("header" or "footer" in the example) as input property and stores the associated TemplateRef in a public template property (the unknown type of TemplateRef could be replaced by the associated context in case it is known/available).

The rendering component can now query for the TemplateSlotDirectives using @ContentChildren and render the stored template to the associated slot:

@Component({
  selector: 'app-render-props-directive',
  templateUrl: './component.html',
  styleUrls: ['./component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class RenderPropsDirectiveComponent {
  @ContentChildren(TemplateSlotDirective) set templateSlots(
    templateSlots: QueryList<TemplateSlotDirective>
  ) {
    this.templateDirectives.next(
      templateSlots.length > 0 ? Array.from(templateSlots) : []
    );
  }

  private templateDirectives: ReplaySubject<Array<TemplateSlotDirective>>;
  templates$: Observable<Partial<Templates>>;

  constructor() {
    this.templateDirectives = new ReplaySubject(1);

    this.templates$ = this.setupTemplates(
      this.templateDirectives.asObservable()
    );
  }

  private setupTemplates(
    templateDirectives$: Observable<Array<TemplateSlotDirective>>
  ): Observable<Partial<Templates>> {
    return templateDirectives$.pipe(
      map((templateDirectives) =>
        templateDirectives.reduce(
          (partialTemplateDirectives, templateDirective) =>
            templateDirective.appTemplateSlot
              ? {
                  ...partialTemplateDirectives,
                  [templateDirective.appTemplateSlot]:
                    templateDirective.template
                }
              : partialTemplateDirectives,
          {}
        )
      ),
      shareReplay({ bufferSize: 1, refCount: true })
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

As usual the rendering component now either renders the custom or fallback content for each slot:

<app-render-template
  [template]="{ customTemplate: (templates$ | async)?.header, defaultTemplate: defaultHeader }"
></app-render-template>
<app-render-template
  [template]="{ customTemplate: (templates$ | async)?.footer, defaultTemplate: defaultHeader }"
></app-render-template>

<ng-template #defaultHeader> <div>Default Header</div> </ng-template>
<ng-template #defaultFooter> <div>Default Footer</div> </ng-template>
Enter fullscreen mode Exit fullscreen mode

As shown below the ng-template wrapper is now replaced by putting the TemplateSlotDirective selector on the the custom content:

<app-render-props-directive>
  <div *appTemplateSlot="'header'">Custom Header</div>
  <div *appTemplateSlot="'footer'">Custom Footer</div>
</app-render-props-directive>
Enter fullscreen mode Exit fullscreen mode

Conclusion

With both the ng-content as well as the ng-template it was/is possible to fulfill the requirements to either display custom content or fall back to rendering a default.
I prefer the ng-template based solution as:

  • When used with a structural directive provides the same ease of use as ng-content for the using component (especially within the template).
  • It allows for extracting all the repetitive rendering related implementations which can be reused for components requiring the same "feature". This is/was not possible for the ng-content based solution due to the issue with querying nested ng-contents using @ContentChildren.

The complete code for the POC can be found here.

Top comments (0)