Angular Component Composition - Part 1 - Introduction

Written by Nicholas Boll

August 08 2015

Components

“Components” is becoming a popular word in web development. The term is vague and generally means small parts. I did a talk recently at a local meetup (video here) about how we at LogRhythm use Angular to create re-usable components. The definition of components extends to backend infrastructure, middle-layer and UI. This series will be mainly focusing on UI components.

Composition

Composition as in composition over inheritance. AngularJS encourages this with dependency injection of services into controllers, but what is often overlooked as directives provide an excellent way to compose UI components. I don’t mean nesting a component within another component, but truly composing a component directive with other directives. This is what my talk was about and I would like to step through how that is possible and why it is a good idea.

How do we do it?

I’m going to define a few terms I’ll use over this series: Component, Decorator and Component Decorator (The Angular team changed Decorator to Directive to not have a naming collision with ES2016 Decorators). The first two are well-defined by the community (I’ve added a few extras). The last is what really adds composition to UI and I make heavy use of.

Component

  • Element selector
  • Isolate scope
  • Has a template
  • Has a controller
  • Has a model

Decorator

  • Attribute selector
  • Does not have a template
  • Has a controller
  • Has a model

Component Decorator

  • Attribute selector
  • Does not have a template
  • Requires a Component or Directive

First, we need to make a component and make sure it has a controller. Actually, for my first example I’m going to make a Decorator, but it acts like a Component. The component should be as minimalistic as possible. This allows for maximum composition with Component Decorators.

Tooltip Decorator

Usage:

<span tooltip tooltip-message="'Hello World!'">Hover over me!</span>

Controller:

function TooltipController () {

  // API for state
  this.model = new TooltipModel({});
}

TooltipController.prototype.show = function showTooltip () {
  if (this.model.shouldShow) {
    this.model.isShowing = true;
  }
};

TooltipController.prototype.hide = function hideTooltip () {
  this.model.isShowing = false;
}
app.controller('TooltipController', TooltipController);

Model:

function TooltipModel (config) {
  // Boolean - if the tooltip should be shown - component decorators can modify this property.
  this.shouldShow = config.shouldShow || true;

  // Boolean - if the tooltip is currently being shown. Component decorators can use this property to test if the tooltip is being shown
  this.isShowing = config.isShowing || false;

  // String - the message the tooltip contains. It is an angular expression
  this.message = config.message || '';

  // Number - the x coordinate of the tooltip
  this.x = config.x || 0;

  // Number - the y coordinate of the tooltip
  this.y = config.y || 0;
}

tooltip Decorator:

app.directive('tooltip', function($compile) {
  return {
    restrict: 'A',
    controller: 'TooltipController',
    // controllerAs: 'tooltip', // we won't use this since we are kind of stepping outside normal angular stuff

    link: function tooltipLink ($scope, $element, $attrs, TooltipController) {
      var $body = angular.element(document.body);
      var $tooltipElement;
      var tooltipScope = $scope.$new(true); // new isolate scope
      tooltipScope.tooltip = TooltipController; // controllerAs in the isolate scope

      // events
      $element.on('mouseover', function onMouseover (event) {
        TooltipController.setPosition(event.clientX + 10, event.clientY + 10);
        TooltipController.show();
        tooltipScope.$digest(); // let Angular know something interesting happened - local digest for performance
      });

      $element.on('mouseout', function onMouseout (event) {
        TooltipController.hide();
        tooltipScope.$digest(); // let Angular know something interesting happened - local digest for performance
      });

      // react to state changes
      tooltipScope.$watch('tooltip.model.isShowing', function (isShowing) {
        if (isShowing) {
          // lazy initialization of tooltip contents
          if (!$tooltipElement) {
            $tooltipElement = $compile('<div class="tooltip">{{tooltip.model.message}}</div>')(tooltipScope);
          }
          $tooltipElement.css({
            top: TooltipController.model.y + 'px',
            left: TooltipController.model.x + 'px'
          });
          $body.append($tooltipElement);
        } else {
          $tooltipElement && $tooltipElement.remove();
        }
      });
    }
  };
});

tooltip-messsage Component Decorator:

app.directive('tooltipMessage', function () {
  return {
    restrict: 'A',
    require: 'tooltip',

    link: function tooltipMessageLink ($scope, $element, $attrs, TooltipController) {
      $scope.$watch($attrs.tooltipMessage, function (message) {
        TooltipController.model.message = message;
      });
    }
  }
});

Why a controller?

The controller of a component becomes the API for other component decorators. When a component decorator requires the component, it will gain access to the instance of that components controller. This is a very powerful concept for composition of components. Decorator components should interact directly with the controller and model.

Why a model?

All components should have a strongly typed model - it becomes the state API for other component decorators and the application. The model should only contain state and no logic.

The link function should be where events are attached and model watches happen. This allows the controller to be unit tested without DOM. In our project, all controllers and services are unit tested. Component link functions are not unit tested, but rather integration tested through Protractor. We do integration testing of components because of the complex differences between browsers.

Component

The the component should interact with the controller and model. It should observe the model state and react to changes rather than directly respond to user events. This separation allows component decorators to call controller methods and the component will just react to state changes and makes controllers easier to test in isolation (without a DOM).

Why is tooltip-message a directive?

tooltip-message is a Component Decorator. Notice how it requires the tooltip Decorator and gets the instance of its controller. It sets up a one-way (Note: NOT two-way) binding that updates the component’s model. This pattern allows the component to just worry about displaying model state with component decorators effecting that state in interesting ways.

tooltip-overflow Component Decorator:

<p style="max-width: 100px;" tooltip tooltip-message="longtext" tooltip-overflow>{{longtext}}</p>
app.directive('tooltipOverflow', function () {
  return {
    restrict: 'A',
    require: 'tooltip',

    link: function tooltipOverflowLink ($scope, $element, $attrs, TooltipController) {
      $element.on('mouseover', function (event) {
        if ($element[0].scrollWidth > $element[0].clientWidth) {
          TooltipController.model.shouldShow = true;
        } else {
          TooltipController.model.shouldShow = false;
        }
      });
    }
  }
});

Now things get interesting. We can conditionally show a tooltip. In this case, when the user moves the mouse over the tooltip target, the width is evaluated to see if an ellipsis is present. If an ellipsis is present, the tooltip will display, otherwise it will not. Try changing the tooltip value in the input in the provided example. For efficiently, the value is evaluated when the user mouses over and not when the tooltip value changes. This keeps watches light (NEVER do DOM measurement inside a $watch - it will really hurt the performance of the application).

Conclusion

The whole idea of composition is to have a small base component with decorators that modify the behavior. These decorators should be small and only do one thing. This is a difficult balance, but keeps additional features to be additional decorators rather than modification of the root component. The alternative is a very large component with a growing number of config flags. We use this composition model to separate our component UI from our application (different code repositories). Usually application component decorators interact with the component API to give the component data. This way we can completely test all expected behaviors of components void of any application dependencies.

Tags

angularjavascriptcomponentcomposition