| /** |
| * @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; |
| } |
| |
| if (this._disableSuggestions) { return; } |
| if (text.length < threshold) { |
| this._suggestions = []; |
| this.value = ''; |
| 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); |
| })(); |