Materialize: autocomplete: allow function for asynchronous completions

Created on 28 Jul 2016  路  7Comments  路  Source: Dogfalo/materialize

It would be nice if a function could be specified to provide completions asynchronously. This could be useful for datasets that cannot be located completely on the client and must be fetched partially from the server, as in the following example:

$("input").autocomplete({
    fetch: function (val, callback) {
        fetch("/api/complete?query=" + encodeURIComponent(val))
            .then(function (response) {
               response.json().then(function (json) {
                   callback(null, json);
               });
            });
    }
});
Autocomplete enhancement

Most helpful comment

By the way, this is how you would use the approach above for querying GitHub:

"Data model" definition:

function GitHubReposModel (user) {
  this.user = user;
}
GitHubReposModel.prototype.complete = function (input, callback) {
  $.ajax({
    url: "https://api.github.com/users/"+encodeURIComponent(this.user)+"/repos",
    error: function (xhr, status, error) {
      callback(error);
    },
    success: function (data) {
      /* GitHub endpoint doesn't allow filtering server-side */
      var lci = input.toLowerCase();
      var filtered = data.filter(function (item) {
        return item.name && item.name.toLowerCase().indexOf(lci) !== -1;
      });
      callback(null, filtered);
    }
  });
};
GitHubReposModel.prototype.render = function (item, input) {
  var highlight = '<span class="highlight">$&</span>';
  var text = item.name.replace(new RegExp(input, 'i'), highlight);
  var li = $('<li></li>');
  var span = $('<span></span>');
  if (item.language) {
    var encodedLanguage = encodeURIComponent(item.language);
    var src = "http://placehold.it/40x40/?text=" + encodedLanguage;
    var img = $('<img src="' + src + '" class="right circle"></img>');
    li.append(img);
  }
  span.html(text);
  li.append(span);
  li.data("value", item.name);
  return li;
};
GitHubReposModel.prototype.extract = function (li) {
  return $(li).data("value");
};

Initialization:

$('input.autocomplete-xhr').autocomplete({
  model: new GitHubReposModel("dogfalo")
});

Markup:

<p>Autocomplete with XHR</p>
<div class="row">
  <div class="col s12">
    <div class="row">
      <div class="input-field col s12">
        <i class="material-icons prefix">textsms</i>
        <input type="text" id="autocomplete-xhr" class="autocomplete-xhr">
        <label for="autocomplete-xhr">Autocomplete XHR</label>
      </div>
    </div>
  </div>
</div>

All 7 comments

Fantastic idea. I had the exact same thought after seeing the addition of autocomplete.

Is initializing the autocomplete inside the asynchronous callback not an option?

I haven't thought of that. But I guess no, whatever gets the suggestions needs to be called in response to user input and needs that input to fetch them.

Oh I see. Would it be fine if you could change the dataset for the autocomplete and set it asynchronously?

What I was thinking of is a client provided hook around the folowing point in js/forms.js#L319..327:

          // Perform search
          $input.on('keyup', function (e) {
            // Capture Enter
            if (e.which === 13) {
              $autocomplete.find('li').first().click();
              return;
            }

            var val = $input.val().toLowerCase();

such that the hook gets called with val and is in charge of generating the dataset for that text, maybe asynchronously, which then gets rendered into li tags.

Also, I believe the code could be improved by extracting the different concerns to configurable functions, namely completion, rendering and value extraction, making the component agnostic of data format and capable of supporting different layouts in the suggestions panel.

Like in the following code (it's just a draft, I need to test it yet):

/* Sample data model which accepts data in the format used
 * by the current implementation of Materialize's autocomple.
 */
function ClientDataModel (data) {
  var a = [];
  for(var key in data) {
    if (data.hasOwnProperty(key)) {
      a.push({text: key, img: data[key]});
    }
  }
  this.data = a;
}

/* fn: complete
 *
 * Given an `input` text, produce a list of suggestions and
 * pass it back to the caller provided `callback`.
 *
 * The returned value is an array but the format of each item
 * it holds remains unspecified and the caller should only
 * handle them through other methods of this class.
 */
ClientDataModel.prototype.complete = function (input, callback) {
  var lowerCasedInput = input.toLowerCase();
  var filtered = this.data.filter(function (item) {
    return item.text.toLowerCase().indexOf(lowerCasedInput) !== -1    
  });
  callback(null, filtered);
};

/* fn: render
 *
 * Given one data `矛tem` (that has been produced by `complete`) and
 * an `矛nput`, render the item to a `li` element where the input is
 * highlighted.
 */
ClientDataModel.prototype.render = function (item, input) {
  var highlight = '<span class="highlight">$&</span>';
  var text = item.text.replace(new RegExp(input, 'i'), highlight);
  var li = $('<li></li>');
  var span = $('<span></span>');
  if (item.img) {
    var img = $('<img src="' + item.img + '" class="right circle"></img>');
    li.append(img);
  }
  span.html(text);
  li.append(span);
  return li;
};

/* fn: extract
 *
 * Given a `li` element rendered by `render`, return the value the input
 * field should be set to.
 */
ClientDataModel.prototype.extract = function (li) {
  return $(li).text().trim();
};

$.fn.autocomplete = function (options) {
  if (options.data) options.model = new ClientDataModel(options.data);

  if (!options.model) {
    console.error("Autocomplete does not have a data model.");
    return;
  }

  return this.each(function() {
    var $input = $(this),
        $inputDiv = $input.closest('.input-field'); // Div to append on

    // Create autocomplete element
    var $autocomplete = $('<ul class="autocomplete-content dropdown-content"></ul>');

    // Append autocomplete element
    if ($inputDiv.length) {
      $inputDiv.append($autocomplete); // Set ul in body
    } else {
      $input.after($autocomplete);
    }

    // Perform search
    $input.on('keyup', function (e) {
      // Capture Enter
      if (e.which === 13) {
        $autocomplete.find('li').first().click();
        return false;
      }

      var val = $input.val();

      // Check if the input isn't empty
      if (val !== '') {
        options.model.complete(val, function (err, suggestions) {
          if (err) return console.error(err); //TODO: handle errors
          $autocomplete.empty();
          suggestions.forEach(function (item) {
            var li = options.model.render(item, val);
            $autocomplete.append(li);
          });
        });
      } else {
        $autocomplete.empty();
      }
    });

    // Set input value
    $autocomplete.on('click', 'li', function () {
      $input.val(options.model.extract(this));
      $autocomplete.empty();
    });
  });
};

It may look quite complex, but there are a few improvements here:

  1. The data structure holding suggestions is an array; thus avoiding ordering issues *
  2. The autocomplete client may specify a custom "data model" allowing for more complex data than a pair of text and image and the possibility to display that data using a custom layout.
  3. Because the rendering of suggestions is done in a callback asynchronous completions are supported.

The last one is what this issue is about and it's very useful for datasets that won't fit in the client unless pre-filtered server-side.

It may make sense not to expose the data model to the client; it's a material design library after all. But having it makes it easy to support the different designs.

By the way, this is how you would use the approach above for querying GitHub:

"Data model" definition:

function GitHubReposModel (user) {
  this.user = user;
}
GitHubReposModel.prototype.complete = function (input, callback) {
  $.ajax({
    url: "https://api.github.com/users/"+encodeURIComponent(this.user)+"/repos",
    error: function (xhr, status, error) {
      callback(error);
    },
    success: function (data) {
      /* GitHub endpoint doesn't allow filtering server-side */
      var lci = input.toLowerCase();
      var filtered = data.filter(function (item) {
        return item.name && item.name.toLowerCase().indexOf(lci) !== -1;
      });
      callback(null, filtered);
    }
  });
};
GitHubReposModel.prototype.render = function (item, input) {
  var highlight = '<span class="highlight">$&</span>';
  var text = item.name.replace(new RegExp(input, 'i'), highlight);
  var li = $('<li></li>');
  var span = $('<span></span>');
  if (item.language) {
    var encodedLanguage = encodeURIComponent(item.language);
    var src = "http://placehold.it/40x40/?text=" + encodedLanguage;
    var img = $('<img src="' + src + '" class="right circle"></img>');
    li.append(img);
  }
  span.html(text);
  li.append(span);
  li.data("value", item.name);
  return li;
};
GitHubReposModel.prototype.extract = function (li) {
  return $(li).data("value");
};

Initialization:

$('input.autocomplete-xhr').autocomplete({
  model: new GitHubReposModel("dogfalo")
});

Markup:

<p>Autocomplete with XHR</p>
<div class="row">
  <div class="col s12">
    <div class="row">
      <div class="input-field col s12">
        <i class="material-icons prefix">textsms</i>
        <input type="text" id="autocomplete-xhr" class="autocomplete-xhr">
        <label for="autocomplete-xhr">Autocomplete XHR</label>
      </div>
    </div>
  </div>
</div>

Added updateData method for autocomplete v1 in bf24cb7d

Was this page helpful?
0 / 5 - 0 ratings

Related issues

MickaelH974 picture MickaelH974  路  3Comments

heshamelmasry77 picture heshamelmasry77  路  3Comments

ericlormul picture ericlormul  路  3Comments

serkandurusoy picture serkandurusoy  路  3Comments

alexknipfer picture alexknipfer  路  3Comments