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:
- Render custom content in case it is provided
- 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>
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>
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'))
)
);
}
}
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 ElementRef
s 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>
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>
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>
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 -->
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-content
s. 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 TemplateRef
s.
<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>
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>
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>) {}
}
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 TemplateSlotDirective
s 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 })
);
}
}
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>
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>
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 nestedng-content
s using@ContentChildren
.
The complete code for the POC can be found here.
Top comments (0)