notification-renderer
v0.0.0
Published
This is a component that can dynamically instantiate Angular components inside of an given html template, and render that template. Thereby allowing you to insert Angular components into HTML that may be generated at run time, or received externally.
Readme
ng-html-renderer
This is a component that can dynamically instantiate Angular components inside of an given html template, and render that template. Thereby allowing you to insert Angular components into HTML that may be generated at run time, or received externally.
How to use
The ng-html-renderer component is given two inputs, components and html.
Components
// components
let myComponents: ComponentSelectorPair[] = [
{
component: MyCoolComponenent,
selector: 'my-cool-component'
},
{
component: MyOtherComponent,
selector: 'other-thing'
},
// ...
]The components list is simply a list of ComponentSelectorPairs, which have the Angular component's class name, and a string selector by which elements will be matched in the html template. The selector does not need to match the selector used in the component's metadata.
The selector will then be expected to found on the element you wish to insert into as an attribute.
HTML
<div class="my-html-partial">
<p>
If you tried to just insert this template into the DOM, you wouldn't be able to do anything with these buttons, unless you tried to grab them manually and add your own event handlers, styles, manual bindings, etc...
</p>
<!-- You can't point this onclick handler at your component class code at 'compile' time -->
<button onclick="myFunc($evevnt)"></button>
<div my-cool-component></div>
<span other-thing data-myCaption="custom value"></span>
</div>Your html is an html partial, that is written as standard html. It is not necessary to write the entire html document (<html> tag, <head> tag, <body> tag...). Attributes are added to elements corresponding to the given selectors for components you want to render in your html. You can add data-* attributes as well for data you want to pass to the component, as though it were through an @Input(). However it is not truly data-bound, and will only write once. Additionally, all values will be passed as strings. If you need a value as a type other than a string, you will need to convert it yourself. Although at runtime the attributes are in lowercase, the renderer will try to match them to fields in your component by a case-insensitive test.
ng-html-renderer
// You have your template/components variables
let myTemplate = `<div class="my-html-partial">
<p>I'm an HTML template that's really just a string. Observe that I'm just a partial, not a full document</p>
<p>Technically any string is valid html not matter how badly formatted
<span></p> like this incorrect closure here.</span>
<p> It doesn't matter what kind of element you put your component selector on.</p>
<span my-cool-component></span>
<p>But some cases may be incredibly impractical</p>
<ul my-component-that-doesn't-resolve-to-lis></ul>
<p class="fancy-paragraph">You can still put classes on your html elements, and those classes should resolve in your global style scope.</p>
<p class="my-component-scoped-css-class">Angular adds attribute selectors to scope component css, so they won't go beyond the component, up or down, without ::ng-deep</p>
<p>In an ideal scenario, the consumer should be aware of classes the template may have, and provide style rules for those classes.</p>
</div>`;
let myComponents: ComponentSelectorPair[] = [
{
component: MyCoolComponenent,
selector: 'my-cool-component'
},
{
component: MyOtherComponent,
selector: 'other-thing'
},
// ...
]
<div class="your-consumer-component-context">
<!-- provide your values through typical data binding -->
<ng-html-renderer [components]="myComponents" [template]="myTemplate"></ng-html-renderer>
</div>After you pass at least a template html value, the ng-html-renderer will then attempt to render your html. Providing components is not necessary, and is advised if it is known ahead of time you won't want to render any new Angular components inside the template (Though that would defeat the purpose of using this component). The renderer will then after appending to the DOM, search the tree for any elements that contain attributes matching the selectors provided, and instantiate the associated component. The component will go through the regular Angular lifecycle and dependency injections as though it was rendered through normal means.
The renderer has one event, renderFinished which will emit when the renderer has fully completed the process of html parsing, appending to the DOM, and inserting dynamically created Angular components.
<div class="your-consumer-component-context">
<!-- provide your values through typical data binding -->
<ng-html-renderer [components]="myComponents" [template]="myTemplate" (renderFinished)="onRenderFinish($event)"></ng-html-renderer>
</div>onRenderFinish(components: ComponentRef<any>[]) {
// components constitutes a list of ComponentRefs corresponding to the components create dynamically. From here you have references to these components by which you can manually interact with them.
(components[0].componentInstance as MyCoolComponent).someEvent.subscribe(() => {});
// ComponentRefs have a field denoting their component type, so you shouldn't need to blindly cast things
}The renderer will destroy any dynamically instantiated components when either itself is destroyed or if it renders new html. There should be no need for the consumer to need manage the lifecycle or lifetime of dynamically created components.
Styling
Since component styles cannot normally apply beyond the scope of the component in which they're defined, in order to correctly style elements in the rendered template, styles need to be available in a global way.
One way to do this would be to define styles in a global stylesheet, or define them in the ng-html-renderer consumer as use the ::ng-deep selector to make shadow piercing styles.
How it works
When html is set, the renderer begins the process of working through the template.
ngOnDestroy() {
this.destoryAllRenderedComponents();
this.destroyComponentFactories();
}
/**
* Attempt to parse the given HTML and add it to the DOM, and render the angular components
* @param html string representing HTML to attempt to render.
*/
private parseToDomElements(html: string): void {
// destroy what we've made so far.
this.ngOnDestroy();
//...
}The first step of the render process is to attempt to try and clean up after our last render attempt by destroying what has been rendered so far and cleaning up potentially stale component factories.
// add the html to our component
this.notifWrapper.nativeElement.innerHTML = html;
// do not attempt further rendering if there are no components to render
if (this.components) {
this.renderAngularComponents(content);
}We then append the created DOM tree to our wrapper element. If we were provided components, we proceed to render them.
It is important to note that the rendered will not make any attempt to sanitize your HTML for malicious javascript or links, it is your responsibility to only use HTML that you trust.
Why not? Angular's built in DOM sanitizer implementation also strips attributes and CSS clases which are fundamental to how the renderer works.
/**
* Try and render the angular components in given element
* @param root element to check for and render angular components under
*/
private renderAngularComponents(root: Element) {
// for each element we should be able to render
for (let component of this.components) {
// find each of those elements in the template, and for each of them...
root.querySelectorAll(`*[${component.selector}]`).forEach(el => {
// We can add the component onto an arbitrary DOM node, this case being the element from the query selector
let cmpRef = this.createComponent(component.selector, el);
// ...We iterate through the components, on each iteration we search the root of our small tree and down for elements with the component selector as an attribute. For each found instance we create the associated component.
/**
* Create a component, generates the component factory if necessary
* @param selector The selector of the component we want to make
* @param element the element to make the component on
*/
private createComponent(selector: string, element: Element): ComponentRef<Type<any>> {
if (!(selector && selector.length && element)) {
let haveSelector = selector && selector.length;
let bothMissing = !(haveSelector || element);
let err = new Error(`${haveSelector ? '' : 'selector'}${bothMissing ? ' and ' : ''}${element ? '' : 'element'} ${bothMissing ? 'were' : 'was'} expected to be a non null value`)
throw err;
}
let componentType = this.componentSelectorMap[selector];
let componentFactory: ComponentFactory<Type<any>>;
let makeComponentFactory = () => {
componentFactory = this.componentFactoryResolver.resolveComponentFactory(componentType);
this.componentFactories[selector] = componentFactory;
}
// if the componentFactories object exists
if (!(this.componentFactories == null && this.componentFactories == undefined)) {
// try to get the factory we want
componentFactory = this.componentFactories[selector];
if (componentFactory == null || componentFactory == undefined) {
// if we didn't get it, make the factory
makeComponentFactory();
}
}
else {
// the componentFactories object doesn't exist at all, must make the factory keeper
this.componentFactories = {};
makeComponentFactory();
}
let component = componentFactory.create(this.injector, [], element);
return component;
}We check to see if we have already made this component factory. If we have made it already, retrieve the reference, otherwise create the factory, if the bookkeeping doesn't exist, create that too. Then create the component and return it. The created component will be put inside the given element, and passed this component's injector reference.
// We can add the component onto an arbitrary DOM node, this case being the element from the query selector
let cmpRef = this.createComponent(component.selector, el);
// attempt to parse out extra data items and give them to the dynamic component.
let attrList = Array.from(el.attributes);
this.attemptToAssignComponentInputs(attrList, cmpRef);We check this element for data attributes intended to pass along to the component. This checks for attributes that match the pattern data-*. For those found attributes, the remainder of the key (suppose data-myField, myField is used) is used to add the attribute value to the component. For these purposes, the component instance is treated as a simple javascript object. The value will be added irrespective of if the component's class. Therefore, fields that are included as data attributes will be added to the component, regardless of if the component's class defines them. However, these values will not be available for use at compile time without skipping type checks.
if (this.componentReferences == null || this.componentReferences == undefined) {
this.componentReferences = [];
}
// add this guy to our reference list
this.componentReferences.push(cmpRef);The last part of creating the Angular component is to push the created component reference on to our list of created components, so we can later destory it.
// emit the component references
let emission = this.componentReferences ? this.componentReferences.slice() : null;
this.renderFinished.emit(emission);After the component render process is down, we emit the renderFinished event with the component references.
Packaged Types
ComponentSelectorPair
interface ComponentSelectorPair {
componentType: Type<any>;
selector: string;
}The ComponentSelectorPair interface represents a component to render and the attribute selector by which an intended creation will be made. componentType should be the class of the Angular component to render. selector is the attribute that will be selected by.
Angular 16+ Migration
Angular 16 is required to utilize this package version. Please use node 18.19.1 and npm 6.14.4.
The NgHtmlRendererModule is no longer a required import, as the HTMLComponentRendererComponent this library exports has been converted to standalone. Consumers that wish to use the renderer must include the import inside of the @Component declaration:
@Component({
selector: 'your-component',
templateUrl: './your-component.component.html',
styleUrls: ['./your-component.component.scss'],
standalone: true,
imports: [HTMLComponentRendererComponent]
})