A simple multi-select dropdown AngularJS directive with search functionality.


Purpose

In a project I am working on right now, I needed a UI element to allow the user select more than one item from a big selection. I liked how the Github widget for selecting issue labels looked. So, that was the inspiration: Goal Image

Dom Manipulations should not exist in controllers, services or anywhere else but in directives. - Angular Best Practices

Building the directive

Desirably, the directive should be as customizable as possible. With that in mind, these are the properties that the directive can take:

  • display-attr: The object attribute that needs to be displayed
  • selected-items: Items that are selected (Should be a subset of all-items)
  • all-items: All the items that will be part of the dropdown
  • add-item: A call-back function when clicking on an unselected item
  • remove-item: A call-back function when clicking on an selected item
<searchable-multiselect display-attr="name"
  selected-items="user.languages" all-items="allLanguages"
  add-item="addLanguageToUser(item,user)"
  remove-item="removeLanguageFromUser(item,user)" >
</searchable-multiselect>

Hence, the directive initially looks like this:

app.directive("searchableMultiselect", this.searchableMultiselect = function($timeout) {
  return {
    templateUrl: 'searchableMultiselect.html',
    restrict: 'E',
    scope: {
      displayAttr: '@',
      selectedItems: '=',
      allItems: '=',
      addItem: '&',
      removeItem: '&'
    },
    link: function(scope, element, attrs) {
    }
  }
});

And the template is a bootstrap-ui dropdown element with a custom search input filter:

<div class="dropdown searchable-multi-select"
dropdown on-toggle="toggled(open)">
  <a class="dropdown-toggle btn btn-default"
  data-toggle="dropdown"
  dropdown-toggle data-target="#"
  tooltip="{{ commaDelimitedSelected() }}"
  ng-class="{'disabled': readOnly}">
    <span class="limit-ellipses"
    ng-style="{ 'width' : width }">
      <small>{{ commaDelimitedSelected() }}</small>
      <b class="caret" ng-if="!readOnly && allItems.length"></b>
    </span>
  </a>
  <ul ng-if="!readOnly && allItems.length"
  class="dropdown-menu dropdown-menu-form form-control"
  role="menu">
    <li>
      <input type="text"
      class="form-control"
      ng-model="searchQuery"
      placeholder="Search"></input>
    </li>
    <li ng-repeat="item in ::allItems track by $index"
    ng-hide="searchQuery.length && item[displayAttr].toLowerCase().indexOf(searchQuery.toLowerCase()) < 0">
      <label class="checkbox clickable"
      ng-click="updateSelectedItems(item)"
      ng-class="{'text-success': isItemSelected(item) }">
         {{ item[displayAttr] }} 
        <span class="glyphicon glyphicon-remove pull-right clickable"
          ng-show="isItemSelected(item)"></span>
      </label>
    </li>
  </ul>
</div>

As you might have noticed, I used ng-hide instead of ng-repeat filter, that is because the filter redraws the DOM on everykey-stroke which is an overkill.

The widget shows a comma-delimitted list of the selected items or ‘Nothing Selected’ string, this is done using a simple scope method:

scope.commaDelimitedSelected = function() {
  var list = "";
  angular.forEach(scope.selectedItems, function (item, index) {
    list += item[scope.displayAttr];
    if (index < scope.selectedItems.length - 1) list += ', ';
  });
  return list.length ? list : "Nothing Selected";
}

Since this string might not fit inside the widget, I also have it displayed as tooltip.

Clicking an item tiggers updateSelectedItem() method, which first looks if the item is selected or not, then calls the proper call-back method on the controller

scope.updateSelectedItems = function(obj) {
  var selectedObj;
  for (i = 0; typeof scope.selectedItems !== 'undefined' && i < scope.selectedItems.length; i++) {
    if (scope.selectedItems[i][scope.displayAttr].toUpperCase() === obj[scope.displayAttr].toUpperCase()) {
      selectedObj = scope.selectedItems[i];
      break;
    }
  }
  if ( typeof selectedObj === 'undefined' ) {
    scope.addItem({item: obj});
  } else {
    scope.removeItem({item: selectedObj});
  }
};

The last scope method I needed was one that returns a boolean indicating whether a given item is selected or not. This is used to set the proper class on the widget

scope.isItemSelected = function(item) {
  if ( typeof scope.selectedItems === 'undefined' ) return false;

  var tmpItem;
  for (i=0; i < scope.selectedItems.length; i++) {
    tmpItem = scope.selectedItems[i];
    if ( typeof tmpItem !== 'undefined'
    &&  typeof tmpItem[scope.displayAttr] !== 'undefined'
    &&  typeof item[scope.displayAttr] !== 'undefined'
    &&  tmpItem[scope.displayAttr].toUpperCase() === item[scope.displayAttr].toUpperCase() ) {
      return true;
    }
  }

  return false;
};

The end result looks like this: Result Image

A full working demo is available on Plunker

Hope this is useful to you.