For the past year, I’ve been working a lot with Angular, building web applications for enterprises. Eventually, I was needed to create a multi-select input component. HTML has a default element for that purpose. But it doesn’t look good by default and we can’t fully control it. These factors make such element not the best option.

I do like Material Design. In Flowhaven we implemented some of its principles within our web application, e.g. inline input fields. They look cool, clear, and, what’s the most important, they require less space, which is especially important on mobile devices.

MD already has a multi-select field, so I decided to build something similar to it.

Here is how the final component will look like.

In this post, I will show you how to build a completely reusable multi-select input component using Angular 1.6. I’ve chosen Ng1 because it’s still widely used, especially across enterprise apps.

Considering the features:

  1. If the user clicks on the input field, he will get a popup with options to select.
  2. The user will be able to filter options via a search bar.
  3. The user will be able to select and deselect all options.
  4. The user can close the popup by clicking ‘Save’ or outside of the popup.
  5. If selected options don’t fit in the field, the text will be cropped with an ellipsis.

Preparation

I’m using a Webpack module bundler with Babel and HTML-loader to build my application. I’ll use ES2015 syntax for JavaScript and SCSS for styles.

Create a working directory and files for our component.

mkdir multi-select-app
cd multi-select-app
touch app.js
touch multi-select.component.js
touch multi-select.component.html

Assume that we have the following Ng app with the boilerplate component.

// app.js
import multiSelect from './multi-select.component.js';

angular.app('MultiSelectApp', [])
  .component('multiSelect', multiSelect);

We will pass our options through the attributes (bindings).

// multi-select.component.js
import tempalte from './multi-select.component.html';

export default {
  template,
  bindings: {
    msLabel: '@',
    msOptions: '<',
  },
}

Now let’s start with the basic template markup to display the field itself.

<!-- multi-select.component.html -->
<div class="Multiselect">
    <input type="text" name="multi" id="multi" class="Input-field Input-field--select text--ellipsis"
            ng-model="$ctrl.value"
            readonly />
    <label for="multi" class="Input-label">{{::$ctrl.msLabel}}</label>
</div>

I already have a CSS code for the Input component. I’m using a modified version of BEM naming convention and custom reset CSS. Please find the SCSS source and compiled CSS code here.

Controller

We have our element and everything is great so far, but there is no functionality yet. Let’s start working on our controller to finalize it. Since I’m writing in the ES2015, I will use a Class interface for my component’s controller.

Let’s start with the basic functionality: when an input is clicked, we are showing the popup with the possible options. If a user selects an option, we add it to the selected array. If this option was already selected, we remove it from an array.

class MultiSelectCtrl {
  constructor() {
    this.selected = [];
  }

  /**
   * onShowOptions
   */
  onShowOptions() {
    this.showOptions = true;
  }

  /**
   * onSelect()
   * @param {String} value
   */
  onSelect(value) {
    const index = this.selected.indexOf(value);
    if (index < 0) {
        this.selected.push(value);
    } else {
        this.selected.splice(index, 1);
    }
    this.value = this.selected.join(', ');
  }
}

export default {
  template,
  controller: MultiSelectCtrl,
  bindings: {
    msLabel: '@',
    msOptions: '<',
  },
}

To see our input in action we need to update the template by adding a popup there.

<div class="Multiselect">
    <input type="text" name="multi" id="multi" class="Input-field Input-field--select u-paddingRight-s text--ellipsis"
            ng-model="$ctrl.value"
            ng-click="$ctrl.onShowOptions($event)"
            readonly />
    <label for="multi" class="Input-label">{{::$ctrl.msLabel}}</label>
    <div class="Multiselect-popup u-border-1"
            ng-class="{ 'isActive': $ctrl.showOptions }">
        <input type="text" placeholder="Search for a {{::$ctrl.msLabel.toLowerCase().slice(0,-1)}}..."
                class="u-paddingVertical-xs u-paddingHorisontal-s u-borderBottom-1"
                style="width: 100%"
                ng-model="$ctrl.searchTerm" />
        <div class="Multiselect-options u-marginTop-xs">
            <div class="u-paddingVertical-xs u-paddingHorisontal-s Btn--pointer Multiselect-option Grid Grid--alignCenter"
                 ng-class="{'text--blue': $ctrl.selected.indexOf(option) > -1}"
                 ng-repeat="option in $ctrl.msOptions | filter:$ctrl.searchTerm"
                 ng-click="$ctrl.onSelect(option, $event)">
                <span class="Radio u-border-1 u-marginRight-s text--12px"
                      ng-class="{'isSelected': $ctrl.selected.indexOf(option) > -1}"></span>
                {{::option}}
            </div>
        </div>
    </div>
</div>

Hide popup and mass actions

Well, we now can open a popup and select options, but how can we close the popup when we were done? The popup should be closed when the user clicks outside of it or, which is better, outside of the options. With this approach, we can add buttons to the popup and they will still close it.

The idea is to add an event listener to the document. The controller won’t know that he needs to update the view, so we need to inject $scope variable to manually call the $apply() function. Also, we want to prevent this behavior when the user selects an option, so we need to add an e.stopPropagation(); to previously created methods.

MultiSelectCtrl {
  constructor($scope) {
    this.selected = [];
    this.$scope = $scope;
  }

  /**
   * $onInit()
   */
  $onInit() {
    document.addEventListener('click', e => {
      if (this.showOptions) {
        this.showOptions = false;
        this.$scope.$apply();
      }
      return;
    });
  }

  onSelect(value, e) {
    e.stopPropagation();
    ...
  }
  onShowOptions(e) {
    e.stopPropagation();
    ...
  }
}
MultiSelectCtrl.$inject = ['$scope'];

Now let’s add a functionality for selecting and deselecting all options.

There two simple methods for that

selectAll = () => {
  this.selected = [...this.msOptions];
  this.value = this.selected.join(', ');
 };

deselectAll = () =>{
  this.selected=[];
  this.value = null;
};

Since we have these methods in place, let’s add buttons to the end of the popup in our template.

<div class="Grid Grid--alignCenter u-paddingVertical-xs u-marginTop-xs">
    <div class="Grid-block Grid-block--weight1 text--center u-paddingLeft-xs">
        <span class="Btn--pointer text--blue text--bold"
                ng-click="$ctrl.selectAll()">
            Select all
        </button>
    </div>
    <div class="Grid-block Grid-block--weight1 text--center">
        <span class="Btn Btn--pointer Btn--blue text--uppercase text--14px text--bold">
            Save
        </span>
    </div>
    <div class="Grid-block Grid-block--weight1 text--center u-paddingRight-xs">
        <span class="Btn--pointer text--blue text--bold"
                ng-click="$ctrl.deselectAll()">
            Deselect all
        </span>
    </div>
</div>

Talk to a parent

The component looks cool and works as expected, but it’s not connected in any way with the rest of the app. Selected options are accessible only inside the component’s scope. How could we use them?

To solve this problem we can use a callback function from parent passed via attributes. This actually will make our input field a completely reusable component. You will be able to add anywhere and pass its selected options to the parent scope.

Ok, what we need to do to achieve this?

First of all, we need to update our component and add a two-way binding called msCallback. It will receive a function from the parent component.

bindings: {
  msLabel: '@',
  msOptions: '<',
  msCallback: '&',
},

Then let’s create a method which will execute this callback and pass scope variables as its parameters. For instance, let’s assume that we will pass the selected options.

passOptionsToParent() {
  this.msCallback({options: this.value});
};

Now let’s add this method to all the actions we have so far and to ‘Save’ button via ngClick.

To use this callback by a parent component you just need to create a method and pass options as a parameter, e.g.

patternsCallback(options) {
  if (options) {
    this.options = [...options.split(', ')];
  } else {
    this.options = [];
  }
};

Here is an example of a final usage of our component.

<multi-select ms-label="Patterns" ms-options="['Flowers', 'Bears', 'Elephants']"
  ms-callback="$ctrl.patternsCallback"
></multi-select>

Conclusion

Now we have a completely reusable component built in Angular. You can modify it to meet your goals, e.g. add pre-selected options.

Here are the final versions of component’s code.

// multi-select.component.js

import template from './multi-select.component.html';
/**
 * MultiSelectCtrl
 */
class MultiSelectCtrl {
    /**
     * constructor()
     * @param {Object} $scope
     */
    constructor($scope) {
        this.selected = [];
        this.$scope = $scope;
    }
    /**
     * $onInit()
     */
    $onInit() {
      document.addEventListener('click', e => {
        if (this.showOptions) {
          this.showOptions = false;
          this.$scope.$apply();
        }
        return;
      });
    }
    /**
      * selectAll()
      */
    selectAll() {
        this.selected = [...this.msOptions];
        this.value = this.selected.join(', ');
        this.passOptionsToParent();
    };
    /**
      * deselectAll()
      */
    deselectAll() {
        this.selected=[];
        this.value = null;
        this.passOptionsToParent();
    };
    /**
     * onSelect()
     * @param {String} value
     * @param {Object} e
     */
    onSelect(value, e) {
        e.stopPropagation();
        const index = this.selected.indexOf(value);
        if (index < 0) {
            this.selected.push(value);
        } else {
            this.selected.splice(index, 1);
        }
        this.value = this.selected.join(', ');
        this.passOptionsToParent();
    }
    /**
     * onShowOptions
     * @param {Object} e
     */
    onShowOptions(e) {
        e.stopPropagation();
        this.showOptions = true;
    }
};

MultiSelectCtrl.$inject = ['$scope'];

export default {
    template,
    controller: MultiSelectCtrl,
    bindings: {
        msLabel: '@',
        msoptions: '<',
        msCallback: '&',
    },
};

<!-- multi-select.component.html -->

<div class="Multiselect">
    <input type="text" name="multi" id="multi" class="Input-field Input-field--select u-paddingRight-s text--ellipsis"
            ng-model="$ctrl.value"
            ng-click="$ctrl.onShowOptions($event)"
            readonly />
    <label for="multi" class="Input-label">{{::$ctrl.msLabel}}</label>
    <div class="Multiselect-popup u-border-1"
            ng-class="{ 'isActive': $ctrl.showOptions }">
        <input type="text" placeholder="Search for a {{::$ctrl.msLabel.toLowerCase().slice(0,-1)}}..."
                class="u-paddingVertical-xs u-paddingHorisontal-s u-borderBottom-1"
                style="width: 100%"
                ng-model="$ctrl.searchTerm"
                ng-click="$event.stopPropagation();" />
        <div class="Multiselect-options u-marginTop-xs">
            <div class="u-paddingVertical-xs u-paddingHorisontal-s Btn--pointer Multiselect-option Grid Grid--alignCenter"
                 ng-class="{'text--blue': $ctrl.selected.indexOf(option) > -1}"
                 ng-repeat="option in $ctrl.msOptions | filter:$ctrl.searchTerm"
                 ng-click="$ctrl.onSelect(option, $event)">
                <span class="Radio u-border-1 u-marginRight-s text--12px"
                      ng-class="{'isSelected': $ctrl.selected.indexOf(option) > -1}"></span>
                {{::option}}
            </div>
        </div>
        <div class="Grid Grid--alignCenter u-paddingVertical-xs u-marginTop-xs">
            <div class="Grid-block Grid-block--weight1 text--center u-paddingLeft-xs">
                <span class="Btn--pointer text--blue text--bold"
                        ng-click="$ctrl.selectAll()">
                    Select all
                </button>
            </div>
            <div class="Grid-block Grid-block--weight1 text--center">
                <span class="Btn Btn--pointer Btn--blue text--uppercase text--14px text--bold"
                        ng-click="$ctrl.passOptionsToParent();">
                    Save
                </span>
            </div>
            <div class="Grid-block Grid-block--weight1 text--center u-paddingRight-xs">
                <span class="Btn--pointer text--blue text--bold"
                        ng-click="$ctrl.deselectAll()">
                    Deselect all
                </span>
            </div>
        </div>
    </div>
</div>