This is a more general question about web components, however I will write examples in Angular as it offers some more ways to solve these problems (e.g. replace even if it is deprecated) and it is also more familiar to me and possibly others.
Update
Because of this comment, I think that many of the problems I am facing are Angular-specific, because Angular "compiles" the directives. (I cannot easily add or remove directive at runtime.) Therefore, I am no longer looking for a universal solution, but for a specific Angular solution. Sorry for this confusion!
Problem
Let's say I want to create a menu bar that might look like this:
<x-menu> <x-menu-item>Open</x-menu-item> <x-menu-item>Edit</x-menu-item> <x-menu-item>Create</x-menu-item> </x-menu>
This may mean the following:
<section class="menu"> <ul class="menu-list"> <li class="menu-list-item"> <button type="button" class="menu-button">Open</button> </li> <li class="menu-list-item"> <button type="button" class="menu-button">Edit</button> </li> <li class="menu-list-item"> <button type="button" class="menu-button">Create</button> </li> </ul> </section>
This is pretty trivial. Problems arise if I want to configure my <x-menu-item> to (existing) directives / attributes. Sometimes an attribute must refer to a button. For instance. a click on <x-menu-item> should probably be proxied for <button> because it is a "real" interactive element inside <x-menu-item> .
<x-menu-item ng-click="foo()">Open</x-menu-item> <li class="menu-list-item"> <button type="button" class="menu-button" ng-click="foo()">Open</button> </li>
However, other attributes must be related to <li> . Say I want to hide <x-menu-item> I probably want to hide everything, not just <button> .
<x-menu-item ng-hide="bar">Open</x-menu-item> <li class="menu-list-item" ng-hide="bar"> <button type="button" class="menu-button">Open</button> </li>
And than, of course, the attributes that affect <li> , as well as <button> . Say I want to disable <x-menu-item> , I probably want to create a <li> style, and I want to disable <button> .
<x-menu-item ng-disabled="baz">Open</x-menu-item> <li class="menu-list-item" ng-class="{ 'is-disabled': baz }"> <button type="button" class="menu-button" ng-disabled="baz">Open</button> </li>
This is basically what I want to achieve. I know some solutions, but they all have their drawbacks.
Solution # 1: dynamically create a template and handle attributes manually
I could replace <x-menu-item> with a full dynamic template and handle the attributes manually. It might look like this (not fully functional):
// directive definition return { restrict: 'E', transclude: true, template: function(tElement, tAttrs) { var buttonAttrs = []; var liAttrs = []; // loop through tAttrs.$attr // save some attributes like ng-click to buttonAttrs // save some attributes like ng-hiden to liAttrs // save some attributes like ng-disabled to buttonAttrs and liAttrs // optionally alter the attr-name and -value before saving (so ng-disabled is converted to a ng-class for liAttrs) // unknown attribute? save it to either buttonAttrs or liAttrs as a default // generate template var template = '<li class="menu-list-item" ' + liAttrs.join(' ') + '>' + '<button class="menu-button" ' + buttonAttrs.join(' ') + ' ng-transclude>' + '</button>' + '</li>'; return tElement.replaceWith(text); } }
In some cases, it really works well. I have a custom <x-checkbox> that uses <input type="checkbox"> internally. In 95% of cases, I want all the attributes placed on the <x-checkbox> to be moved to <input type="checkbox"> and only some of the wrappers around <input type="checkbox"> .
I really handle ng-disabled here. If you're curious about how this might look, here is an example:
angular.forEach(tAttrs.$attr, function(attrHtml, attrJs) { buttonAttrs.push(attrHtml + '="' + tAttrs[attrJs] + '"'); if (attrHtml === 'ng-disabled') { liAttrs.push('ng-class="{ \'is-disabled\': ' + tAttrs[attrJs] + ' }"'); } });
Disadvantages: I need to decide where to place attributes that I don’t know about in advance. Should they fit on <button> or <li> ? I think I want more attributes on <button> than on <li> because my <x-menu-item> is basically a wrapped button, and using it is like using a button. The developer would expect <x-menu-item> to work as a <button> . However, it seems strange not to place unknown attributes on the root element (in this case <li> ). You can also expect that the attributes on <li> will affect <button> if necessary (for example, the CSS class). I also create my markup in JavaScript, instead of plain HTML.
Replace or not replace
I know its obsolete, but sometimes I like to use my directive with my template. Say someone puts id in my directive, I like to move id to the canonical element in the template representing the directive (.eg on a <x-checkbox> id will be passed to <input checkbox="type"> ), therefore, if someone tries to getElementById , he will get a canonical element. If I do not replace the entire directive, I will need to decide which attributes (or all) should be removed in the directive because they were moved to another element. This may be a mistake if you missed something (and suddenly get the same id twice).
Solution # 2: use prefix attributes
Similar to # 1, but the user decides whether to use the attribute for the directive or for certain elements. It might look like this:
<x-menu-item li-ng-hide="bar" button-ng-click="foo()">Open</x-menu-item> <li class="menu-list-item" ng-hide="bar"> <button type="button" class="menu-button" ng-click="foo()">Open</button> </li>
Disadvantages: it becomes more detailed, but provides more flexibility. For instance. the developer can create a user id for the directive, li and button. But what about ng-disabled ? Should the developer post button-ng-disabled as well as li-ng-class ? This is cumbersome and error prone. Therefore, we will probably have to handle these cases manually again ...
Solution # 3: use two directives
If we cannot decide how to handle our attributes, we can introduce two directives. Thus, we do not introduce artificially prefix attributes.
<x-menu-item ng-hide="bar"> <x-menu-button ng-click="foo()">Open</x-menu-button> </x-menu-item> <li class="menu-list-item" ng-hide="bar"> <button type="button" class="menu-button" ng-click="foo()">Open</button> </li>
Disadvantages: it is not very dry and therefore prone to errors. I always have an <x-menu-button> in my <x-menu-item> . There will never be an empty <x-menu-item> or <x-menu-item> with another child. I also have the same problem with ng-disabled as in solution # 2. The developer should be able to easily deactivate my entire <x-menu-item> . It should not add a specific ng-class style for styling and disable the button itself.
Solution # 4: use a common interface
Limit your interface. Instead of trying to remain universal (which is nice, but cumbersome), you should limit its interface. Instead of special handling for ng-disabled , ng-hide and ng-click try to identify your common use cases and suggest a more convenient interface for using them. Thus, we process explicitly defined attributes in a special way.
<x-menu-item hidden="bar" action="foo()" disabled="baz">Open</x-menu-item> <li class="menu-list-item" ng-show="bar" ng-class="{ 'is-disabled': baz }"> <button type="button" class="menu-button" ng-click="foo()" ng-disabled="baz">Open</button> </li>
Disadvantages: this approach is not very intuitive. Every Angular developer knows ng-click . No one knows my action attribute.
(partially) Solution # 5: DOM proxy events
Instead of moving ng-click from directives to buttons, this sometimes can not be useful if directives listen for clicks on themselves and automatically start clicking on a button (or vice versa) - it depends on the use of the case).
Solution # 6: Dirty queries.
See @ gulin-serge for details. Brief explanation: "Decorating" existing ng-click directives with user logic if it is used on a specific element and does not allow the use of default behavior.
Disadvantages: each ng-click will be checked if it is used on a specific element, even if it is not. This check is a small invoice. You should also remove the default ng-click behavior, which may lead to unexpected behavior. For instance. The ngTouch corner ngTouch decorates each ng-click , so it also triggers a touch event. This is what should happen for <x-menu-item> , but now you will need to check if ngTouch used manually, and if it is true, listen for touch events. This is a bug and does not scale. This “decoration step” is currently taking place in the link phase, which may have its own flaws: it would be difficult here to create an ng-class for an <li> dependent on ng-disabled . You will need to use $compile , which can have unexpected effects on its own. (For example, I used it on <select> and suddenly all <options> were duplicated. It can be difficult to debug.) Other directives have default behavior that is too useful for easing (for example, ng-class is an "animation" and installs utility classes such as ng-enter - this would not be enough to rebuild some custom element.addClass(cssClass) ).
(partially) Solution # 7: Use multiple templates.
Sometimes it’s enough to use several templates, which are selected depending on some attributes. This can happen inside the templateUrl function.
<x-menu-item>Open</x-menu-item> <li class="menu-list-item"> <button type="button" class="menu-button">Open</button> </li>
Or:
<x-menu-item disabled="baz">Open</x-menu-item> <li class="menu-list-item" ng-class="{ 'is-disabled': baz }"> <button type="button" class="menu-button" ng-disabled="baz">Open</button> </li>
Disadvantages: this is not very DRY. If you want to change the menu-list-item class, you need to do this in two templates. But it's nice to write templates in HTML again, and not like JavaScript strings. But it does not scale well if you have more variations. However, this may be your only solution, if not only some attributes change, but all the markup.
Solution # 8. Try to initialize each hidden directive with some default behavior (even if it's some noop ).
Maybe each specially crafted attribute can be initialized with some default value, even if this value does nothing. The default behavior can be overridden.
<x-menu-item>Open</x-menu-item> <li class="menu-list-item" ng-hide="isHidden" ng-class="{ 'is-disabled': isDisabled }"> <button type="button" class="menu-button" ng-disabled="isDisabled" ng-click="action()">Open</button> </li>
Disadvantages: you initialize directives that are sometimes never used. This can be a performance issue. But this whole approach is relatively clean. This is currently my favorite decision.
Decision #?:???
...