/** * @license * 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'; const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+/g; const DEBOUNCE_WAIT_MS = 200; /** * @appliesMixin Gerrit.FireMixin * @appliesMixin Gerrit.KeyboardShortcutMixin */ class GrAutocomplete extends Polymer.mixinBehaviors( [ Gerrit.FireBehavior, Gerrit.KeyboardShortcutBehavior, ], Polymer.GestureEventListeners( Polymer.LegacyElementMixin( Polymer.Element))) { static get is() { return 'gr-autocomplete'; } /** * Fired when a value is chosen. * * @event commit */ /** * Fired when the user cancels. * * @event cancel */ /** * Fired on keydown to allow for custom hooks into autocomplete textbox * behavior. * * @event input-keydown */ static get properties() { return { /** * Query for requesting autocomplete suggestions. The function should * accept the input as a string parameter and return a promise. The * promise yields an array of suggestion objects with "name", "label", * "value" properties. The "name" property will be displayed in the * suggestion entry. The "label" property will, when specified, appear * next to the "name" as label text. The "value" property will be emitted * if that suggestion is selected. * * @type {function(string): Promise} */ query: { type: Function, value() { return function() { return Promise.resolve([]); }; }, }, /** * The number of characters that must be typed before suggestions are * made. If threshold is zero, default suggestions are enabled. */ threshold: { type: Number, value: 1, }, allowNonSuggestedValues: Boolean, borderless: Boolean, disabled: Boolean, showSearchIcon: { type: Boolean, value: false, }, /** * Vertical offset needed for an element with 20px line-height, 4px * padding and 1px border (30px height total). Plus 1px spacing between * input and dropdown. Inputs with different line-height or padding will * need to tweak vertical offset. */ verticalOffset: { type: Number, value: 31, }, text: { type: String, value: '', notify: true, }, placeholder: String, clearOnCommit: { type: Boolean, value: false, }, /** * When true, tab key autocompletes but does not fire the commit event. * When false, tab key not caught, and focus is removed from the element. * See Issue 4556, Issue 6645. */ tabComplete: { type: Boolean, value: false, }, value: { type: String, notify: true, }, /** * Multi mode appends autocompleted entries to the value. * If false, autocompleted entries replace value. */ multi: { type: Boolean, value: false, }, /** * When true and uncommitted text is left in the autocomplete input after * blurring, the text will appear red. */ warnUncommitted: { type: Boolean, value: false, }, /** * When true, querying for suggestions is not debounced w/r/t keypresses */ noDebounce: { type: Boolean, value: false, }, /** @type {?} */ _suggestions: { type: Array, value() { return []; }, }, _suggestionEls: { type: Array, value() { return []; }, }, _index: Number, _disableSuggestions: { type: Boolean, value: false, }, _focused: { type: Boolean, value: false, }, /** The DOM element of the selected suggestion. */ _selected: Object, }; } static get observers() { return [ '_maybeOpenDropdown(_suggestions, _focused)', '_updateSuggestions(text, threshold, noDebounce)', ]; } get _nativeInput() { // In Polymer 2 inputElement isn't nativeInput anymore return this.$.input.$.nativeInput || this.$.input.inputElement; } attached() { super.attached(); this.listen(document.body, 'click', '_handleBodyClick'); } detached() { super.detached(); this.unlisten(document.body, 'click', '_handleBodyClick'); this.cancelDebouncer('update-suggestions'); } get focusStart() { return this.$.input; } focus() { this._nativeInput.focus(); } selectAll() { const nativeInputElement = this._nativeInput; if (!this.$.input.value) { return; } nativeInputElement.setSelectionRange(0, this.$.input.value.length); } clear() { this.text = ''; } _handleItemSelect(e) { // Let _handleKeydown deal with keyboard interaction. if (e.detail.trigger !== 'click') { return; } this._selected = e.detail.selected; this._commit(); } get _inputElement() { // Polymer2: this.$ can be undefined when this is first evaluated. return this.$ && this.$.input; } /** * Set the text of the input without triggering the suggestion dropdown. * * @param {string} text The new text for the input. */ setText(text) { this._disableSuggestions = true; this.text = text; this._disableSuggestions = false; } _onInputFocus() { this._focused = true; this._updateSuggestions(this.text, this.threshold, this.noDebounce); this.$.input.classList.remove('warnUncommitted'); // Needed so that --paper-input-container-input updated style is applied. this.updateStyles(); } _onInputBlur() { this.$.input.classList.toggle('warnUncommitted', this.warnUncommitted && this.text.length && !this._focused); // Needed so that --paper-input-container-input updated style is applied. this.updateStyles(); } _updateSuggestions(text, threshold, noDebounce) { // Polymer 2: check for undefined if ([text, threshold, noDebounce].some(arg => arg === undefined)) { return; } // Reset _suggestions for every update // This will also prevent from carrying over suggestions: // @see Issue 12039 this._suggestions = []; // TODO(taoalpha): Also skip if text has not changed if (this._disableSuggestions) { return; } if (text.length < threshold) { this.value = ''; return; } if (!this._focused) { return; } const update = () => { this.query(text).then(suggestions => { if (text !== this.text) { // Late response. return; } for (const suggestion of suggestions) { suggestion.text = suggestion.name; } this._suggestions = suggestions; Polymer.dom.flush(); if (this._index === -1) { this.value = ''; } }); }; if (noDebounce) { update(); } else { this.debounce('update-suggestions', update, DEBOUNCE_WAIT_MS); } } _maybeOpenDropdown(suggestions, focused) { if (suggestions.length > 0 && focused) { return this.$.suggestions.open(); } return this.$.suggestions.close(); } _computeClass(borderless) { return borderless ? 'borderless' : ''; } /** * _handleKeydown used for key handling in the this.$.input AND all child * autocomplete options. */ _handleKeydown(e) { this._focused = true; switch (e.keyCode) { case 38: // Up e.preventDefault(); this.$.suggestions.cursorUp(); break; case 40: // Down e.preventDefault(); this.$.suggestions.cursorDown(); break; case 27: // Escape e.preventDefault(); this._cancel(); break; case 9: // Tab if (this._suggestions.length > 0 && this.tabComplete) { e.preventDefault(); this._handleInputCommit(true); this.focus(); } else { this._focused = false; } break; case 13: // Enter if (this.modifierPressed(e)) { break; } e.preventDefault(); this._handleInputCommit(); break; default: // For any normal keypress, return focus to the input to allow for // unbroken user input. this.focus(); // Since this has been a normal keypress, the suggestions will have // been based on a previous input. Clear them. This prevents an // outdated suggestion from being used if the input keystroke is // immediately followed by a commit keystroke. @see Issue 8655 this._suggestions = []; } this.fire('input-keydown', {keyCode: e.keyCode, input: this.$.input}); } _cancel() { if (this._suggestions.length) { this.set('_suggestions', []); } else { this.fire('cancel'); } } /** * @param {boolean=} opt_tabComplete */ _handleInputCommit(opt_tabComplete) { // Nothing to do if the dropdown is not open. if (!this.allowNonSuggestedValues && this.$.suggestions.isHidden) { return; } this._selected = this.$.suggestions.getCursorTarget(); this._commit(opt_tabComplete); } _updateValue(suggestion, suggestions) { if (!suggestion) { return; } const completed = suggestions[suggestion.dataset.index].value; if (this.multi) { // Append the completed text to the end of the string. // Allow spaces within quoted terms. const tokens = this.text.match(TOKENIZE_REGEX); tokens[tokens.length - 1] = completed; this.value = tokens.join(' '); } else { this.value = completed; } } _handleBodyClick(e) { const eventPath = Polymer.dom(e).path; for (let i = 0; i < eventPath.length; i++) { if (eventPath[i] === this) { return; } } this._focused = false; } _handleSuggestionTap(e) { e.stopPropagation(); this.$.cursor.setCursor(e.target); this._commit(); } /** * Commits the suggestion, optionally firing the commit event. * * @param {boolean=} opt_silent Allows for silent committing of an * autocomplete suggestion in order to handle cases like tab-to-complete * without firing the commit event. */ _commit(opt_silent) { // Allow values that are not in suggestion list iff suggestions are empty. if (this._suggestions.length > 0) { this._updateValue(this._selected, this._suggestions); } else { this.value = this.text || ''; } const value = this.value; // Value and text are mirrors of each other in multi mode. if (this.multi) { this.setText(this.value); } else { if (!this.clearOnCommit && this._selected) { this.setText(this._suggestions[this._selected.dataset.index].name); } else { this.clear(); } } this._suggestions = []; if (!opt_silent) { this.fire('commit', {value}); } this._textChangedSinceCommit = false; } _computeShowSearchIconClass(showSearchIcon) { return showSearchIcon ? 'showSearchIcon' : ''; } } customElements.define(GrAutocomplete.is, GrAutocomplete); })();