Angular Component Composition - Part 2 - Dropdown Component

Written by Nicholas Boll

August 30 2015

Building the dropdown component

This article builds on Part 1 - Introduction of the series. Please take some time to read that article first (should only take a few minutes).

The introduction built a very simple component to showcase the idea of component composition. Now we will go in-depth starting with a dropdown component. There are many examples of dropdowns available, but this is going to be a minimalistic component that will be very easy to extend.

Composition

I like the Angular Bootstrap project because components there are very composable, but also very difficult to read. Here is an example from UI Bootstrap.

<div class="btn-group" dropdown is-open="status.isopen">
  <button id="single-button" type="button" class="btn btn-primary" dropdown-toggle ng-disabled="disabled">
    Button dropdown <span class="caret"></span>
  </button>
  <ul class="dropdown-menu" role="menu" aria-labelledby="single-button">
    <li role="menuitem"><a href="#">Action</a></li>
    <li role="menuitem"><a href="#">Another action</a></li>
    <li role="menuitem"><a href="#">Something else here</a></li>
    <li class="divider"></li>
    <li role="menuitem"><a href="#">Separated link</a></li>
  </ul>
</div>

You can tell that block of HTML is a dropdown by looking at the template, right? Components should be an abstraction that is easy to think about in terms of real objects on the screen. Also why should the application developer have to be concerned with all the aria stuff? Lets start to build a dropdown from scratch, but this is the API we’re going for:

<ui-dropdown
  ui-dropdown-options="['a','b','c','d']"
  placeholder="Please Select"
  ng-model="selected"
></ui-dropdown>

I’m going to use ES6 from now on. Here is the model for the dropdown:

Model

var DropdownModel = Scheming.create({
  display: {
    type: String,
    default: null
  },
  placeholder: {
    type: String,
    default: 'Please Select'
  },
  intent: {
    type: '*',
    default: null
  },
  options: {
    type: ['*'],
    default: []
  },
  isOpened: {
    type: Boolean,
    default: false
  },
  isFocused: {
    type: Boolean,
    default: false
  }
});

The model determines the state of the component - and only this state. The model is meant to be reactive - the component’s link and template react to model changes. We’re using Scheming to give us a reactive model outside of Angular’s $scope.

Note: This is actually a very important part of component composition - models are where state is held and we can observe these state changes outside of Angular’s $scope hierarchy. Without this observable model, component decorators would not work because the decorator’s scope isn’t actually the component’s scope.

Controller

class DropdownController {
  constructor (uiDropdownModel) {
    if (!this.model) {
      this.model = new uiDropdownModel();
    }
  }

  setOptions (options = []) {
    this.model.options = options;
  }

  selectItem (item) {
    this.model.display = item;
    this.model.isOpened = false;
  }

  isIntent (item) {
    return item === this.model.intent;
  }

  setIntent (item) {
    this.model.intent = item;
  }

  focus () {
    this.model.isFocused = true;
    this.model.isOpened = true;
  }

  blur () {
    this.model.isFocused = false;
    this.model.isOpened = false;
  }
}

The controller is meant to house methods that interact with the model - the view should not directly interact with it. This separation allows the controller to be easily unit tested. The controller’s constructor has tests for a model to be predefined and will create a new on if not defined. I have done this to allow the component to be part of an owning component to pass down an instantiated model. This can be very useful for components like tabsets where something else wants to change tabs - after all, the model is just state and the view just renders that state. I haven’t found a good use-case to make dropdown models owned by a container, but to be consistent, all components have this feature. The rest of the controller will make more sense with the directive.

function DropdownComponent () {
  return {
    restrict: 'E',

    scope: {
      model: '=uiDropdownModel'
    },

    controller: 'uiDropdownController',
    controllerAs: 'dropdown',
    bindToController: true,

    require: ['uiDropdown', '?ngModel'],

    template: `
      <button
        ui-dropdown-button
        type="button"
        class="ui button selected"
        ng-disabled="dropdown.model.isDisabled"
        ng-class="{focus: dropdown.model.isFocused}"
      >
        {{ dropdown.model.display || dropdown.model.placeholder }}
      </button>
      <div class="options-container" ng-if="dropdown.model.isOpened">
        <ul class="options" role="menu">
          <li
            class="option"
            role="menuitem"
            ng-repeat="item in dropdown.model.options"
            ng-mouseover="dropdown.setIntent(item)"
            ng-class="{intent: dropdown.isIntent(item)}"
            ng-click="dropdown.selectItem(item)"
          >
            {{item}}
          </li>
        </ul>
      </div>
    `,

    link: {

      pre: function ($scope, $element, $attrs, [dropdown, ngModel]) {

        // ngModel - only two-way data-binding allowed
        if (ngModel) {
          ngModel.$render = function () {
            dropdown.selectItem(ngModel.$viewValue);
          };

          $scope.schemingWatch(dropdown.model, 'display', function (value, oldValue) {
            if (value !== oldValue) {
              // tell ngModel about a change only if there is one
              ngModel.$setViewValue(value);
            }
          });

          // observed view properties
          $scope.schemingWatch(dropdown.model, ['isOpened', 'isFocused', 'options'], function () {
            $scope.$digest();
          });

        }
      },

      post: function ($scope, $element, $attrs, [dropdown, ngModel]) {
        $element.on('mousedown', (event) => {
          // prevent unintended focus changes
          event.preventDefault();
        });
      }
    }
  };
}

Like the tooltip component in the introduction, the link function is large here as well. The component uses a factory function that returns a Directive Definition Object. I will use this pattern until something like angular.component is released. I’ll note some choices here:

Restrict

restrict: 'E'

All components should be element selectors - it is easier to recognize them at a glance and it is obvious who owns the isolate scope.

Scope

scope: { model: '=uiDropdownModel' }

All components should have an isolate scope. While this is technically a 2-way reference binding, the reference should never be changed by either side. Items here should be intended as 1-way data bound properties (like props in ReactJS). I tend to prefix all properties with ui-{component_name}- to make an obvious association with the component. It is temping to create config properties here - I suggest avoiding the urge as it defeats the purpose of small, composable components. More on this later.

Controller

controller: 'uiDropdownController',
controllerAs: 'dropdown'

All composable components should have a controller and that scope name should be a short name of the component. The controller is referenced by a string for unit testing. It is possible to grab a controller registered with a DDO, but it is silly and difficult.

BindToController

bindToController: 'true'

This sets the scope to bind directly to the controller instead of $scope. We are trying to avoid $scope as much as possible - keeping as much DOM logic in the link function as possible. In this component, defining ui-dropdown-model="someDropdownModel" will actually set the model property directly on the controller instance and will be defined by the time the constructor is called. Handy.

Require

require: ['uiDropdown', '?ngModel']

The optional ngModel requirement will inject the instance of the ng-model controller into the linking function. This allows the dropdown component to act like other form elements with value binding, validation, etc.

Template

The template is inlined for performance. You can use Webpack or Browserify to do this instead:

require('./template.html');

// ...
return {
  template: template
};

The template contains a ui-dropdown-button helper directive to attach events to effect the state of the component. Composition can be parent/child.

The link function is broken into a pre and post link. It is currently considered bad practice to ever use compile or preLink, but pre and post link have an important distinction between when they get called in the lifecycle of compiling child components. preLink on a parent gets called before the preLink of a child. postLink on a parent gets called after the postLink of a child. You can find more information about the lifecycle of directives here.

The preLink function sets up model/$scope listeners. The preLink also composes components - this is a little strange that controllers don’t get this information (yet), but it is how we have to do it for now. The preLink fires right after the controller instantiates - which guarantees the component’s controller is in the correct state for any child components. The dropdown also sets up ngModel hookups if present.

The postLink sets up DOM event listeners. This has to be done in postLink because in the case of transclusion, the $element variable will be the final DOM in postLink, but will be a cached clone in preLink.

function DropdownOptionsDecorator () {
  return {
    restrict: 'A',
    require: 'uiDropdown',

    link: {
      pre: function ($scope, $element, $attrs, dropdown) {
        $scope.$watchCollection($attrs.uiDropdownOptions, function (options) {
          if (options) {
            dropdown.setOptions(options);
          }
        });
      }
    }
  }
}

This decorator dog-foods our API to provide a very simple case for static options passed in from a parent source (ex: page controller).

Wait, why isn’t options just passed into the dropdown component through the isolate scope definition? Well, we are trying to keep the dropdown component as light and composable as possible, without making any assumptions about how an application might use the component. Having options directly passed through and attribute makes an assumption that options are static and moves the responsibility of providing options to some view controller. This may seem reasonable, but what if we don’t know the options ahead of time? What if getting options isn’t the responsibility of a parent view controller? We ran into major issues with this type of assumption on a page with many dropdowns that all requested dynamic data as the user interacted with them. And dropdowns were used on more than one page, which meant binding logic had to be copy/pasted from one page controller to the next.

Dynamic Dropdown Options Decorator

This example just uses $timeout to fulfill an options request, but the idea is that a request is made to a backend and a response comes back. This type of decorator would actually be part of the application’s code since only your application knows how to talk to a backend.

function DropdownDynamicOptionsDecorator ($timeout) {
  return {
    restrict: 'A',
    require: 'uiDropdown',

    link: {
      pre: function ($scope, $element, $attrs, dropdown) {
        $scope.schemingWatch(dropdown.model, 'isOpened', function (isOpened) {
          if (isOpened) {
            $timeout(function () {
              dropdown.setOptions(['a','b','c'].map((o) => $attrs.uiDropdownDynamicOptionsPrefix + ' ' + o));
            }, 50, false);
          } else {
            dropdown.setOptions([]);
          }
        });
      }
    }
  }
}

More composition

Here is an example component in our application:

<ui-dropdown
  class="small"
  placeholder="{{ AlarmsFilters.filters.byAlarmStatus.selected }}"
  ui-dropdown-keys="{ display: 'display', selected: 'selected' }"
  ui-dropdown-url="/html/templates/distinct-value.html"
  distinct-value-dropdown="alarmStatus"
  distinct-value-index="ALARM_INDEX_ID"
  distinct-value-transform="AlarmOptions.FilterByAlarmStatusOptions"
  lucene-query="alarmQuery"
  lucene-query-filter="{ field: 'alarmStatus', type: 'Number', modelKey: 'value' }"
  ng-model="AlarmsFilters.filters.byAlarmStatus"
  ng-class="{applied : AlarmsFilters.filters.byAlarmStatus.value !== AlarmsFilters.defaultFilters().byAlarmStatus.value}"
></ui-dropdown>

This is the dropdown component with a distinct-value decorator that gets distinct alarmStatus field values from the server - the guts of this decorator are very similar to the dynamic decorator shown earlier. The lucene-query decorator is optionally required by the distinct-value decorator to modify the query made to the server. There is actually many of these dropdowns on the page - all working together to create a filtered query for a result set. My recorded talk goes over this at 23:55

Conclusion

Component composition is a bit difficult in Angular 1.x, but very powerful. It allows us to compose smaller pieces together to make something very useful. We have dropdowns that get options from different sources and just have different decorators to talk to backend endpoints. This allows the page controller to not have to worry about getting data to the dropdowns, but only care about the values of each dropdown. It keeps concerns at the right level.

Tags

angularjavascriptcomponentcomposition