Material: including ng-model in md-autocomplete

Created on 24 Apr 2015  路  23Comments  路  Source: angular/material

Currently autocomplete uses md-selected-item for parameter initialization.

There are some issues with this, since you can f.e. add a random value/display object and it will show up, even if the item does not exist in the item list, as shown in this example. http://plnkr.co/edit/RkHGrqKXNfjINkuNKIrs?p=preview

I have been struggling the whole day simply to make two autocomplete elements exchange values when an item is selected, when it could be so much easier if I used ng-model. It feels unnecessarily complicated to initialize autocomplete with an object, instead of simply working as a select with the ng-model addressing the desirable value/display pair.

won't fix

Most helpful comment

For anyone still looking a for a way to set ng-model for autocomplete, here's a little directive I wrote that may be helpful. The bigger problem this solved, for me, was that it made it much simpler to implement inside an ng-repeat -- data table in my case -- because all of the js logic is contained within the directive. Feel free to reach out if you have any questions or suggestions for improvements! -Nate

// SIMPLE AUTOCOMPLETE WRAPPER - USAGE..

<simple-autocomplete
    ng-model="vm.myModel"
    ng-change="vm.callLocalFunctionOnModelChange()"
    options="vm.arrayOfObjectsToSearch"
    search-key="key" 
    search-value="value" 
    placeholder="-- Search Whatever --">
</simple-autocomplete>
'use strict';

angular
    .module('app')
    .directive('simpleAutocomplete', simpleAutocompleteDirective);

function simpleAutocompleteDirective() {
    var directive = {
        restrict: 'E',
        template:
                '<md-autocomplete' +
                '    md-selected-item="vm.selectedItem"' +
                '    md-selected-item-change="vm.ngModel = vm.selectedItem[vm.searchKey]; vm.change();"' +
                '    md-search-text="vm.searchText"' +
                '    md-items="item in vm.searchQuery(vm.searchText)"' +
                '    md-item-text="item[vm.searchValue]"' +
                '    md-delay="100"' +
                '    md-no-cache="true"' +
                '    md-min-length="0"' +
                '    placeholder="{{ vm.placeholder }}">' +
                '      <md-item-template>' +
                '         <span>{{ item[vm.searchValue] }}</span>' +
                '      </md-item-template>' +
                '</md-autocomplete>',
        scope: {
            options     : '<',
            ngModel     : '=',
            ngChange    : '&',
            searchKey   : '@',
            searchValue : '@',
            placeholder : '@'
        },
        controller: SimpleAutocompleteController, 
        controllerAs: 'vm',
        bindToController: true 
    };

    return directive;
};

function SimpleAutocompleteController() {
    var vm = this;

    // vm-bound functions
    vm.searchQuery = searchQuery;
    vm.change = change;

    // set selected (default) item based on current model
    vm.selectedItem = vm.options.filter(function(item) {
        return (item[vm.searchKey] === vm.ngModel)
    })[0] || null;

    // sanitize field by making it lowercase and removing periods (so "U.S. bond" will return on "us bond")
    vm.options.forEach(function(item) {
        item.cleanSearch = angular.lowercase(item[vm.searchValue]).replace(/\./g, '');
    });

    function change() {
        // add small 10ms timeout to ensure ngModel is updated before firing change
        setTimeout(function() {
            vm.ngChange();
        }, 10);
    };

    function searchQuery(query) {
        query = angular.lowercase(query).replace(/\./g, '') || '';

        var results = query ? vm.options.filter(function(item) {
            return (item.cleanSearch.includes(query))
        }) : vm.options;

        return results;
    };
};

All 23 comments

+1

+1 for using native AngularJS as much as possible.

We can't use ng-model due to ambiguity since we have multiple models being bound. We need to allow the user to access both the selected element as well as the text that is entered.

The issue is that there is a lot of gray area between md-autocomplete and md-select - and we are definitely looking into how to best make a clear distinction between the two. It's entirely possible that md-autocomplete will be simplified and some of its functionality be moved into a searchable md-select, but we haven't come to a clear decision yet on what these API's should look like.

+1

:+1: I just cant control things like $dirty as long as current implementation not rely on those.

All you gotta do is something like this to get the autocomplete input to bind to a model:

// Controller
var vm = this;
vm.goal = DataService.goals().one();
vm.selectedItemChange = selectedItemChange;
vm.searchTextChange   = searchTextChange;

function searchTextChange(text) {
      vm.goal.name = text;
    }

    function selectedItemChange(item) {
      vm.goal.name = item.name;
    }
<!--View-->
<md-autocomplete
          md-selected-item="new.selectedItem"
          md-search-text-change="new.searchTextChange(new.searchText)"
          md-search-text="new.searchText"
          md-selected-item-change="new.selectedItemChange(item)"
          md-items="item in new.querySearch(new.searchText)"
          md-item-text="item.name"
          md-min-length="0"
          placeholder="Choose a goal or write your own."
          md-menu-class="autocomplete-custom-template">

        <md-item-template>
          <span class="item-title">
            <span> {{item.name}} </span>
          </span>
        </md-item-template>

      </md-autocomplete>

+1

@robertmesserle
this is a painful limitation caused by the way angular was designed: it has a single ng-model directive to handle all data-binding related stuff, including validation. This basically prevents creating reusable components that bind multiple data. If you'd like to create a reusable component for example to edit a person name and address, you can't have two different bindings for that.
It can be a reasonable response to this limitation that in this case you have to create a Person object and bind the web component to that - so the Person object encapsulates all the data you wish to bind.

This still has some pain points of course. If you'd like to add a special validator to the email field when using this component (for example it's required in one part of your app but not on the other), you can easily just use ng-required. But you can still create your own validation directive that plays well with ng-form.

I think this approach might be a good one for md-autocomplete: create an object that encapsulates everything you want to bind. This would let us create validation directives that play nice with ngForm.

+1. And a searchable md-select would be great.

(@cleever a searchable md-select is a different issue I think :) - in our app we use md-autocomplete with min-length=0, so that clicking in an empty componenet opens up the dropdown - this is quite similar to a searchable md-select, it might work for your use-cases as well)

I understand your point @gabor-farkas. But I think that we don't need two differents components to do similar things. I'm currently using md-autocomplete like you said, but I miss the dropdown arrow and it is a bit confusing for users. Sometimes they have a real combobox(md-select), that is completely static and they cannot search on it and sometimes they have a text input that they can search things but do not looks like a select box.

Cheers ;)

@cleever yup, I've also tried to have a custom modified md-autocomplete that has a dropdown arrow, but the code isn't easily extensible to support this: the expression you give for md-items actually relies on the expression for searchtext, so you cannot call getSuggestedItems(null) for example from the controller of md-autocomplete. You can only evaluate mdItems. If you set the searchtext expression to null then it will remove the presented value from the dom and it will actually also set the value in md-selected-item to null, so you cannot have the dropdown without actually resetting the value. The code of the md-autocomplete controller would need a lot of refactoring before this can be possible.

@robertmesserle we've got another idea when discussing things with collegues
The basic semantic problem with ng-model is that a data binding should not belong to an html element (input element or web component), but to a property of the element. Also, validation rules don't belong to the element, but to the binding. To have this properly reflected in the template, we can create a custom directive, eg. bind-property, and it could be used like:

  <input type="text">
    <bind-property name="value" ng-model="myValue" [ng-required] [...]/>
  </input>

This approach would allow md-autocomplete to have various separate data bindings, each with their respective validation rules

   <md-autocomplete md-selected-item="selectedItem" [...]>
      <bind-property name="md-selected-item" ng-model="ctrl.selectedItem" [ng-required] [...]>
   </md-autocomplete>

@gabor-farkas good idea.

Also, I created a new issue #6118 about dropdown arrow.

here's a working example for my previous proposal http://jsfiddle.net/hhcofcmds/pgwuo9v7/1/

@gabor-farkas how to add md-input-invalid class to this example?

@Knoxvillekm good question, this solution doesn't support handling that class. But note that it's actually the md-autocomplete that doesn't work together with the existing ng forms features. You can probably create an own directive that adds the md-input-invalid class the element where it belongs.
But unfortunately these solutions can be quite tricky for example if the input is invalid by default, because you cannot really make sure to run your code after the md-autocomplete stuff is completely initialized (autocomplete runs some stuff deferredly, and you cannot hook on that).

For anyone still looking a for a way to set ng-model for autocomplete, here's a little directive I wrote that may be helpful. The bigger problem this solved, for me, was that it made it much simpler to implement inside an ng-repeat -- data table in my case -- because all of the js logic is contained within the directive. Feel free to reach out if you have any questions or suggestions for improvements! -Nate

// SIMPLE AUTOCOMPLETE WRAPPER - USAGE..

<simple-autocomplete
    ng-model="vm.myModel"
    ng-change="vm.callLocalFunctionOnModelChange()"
    options="vm.arrayOfObjectsToSearch"
    search-key="key" 
    search-value="value" 
    placeholder="-- Search Whatever --">
</simple-autocomplete>
'use strict';

angular
    .module('app')
    .directive('simpleAutocomplete', simpleAutocompleteDirective);

function simpleAutocompleteDirective() {
    var directive = {
        restrict: 'E',
        template:
                '<md-autocomplete' +
                '    md-selected-item="vm.selectedItem"' +
                '    md-selected-item-change="vm.ngModel = vm.selectedItem[vm.searchKey]; vm.change();"' +
                '    md-search-text="vm.searchText"' +
                '    md-items="item in vm.searchQuery(vm.searchText)"' +
                '    md-item-text="item[vm.searchValue]"' +
                '    md-delay="100"' +
                '    md-no-cache="true"' +
                '    md-min-length="0"' +
                '    placeholder="{{ vm.placeholder }}">' +
                '      <md-item-template>' +
                '         <span>{{ item[vm.searchValue] }}</span>' +
                '      </md-item-template>' +
                '</md-autocomplete>',
        scope: {
            options     : '<',
            ngModel     : '=',
            ngChange    : '&',
            searchKey   : '@',
            searchValue : '@',
            placeholder : '@'
        },
        controller: SimpleAutocompleteController, 
        controllerAs: 'vm',
        bindToController: true 
    };

    return directive;
};

function SimpleAutocompleteController() {
    var vm = this;

    // vm-bound functions
    vm.searchQuery = searchQuery;
    vm.change = change;

    // set selected (default) item based on current model
    vm.selectedItem = vm.options.filter(function(item) {
        return (item[vm.searchKey] === vm.ngModel)
    })[0] || null;

    // sanitize field by making it lowercase and removing periods (so "U.S. bond" will return on "us bond")
    vm.options.forEach(function(item) {
        item.cleanSearch = angular.lowercase(item[vm.searchValue]).replace(/\./g, '');
    });

    function change() {
        // add small 10ms timeout to ensure ngModel is updated before firing change
        setTimeout(function() {
            vm.ngChange();
        }, 10);
    };

    function searchQuery(query) {
        query = angular.lowercase(query).replace(/\./g, '') || '';

        var results = query ? vm.options.filter(function(item) {
            return (item.cleanSearch.includes(query))
        }) : vm.options;

        return results;
    };
};

@NateVonSmith and everyone here, don't you have a feeling there is something wrong with the autocomplete itself, not the way we are using it?

@easy-one it's pretty typical that a directive wouldn't use ng-model for the directive's own model so I wouldn't really say it's wrong / necessary but I'd absolutely agree that their implementation isn't ideal given the way many people are using it and is esp. difficult to implement multiple times within the same view. ** I updated my directive for anyone who may have grabbed the code previously.

+1

+1

In case anyone wanted to use @NateVonSmith custom autocomplete directive above and populate the options list using a async call to the server there are some tweaks needed. Nate's directive works pretty well however setting the default value to the ng-Model has issues if you are loading options asynchronously. This was solved by moving that code to the link function and adding a watch on the options field.

function simpleAutocompleteDirective() {
        var directive = {
        restrict: 'E',
        template:
                '<md-autocomplete' +
                '    md-selected-item="vm.selectedItem"' +
                '    md-selected-item-change="vm.ngModel = vm.selectedItem[vm.searchKey]; vm.change();"' +
                '    md-search-text="vm.searchText"' +
                '    md-items="item in vm.searchQuery(vm.searchText)"' +
                '    md-item-text="item[vm.searchValue]"' +
                '    md-delay="100"' +
                '    md-no-cache="true"' +
                '    md-min-length="0"' +
                '    placeholder="{{ vm.placeholder }}">' +
                '      <md-item-template>' +
                '         <span>{{ item[vm.searchValue] }}</span>' +
                '      </md-item-template>' +
                '</md-autocomplete>',
        scope: {
            options     : '<',
            ngModel     : '=',
            ngChange    : '&',
            searchKey   : '@',
            searchValue : '@',
            placeholder : '@'
        },
        link: function(scope, element, attrs) {
            scope.$watch('vm.options', function(newValue, oldValue) {    
                scope.vm.selectedItem = scope.vm.options.filter(function(item) {
                return (item[scope.vm.searchKey] === scope.vm.ngModel)
                })[0] || null;         
            }, true);
        },
        controller: SimpleAutocompleteController, 
        controllerAs: 'vm',
        bindToController: true 
    };

    return directive;
};

Was this page helpful?
0 / 5 - 0 ratings

Related issues

Dona278 picture Dona278  路  3Comments

epelc picture epelc  路  3Comments

nikhildev picture nikhildev  路  3Comments

achaussende picture achaussende  路  3Comments

WebTechnolog picture WebTechnolog  路  3Comments