Converts gr-reviewer-list to use a new, generic autocomplete

Introduces gr-autocomplete, which is a generic component for
autocompleted text inputs. The reviewer-specific autocomplete box found
in gr-reviewer-list is converted to use gr-autocomplete.

Change-Id: I47426740d3372ac0fed08e269aff280d444fe0f4
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
index 3fee424..e3489c3 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
@@ -16,9 +16,9 @@
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../shared/gr-account-chip/gr-account-chip.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-reviewer-list">
@@ -45,21 +45,6 @@
       gr-account-chip {
         margin-top: .3em;
       }
-      .dropdown {
-        background-color: #fff;
-        box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
-        position: absolute;
-        left: 0;
-        top: 100%;
-        z-index: 1000;
-      }
-      .dropdown .reviewer {
-        cursor: pointer;
-        padding: .5em .75em;
-      }
-      .dropdown .reviewer[selected] {
-        background-color: #ccc;
-      }
       .remove,
       .cancel {
         color: #999;
@@ -89,29 +74,24 @@
     <div class="controlsContainer" hidden$="[[!mutable]]">
       <div class="autocompleteContainer" hidden$="[[!_showInput]]">
         <div class="inputContainer">
-          <input is="iron-input" id="input"
-              bind-value="{{_inputVal}}" disabled$="[[disabled]]">
-          <gr-button link class="cancel" on-tap="_handleCancelTap">×</gr-button>
-        </div>
-        <div class="dropdown" hidden$="[[_hideAutocomplete]]">
-          <template is="dom-repeat" items="[[_autocompleteData]]" as="reviewer">
-            <div class="reviewer"
-                data-index$="[[index]]"
-                on-mouseenter="_handleMouseEnterItem"
-                on-tap="_handleItemTap"
-                selected$="[[_computeSelected(index, _selectedIndex)]]">
-              <template is="dom-if" if="[[reviewer.account]]">
-                <gr-account-label
-                    account="[[reviewer.account]]" show-email></gr-account-label>
-              </template>
-              <template is="dom-if" if="[[reviewer.group]]">
-                <span>[[reviewer.group.name]] (group)</span>
-              </template>
-            </div>
-          </template>
+          <gr-autocomplete
+              id="input"
+              threshold="3"
+              query="[[_query]]"
+              disabled="[[disabled]]"
+              on-commit="_sendAddRequest"
+              on-cancel="_handleCancelTap"></gr-autocomplete>
+          <gr-button
+              link
+              class="cancel"
+              on-tap="_handleCancelTap">×</gr-button>
         </div>
       </div>
-      <gr-button link id="addReviewer" class="addReviewer" on-tap="_handleAddTap"
+      <gr-button
+          link
+          id="addReviewer"
+          class="addReviewer"
+          on-tap="_handleAddTap"
           hidden$="[[_showInput]]">Add reviewer</gr-button>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
index de99039..84c15b1 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
@@ -37,38 +37,15 @@
         type: Array,
         value: function() { return []; },
       },
-      _autocompleteData: {
-        type: Array,
-        value: function() { return []; },
-        observer: '_autocompleteDataChanged',
-      },
-      _inputVal: {
-        type: String,
-        value: '',
-        observer: '_inputValChanged',
-      },
-      _inputRequestHandle: Number,
-      _inputRequestTimeout: {
-        type: Number,
-        value: 250,
-      },
       _showInput: {
         type: Boolean,
         value: false,
       },
-      _hideAutocomplete: {
-        type: Boolean,
-        value: true,
-        observer: '_hideAutocompleteChanged',
-      },
-      _selectedIndex: {
-        type: Number,
-        value: 0,
-      },
-      _boundBodyClickHandler: {
+
+      _query: {
         type: Function,
         value: function() {
-          return this._handleBodyClick.bind(this);
+          return this._getReviewerSuggestions.bind(this);
         },
       },
 
@@ -77,25 +54,10 @@
       _xhrPromise: Object,
     },
 
-    behaviors: [
-      Gerrit.KeyboardShortcutBehavior,
-    ],
-
     observers: [
       '_reviewersChanged(change.reviewers.*, change.owner)',
     ],
 
-    detached: function() {
-      this._clearInputRequestHandle();
-    },
-
-    _clearInputRequestHandle: function() {
-      if (this._inputRequestHandle != null) {
-        this.cancelAsync(this._inputRequestHandle);
-        this._inputRequestHandle = null;
-      }
-    },
-
     _reviewersChanged: function(changeRecord, owner) {
       var result = [];
       var reviewers = changeRecord.base;
@@ -121,21 +83,6 @@
       return false;
     },
 
-    _computeSelected: function(index, selectedIndex) {
-      return index == selectedIndex;
-    },
-
-    _handleBodyClick: function(e) {
-      var eventPath = Polymer.dom(e).path;
-      for (var i = 0; i < eventPath.length; i++) {
-        if (eventPath[i] == this) {
-          return;
-        }
-      }
-      this._selectedIndex = -1;
-      this._autocompleteData = [];
-    },
-
     _handleRemove: function(e) {
       e.preventDefault();
       var target = Polymer.dom(e).rootTarget;
@@ -170,139 +117,27 @@
 
     _handleCancelTap: function(e) {
       e.preventDefault();
+      this.$.input.clear();
       this._cancel();
     },
 
-    _handleMouseEnterItem: function(e) {
-      this._selectedIndex =
-          parseInt(Polymer.dom(e).rootTarget.getAttribute('data-index'), 10);
-    },
-
-    _handleItemTap: function(e) {
-      var reviewerEl;
-      var eventPath = Polymer.dom(e).path;
-      for (var i = 0; i < eventPath.length; i++) {
-        var el = eventPath[i];
-        if (el.classList && el.classList.contains('reviewer')) {
-          reviewerEl = el;
-          break;
-        }
-      }
-      this._selectedIndex =
-          parseInt(reviewerEl.getAttribute('data-index'), 10);
-      this._sendAddRequest();
-    },
-
-    _autocompleteDataChanged: function(data) {
-      this._hideAutocomplete = data.length == 0;
-    },
-
-    _hideAutocompleteChanged: function(hidden) {
-      if (hidden) {
-        document.body.removeEventListener('click',
-            this._boundBodyClickHandler);
-        this._selectedIndex = -1;
-      } else {
-        document.body.addEventListener('click', this._boundBodyClickHandler);
-        this._selectedIndex = 0;
-      }
-    },
-
-    _inputValChanged: function(val) {
-      var sendRequest = function() {
-        if (this.disabled || val == null || val.trim().length == 0) {
-          return;
-        }
-        if (val.length < this.suggestFrom) {
-          this._clearInputRequestHandle();
-          this._hideAutocomplete = true;
-          this._selectedIndex = -1;
-          return;
-        }
-        this._lastAutocompleteRequest =
-            this._getSuggestedReviewers(this.change._number, val).then(
-                this._handleReviewersResponse.bind(this));
-      }.bind(this);
-
-      this._clearInputRequestHandle();
-      if (this._inputRequestTimeout == 0) {
-        sendRequest();
-      } else {
-        this._inputRequestHandle =
-            this.async(sendRequest, this._inputRequestTimeout);
-      }
-    },
-
-    _handleReviewersResponse: function(response) {
-      this._autocompleteData = response.filter(function(reviewer) {
-        var account = reviewer.account;
-        if (!account) { return true; }
-        for (var i = 0; i < this._reviewers.length; i++) {
-          if (account._account_id == this.change.owner._account_id ||
-              account._account_id == this._reviewers[i]._account_id) {
-            return false;
-          }
-        }
-        return true;
-      }, this);
-    },
-
-    _getSuggestedReviewers: function(changeNum, inputVal) {
-      return this.$.restAPI.getChangeSuggestedReviewers(changeNum, inputVal);
-    },
-
-    _handleKey: function(e) {
-      if (this._hideAutocomplete) {
-        if (e.keyCode == 27) {  // 'esc'
-          e.preventDefault();
-          this._cancel();
-        }
-        return;
-      }
-
-      switch (e.keyCode) {
-        case 38:  // 'up':
-          e.preventDefault();
-          this._selectedIndex = Math.max(this._selectedIndex - 1, 0);
-          break;
-        case 40:  // 'down'
-          e.preventDefault();
-          this._selectedIndex = Math.min(this._selectedIndex + 1,
-                                         this._autocompleteData.length - 1);
-          break;
-        case 27:  // 'esc'
-          e.preventDefault();
-          this._hideAutocomplete = true;
-          break;
-        case 13:  // 'enter'
-          e.preventDefault();
-          this._sendAddRequest();
-          break;
-      }
-    },
-
     _cancel: function() {
       this._showInput = false;
-      this._selectedIndex = 0;
-      this._inputVal = '';
-      this._autocompleteData = [];
+      this.$.input.clear();
       this.$.addReviewer.focus();
     },
 
-    _sendAddRequest: function() {
-      this._clearInputRequestHandle();
-
+    _sendAddRequest: function(e, reviewer) {
       var reviewerID;
-      var reviewer = this._autocompleteData[this._selectedIndex];
       if (reviewer.account) {
         reviewerID = reviewer.account._account_id;
       } else if (reviewer.group) {
         reviewerID = reviewer.group.id;
       }
-      this._autocompleteData = [];
+
       this.disabled = true;
       this._xhrPromise = this._addReviewer(reviewerID).then(function(response) {
-        this.change.reviewers['CC'] = this.change.reviewers['CC'] || [];
+        this.change.reviewers.CC = this.change.reviewers.CC || [];
         this.disabled = false;
         if (!response.ok) { return response; }
 
@@ -311,7 +146,6 @@
             this.push('change.removable_reviewers', r);
             this.push('change.reviewers.CC', r);
           }, this);
-          this._inputVal = '';
           this.$.input.focus();
         }.bind(this));
       }.bind(this)).catch(function(err) {
@@ -327,5 +161,47 @@
     _removeReviewer: function(id) {
       return this.$.restAPI.removeChangeReviewer(this.change._number, id);
     },
+
+    _notInList: function(reviewer) {
+      var account = reviewer.account;
+      if (!account) { return true; }
+      if (account._account_id === this.change.owner._account_id) {
+        return false;
+      }
+      for (var i = 0; i < this._reviewers.length; i++) {
+        if (account._account_id === this._reviewers[i]._account_id) {
+          return false;
+        }
+      }
+      return true;
+    },
+
+    _makeSuggestion: function(reviewer) {
+      if (reviewer.account) {
+        return {
+          name: reviewer.account.name + ' (' + reviewer.account.email + ')',
+          value: reviewer,
+        };
+      } else if (reviewer.group) {
+        return {
+          name: reviewer.group.name,
+          value: reviewer,
+        };
+      }
+    },
+
+    _getReviewerSuggestions: function(input) {
+      var xhr = this.$.restAPI.getChangeSuggestedReviewers(
+          this.change._number, input);
+
+      this._lastAutocompleteRequest = xhr;
+
+      return xhr.then(function(reviewers) {
+        if (!reviewers) { return []; }
+        return reviewers
+            .filter(this._notInList.bind(this))
+            .map(this._makeSuggestion);
+      }.bind(this));
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
index 9311e91..b89bc4a 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
@@ -34,9 +34,11 @@
 <script>
   suite('gr-reviewer-list tests', function() {
     var element;
+    var autocompleteInput;
 
     setup(function() {
       element = fixture('basic');
+      autocompleteInput = element.$.input.$.input;
       stub('gr-rest-api-interface', {
         getChangeSuggestedReviewers: function() {
           return Promise.resolve([
@@ -112,7 +114,9 @@
       assert.isFalse(
           element.$$('.autocompleteContainer').hasAttribute('hidden'));
       assert.equal(getActiveElement().id, 'input');
-      MockInteractions.pressAndReleaseKeyOn(element, 27); // 'esc'
+
+      MockInteractions.pressAndReleaseKeyOn(autocompleteInput, 27); // 'esc'
+
       assert.isFalse(element.$$('.addReviewer').hasAttribute('hidden'));
       assert.isTrue(
           element.$$('.autocompleteContainer').hasAttribute('hidden'));
@@ -178,15 +182,13 @@
     test('autocomplete starts at >= 3 chars', function() {
       element._inputRequestTimeout = 0;
       element._mutable = true;
-      var requestStub = sinon.stub(element, '_getSuggestedReviewers',
-        function() {
-          assert(false, '_getSuggestedReviewers should not be called for ' +
-              'input lengths of less than 3 chars');
-        }
-      );
-      element._inputVal = 'fo';
+      element.change = {_number: 123};
+
+      element.$.input.text = 'fo';
+
       flushAsynchronousOperations();
-      requestStub.restore();
+
+      assert.isFalse(element.$.restAPI.getChangeSuggestedReviewers.called);
     });
 
     test('add/remove reviewer flow', function(done) {
@@ -200,35 +202,21 @@
       element._mutable = true;
       MockInteractions.tap(element.$$('.addReviewer'));
       flushAsynchronousOperations();
-      element._inputVal = 'andy';
+      element.$.input.text = 'andy';
 
       element._lastAutocompleteRequest.then(function() {
         flushAsynchronousOperations();
-        assert.isFalse(element.$$('.dropdown').hasAttribute('hidden'));
-        var itemEls = Polymer.dom(element.root).querySelectorAll('.reviewer');
-        assert.equal(itemEls.length, 3);
-        assert.isTrue(itemEls[0].hasAttribute('selected'));
-        assert.isFalse(itemEls[1].hasAttribute('selected'));
 
-        MockInteractions.pressAndReleaseKeyOn(element, 40); // 'down'
-        assert.isFalse(itemEls[0].hasAttribute('selected'));
-        assert.isTrue(itemEls[1].hasAttribute('selected'));
+        MockInteractions.pressAndReleaseKeyOn(autocompleteInput, 27); // 'esc'
+        assert.isTrue(element.$$('.autocompleteContainer')
+            .hasAttribute('hidden'));
 
-        MockInteractions.pressAndReleaseKeyOn(element, 38); // 'up'
-        assert.isTrue(itemEls[0].hasAttribute('selected'));
-        assert.isFalse(itemEls[1].hasAttribute('selected'));
+        MockInteractions.tap(element.$$('.addReviewer'));
 
-        MockInteractions.pressAndReleaseKeyOn(element, 27); // 'esc'
-        assert.isTrue(element.$$('.dropdown').hasAttribute('hidden'));
-
-        element._inputVal = 'andyb';
+        element.$.input.text = 'andyb';
         element._lastAutocompleteRequest.then(function() {
-          assert.isFalse(element.$$('.dropdown').hasAttribute('hidden'));
-          var itemEls = Polymer.dom(element.root).querySelectorAll('.reviewer');
-          assert.equal(itemEls.length, 3);
-          assert.isTrue(itemEls[0].hasAttribute('selected'));
-          assert.isFalse(itemEls[1].hasAttribute('selected'));
-          MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
+
+          MockInteractions.pressAndReleaseKeyOn(autocompleteInput, 13); // 'enter'
           assert.isTrue(element.disabled);
 
           element._xhrPromise.then(function() {
@@ -253,5 +241,63 @@
         });
       });
     });
+
+    test('_makeSuggestion', function() {
+      var account = {
+        _account_id: 123456,
+        name: 'name',
+        email: 'email'
+      };
+      var group = {
+        id: '123456',
+        name: 'name',
+      };
+
+      var suggestion = element._makeSuggestion({account: account});
+
+      assert.deepEqual(suggestion, {
+        name: 'name (email)',
+        value: {account: account},
+      });
+
+      suggestion = element._makeSuggestion({group: group});
+
+      assert.deepEqual(suggestion, {
+        name: 'name',
+        value: {group: group},
+      });
+    });
+
+    test('_notInList', function() {
+      var group = {
+        id: '123456',
+        name: 'name',
+      };
+      var account = {
+        _account_id: 123456,
+        name: 'name',
+        email: 'email',
+      };
+
+      element.change = {owner: {_account_id: 123456}};
+
+      // Is true when passing a group.
+      assert.isTrue(element._notInList({group: group}));
+
+      // Is false when passing the change owner.
+      assert.isFalse(element._notInList({account: account}));
+
+      element.change.owner._account_id = 789;
+
+      // Is true when passing a different user than the change owner, and is not
+      // in the reviewer list.
+      assert.isTrue(element._notInList({account: account}));
+
+      element._reviewers = [{_account_id: 123456}];
+
+      // Is false when passing a different user than the change owner, but *is*
+      // the reviewer list.
+      assert.isFalse(element._notInList({account: account}));
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
new file mode 100644
index 0000000..d5c1140
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
@@ -0,0 +1,68 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
+
+<dom-module id="gr-autocomplete">
+  <template>
+    <style>
+      input {
+        font-size: 1em;
+      }
+      #suggestions {
+        background-color: #fff;
+        box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
+        position: absolute;
+        z-index: 10;
+      }
+      ul {
+        list-style: none;
+      }
+      li {
+        cursor: pointer;
+        padding: .5em .75em;
+      }
+      li.selected {
+        background-color: #eee;
+      }
+    </style>
+    <input
+        id="input"
+        is="iron-input"
+        disabled$="[[disabled]]"
+        bind-value="{{text}}"
+        on-keydown="_handleInputKeydown"
+        on-focus="_updateSuggestions" />
+    <div
+        id="suggestions"
+        hidden$="[[_computeSuggestionsHidden(_suggestions)]]">
+      <ul>
+        <template is="dom-repeat" items="[[_suggestions]]">
+          <li
+              data-index$="[[index]]"
+              on-tap="_handleSuggestionTap">[[item.name]]</li>
+        </template>
+      </ul>
+    </div>
+    <gr-cursor-manager
+        id="cursor"
+        index="{{_index}}"
+        cursor-target-class="selected"
+        stops="[[_getSuggestionElems(_suggestions)]]"></gr-cursor-manager>
+  </template>
+  <script src="gr-autocomplete.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
new file mode 100644
index 0000000..dfe0575
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
@@ -0,0 +1,171 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-autocomplete',
+
+    /**
+     * Fired when a value is chosen.
+     *
+     * @event commit
+     */
+
+    /**
+     * Fired when the user cancels.
+     *
+     * @event cancel
+     */
+
+    properties: {
+
+      /**
+       * Query for requesting autocomplete suggestions. The function should
+       * accept the input as a string parameter and return a promise. The
+       * promise should yield an array of suggestion objects with "name" and
+       * "value" properties. The "name" property will be displayed in the
+       * suggestion entry. The "value" property will be emitted if that
+       * suggestion is selected.
+       *
+       * @type {function(String): Promise<Array<Object>>}
+       */
+      query: {
+        type: Function,
+        value: function() {
+          return function() {
+            return Promise.resolve([]);
+          };
+        },
+      },
+
+      /**
+       * The number of characters that must be typed before suggestions are
+       * made.
+       */
+      threshold: {
+        type: Number,
+        value: 1,
+      },
+
+      disabled: Boolean,
+
+      text: {
+        type: String,
+        observer: '_updateSuggestions',
+      },
+
+      _value: {
+        type: Object,
+        computed: '_getValue(_suggestions, _index)'
+      },
+
+      _suggestions: {
+        type: Array,
+        value: function() { return []; },
+      },
+
+      _index: Number,
+    },
+
+    attached: function() {
+      this.listen(document.body, 'click', '_handleBodyClick');
+    },
+
+    detached: function() {
+      this.unlisten(document.body, 'click', '_handleBodyClick');
+    },
+
+    focus: function() {
+      this.$.input.focus();
+    },
+
+    clear: function() {
+      this.text = '';
+    },
+
+    _updateSuggestions: function() {
+      if (this.text.length < this.threshold) {
+        this._suggestions = [];
+        return;
+      }
+
+      this.query(this.text).then(function(suggestions) {
+        this._suggestions = suggestions;
+        this.$.cursor.moveToStart();
+      }.bind(this));
+    },
+
+    _computeSuggestionsHidden: function(suggestions) {
+      return !suggestions.length;
+    },
+
+    _getSuggestionElems: function() {
+      Polymer.dom.flush();
+      return this.$.suggestions.querySelectorAll('li');
+    },
+
+    _handleInputKeydown: function(e) {
+      switch (e.keyCode) {
+        case 38: // Up
+          e.preventDefault();
+          this.$.cursor.previous();
+          break;
+        case 40: // Down
+          e.preventDefault();
+          this.$.cursor.next();
+          break;
+        case 27: // Escape
+          e.preventDefault();
+          this._cancel();
+          break;
+        case 13: // Enter
+          e.preventDefault();
+          this._commit();
+          this._suggestions = [];
+          break;
+      }
+    },
+
+    _cancel: function() {
+      this._suggestions = [];
+      this.fire('cancel');
+    },
+
+    _getValue: function(suggestions, index) {
+      if (!suggestions.length || index === -1) { return null; }
+      return suggestions[index].value;
+    },
+
+    _handleBodyClick: function(e) {
+      var eventPath = Polymer.dom(e).path;
+      for (var i = 0; i < eventPath.length; i++) {
+        if (eventPath[i] == this) {
+          return;
+        }
+      }
+      this._suggestions = [];
+    },
+
+    _handleSuggestionTap: function(e) {
+      this.$.cursor.setCursor(e.target);
+      this._commit();
+    },
+
+    _commit: function() {
+      this.fire('commit', this._value);
+      this.clear();
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
new file mode 100644
index 0000000..6a8f62c
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
@@ -0,0 +1,174 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-reviewer-list</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-autocomplete.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-autocomplete></gr-autocomplete>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-autocomplete tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('renders', function(done) {
+      var promise;
+      var queryStub = sinon.spy(function(input) {
+        return promise = Promise.resolve([
+          {name: input + ' 0', value: 0},
+          {name: input + ' 1', value: 1},
+          {name: input + ' 2', value: 2},
+          {name: input + ' 3', value: 3},
+          {name: input + ' 4', value: 4},
+        ]);
+      });
+      element.query = queryStub;
+
+      assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
+      assert.equal(element.$.cursor.index, -1);
+
+      element.text = 'blah';
+
+      assert.isTrue(queryStub.called);
+
+      promise.then(function() {
+        assert.isFalse(element.$.suggestions.hasAttribute('hidden'));
+
+        var suggestions = element.$.suggestions.querySelectorAll('li');
+        assert.equal(suggestions.length, 5);
+
+        for (var i = 0; i < 5; i++) {
+          assert.equal(suggestions[i].textContent, 'blah ' + i);
+        }
+
+        assert.notEqual(element.$.cursor.index, -1);
+
+        done();
+      });
+    });
+
+    test('emits cancel', function(done) {
+      var promise;
+      var queryStub = sinon.spy(function() {
+        return promise = Promise.resolve([
+          {name: 'blah', value: 123},
+        ]);
+      });
+      element.query = queryStub;
+
+      assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
+
+      element.text = 'blah';
+
+      promise.then(function() {
+        assert.isFalse(element.$.suggestions.hasAttribute('hidden'));
+
+        var cancelHandler = sinon.spy();
+        element.addEventListener('cancel', cancelHandler);
+
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 27); // Esc
+
+        assert.isTrue(cancelHandler.called);
+        assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
+
+        done();
+      });
+    });
+
+    test('emits commit and handles cursor movement', function(done) {
+      var promise;
+      var queryStub = sinon.spy(function(input) {
+        return promise = Promise.resolve([
+          {name: input + ' 0', value: 0},
+          {name: input + ' 1', value: 1},
+          {name: input + ' 2', value: 2},
+          {name: input + ' 3', value: 3},
+          {name: input + ' 4', value: 4},
+        ]);
+      });
+      element.query = queryStub;
+
+      assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
+      assert.equal(element.$.cursor.index, -1);
+
+      element.text = 'blah';
+
+      promise.then(function() {
+        assert.isFalse(element.$.suggestions.hasAttribute('hidden'));
+
+        var commitHandler = sinon.spy();
+        element.addEventListener('commit', commitHandler);
+
+        assert.equal(element.$.cursor.index, 0);
+        assert.equal(element._value, 0);
+
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 40); // Down
+
+        assert.equal(element.$.cursor.index, 1);
+        assert.equal(element._value, 1);
+
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 40); // Down
+
+        assert.equal(element.$.cursor.index, 2);
+        assert.equal(element._value, 2);
+
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 38); // Up
+
+        assert.equal(element.$.cursor.index, 1);
+        assert.equal(element._value, 1);
+
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
+
+        assert.isTrue(commitHandler.called);
+        assert.equal(commitHandler.getCall(0).args[0].detail, 1);
+        assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
+
+        done();
+      });
+    });
+
+    test('threshold guards the query', function() {
+      var queryStub = sinon.spy(function() {
+        return Promise.resolve([]);
+      });
+      element.query = queryStub;
+
+      element.threshold = 2;
+
+      element.text = 'a';
+
+      assert.isFalse(queryStub.called);
+
+      element.text = 'ab';
+
+      assert.isTrue(queryStub.called);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
index e1990c4..7fff3d0 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
@@ -43,6 +43,7 @@
       index: {
         type: Number,
         value: -1,
+        notify: true,
       },
 
       /**
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index 073d7e1..32b7536 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -55,6 +55,7 @@
     'diff/gr-selection-action-box/gr-selection-action-box_test.html',
     'settings/gr-menu-editor/gr-menu-editor_test.html',
     'settings/gr-settings-view/gr-settings-view_test.html',
+    'shared/gr-autocomplete/gr-autocomplete_test.html',
     'shared/gr-account-label/gr-account-label_test.html',
     'shared/gr-account-link/gr-account-link_test.html',
     'shared/gr-alert/gr-alert_test.html',