Pipo
Pipo

Reputation: 5821

What is a good way for a Angular directive to act as an facade to other elements?

This is more a generic question about Web Components, however I'll write the examples in Angular as it offers some more ways to handle this problems (like replace even if it is deprecated) and it is also more familiar to me and probably others.

Update

Because of this comment I think many problems I face are Angular specific, because of the way Angular "compiles" directives. (I can't easily add or remove a directive at runtime.) Therefor I don't search for a generic solution anymore, but for a Angular specific solution. Sorry for this confusion!

Problem

Say I want to create a menu bar which could 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 could translate to this:

<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 fairly trivial. The problems arise, if I want to configure my <x-menu-item> with (existing) directives/attributes. Sometimes an attribute should refer to the button. E.g. a click on <x-menu-item> should probably be proxied to the <button>, because it is the "real" interactive element inside <x-menu-item>.

<x-menu-item ng-click="foo()">Open</x-menu-item>

<!-- → possible translation -->

<li class="menu-list-item">
  <button type="button" class="menu-button" ng-click="foo()">Open</button>
</li>

However other attributes should refer to the <li>. Say I want to hide <x-menu-item> I probably want to hide everything, not just the <button>.

<x-menu-item ng-hide="bar">Open</x-menu-item>

<!-- → possible translation -->

<li class="menu-list-item" ng-hide="bar">
  <button type="button" class="menu-button">Open</button>
</li>

And than there are of course attributes which affect the <li> as well as the <button>. Say I want to disable the <x-menu-item> I probably want to style the <li> and I want to disable the <button>.

<x-menu-item ng-disabled="baz">Open</x-menu-item>

<!-- → possible translation -->

<li class="menu-list-item" ng-class="{ 'is-disabled': baz }">
  <button type="button" class="menu-button" ng-disabled="baz">Open</button>
</li>

That is basically what I want to achieve. I know some solutions, but all have their downsides.

Solution #1: Generate template dynamically and handle attributes manually

I could replace the <x-menu-item> with a complete dynamic template and handle the attributes manually. It could 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);
  }
}

This actually works quite well in some cases. I have a custom <x-checkbox> which uses a <input type="checkbox"> internally. In 95% cases I want all attributes placed on <x-checkbox> to be moved to <input type="checkbox"> and just some on a wrapper around <input type="checkbox">.

I actually handle ng-disabled here, too. In case you wonder how this could look like, 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] + ' }"');
  }
});

Downsides: I need to decide where to place attributes I don't know beforehand. Should they be placed on the <button> or <li>? I think I want more attributes on the <button> than on the <li>, because my <x-menu-item> is basically a wrapped button and using it feels like you would use button. A developer would expect <x-menu-item> to work like a <button>. However it seems strange to not place unknown attributes on the root element (in this case <li>). One would also expect that attributes on <li> would affect <button>, if necessary (like a CSS class does). I also create my markup in JavaScript, instead of plain HTML.

Replace or don't replace

I know its deprecated, but sometimes I like to use replace my directive with my template. Say someone places an id on my directive, I like to move the id to the canonical element in the template representing the directive (.e.g. on a <x-checkbox> the id would be transferred to the <input checkbox="type">). So if somebody tries to getElementById he will get the canonical element behind it. If I don't replace the whole directive, I would need to decide which attributes (or all) should be removed on the directive, because they were moved to a different element. This can be buggy, if you miss something (and suddenly have the same id twice).

Solution #2: Use prefixed attributes

Similar to #1, but the user decides if an attribute should be used on the directive or on certain elements. It could look like this:

<x-menu-item li-ng-hide="bar" button-ng-click="foo()">Open</x-menu-item>

<!-- → possible translation -->

<li class="menu-list-item" ng-hide="bar">
  <button type="button" class="menu-button" ng-click="foo()">Open</button>
</li>

Downsides: This one gets more verbose, but offers more flexibility. E.g. a developer could create a custom id for the directive, the li and the button. But what is with ng-disabled? Should the developer place a button-ng-disabled as well as a li-ng-class? That is cumbersome and error prone. So we probably need to handle those cases manually again...

Solution #3: Use two directives

If we can't decide how to handle our attributes, we could introduce two directives. That way we don't introduce artificially prefixed attributes.

<x-menu-item ng-hide="bar">
  <x-menu-button ng-click="foo()">Open</x-menu-button>
</x-menu-item>

<!-- → possible translation -->

<li class="menu-list-item" ng-hide="bar">
  <button type="button" class="menu-button" ng-click="foo()">Open</button>
</li>

Downsides: This isn't very dry and therefor error prone. I always have a <x-menu-button> in my <x-menu-item>. There will never be an empty <x-menu-item> nor a <x-menu-item> with a different child element. I also have the same problem with ng-disabled as in solution #2. A developer should be able to easily deactivate my whole <x-menu-item>. He shouldn't care to add a certain ng-class for styling purposes and disable the button on his own.

Solution #4: Use a generic interface

Limit your interface. Instead of trying to stay generic (which is nice, but cumbersome) one should limit its interface. Instead of special handling for ng-disabled, ng-hide and ng-click try to identify your common use cases and offer a more custom interface to use them. That way we only handle explicitly defined attributes in a special way.

<x-menu-item hidden="bar" action="foo()" disabled="baz">Open</x-menu-item>

<!-- → possible translation -->

<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>

Downsides: This approach isn't very intuitive. Every Angular developer knows ng-click. No one knows my action attribute.

(Partly) Solution #5: Proxy DOM events

Instead of moving a ng-click from the directive to buttons it can't sometimes be useful, if the directives listens for clicks on itself and automatically trigger a click on the button (or the other way around - it depends on the use case).

Solution #6: Dirty queries.

See the answer from @gulin-serge for details. Short explanation: "Decorating" existing directives like ng-click with custom logic, if it is used on a certain element and prevent using default behavior.

Downsides: Every ng-click will be checked, if it is used on a certain element even if this is not the case. This checking is a small overhead. You must also remove the default behavior of ng-click which can result in unexpected behavior. E.g. Angulars ngTouch module decorates every ng-click so it also called on a touch event. This is something which should happen for <x-menu-item>, too, but you would now need to check, if ngTouch is used manually and if this is true, listen for touch events as well. This is error prone and doesn't scale. This "decoration step" currently happens on the link phase which can have its own downsides: it would be hard to generate a ng-class for the <li> dependent on ng-disabled here. You would need to use $compile which can have unexpected effects on its own. (E.g. I used it on <select> ones and suddenly all <options> were duplicated. That can be hard to debug.) Other directives have a default behavior which is too useful to loose (e.g. ng-class is "animation aware" and sets utility classes like ng-enter - it wouldn't be enough to rebuild some custom element.addClass(cssClass)).

(Partly) Solution #7: Use multiple templates.

Sometimes it is sufficient to use multiple templates which are chosen dependent on some attributes. This can happen inside the templateUrl function.

<x-menu-item>Open</x-menu-item>

<!-- → possible translation using "template-default.html" -->

<li class="menu-list-item">
  <button type="button" class="menu-button">Open</button>
</li>

Or:

<x-menu-item disabled="baz">Open</x-menu-item>

<!-- → possible translation using "template-disabled.html" -->

<li class="menu-list-item" ng-class="{ 'is-disabled': baz }">
  <button type="button" class="menu-button" ng-disabled="baz">Open</button>
</li>

Downsides: This isn't 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 finally write templates in HTML again and not as JavaScript strings. But it doesn't scale well, if you have more variation. However this can be your only solution, if not just some attributes change, but the whole markup behind it.

Solution #8: Try to initialize every hidden directive with some default behavior (even if it is some noop).

Maybe every specially handled 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>

<!-- → possible translation -->

<!-- $scope.isDisbabled = has('ng-disabled') ? use('ng-disabled') : false -->
<!-- $scope.action = has('ng-click') ? use('ng-click') : angular.noop -->
<!-- $scope.isHidden = has('ng-hide') ? use('ng-hide') : false -->
<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>

Downsides: You initialize directives which sometimes are never used. This can be a performance problem. But all in all this approach is relatively clean. This is currently my favorite solution.

Solution #?: ???

...

Upvotes: 5

Views: 896

Answers (2)

Fuzzical Logic
Fuzzical Logic

Reputation: 13015

Update: Rewrite based on Comments

This is largely rewritten based on the clarification within the comments. As a pure component-based framework, the idea behind Polymer Elements is give developers a simple way to accomplish two tasks. The first is the ability to make simple Elements with previously un-programmed functionality. The second is the ability to make complex controls with simple custom markup.

This is a considerably different focus than Angular, which consolidates functionality into directives that translate custom markup into native markup and couples the functionality to the translated markup.

Notes on Polymer:

Polymer is primarily a framework polyfill until Web Components, Shadow DOM, Scoped CSS and HTML Imports becomes standardized and implemented. HTML Templates is already implemented pretty well in modern browsers and is worth reading up on. That said, Polymer won't just go away when those are standard, as it provides convenience functions and attributes, as well as a good set of components to start with. The polyfills are considered to be slower than native support, but that is to be expected.

Differences between Polymer and Angular:

Unlike Angular, when you create an element, you are not 'translating' it from one markup-set to another. You are actually defining its functionality and presentation. Even though it may provide additional markup within its Template(s) and Shadow DOM, that additional markup are all functional elements (whether custom or native).

This also means that your elements can have their own CSS Classes and IDs are are handled by the browser allowing for different presentation quite easily. Selector Engines will get the actual custom element and can get the properties and methods associated with them.

Simple Demonstration:

<polymer-element name="x-menu">
    <template>
        <style>
        /* Scoped Style rules */
        </style>
        <content></content>
    </template>
    <script>
    /* Registers the Element with the browser. */
    Polymer('x-menu', {
        // Additional Element properties and methods
    });
    </script>
</polymer-element>

<polymer-element name="x-menuitem">
    <template>
        <style>
        /* Scoped style rules */
        </style>
        <button type="button" class="{{parentElement.classList}}">
            <content></content>
        </button>
    </template>
    <script>
    /* Registers the element with the browser */
    Polymer('x-menuitem', {
        // Additional Element properties and methods
    });
    </script>
</polymer-element>

Actual Usage

<x-menu class="cool">
    <x-menuitem>Open</x-menuitem>
    <x-menuitem>Edit</x-menuitem>
    <x-menuitem>Create</x-menuitem>
</x-menu>

When you actually run this, you will see that the button actually copies the classList from the parentElement. In other words: {{parentElement.classList}} is actually shorthand for this.parentElement.classList. Understanding this, you can actually build a number of functions amd properties that are based on parent markup. In contrast, the reverse can be done, as well. You can also use document.querySelector('x-menu') and you will get the <x-menu>.

Additionally, since these are attributes that apply only your own custom element, you need not worry about namespacing the attributes. No other element will understand your attributes, and the browser won't try to do anything funny with them.

Update: Applying Polymer to your Needs

First, and foremost, using your example above, if you have two elements, then you will have two custom elements. How coupled they are depends largely on how they are programmed. It needs be understood that there is only need for native elements within your custom elements if you need multiple types of functionality or presentation. I would recommend, at first, not utilizing native elements except where you need for styling. With Polymer, it is generally best to start small...

For your functional example, there is no real need to have anything other than the content unless you need a) multiple styling blocks or b) to integrate the functionality of another element or component. Since you require click functionality and the ability to focus/style, the most one should add is a button. The rest can easily be handled in CSS.

Getting out of the mindset that a component should handle multiple elements is important, because it is only true when you need multiple elements. A simple element is there to extend the HTML Element set for your needs. Only worry about managing other elements (either custom or native) when you need an advanced component.

Update: Facading Elements

Polymer provides several mechanisms for having an element have multiple presentations or function based on properties. The simplest mechanism is template binding which changes the relevant document fragment based on conditions.

<polymer-element name="x-menuitem">
    <template>
        <template if="{{condition}}">
            <style>Uses this set of styles</style>
        </template>
        <template if="{{condition2 OR !condition}}">
            <style>Use this stylesheet instead</style>
        </template>
        <content></content>
    </template>
    <script>Polymer Registration</script>
</polymer-element>

There may be any number of conditions, and since <template> is just an element, you can actually put any elements in there, including more <template> bindings.

Update: Two examples with Disabled

In the first example, we will simply make a <x-menu-item> with the ability to disable it. We're only going to use the core element, without an <li> or <button>. To disable the item, you may either set the attribute directly (when you mark it up, or get the element via a selector query and set the disabled property.

<polymer-element name="x-menuitem">
    <template>
        <style>
            :host { color:blue; }
            :host([disabled]) { color:red; }
        </style>
        <content><content>
    </template>
    <script> 
        Polymer('x-menu-item', {
            publish: {
                disabled: {
                    value: false,
                    reflect: true
                }
            },
            method: function() {
                if (this.disabled) return;
            }
        });
    </script>
</polymer-element>

In our second example, we will use an <x-menu-item> with child <li> and <button>. This will have some conditional template binding so that it renders with a different classes. It can still be disabled the same as the above.

<polymer-element name="x-menuitem">
    <template>
        <style>
            :host #item {
                /* styles */
            }
            :host([disabled]) #item { 
                /* styles */
            }
        </style>
        <template if="{{disabled}}">
            <li id="item" class="disabled_class1">
                <button class="disabled_class2">
                    <content><content>
                </button>
            </li>
        </template>
        <template if="{{!disabled}}">
            <li id="item" class="enabled_class1">
                <button class="enabled_class2">
                    <content><content>
                </button>
            </li>
        </template>
    </template>
    <script> 
        Polymer('x-menu-item', {
            publish: {
                disabled: {
                    value: false,
                    reflect: true
                }
            },
            method: function() {
                if (this.disabled) return;
            }
        });
    </script>
</polymer-element>

Final Note Updated:

Keep in mind that the above examples are just two declarative ways to accomplish what you need. There are other declarative techniques to accomplish the same goal. Further, you may always utilize the ES/JS to imperatively define everything as well, giving even more options.

There is a lot to Polymer that is matches default browser behavior for HTML Elements. That does not mean there isn't a lot to know. This answer was merely to demonstrate one specific desired functionality of your question. Since the development process and terminology is so different, I wasn't sure what else should be addressed. Whatever might be missing can certainly be added to the answer in updates; simply let me know which specifically you would like added.

Upvotes: 0

Serge S. Gulin
Serge S. Gulin

Reputation: 116

Possible #6. Dirty queries.

What if we will use directive definition as a query expression for the case when we should switch off default implementation?

You write something like:

  .directive('ngClick', function() {
  return {
    restrict: 'A',
    priority: 100, // higher than default
    link: function(scope, element, attr) {

      // don't do that magic in other cases
      if (element[0].nodeName !== 'X-MENU-ITEM') return; 

      element.bind('click', function() {
        // passthrough attr value to a controller/scope/etc
      })

      // switch off default implementation based on attr value
      delete attr.ngClick;
    }
  }})

This will switch off default implementation of ng-click at your tags. Same job for ng-hide/ng-show/etc.

Yep, it looks terrible by sense, but result is closer to your idea. Of course it will slow down linking process of compile a bit.


P.S. According to your list I prefer #2 but with custom directive namespace. Something like:

<app-menu-item app-click="..." app-hide="..."/>

And add a convention to docs to use app prefix for all custom things and behaviour. Where app is an abbr of project name usually.

Upvotes: 1

Related Questions