angular
angular copied to clipboard
Support for Dynamic Content Projection
Use Case
<ng-content> allows for static (compile time resolution) projection of content. There are cases where the projection needs to be controlled programmatically. A common example is when the parent component would like to wrap/decorate the child components during projection.
Mental Model
The mental model would be that <ng-content> is for fast static projection, but for dynamic projection one would use a query to get a hold of the child components in the form of ViewRefs which could then be inserted programmatically at any location in the tree.
Possible Solution
ContentViewRefextendsViewRef@Content(selector)Provides a way to select direct children content elements using CSS selectors. (This would be after directives such asngForwould unroll the content, and it would be in the correct order.)- This would allow selection of elements rather than directives. These
ContentViewRefs could then be reinserted to anyViewContainerRef - We would need
ngViewOutlet(similar tongTemplateOutlet).
@Component({
template: `
<div *ngFor="let view of allViews">
<template ngViewOutlet="view"></template>
</div>
<ng-content><!-- project non selected nodes, such as text nodes --></ng-content>
`
})
class MySelect {
@Content('*') allViews: ContentViewRef[];
// another example
@Content('my-option') optionViews: ContentViewRef[];
}
@Component({
selector: 'app',
template: `
<my-select>
Option
<my-option>header</my-option>
<my-option *ngFor="let item in items">unrolled items {{item}}</my-option>
<my-option-group>
<my-option>
foo <b>bar</b>
<other-directive />
</my-option>
<my-separator></my-separator>
<my-option></my-option>
</my-option-group>
</my-select>
`
})
class App {}
@tbosch looking for your input on how feasible this is. @pkozlowski-opensource & @mlaval looking for your input if this fits your use case. /cc @vsavkin
@mhevery thnx for opening this one. After reading through the proposal it looks like it would cover use-cases we've discussed yesterday plus solve some other ones I've bumped into previously. So it is all good from this perspective.
There would be much more details to figure out and APIs to design, but IMO we can start working off your initial proposal. It would be great to have @tbosch input and see how we can move forward on this item.
Technically this would work.
However, I don't like calling these projected elements view as:
- these ContentViews consist of always only one element, not potentially multiple ones.
- we explain to our users that a "View" is an instance of a template, which is either the content of the
templateproperty in the@Componentannotation or an embedded template via<template>or<...*...>. These ContentViews are none of this. - Using a
ViewRefwould require using anAppViewinternally to keep track of the elements. However, aAppViewclass contains a lot of logic that is not needed for this use case (e.g. change detectors, debug information, ...) that is not needed here.
Under the cover, we would do the following:
- for each property annotation with
@Content, collect the list of matching elements from the content area of the component. Note that this needs to to already recurse into projectedViewContainerRefs (e.g. from projectedngFors). I.e. the property needs to be updated whenever a projectedngForunrolls. - have an API to insert these nodes /
ViewContainerRefs after/before other nodes.
Regarding how / when to update the property: Use a QueryList as value for the property annotated with @Content as well.
- i.e. use the same algorithm we already use for marking
QueryLists as dirty when e.g. anngForunrolls - i.e. update the
QueryListafter the content has been checked only if the query is dirty.
Inserting the nodes requires a container that keeps track of them:
Given the following example:
<template [ngIf]>
<template [ngIf]></template>
</template>
Here, the nodes of the nested ngIf template are siblings to the nodes of the parent ngIf template in the DOM. When the parent ngIf becomes false, it also needs to detach the nodes of the nested ngIf template. For this to work, the root nodes of a view are represented as a list that contains either plain nodes or AppElements. When a view needs to be attached/detached, it loops over these nodes and recursed into the root AppElements ViewContainerRef as well.
For ContentViewRefs we need a similar mechanism. I.e. if nodes are inserted at the top level of a View, we need to keep track of the inserted nodes on the AppElement so that we can remove them when needed. It could be a ViewContainerRef or a different container.
Given the above comments / analysis, I think we should do the following:
class MySelect {
// query for nodes:
@ContentChildren('my-option') optionEls: QueryList<ElementRef>;
// inserting nodes:
someAction() {
this.someViewContainer.insertElements(this.optionEls);
}
}
As the optionEls could contain an unrolled ngFor, ViewContainerRef.insertElements needs to subscribe to changes of the QueryList and append new nodes / detach old nodes when the QueryList changes.
Also, we should change the semantics of @ContentChildren / @ViewChildren as follows:
- when given a string, always do a css selector for this element / elements. Don't special case elements that have references on them any more. Right now,
@ContentChildren('someString')only selects elements that have a referencesomeString, e.g.<div #someString>. - if no
readproperty is given in@ContentChildren/@ViewChildren, always return theElementRefs. If a user needs a special directive / component, he should use thereadproperty. Right now,@ContentChildren(...)returns the component instance if there is one present on that element and otherwise anElementRef.
Finally, we should rename ViewContainerRef and its properties:
ViewContainerRef->ContainerRefViewContainerRef.views->ContainerRef.entries: Array<QueryList | ViewRef>
Applied to the use case of wrapping projected elements:
@Component({
template: `
<div *ngFor="let contentEl of allContentEls">
<template [ngViewOutlet]="contentEl"></template>
</div>
`
})
class MySelect {
@ContentChildren('*') allContentEls: QueryList<ElementRef>;
}
I.e. ngViewOutlet will support taking a element directly, wrap it into a QueryList that just contains that single element and call ViewContainerRef.insertElements on its ViewContainerRef.
@tbosch the latest proposal looks really great! I still need to go over all the use-cases I've got on my mind, but looks like a very promising start.
One question for now, though: would I be able to get directives instances present on a given Element so I can make decisions based on this? If so, what would be the proposed API?
To get directives, you have to query for them as well:
class MySelect {
@ContentChildren('*', read: MyOption) allContentOptions: QueryList<MyOption>;
}
We could change this to allow an array in read:
class MySelect {
@ContentChildren('*', read: [MyOption, MyOptionGroup]) allContentOptions: QueryList<MyOption | MyOptionGroup>;
}
Or if you need the combination of ElementRef and MyOption:
class MySelect {
@ContentChildren('*', readTuple: [ElementRef, MyOption, MyOptionGroup]) allContentOptions: QueryList<Array<ElementRef | MyOption | MyOptionGroup>>;
}
The QueryList would always contain an array with 3 entries with the indices:
- 0:
ElementRefinstance - 1:
MyOptioninstance if existing or null - 2:
MyOptionGroupinstance if existing or null
Can does this feature allow us to do processing like $compile ?
@Component({
selector: 'my-cmp',
template: `
<template [ngViewOutlet]="dynamicTemplate"></template>
`
})
class MyComponent {
dynamicTemplate = `<p>Hello</p>`;
}
renderer HTML
<my-cmp>
<p>Hello</p>
</my-cmp>
@laco0416 sorry this has nothing to do with $compile it is more of a custom transclude
@pkozlowski-opensource @mhevery I changed my proposal above to use ElementRefs instead of plain DOM elements. This more aligned to how we give users access to elements in DI.
Also, I think we should only support this API for elements, not for text nodes (which would only be selected by * anyways). Text nodes are just too brittle when it comes to how many there are / looping over them (e.g. a user just adding whitespace between elements results in extra entries).
@tbosch agreed.
Talked with @mhevery a bit more how we can prevent breaking changes. We came up with the following:
- Introduce a new
ElementContainerRefwhich has aaddElements(els: QueryList<ElementRef>)-> no need to changeViewContainerRef - Introduce special selector prefix
css:...(e.g.@ContentChildren('css:my-conent')). With that, Angular will use css selectors to find elements. Also, the result would always beElementRefs (which could still be overwritten via thereadproperty) -> no breaking changes needed for queries either.
@tbosch is this new plan a temporary measure just to avoid breaking changes? Or a proposal for the final solution?
@pkozlowski-opensource the new plan is the actual solution. (since we don't see any benefits of adding breaking changes)
@tbosch Is the css: prefix supposed to land in 2.0? We are currently implementing a component-library that could really benefit from this, as we are now adding dozens of dumb directives just to make a selector "queryable" (also this destroys our tslint statistics, because of wrong selectors for Directives :D). Would be cool to know if this is planned to land in near future! thanks in advance!
I am unsure if I've read this issue correctly. Is there currently no other way to insert a component into another component (i.e. nested) programmatically? ViewContainerRef.createComponent() adds the new component instance as a sibling! This is strange behavior and not sure if it is intended.
@patrickmichalina I've done a bunch of reading on this issue and I believe the behavior of ViewContainerRef.createComponent() is expected. Furthermore, I thought I had found a way to create the component as a child using the ComponentResolver and getting a ComponentFactory and calling create() on that factory. However, that does not seem to work as expected (see issue: 10523).
I was thinking about post a new Feature Request Issue, but I would like that someone here check out that before. I think this issue is related.
That is about ViewContainerRef.createEmbeddedView and @ContentChildren or @ViewChildren, the QueryList property don't detect when a child is added using createEmbeddedView. I have a SO question here and a plunkr that reproduce it: http://plnkr.co/edit/JH7acOXvOOnml53WoIk6?p=preview
I think this feature covert my problem, am I right?
@Mutmansky Thanks for the link. I have figured out a (hacky) workaround that works OK (so far), it is not ideal since it relies on accessing the .nativeElement. But it gets the job done until I can figure out a better way. Code is provided below to see what I did.
public start(containerRef: ViewContainerRef): void { // the component's view ref that we are inserting INTO
let componentFactory = this._compiler.compileComponentSync(YourComponent); // YourComponent was imported with SystemJS
this.spinnerComponent = containerRef.createComponent(componentFactory, 0, this._injector); // this.spinnerComponent is a local instance in the class
containerRef.element.nativeElement.appendChild(this.spinnerComponent.location.nativeElement); // this is where we get to append the node INSIDE the parent component.
}
@patrickmichalina Yes, this is essentially what I'm doing for now as well. Since I'm using ag-Grid and ag-Grid can take a native element, I'm creating the component in an arbitrary location and then giving the native element to ag-Grid and letting it move it. It's not really the best solution in that it involves manual manipulation of the DOM, which Angular2 frowns upon, but it's the only thing I can get to work for now.
What about directives? it should be possible to attach directives on the projected content. How can I workaround until this is fixed?
Hi Guys,
What is the status fo this Mental Model?
Creating and placing component programatically is straight forward but quite not sure how to do this with ng-content.
- Resolve component
- create component with viewContainerRef ...
<my-button> My button Title</my-button>
I can place this button into container but how to add add this content? Just need to add this My button Title in generic maner
Thank you for any pointers,
-frank
edit: Temporary the above solution works for me with the nativeElement.appendChild, but ideally it should be supported by API
Hey Guys,
Having some difficulty with ngTemplateOutlet and ngOutletContext... I'm trying to get a more dynamic table component to work by using templates for the cells, but the row (of data) is not being applied to the context of the template (it shows the parent context that the template was originally created in, i think). I thought this might be a good use case for you to see as well...
Here is the component:
@Component({
selector: 'simple-table',
template: '
<table>
<tr>
<th *ngFor="let column of children">{{column.displayName}}</th>
</tr>
<tr *ngFor="let rowData of data">
<td *ngFor="let column of children"><template [ngTemplateOutlet]="column?.templateElement" [ngOutletContext]="rowData"></template></td>
</tr>
</table>'
})
export class SimpleTableComponent {
@Input() data: Array<any> = [];
@ContentChildren(SimpleTableColumnDirective) children: QueryList<SimpleTableColumnDirective>;
}
here is the data in the parent component:
private data = [{name: 'Simple Activity', enabled: true, type: 'P2P'}, {name: 'Complex Activity', enabled: true, type: '12M'}];
and here is the html I'm trying to use it with:
<simple-table [data]="data">
<simple-column displayName="name"><template><heading [text]="name"></heading></template></simple-column>
<simple-column displayName="Enabled"><template>{{data[0].enabled}}</template></simple-column>
<simple-column displayName="Type"><template>{{this | json}}</template></simple-column>
<simple-column displayName="Other Type"><template>{{row | json}}</template></simple-column>
</simple-table>
Here is what is displayed (the rows only show for the middle two columns):
<simple-table ng-reflect-data="[object Object],[object Object]">
<table>
<tbody><tr>
<!--template bindings={
"ng-reflect-ng-for-of": "[object Object],[object Object],[object Object],[object Object]"
}--><th>Name</th><th>Enabled</th><th>Type</th><th>Other Type</th>
</tr>
<!--template bindings={
"ng-reflect-ng-for-of": "[object Object],[object Object]"
}--><tr>
<!--template bindings={
"ng-reflect-ng-for-of": "[object Object],[object Object],[object Object],[object Object]"
}--><td><!--template bindings={
"ng-reflect-ng-outlet-context": "[object Object]",
"ng-reflect-ng-template-outlet": "[object Object]"
}--><heading><div class="view-header header-medium" ng-reflect-klass="view-header" ng-reflect-ng-class="header-medium">
</div>
</heading></td><td><!--template bindings={
"ng-reflect-ng-outlet-context": "[object Object]",
"ng-reflect-ng-template-outlet": "[object Object]"
}-->true</td><td><!--template bindings={
"ng-reflect-ng-outlet-context": "[object Object]",
"ng-reflect-ng-template-outlet": "[object Object]"
}-->{
"data": [
{
"name": "Simple Activity",
"enabled": true,
"type": "P2P"
},
{
"name": "Complex Activity",
"enabled": true,
"type": "12M"
}
]
}</td><td><!--template bindings={
"ng-reflect-ng-outlet-context": "[object Object]",
"ng-reflect-ng-template-outlet": "[object Object]"
}--></td>
</tr><tr>
<!--template bindings={
"ng-reflect-ng-for-of": "[object Object],[object Object],[object Object],[object Object]"
}--><td><!--template bindings={
"ng-reflect-ng-outlet-context": "[object Object]",
"ng-reflect-ng-template-outlet": "[object Object]"
}--><heading><div class="view-header header-medium" ng-reflect-klass="view-header" ng-reflect-ng-class="header-medium">
</div>
</heading></td><td><!--template bindings={
"ng-reflect-ng-outlet-context": "[object Object]",
"ng-reflect-ng-template-outlet": "[object Object]"
}-->true</td><td><!--template bindings={
"ng-reflect-ng-outlet-context": "[object Object]",
"ng-reflect-ng-template-outlet": "[object Object]"
}-->{
"data": [
{
"name": "Simple Activity",
"enabled": true,
"type": "P2P"
},
{
"name": "Complex Activity",
"enabled": true,
"type": "12M"
}
]
}</td><td><!--template bindings={
"ng-reflect-ng-outlet-context": "[object Object]",
"ng-reflect-ng-template-outlet": "[object Object]"
}--></td>
</tr>
</tbody></table>
</simple-table>
Do you guys have any thoughts? I'm feeling a bit lost.
@samuel-gay GitHub issues are for bug reports and feature requests. For support questions please use other channels like the ones listed in CONTRIBUTING - Got a Question or Problem?
:+1:
Also, I think we should only support this API for elements, not for text nodes (which would only be selected by * anyways).
@tbosch, @mhevery
This would be unfortunate for us for the following reason. We support an enterprise component library built with native JS. We have successfully created adapters for various libraries, including Angular 2, so these components can be used by various teams in the enterprise. However, because we can't programmatically access the entire template we require content projection to use a special directive. Example:
<my-button>
<content-directive>Hello<content-directive>
<my-button>
Alternatively, we provide content projection through a property:
<my-button content="Hello"><my-button>
Both of these cases make the Angular experience a bit awkward here. If we could select all children, including text nodes, then we could update our adapter to support this:
<my-button>Hello<my-button>
I understand our use case isn't your design goal, but I wanted to let you know it's out there in the wild. Right now Angular is the odd man out at our enterprise with regards to content projection. Not the end of the world, but it would be nice if it behaved the way it does with other frameworks we have adapters for, or how it would in a fully native Angular environment.
@alindsay55661 text node can be wrappered by <template>hello</template>. I use TemplateRef, ELementRef to pass though all over
@alindsay55661 text node can be wrappered by hello
Bottom line is we need to be able to select the text node, or more specifically, the entire template (*). Maybe I misunderstood, it sounded like @tbosch was suggesting selection using * should not be supported.