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);