Skip to content
This repository has been archived by the owner on Apr 12, 2024. It is now read-only.

feat($compile): multiple transclusion via named slots #12934

Closed

Conversation

petebacondarwin
Copy link
Member

In this version of multiple transclusion you specify the transclusion slots in the transclude property of the DDO, where the keys are the node names of transclusion zones and the values are the names of the slots in the template:

directive('minionComponent', function() {
  return {
    restrict: 'E',
    scope: {},
    transclude: {
      minion: 'minionSlot',
      boss: 'bossSlot'
    },
    template:
      '<div class="boss" ng-transclude="bossSlot"></div>' +
      '<div class="minion" ng-transclude="minionSlot"></div>' +
      '<div class="other" ng-transclude></div>'
  };
});

So the following HTML

<minion-component>
  <minion>stuart</minion>
  <minion>bob</minion>
  <span>dorothy</span>
  <boss>gru</boss>
</minion-component>

will be compiled and rendered to

<minion-component>
  <div class="boss" ng-transclude="bossSlot">
    <boss>gru</boss>
  </div>
  <div class="minion" ng-transclude="minionSlot">
    <minion>stuart</minion>
    <minion>bob</minion>
  </div>
  <div class="other" ng-transclude>
    <span>dorothy</span>
  </div>
</minion-component>

Replaces #12742 and #11736

@petebacondarwin
Copy link
Member Author

This is a WIP and, of course, needs optimizing and documenting.

@Narretz Narretz added this to the 1.5.x - migration-facilitation milestone Sep 24, 2015
@petebacondarwin
Copy link
Member Author

Added support for required and optional slots; where the compiler will error if a required slot is not filled.

@petebacondarwin
Copy link
Member Author

@kara, @IgorMinar & @fenduru what do you think of this approach?

@Narretz
Copy link
Contributor

Narretz commented Sep 24, 2015

This is more explicit than #12742, which is good. Are there any other major differences?

@fenduru
Copy link

fenduru commented Sep 24, 2015

@petebacondarwin I like the way this is coming along. After playing with it a little bit, here are a couple of breaking test cases. The first is a bug, and the second is more of desired behavior.

Moving the elements around during the compile step means that we're relying on the DOM being available already. This prevents the child elements from being created via directives like ngTransclude and ngInclude, which are both useful when composing other directives. Perhaps instead of looking at the node's children to find matching elements it would somehow be possible to look at the node's parent to see if there is a matching slot instead (although this would require running that check on all elements, rather than just elements known to use transclusion)? This is the only real dealbreaker I see with this implementation.

Due to #8914, I strongly advocate never using the attribute version of ngTransclude to my coworkers. It would be nice to have a similar syntax for the element version, maybe something like: <ng-transclude slot="slotName">

Should the key in DDO's transclude object use element-name or the normalized elementName for consistency?

One use case I have for this kind of feature would be to ensure proper order of child elements (instead of relying on consumers providing them in the correct order). For instance, I have a directive like:

directive('foo', function() {
  return {
    restrict: 'E',
    transclude: {
      'one': 'oneSlot',
      'two': 'twoSlot',
      'three': 'threeSlot'
    },
    template: '<div ng-transclude="oneSlot"></div>' +
              '<div ng-transclude="twoSlot"></div>' +
              '<div ng-transclude="threeSlot"></div>'
  };
})

So when a consumer writes:

<foo>
  <two>...</two>
  <three>...</three>
  <one>...</one>
</foo>

they are rendered in the correct order. The problem with this is that this slots implementation requires an additional wrapper around each slot content (since you need an ngTransclude), which limits the ability to style using sibling or :last-child selectors. The way I'm avoiding this in my userspace implementation of slots is by having the slot element be transclude: 'element'. It would be really convenient if this was the case for ngTransclude too.

// Check for required slots that were not filled
forEach(filledSlots, function(filled, slotName) {
if (!filled) {
throw $compileMinErr('reqslot', 'Required transclusion slot `{0}` was not filled.');
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we're missing the slotName here to replace {0}:

throw $compileMinErr('reqslot', 'Required transclusion slot `{0}` was not filled.', slotName);

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spot on @kara! Thanks

@kara
Copy link

kara commented Sep 24, 2015

I like this approach. Setting up a transclude map makes sense, and the ? flag for optional slots is a pretty useful addition.

The only thing still missing for me is the ability to use selectors other than node names, like it would be possible to do with ng-content select in Angular 2. Currently, it looks like you have to use something like <boss>, and can't select by attribute (<div boss>), due to directiveValue[_nodeName(node)]. Any way we can add this without causing performance issues?

@petebacondarwin
Copy link
Member Author

@fenduru - thank you for your detailed and helpful response. I took a look at the failing tests you provided. I have added a fix for the bug and I will look into how we can support the second.

By the way, you can combine the element ng-transclude with an attribute of the same name: <ng-transclude ng-transclude="boss"></ng-transclude>, which gives you what you need there.

@petebacondarwin
Copy link
Member Author

@kara I think it is reasonable to restrict the things that can be selected, both for performance reasons but also, IMO, to make the whole thing easier to grok when you come across a directive that makes use of this multi-transclusion feature. Once this is in, I would be willing to have discussion about making the selector more general, since that would not be a breaking change to the currently proposed API.

@petebacondarwin
Copy link
Member Author

Should the key in DDO's transclude object use element-name or the normalized elementName for consistency?

We could normalise the element name, and store match against the normalized form stored as the key in the transclude object. I will implement that.

@petebacondarwin
Copy link
Member Author

The way I'm avoiding this in my userspace implementation of slots is by having the slot element be transclude: 'element'. It would be really convenient if this was the case for ngTransclude too.

Do you mean that we should have an ng-transclude-element directive that replaces itself with the transcluded content rather than appending it?

Or are you saying that the transclude property should have the option of only transcluding the content of the matching node, rather than the whole node?

@fenduru
Copy link

fenduru commented Sep 25, 2015

By the way, you can combine the element ng-transclude with an attribute of the same name: , which gives you what you need there.

@petebacondarwin are you sure that would work? I would expect the element's link function to run, transcluding all of the non-slot content, then the attribute directive to also run transcluding the "boss" slot as well.

Also, I'm saying it would be nice to have something like ng-transclude-element that replaces itself with the transcluded content rather than appending it.

@petebacondarwin
Copy link
Member Author

@fenduru
Copy link

fenduru commented Sep 25, 2015

@petebacondarwin That actually only happens to work by chance. Both instances of ngTransclude are being linked, but you are relying on the fact that the attribute directive gets linked second. More so, when the attribute directive links, it empties the element (removing the content transcluded by the element directive) and leaks a transclusion scope. So basically that example is transcluding everything, then removing the elements from the DOM (without destroying the scope), then transcluding just the slot content.

Using that would break certain directives I've written that register with a parent directive during link, and unregister during scope.$on('$destroy', ....

@fenduru
Copy link

fenduru commented Sep 25, 2015

@petebacondarwin I've alluded to a userspace version of slots that I've written. Here is a gist with the approach in case it provides any insight.

Couple of key points:

  • I called them "outlets" instead of "slots" (I actually prefer slots I think)
  • Doesn't require the content element to be a direct child (not sure if this is good or bad)
  • Forces the child directive to know which parent/slot it belongs in
  • Must pass the controller to the outlet, otherwise things get screwy when you start nesting directives. (This takes advantage of how transclusion scopes work to talk to the correct parent, rather than using require which would rely on DOM order)
  • The parent needs a special controller (or needs to mix in that functionality)
  • Only supports 1:1 child to slot mappings, rather than n:1 like in your implementation (n:1 would be very useful though)

@petebacondarwin
Copy link
Member Author

By jove @fenduru you are right! I never knew that. I always assumed that if the element matched then we wouldn't match the same on an attribute or class.

@petebacondarwin
Copy link
Member Author

I like your work @fenduru - but I would like to get the feature more baked into the core so that users don't need to use custom helpers and controllers. I will check it out and see if there are useful ideas we can use. I am wondering whether transclude: {...} should imply $$tlb.

@fenduru
Copy link

fenduru commented Sep 25, 2015

@petebacondarwin in my implementation only the child elements have $$tlb since they are the ones that get moved, so not sure if transclude: {...} implying $$tlb buys us anything (although tbh I have no idea what $$tlb actually means/stands for)

But yeah, I would also love if this was baked in (hence my interest in this PR :P)

@petebacondarwin
Copy link
Member Author

I think (:embarassed:) that $$tlb stands for "transclude late bound".

@petebacondarwin petebacondarwin force-pushed the multi-transclude-2 branch 3 times, most recently from 649a7cd to e0abcdb Compare October 2, 2015 08:37
@@ -1472,7 +1472,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {

function createBoundTranscludeFn(scope, transcludeFn, previousBoundTranscludeFn) {

var boundTranscludeFn = function(transcludedScope, cloneFn, controllers, futureParentElement, containingScope) {
var boundTranscludeFn = function boundTranscludeFn(transcludedScope, cloneFn, controllers, futureParentElement, containingScope) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just added this name to the function to aid with debugging. It will be stripped out during minification, I believe.

@petebacondarwin petebacondarwin force-pushed the multi-transclude-2 branch 2 times, most recently from 55c1ef7 to fe4d16b Compare October 2, 2015 08:41
@fenduru
Copy link

fenduru commented Oct 2, 2015

This is looking really good!

I'm sad to see the removal of the composition test case. Without it, it will be impossible to use this feature when constructing recursive directives. The only way I can think to solve this would be to allow things like ngTransclude and ngInclude to be special cased such that when they call $transclude() they can proxy to their parent's $$slots.

@petebacondarwin
Copy link
Member Author

@fenduru I'm afraid it is more difficult than that. Because the transclude slot parsing is done at compile time there is no chance for "dynamic" slots to be generated at link time (e.g. via ng-transclude) since it is too late by then.

I believe the only solution would be to go more down the lines of your library where all the transclusion slots are computed after compilation has finished.

I discussed this with @IgorMinar and we felt that for the time being this was the best way forward, since it gives us performance and also compile time checking that required slots have been filled.

@fenduru
Copy link

fenduru commented Oct 5, 2015

@petebacondarwin I don't think requiring your slots to be present during compile time (i.e. part of your template) is unreasonable at all. On the other side though - the content that the user provided - we (directive authors) have no control over how the consumer puts the content there and would have to document that they cannot use ng-transclude/ng-include/similar to insert the child elements.

This limitation unfortunately means it won't be usable for my use case (reusable low level components) since it would prevent composition, which is kind of the point of using transclusion.

@petebacondarwin
Copy link
Member Author

I am going to merge this in as it is. The ability to specify content for zones dynamically would use the same API so it is feasible it could be added in the future without a BC.

@pkozlowski-opensource
Copy link
Member

Wooooohooooo!

@fenduru
Copy link

fenduru commented Oct 13, 2015

@petebacondarwin you're right it wouldn't be a breaking change.

Do you know anything about transclusion in angular 2? Are the use cases I brought up being considered?

@petebacondarwin
Copy link
Member Author

@fenduru I'm not sure about this in Angular 2, yet. Perhaps @tbosch or similar would be able to shed some light on this?

@gkalpak
Copy link
Member

gkalpak commented Oct 14, 2015

I just managed to take a proper look (:flushed:).
👏 Good stuff ! 👏

@petebacondarwin
Copy link
Member Author

Thanks @gkalpak

@tbosch
Copy link
Contributor

tbosch commented Oct 15, 2015

In Ng2 we are using <ng-content select="...">, i.e. a selector based approach. This works in Ng2 as we can execute the content projection in a build step so we don't get a performance penalty. For Ng1 the named approach is a lot easier and performant to implement...

@petebacondarwin
Copy link
Member Author

Thanks for the info @tbosch
So from an upgrade point of view, the ng2 approach is a superset of this PR and so it will be straightforward to convert from ng1 named multislot approach to the ng2 content selector approach

@ilyes-garifullin
Copy link

Hello! Really looking forward to when this feature will be in the release. I decided to try how it will work. And I noticed that it is not working properly, now. Content from both ng-tansclude slots dublicates, instead of every slot to render its own content.

It can can be seen in the multi-slot transclusion Plunker example, at official documentation.
https://docs.angularjs.org/api/ng/directive/ngTransclude

@petebacondarwin
Copy link
Member Author

@ilyes-garifullin - can you clarify what the problem is? The demos look like they are working for me.

@ilyes-garifullin
Copy link

Here is a screen shot from documentation:
image

And here is from Plunker:
image

And here is a screen shot of rendered html markup from chrome console tool:
image

pane-title and pane-body elements are renderen inside both ng-transclude="title" and ng-transclude="body" directives, but pane-title should be rendered inside ng-transclude="title" and pane-body inside ng-transclude="body", as I understand

The same result I get when cloned the latest version of angular from github

@lgalfaso
Copy link
Contributor

The issue in the plunker is that it is pointing to 1.5.beta1 and the multi-transclude code is not there. If you change angular version in the plunker from beta1 to https://code.angularjs.org/snapshot/angular.js then it works as expected. This should get fixed once beta2 is out

@ilyes-garifullin
Copy link

Oh, I see, thank you!

@jtrancas
Copy link

I'm totally new to AngularJS, however, I believe I found an issue when jQuery is being included along with Angular, I could not reproduce the error when Angular is running without jQuery.
The situation is the following:

  • jQuery is included.
  • There's a directive with all the transclude slots marked as optional.
  • There's a directive occurrence with no child slots defined.

You can check the following Plnkr: http://plnkr.co/edit/f9SN35IMPrmeyuPz0Pih?p=preview
The pane directive has no child nodes, all its transclude slots are optional, and you may notice the following error being raised in the console:

TypeError: Cannot read property 'replace' of undefined
    at directiveNormalize (angular.js:9033)
    at angular.js:8095
    at forEach (angular.js:354)
    at applyDirectivesToNode (angular.js:8094)
    at compileNodes (angular.js:7620)
    at compileNodes (angular.js:7632)
    at compileNodes (angular.js:7632)
    at compile (angular.js:7527)
    at angular.js:1677
    at Scope.$eval (angular.js:16127)

If you remove the jQuery inclusion it all goes well.

@petebacondarwin
Copy link
Member Author

@jtrancas - thanks. I have created an issue to track this bug #13169

@josip
Copy link

josip commented Nov 25, 2015

From my quick tests looks like a tiny regression was introduced with this PR, in 1.4 this used to work:

<div ng-transclude="ng-transclude"></div>

But in 1.5 it throws an error:

[$compile:noslot] No parent directive that requires a transclusion with slot name "ng-transclude"

Maybe "ng-transclude" could be added to the list of values that trigger the default behaviour (as empty string/value already do)?


Funny ng-transclude="ng-transclude" comes from Jade (which doesn't like empty attributes):

div(ng-transclude)

@petebacondarwin
Copy link
Member Author

OK @josip, that sounds reasonable.

petebacondarwin added a commit to petebacondarwin/angular.js that referenced this pull request Nov 25, 2015
… its key

Some preprocessors such as Jade will automatically provide a value for an attribute
rather than leave it empty. E.g. `<div ng-transclude="ng-transclude">`.
In these situations we still want to use the default transclusion slot.

Closes angular#12934
@petebacondarwin
Copy link
Member Author

@josip can you take a look at #13383?

petebacondarwin added a commit that referenced this pull request Nov 26, 2015
… its key

Some preprocessors such as Jade will automatically provide a value for an attribute
rather than leave it empty. E.g. `<div ng-transclude="ng-transclude">`.
In these situations we still want to use the default transclusion slot.

Closes #12934
Closes #13383
@gogromat
Copy link

gogromat commented Dec 8, 2015

I was also struggling to make multi ngTrasclude to work, even with beta 2.

The Plunkr already points to beta 2 (1.5.0-beta.2), and it produces:
Error: [$compile:reqslot] http://errors.angularjs.org/1.5.0-beta.2/$compile/reqslot?p0=pane-body

However if I put ngTransclude keys and rename html elements as in this Plunkr I made, it seems to work fine:

  transclude: {
    'paneb': 'paneb',
    'panet': '?panet',
    'panef': '?panef'
  }

and:

<pane>
    <panet><a ng-href="{{link}}">{{title}}</a></panet>
    <paneb><p>{{text}}</p></paneb> 
</pane>

So something is fishy about those dashes...

@petebacondarwin
Copy link
Member Author

@gogromat - there has been a bit of churn this weekend with the API for multi-slot transclude.
The currently visible docs - https://docs.angularjs.org/api/ng/directive/ngTransclude - (as of 10am GMT 8 December 2015) is not correct.

We are releasing RC1 today, in which the API should be stabilized.

The transclude map should be:

{
  slotName: 'camelCaseElementName'
}

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.