Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/elements/chops/chops-autocomplete/chops-autocomplete.js b/static_src/elements/chops/chops-autocomplete/chops-autocomplete.js
new file mode 100644
index 0000000..dab8f85
--- /dev/null
+++ b/static_src/elements/chops/chops-autocomplete/chops-autocomplete.js
@@ -0,0 +1,632 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html} from 'lit-element';
+import {NON_EDITING_KEY_EVENTS} from 'shared/dom-helpers.js';
+
+/**
+ * @type {RegExp} Autocomplete options are matched at word boundaries. This
+ *   Regex specifies what counts as a boundary between words.
+ */
+const DELIMITER_REGEX = /[^a-z0-9]+/i;
+
+/**
+ * Specifies what happens to the input element an autocomplete
+ * instance is attached to when a user selects an autocomplete option. This
+ * constant specifies the default behavior where a form's entire value is
+ * replaced with the selected value.
+ * @param {HTMLInputElement} input An input element.
+ * @param {string} value The value of the selected autocomplete option.
+ */
+const DEFAULT_REPLACER = (input, value) => {
+  input.value = value;
+};
+
+/**
+ * @type {number} The default maximum of completions to render at a time.
+ */
+const DEFAULT_MAX_COMPLETIONS = 200;
+
+/**
+ * @type {number} Globally shared counter for autocomplete instances to help
+ *   ensure that no two <chops-autocomplete> options have the same ID.
+ */
+let idCount = 1;
+
+/**
+ * `<chops-autocomplete>` shared autocomplete UI code that inter-ops with
+ * other code.
+ *
+ * chops-autocomplete inter-ops with any input element, whether custom or
+ * native that can receive change handlers and has a 'value' property which
+ * can be read and set.
+ *
+ * NOTE: This element disables ShadowDOM for accessibility reasons: to allow
+ * aria attributes from the outside to reference features in this element.
+ *
+ * @customElement chops-autocomplete
+ */
+export class ChopsAutocomplete extends LitElement {
+  /** @override */
+  render() {
+    const completions = this.completions;
+    const currentValue = this._prefix.trim().toLowerCase();
+    const index = this._selectedIndex;
+    const currentCompletion = index >= 0 &&
+      index < completions.length ? completions[index] : '';
+
+    return html`
+      <style>
+        /*
+         * Really specific class names are necessary because ShadowDOM
+         * is disabled for this component.
+         */
+        .chops-autocomplete-container {
+          position: relative;
+        }
+        .chops-autocomplete-container table {
+          padding: 0;
+          font-size: var(--chops-main-font-size);
+          color: var(--chops-link-color);
+          position: absolute;
+          background: var(--chops-white);
+          border: var(--chops-accessible-border);
+          z-index: 999;
+          box-shadow: 2px 3px 8px 0px hsla(0, 0%, 0%, 0.3);
+          border-spacing: 0;
+          border-collapse: collapse;
+          /* In the case when the autocomplete extends the
+           * height of the viewport, we want to make sure
+           * there's spacing. */
+          margin-bottom: 1em;
+        }
+        .chops-autocomplete-container tbody {
+          display: block;
+          min-width: 100px;
+          max-height: 500px;
+          overflow: auto;
+        }
+        .chops-autocomplete-container tr {
+          cursor: pointer;
+          transition: background 0.2s ease-in-out;
+        }
+        .chops-autocomplete-container tr[data-selected] {
+          background: var(--chops-active-choice-bg);
+          text-decoration: underline;
+        }
+        .chops-autocomplete-container td {
+          padding: 0.25em 8px;
+          white-space: nowrap;
+        }
+        .screenreader-hidden {
+          clip: rect(1px, 1px, 1px, 1px);
+          height: 1px;
+          overflow: hidden;
+          position: absolute;
+          white-space: nowrap;
+          width: 1px;
+        }
+      </style>
+      <div class="chops-autocomplete-container">
+        <span class="screenreader-hidden" aria-live="polite">
+          ${currentCompletion}
+        </span>
+        <table
+          ?hidden=${!completions.length}
+        >
+          <tbody>
+            ${completions.map((completion, i) => html`
+              <tr
+                id=${completionId(this.id, i)}
+                ?data-selected=${i === index}
+                data-index=${i}
+                data-value=${completion}
+                @mouseover=${this._hoverCompletion}
+                @mousedown=${this._clickCompletion}
+                role="option"
+                aria-selected=${completion.toLowerCase() ===
+                  currentValue ? 'true' : 'false'}
+              >
+                <td class="completion">
+                  ${this._renderCompletion(completion)}
+                </td>
+                <td class="docstring">
+                  ${this._renderDocstring(completion)}
+                </td>
+              </tr>
+            `)}
+          </tbody>
+        </table>
+      </div>
+    `;
+  }
+
+  /**
+   * Renders a single autocomplete result.
+   * @param {string} completion The string for the currently selected
+   *   autocomplete value.
+   * @return {TemplateResult}
+   */
+  _renderCompletion(completion) {
+    const matchDict = this._matchDict;
+
+    if (!(completion in matchDict)) return completion;
+
+    const {index, matchesDoc} = matchDict[completion];
+
+    if (matchesDoc) return completion;
+
+    const prefix = this._prefix;
+    const start = completion.substr(0, index);
+    const middle = completion.substr(index, prefix.length);
+    const end = completion.substr(index + prefix.length);
+
+    return html`${start}<b>${middle}</b>${end}`;
+  }
+
+  /**
+   * Finds the docstring for a given autocomplete result and renders it.
+   * @param {string} completion The autocomplete result rendered.
+   * @return {TemplateResult}
+   */
+  _renderDocstring(completion) {
+    const matchDict = this._matchDict;
+    const docDict = this.docDict;
+
+    if (!completion in docDict) return '';
+
+    const doc = docDict[completion];
+
+    if (!(completion in matchDict)) return doc;
+
+    const {index, matchesDoc} = matchDict[completion];
+
+    if (!matchesDoc) return doc;
+
+    const prefix = this._prefix;
+    const start = doc.substr(0, index);
+    const middle = doc.substr(index, prefix.length);
+    const end = doc.substr(index + prefix.length);
+
+    return html`${start}<b>${middle}</b>${end}`;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      /**
+       * The input this element is for.
+       */
+      for: {type: String},
+      /**
+       * Generated id for the element.
+       */
+      id: {
+        type: String,
+        reflect: true,
+      },
+      /**
+       * The role attribute, set for accessibility.
+       */
+      role: {
+        type: String,
+        reflect: true,
+      },
+      /**
+       * Array of strings for possible autocompletion values.
+       */
+      strings: {type: Array},
+      /**
+       * A dictionary containing optional doc strings for each autocomplete
+       * string.
+       */
+      docDict: {type: Object},
+      /**
+       * An optional function to compute what happens when the user selects
+       * a value.
+       */
+      replacer: {type: Object},
+      /**
+       * An Array of the currently suggested autcomplte values.
+       */
+      completions: {type: Array},
+      /**
+       * Maximum number of completion values that can display at once.
+       */
+      max: {type: Number},
+      /**
+       * Dict of locations of matched substrings. Value format:
+       * {index, matchesDoc}.
+       */
+      _matchDict: {type: Object},
+      _selectedIndex: {type: Number},
+      _prefix: {type: String},
+      _forRef: {type: Object},
+      _boundToggleCompletionsOnFocus: {type: Object},
+      _boundNavigateCompletions: {type: Object},
+      _boundUpdateCompletions: {type: Object},
+      _oldAttributes: {type: Object},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    this.strings = [];
+    this.docDict = {};
+    this.completions = [];
+    this.max = DEFAULT_MAX_COMPLETIONS;
+
+    this.role = 'listbox';
+    this.id = `chops-autocomplete-${idCount++}`;
+
+    this._matchDict = {};
+    this._selectedIndex = -1;
+    this._prefix = '';
+    this._boundToggleCompletionsOnFocus =
+      this._toggleCompletionsOnFocus.bind(this);
+    this._boundUpdateCompletions = this._updateCompletions.bind(this);
+    this._boundNavigateCompletions = this._navigateCompletions.bind(this);
+    this._oldAttributes = {};
+  }
+
+  // Disable shadow DOM to allow aria attributes to propagate.
+  /** @override */
+  createRenderRoot() {
+    return this;
+  }
+
+  /** @override */
+  disconnectedCallback() {
+    super.disconnectedCallback();
+
+    this._disconnectAutocomplete(this._forRef);
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('for')) {
+      const forRef = this.getRootNode().querySelector('#' + this.for);
+
+      // TODO(zhangtiff): Make this element work with custom input components
+      // in the future as well.
+      this._forRef = (forRef.tagName || '').toUpperCase() === 'INPUT' ?
+        forRef : undefined;
+      this._connectAutocomplete(this._forRef);
+    }
+    if (this._forRef) {
+      if (changedProperties.has('id')) {
+        this._forRef.setAttribute('aria-owns', this.id);
+      }
+      if (changedProperties.has('completions')) {
+        // a11y. Tell screenreaders whether the autocomplete is expanded.
+        this._forRef.setAttribute('aria-expanded',
+          this.completions.length ? 'true' : 'false');
+      }
+
+      if (changedProperties.has('_selectedIndex') ||
+          changedProperties.has('completions')) {
+        this._updateAriaActiveDescendant(this._forRef);
+
+        this._scrollCompletionIntoView(this._selectedIndex);
+      }
+    }
+  }
+
+  /**
+   * Sets the aria-activedescendant attribute of the element (ie: an input form)
+   * that the autocomplete is attached to, in order to tell screenreaders about
+   * which autocomplete option is currently selected.
+   * @param {HTMLInputElement} element
+   */
+  _updateAriaActiveDescendant(element) {
+    const i = this._selectedIndex;
+
+    if (i >= 0 && i < this.completions.length) {
+      const selectedId = completionId(this.id, i);
+
+      // a11y. Set the ID of the currently selected element.
+      element.setAttribute('aria-activedescendant', selectedId);
+
+      // Scroll the container to make sure the selected element is in view.
+    } else {
+      element.setAttribute('aria-activedescendant', '');
+    }
+  }
+
+  /**
+   * When a user moves up or down from an autocomplete option that's at the top
+   * or bottom of the autocomplete option container, we must scroll the
+   * container to make sure the user always sees the option they've selected.
+   * @param {number} i The index of the autocomplete option to put into view.
+   */
+  _scrollCompletionIntoView(i) {
+    const selectedId = completionId(this.id, i);
+
+    const container = this.querySelector('tbody');
+    const completion = this.querySelector(`#${selectedId}`);
+
+    if (!completion) return;
+
+    const distanceFromTop = completion.offsetTop - container.scrollTop;
+
+    // If the completion is above the viewport for the container.
+    if (distanceFromTop < 0) {
+      // Position the completion at the top of the container.
+      container.scrollTop = completion.offsetTop;
+    }
+
+    // If the compltion is below the viewport for the container.
+    if (distanceFromTop > (container.offsetHeight - completion.offsetHeight)) {
+      // Position the compltion at the bottom of the container.
+      container.scrollTop = completion.offsetTop - (container.offsetHeight -
+        completion.offsetHeight);
+    }
+  }
+
+  /**
+   * Changes the input's value according to the rules of the replacer function.
+   * @param {string} value - the value to swap in.
+   * @return {undefined}
+   */
+  completeValue(value) {
+    if (!this._forRef) return;
+
+    const replacer = this.replacer || DEFAULT_REPLACER;
+    replacer(this._forRef, value);
+
+    this.hideCompletions();
+  }
+
+  /**
+   * Computes autocomplete values matching the current input in the field.
+   * @return {boolean} Whether any completions were found.
+   */
+  showCompletions() {
+    if (!this._forRef) {
+      this.hideCompletions();
+      return false;
+    }
+    this._prefix = this._forRef.value.trim().toLowerCase();
+    // Always select the first completion by default when recomputing
+    // completions.
+    this._selectedIndex = 0;
+
+    const matchDict = {};
+    const accepted = [];
+    matchDict;
+    for (let i = 0; i < this.strings.length &&
+        accepted.length < this.max; i++) {
+      const s = this.strings[i];
+      let matchIndex = this._matchIndex(this._prefix, s);
+      let matches = matchIndex >= 0;
+      if (matches) {
+        matchDict[s] = {index: matchIndex, matchesDoc: false};
+      } else if (s in this.docDict) {
+        matchIndex = this._matchIndex(this._prefix, this.docDict[s]);
+        matches = matchIndex >= 0;
+        if (matches) {
+          matchDict[s] = {index: matchIndex, matchesDoc: true};
+        }
+      }
+      if (matches) {
+        accepted.push(s);
+      }
+    }
+
+    this._matchDict = matchDict;
+
+    this.completions = accepted;
+
+    return !!this.completions.length;
+  }
+
+  /**
+   * Finds where a given user input matches an autocomplete option. Note that
+   * a match is only found if the substring is at either the beginning of the
+   * string or the beginning of a delimited section of the string. Hence, we
+   * refer to the "needle" in this function a "prefix".
+   * @param {string} prefix The value that the user inputed into the form.
+   * @param {string} s The autocomplete option that's being compared.
+   * @return {number} An integer for what index the substring is found in the
+   *   autocomplete option. Returns -1 if no match.
+   */
+  _matchIndex(prefix, s) {
+    const matchStart = s.toLowerCase().indexOf(prefix.toLocaleLowerCase());
+    if (matchStart === 0 ||
+        (matchStart > 0 && s[matchStart - 1].match(DELIMITER_REGEX))) {
+      return matchStart;
+    }
+    return -1;
+  }
+
+  /**
+   * Hides autocomplete options.
+   */
+  hideCompletions() {
+    this.completions = [];
+    this._prefix = '';
+    this._selectedIndex = -1;
+  }
+
+  /**
+   * Sets an autocomplete option that a user hovers over as the selected option.
+   * @param {MouseEvent} e
+   */
+  _hoverCompletion(e) {
+    const target = e.currentTarget;
+
+    if (!target.dataset || !target.dataset.index) return;
+
+    const index = Number.parseInt(target.dataset.index);
+    if (index >= 0 && index < this.completions.length) {
+      this._selectedIndex = index;
+    }
+  }
+
+  /**
+   * Sets the value of the form input that the user is editing to the
+   * autocomplete option that the user just clicked.
+   * @param {MouseEvent} e
+   */
+  _clickCompletion(e) {
+    e.preventDefault();
+    const target = e.currentTarget;
+    if (!target.dataset || !target.dataset.value) return;
+
+    this.completeValue(target.dataset.value);
+  }
+
+  /**
+   * Hides and shows the autocomplete completions when a user focuses and
+   * unfocuses a form.
+   * @param {FocusEvent} e
+   */
+  _toggleCompletionsOnFocus(e) {
+    const target = e.target;
+
+    // Check if the input is focused or not.
+    if (target.matches(':focus')) {
+      this.showCompletions();
+    } else {
+      this.hideCompletions();
+    }
+  }
+
+  /**
+   * Implements hotkeys to allow the user to navigate autocomplete options with
+   * their keyboard. ie: pressing up and down to select options or Esc to close
+   * the form.
+   * @param {KeyboardEvent} e
+   */
+  _navigateCompletions(e) {
+    const completions = this.completions;
+    if (!completions.length) return;
+
+    switch (e.key) {
+      // TODO(zhangtiff): Throttle or control keyboard navigation so the user
+      // can't navigate faster than they can can perceive.
+      case 'ArrowUp':
+        e.preventDefault();
+        this._navigateUp();
+        break;
+      case 'ArrowDown':
+        e.preventDefault();
+        this._navigateDown();
+        break;
+      case 'Enter':
+      // TODO(zhangtiff): Add Tab to this case as well once all issue detail
+      // inputs use chops-autocomplete.
+        e.preventDefault();
+        if (this._selectedIndex >= 0 &&
+            this._selectedIndex <= completions.length) {
+          this.completeValue(completions[this._selectedIndex]);
+        }
+        break;
+      case 'Escape':
+        e.preventDefault();
+        this.hideCompletions();
+        break;
+    }
+  }
+
+  /**
+   * Selects the completion option above the current one.
+   */
+  _navigateUp() {
+    const completions = this.completions;
+    this._selectedIndex -= 1;
+    if (this._selectedIndex < 0) {
+      this._selectedIndex = completions.length - 1;
+    }
+  }
+
+  /**
+   * Selects the completion option below the current one.
+   */
+  _navigateDown() {
+    const completions = this.completions;
+    this._selectedIndex += 1;
+    if (this._selectedIndex >= completions.length) {
+      this._selectedIndex = 0;
+    }
+  }
+
+  /**
+   * Recomputes autocomplete completions when the user types a new input.
+   * Ignores KeyboardEvents that don't change the input value of the form
+   * to prevent excess recomputations.
+   * @param {KeyboardEvent} e
+   */
+  _updateCompletions(e) {
+    if (NON_EDITING_KEY_EVENTS.has(e.key)) return;
+    this.showCompletions();
+  }
+
+  /**
+   * Initializes the input element that this autocomplete instance is
+   * attached to with aria attributes required for accessibility.
+   * @param {HTMLInputElement} node The input element that the autocomplete is
+   *   attached to.
+   */
+  _connectAutocomplete(node) {
+    if (!node) return;
+
+    node.addEventListener('keyup', this._boundUpdateCompletions);
+    node.addEventListener('keydown', this._boundNavigateCompletions);
+    node.addEventListener('focus', this._boundToggleCompletionsOnFocus);
+    node.addEventListener('blur', this._boundToggleCompletionsOnFocus);
+
+    this._oldAttributes = {
+      'aria-owns': node.getAttribute('aria-owns'),
+      'aria-autocomplete': node.getAttribute('aria-autocomplete'),
+      'aria-expanded': node.getAttribute('aria-expanded'),
+      'aria-haspopup': node.getAttribute('aria-haspopup'),
+      'aria-activedescendant': node.getAttribute('aria-activedescendant'),
+    };
+    node.setAttribute('aria-owns', this.id);
+    node.setAttribute('aria-autocomplete', 'both');
+    node.setAttribute('aria-expanded', 'false');
+    node.setAttribute('aria-haspopup', 'listbox');
+    node.setAttribute('aria-activedescendant', '');
+  }
+
+  /**
+   * When <chops-autocomplete> is disconnected or moved to a difference form,
+   * this function removes the side effects added by <chops-autocomplete> on the
+   * input element that <chops-autocomplete> is attached to.
+   * @param {HTMLInputElement} node The input element that the autocomplete is
+   *   attached to.
+   */
+  _disconnectAutocomplete(node) {
+    if (!node) return;
+
+    node.removeEventListener('keyup', this._boundUpdateCompletions);
+    node.removeEventListener('keydown', this._boundNavigateCompletions);
+    node.removeEventListener('focus', this._boundToggleCompletionsOnFocus);
+    node.removeEventListener('blur', this._boundToggleCompletionsOnFocus);
+
+    for (const key of Object.keys(this._oldAttributes)) {
+      node.setAttribute(key, this._oldAttributes[key]);
+    }
+    this._oldAttributes = {};
+  }
+}
+
+/**
+ * Generates a unique HTML ID for a given autocomplete option, for use by
+ * aria-activedescendant. Note that because the autocomplete element has
+ * ShadowDOM disabled, we need to make sure the ID is specific enough to be
+ * globally unique across the entire application.
+ * @param {string} prefix A unique prefix to differentiate this autocomplete
+ *   instance from other autocomplete instances.
+ * @param {number} i The index of the autocomplete option.
+ * @return {string} A unique HTML ID for a given autocomplete option.
+ */
+function completionId(prefix, i) {
+  return `${prefix}-option-${i}`;
+}
+
+customElements.define('chops-autocomplete', ChopsAutocomplete);