Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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 #?: ???

...

like image 703
Pipo Avatar asked Nov 09 '22 19:11

Pipo


1 Answers

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.

like image 52
Gulin Serge Avatar answered Nov 15 '22 05:11

Gulin Serge