| /** |
| * @license |
| * Copyright 2016 Google LLC |
| * SPDX-License-Identifier: Apache-2.0 |
| */ |
| import '@polymer/paper-input/paper-input'; |
| import '../gr-autocomplete-dropdown/gr-autocomplete-dropdown'; |
| import '../gr-cursor-manager/gr-cursor-manager'; |
| import '../../../styles/shared-styles'; |
| import { |
| AutocompleteQueryStatus, |
| AutocompleteQueryStatusType, |
| GrAutocompleteDropdown, |
| ItemSelectedEventDetail, |
| } from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown'; |
| import {fire} from '../../../utils/event-util'; |
| import { |
| debounce, |
| DelayedTask, |
| ResolvedDelayedTaskStatus, |
| } from '../../../utils/async-util'; |
| import {PropertyType} from '../../../types/common'; |
| import {modifierPressed} from '../../../utils/dom-util'; |
| import {sharedStyles} from '../../../styles/shared-styles'; |
| import {LitElement, html, css, PropertyValues} from 'lit'; |
| import {customElement, property, query, state} from 'lit/decorators.js'; |
| import {ValueChangedEvent} from '../../../types/events'; |
| import {PaperInputElement} from '@polymer/paper-input/paper-input'; |
| import {IronInputElement} from '@polymer/iron-input'; |
| |
| const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+/g; |
| const DEBOUNCE_WAIT_MS = 200; |
| |
| export type AutocompleteQuery<T = string> = ( |
| text: string |
| ) => Promise<Array<AutocompleteSuggestion<T>>>; |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'gr-autocomplete': GrAutocomplete; |
| } |
| interface HTMLElementEventMap { |
| 'text-changed': ValueChangedEvent<string>; |
| 'value-changed': ValueChangedEvent<string>; |
| } |
| } |
| |
| export interface AutocompleteSuggestion<T = string> { |
| name?: string; |
| label?: string; |
| value?: T; |
| text?: string; |
| } |
| |
| @customElement('gr-autocomplete') |
| export class GrAutocomplete extends LitElement { |
| /** |
| * Fired when a value is chosen. |
| * |
| * @event commit |
| */ |
| |
| /** |
| * Fired when the user cancels. |
| * |
| * @event cancel |
| */ |
| |
| /** |
| * 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. |
| * |
| * If query fails, the function should return rejected promise containing |
| * an Error. The "message" property will be shown in a dropdown instead of |
| * rendering suggestions. |
| */ |
| @property({type: Object}) |
| query?: AutocompleteQuery = () => Promise.resolve([]); |
| |
| @query('#input') input?: PaperInputElement; |
| |
| @query('#suggestions') suggestionsDropdown?: GrAutocompleteDropdown; |
| |
| /** |
| * The number of characters that must be typed before suggestions are |
| * made. If threshold is zero, default suggestions are enabled. |
| */ |
| @property({type: Number}) |
| threshold = 1; |
| |
| @property({type: Boolean, attribute: 'allow-non-suggested-values'}) |
| allowNonSuggestedValues = false; |
| |
| @property({type: Boolean}) |
| borderless = false; |
| |
| @property({type: Boolean}) |
| disabled = 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. |
| */ |
| @property({type: Number}) |
| verticalOffset = 31; |
| |
| @property({type: String}) |
| text = ''; |
| |
| @property({type: String}) |
| placeholder = ''; |
| |
| @property({type: Boolean, attribute: 'clear-on-commit'}) |
| clearOnCommit = 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. |
| */ |
| @property({type: Boolean, attribute: 'tab-complete'}) |
| tabComplete = false; |
| |
| @property({type: String}) |
| value = ''; |
| |
| /** |
| * Multi mode appends autocompleted entries to the value. |
| * If false, autocompleted entries replace value. |
| */ |
| @property({type: Boolean}) |
| multi = false; |
| |
| /** |
| * When true and uncommitted text is left in the autocomplete input after |
| * blurring, the text will appear red. |
| */ |
| @property({type: Boolean, attribute: 'warn-uncommitted'}) |
| warnUncommitted = false; |
| |
| @property({type: Boolean, attribute: 'show-blue-focus-border'}) |
| showBlueFocusBorder = false; |
| |
| /** |
| * Invisible label for input element. This label is exposed to |
| * screen readers by paper-input |
| */ |
| @property({type: String}) |
| label = ''; |
| |
| @state() suggestions: AutocompleteSuggestion[] = []; |
| |
| @state() queryStatus?: AutocompleteQueryStatus; |
| |
| @state() index: number | null = null; |
| |
| // Enabled to suppress showing/updating suggestions when changing properties |
| // that would normally trigger the update. |
| disableDisplayingSuggestions = false; |
| |
| // private but used in tests |
| focused = false; |
| |
| @state() selected: HTMLElement | null = null; |
| |
| /** |
| * The query id that status or suggestions correspond to. |
| */ |
| private activeQueryId = 0; |
| |
| /** |
| * Last scheduled update suggestions task. |
| */ |
| private updateSuggestionsTask?: DelayedTask; |
| |
| // Generate ids for scheduled suggestion queries to easily distinguish them. |
| private static NEXT_QUERY_ID = 1; |
| |
| private static getNextQueryId() { |
| return GrAutocomplete.NEXT_QUERY_ID++; |
| } |
| |
| /** |
| * @return Promise that resolves when suggestions are update. |
| */ |
| get latestSuggestionUpdateComplete(): |
| | Promise<ResolvedDelayedTaskStatus> |
| | undefined { |
| return this.updateSuggestionsTask?.promise; |
| } |
| |
| get nativeInput() { |
| return (this.input!.inputElement as IronInputElement) |
| .inputElement as HTMLInputElement; |
| } |
| |
| static override styles = [ |
| sharedStyles, |
| css` |
| paper-input.borderless { |
| border: none; |
| padding: 0; |
| } |
| paper-input { |
| background-color: var(--view-background-color); |
| color: var(--primary-text-color); |
| border: 1px solid var(--prominent-border-color, var(--border-color)); |
| border-radius: var(--border-radius); |
| padding: var(--spacing-s); |
| --paper-input-container_-_padding: 0; |
| --paper-input-container-input_-_font-size: var(--font-size-normal); |
| --paper-input-container-input_-_line-height: var(--line-height-normal); |
| /* This is a hack for not being able to set height:0 on the underline |
| of a paper-input 2.2.3 element. All the underline fixes below only |
| actually work in 3.x.x, so the height must be adjusted directly as |
| a workaround until we are on Polymer 3. */ |
| height: var(--line-height-normal); |
| --paper-input-container-underline-height: 0; |
| --paper-input-container-underline-wrapper-height: 0; |
| --paper-input-container-underline-focus-height: 0; |
| --paper-input-container-underline-legacy-height: 0; |
| --paper-input-container-underline_-_height: 0; |
| --paper-input-container-underline_-_display: none; |
| --paper-input-container-underline-focus_-_height: 0; |
| --paper-input-container-underline-focus_-_display: none; |
| --paper-input-container-underline-disabled_-_height: 0; |
| --paper-input-container-underline-disabled_-_display: none; |
| /* Hide label for input. The label is still visible for |
| screen readers. Workaround found at: |
| https://github.com/PolymerElements/paper-input/issues/478 */ |
| --paper-input-container-label_-_display: none; |
| } |
| paper-input.showBlueFocusBorder:focus { |
| border: 2px solid var(--input-focus-border-color); |
| /* |
| * The goal is to have a thicker blue border when focused and a thinner |
| * gray border when blurred. To avoid shifting neighboring elements |
| * around when the border size changes, a negative margin is added to |
| * compensate. box-sizing: border-box; will not work since there is |
| * important padding to add around the content. |
| */ |
| margin: -1px; |
| } |
| paper-input.warnUncommitted { |
| --paper-input-container-input_-_color: var(--error-text-color); |
| --paper-input-container-input_-_font-size: inherit; |
| } |
| `, |
| ]; |
| |
| override connectedCallback() { |
| super.connectedCallback(); |
| document.addEventListener('click', this.handleBodyClick); |
| } |
| |
| override disconnectedCallback() { |
| document.removeEventListener('click', this.handleBodyClick); |
| this.updateSuggestionsTask?.cancel(); |
| super.disconnectedCallback(); |
| } |
| |
| override willUpdate(changedProperties: PropertyValues) { |
| if (changedProperties.has('text') || changedProperties.has('threshold')) { |
| this.updateSuggestions(); |
| } |
| if ( |
| changedProperties.has('suggestions') || |
| changedProperties.has('queryStatus') |
| ) { |
| this.updateDropdownVisibility(); |
| } |
| if (changedProperties.has('text')) { |
| fire(this, 'text-changed', {value: this.text}); |
| } |
| if (changedProperties.has('value')) { |
| fire(this, 'value-changed', {value: this.value}); |
| } |
| } |
| |
| override render() { |
| return html` |
| <paper-input |
| .noLabelFloat=${true} |
| id="input" |
| class=${this.computeClass()} |
| ?disabled=${this.disabled} |
| .value=${this.text} |
| @value-changed=${(e: ValueChangedEvent) => { |
| this.text = e.detail.value; |
| }} |
| .placeholder=${this.placeholder} |
| @keydown=${this.handleKeydown} |
| @focus=${this.onInputFocus} |
| @blur=${this.onInputBlur} |
| autocomplete="off" |
| .label=${this.label} |
| > |
| <div slot="prefix"> |
| <slot name="prefix"></slot> |
| </div> |
| |
| <div slot="suffix"> |
| <slot name="suffix"></slot> |
| </div> |
| </paper-input> |
| <gr-autocomplete-dropdown |
| .verticalOffset=${this.verticalOffset} |
| id="suggestions" |
| @item-selected=${this.handleItemSelect} |
| @dropdown-closed=${this.focusWithoutDisplayingSuggestions} |
| .suggestions=${this.suggestions} |
| .queryStatus=${this.queryStatus} |
| role="listbox" |
| .index=${this.index} |
| > |
| </gr-autocomplete-dropdown> |
| `; |
| } |
| |
| get focusStart() { |
| return this.input; |
| } |
| |
| override focus() { |
| this.nativeInput.focus(); |
| } |
| |
| private focusWithoutDisplayingSuggestions() { |
| this.disableDisplayingSuggestions = true; |
| this.focus(); |
| |
| this.updateComplete.then(() => { |
| this.disableDisplayingSuggestions = false; |
| }); |
| } |
| |
| selectAll() { |
| const nativeInputElement = this.nativeInput; |
| if (!this.input?.value) { |
| return; |
| } |
| nativeInputElement.setSelectionRange(0, this.input?.value.length); |
| } |
| |
| clear() { |
| this.text = ''; |
| } |
| |
| private handleItemSelectEnter( |
| e: CustomEvent<ItemSelectedEventDetail> | KeyboardEvent |
| ) { |
| this.handleInputCommit(); |
| e.stopPropagation(); |
| e.preventDefault(); |
| this.focusWithoutDisplayingSuggestions(); |
| } |
| |
| handleItemSelect(e: CustomEvent<ItemSelectedEventDetail>) { |
| if (e.detail.trigger === 'click') { |
| this.selected = e.detail.selected; |
| this._commit(); |
| e.stopPropagation(); |
| e.preventDefault(); |
| this.focusWithoutDisplayingSuggestions(); |
| } else if (e.detail.trigger === 'enter') { |
| this.handleItemSelectEnter(e); |
| } else if (e.detail.trigger === 'tab') { |
| if (this.tabComplete) { |
| this.handleInputCommit(true); |
| e.stopPropagation(); |
| e.preventDefault(); |
| this.focus(); |
| } else { |
| this.setFocus(false); |
| } |
| } |
| } |
| |
| /** |
| * Set the text of the input without triggering the suggestion dropdown. |
| * |
| * @param text The new text for the input. |
| */ |
| setText(text: string) { |
| this.disableDisplayingSuggestions = true; |
| this.text = text; |
| |
| this.updateComplete.then(() => { |
| this.disableDisplayingSuggestions = false; |
| }); |
| } |
| |
| onInputFocus() { |
| this.setFocus(true); |
| this.updateSuggestions(); |
| this.input?.classList.remove('warnUncommitted'); |
| // Needed so that --paper-input-container-input updated style is applied. |
| this.requestUpdate(); |
| } |
| |
| 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.requestUpdate(); |
| } |
| |
| updateSuggestions() { |
| if (this.text === undefined || this.threshold === undefined) return; |
| |
| // Reset suggestions for every update |
| // This will also prevent from carrying over suggestions: |
| // @see Issue 12039 |
| this.resetQueryOutput(); |
| |
| // TODO(taoalpha): Also skip if text has not changed |
| |
| if (this.disableDisplayingSuggestions) { |
| return; |
| } |
| |
| if (!this.query) { |
| return; |
| } |
| |
| if (this.text.length < this.threshold) { |
| this.value = ''; |
| return; |
| } |
| |
| if (!this.focused) { |
| return; |
| } |
| |
| const queryId = GrAutocomplete.getNextQueryId(); |
| this.activeQueryId = queryId; |
| this.setQueryStatus({ |
| type: AutocompleteQueryStatusType.LOADING, |
| message: 'Loading...', |
| }); |
| this.updateSuggestionsTask = debounce( |
| this.updateSuggestionsTask, |
| this.createUpdateTask(queryId, this.query, this.text), |
| DEBOUNCE_WAIT_MS |
| ); |
| } |
| |
| private createUpdateTask( |
| queryId: number, |
| query: AutocompleteQuery, |
| text: string |
| ): () => Promise<void> { |
| return async () => { |
| let suggestions: AutocompleteSuggestion[]; |
| try { |
| suggestions = await query(text); |
| } catch (e) { |
| this.value = ''; |
| if (typeof e === 'string') { |
| this.setQueryStatus({ |
| type: AutocompleteQueryStatusType.ERROR, |
| message: e, |
| }); |
| } else if (e instanceof Error) { |
| this.setQueryStatus({ |
| type: AutocompleteQueryStatusType.ERROR, |
| message: e.message, |
| }); |
| } |
| return; |
| } |
| if (queryId !== this.activeQueryId) { |
| // Late response. |
| return; |
| } |
| for (const suggestion of suggestions) { |
| suggestion.text = suggestion?.name ?? ''; |
| } |
| this.setSuggestions(suggestions); |
| if (this.index === -1) { |
| this.value = ''; |
| } |
| }; |
| } |
| |
| setFocus(focused: boolean) { |
| if (focused === this.focused) return; |
| this.focused = focused; |
| this.updateDropdownVisibility(); |
| } |
| |
| private shouldShowDropdown() { |
| return (this.suggestions.length > 0 || this.queryStatus) && this.focused; |
| } |
| |
| updateDropdownVisibility() { |
| if (this.shouldShowDropdown()) { |
| this.suggestionsDropdown?.open(); |
| return; |
| } |
| this.suggestionsDropdown?.close(); |
| } |
| |
| computeClass() { |
| const classes = []; |
| if (this.borderless) classes.push('borderless'); |
| if (this.showBlueFocusBorder) classes.push('showBlueFocusBorder'); |
| return classes.join(' '); |
| } |
| |
| /** |
| * handleKeydown used for key handling in the this.input?. |
| */ |
| handleKeydown(e: KeyboardEvent) { |
| this.setFocus(true); |
| switch (e.key) { |
| case 'ArrowUp': |
| e.preventDefault(); |
| this.suggestionsDropdown?.cursorUp(); |
| break; |
| case 'ArrowDown': |
| e.preventDefault(); |
| this.suggestionsDropdown?.cursorDown(); |
| break; |
| case 'Escape': |
| e.preventDefault(); |
| this.cancel(); |
| break; |
| case 'Tab': |
| if ( |
| this.queryStatus?.type === AutocompleteQueryStatusType.LOADING && |
| this.tabComplete |
| ) { |
| e.preventDefault(); |
| // Queue tab on load. |
| this.queryStatus = { |
| type: AutocompleteQueryStatusType.LOADING, |
| message: 'Loading... (Handle Tab on load)', |
| }; |
| const queryId = this.activeQueryId; |
| this.latestSuggestionUpdateComplete?.then(() => { |
| if (queryId === this.activeQueryId) { |
| this.handleInputCommit(/* _tabComplete=*/ true); |
| } |
| }); |
| } else if (this.suggestions.length > 0 && this.tabComplete) { |
| e.preventDefault(); |
| this.handleInputCommit(/* _tabComplete=*/ true); |
| this.focus(); |
| } else { |
| this.setFocus(false); |
| } |
| break; |
| case 'Enter': |
| if (modifierPressed(e)) { |
| break; |
| } |
| e.preventDefault(); |
| if (this.queryStatus?.type === AutocompleteQueryStatusType.LOADING) { |
| // Queue enter on load. |
| this.queryStatus = { |
| type: AutocompleteQueryStatusType.LOADING, |
| message: 'Loading... (Handle Enter on load)', |
| }; |
| const queryId = this.activeQueryId; |
| this.latestSuggestionUpdateComplete?.then(() => { |
| if (queryId === this.activeQueryId) { |
| this.handleItemSelectEnter(e); |
| } |
| }); |
| } else if (this.suggestions.length > 0) { |
| // If suggestions are shown, act as if the keypress is in dropdown. |
| // suggestions length is 0 if error is shown. |
| this.handleItemSelectEnter(e); |
| } else { |
| 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.resetQueryOutput(); |
| this.activeQueryId = 0; |
| } |
| } |
| |
| cancel() { |
| if (this.shouldShowDropdown()) { |
| this.resetQueryOutput(); |
| // If query is in flight by setting id to 0 we indicate that the results |
| // are outdated. |
| this.activeQueryId = 0; |
| this.requestUpdate(); |
| } else { |
| fire(this, 'cancel', {}); |
| } |
| } |
| |
| handleInputCommit(_tabComplete?: boolean) { |
| // Nothing to do if no suggestions. |
| if ( |
| !this.allowNonSuggestedValues && |
| (this.suggestionsDropdown?.isHidden || this.suggestions.length === 0) |
| ) { |
| return; |
| } |
| |
| this.selected = this.suggestionsDropdown?.getCursorTarget() ?? null; |
| this._commit(_tabComplete); |
| } |
| |
| updateValue( |
| suggestion: HTMLElement | null, |
| suggestions: AutocompleteSuggestion[] |
| ) { |
| if (!suggestion) { |
| return; |
| } |
| const index = Number(suggestion.dataset['index']!); |
| if (isNaN(index)) return; |
| const completed = suggestions[index].value; |
| if (completed === undefined || completed === null) return; |
| 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); |
| if (tokens?.length) { |
| tokens[tokens.length - 1] = completed; |
| this.value = tokens.join(' '); |
| } |
| } else { |
| this.value = completed; |
| } |
| } |
| |
| private readonly handleBodyClick = (e: Event) => { |
| const eventPath = e.composedPath(); |
| if (!eventPath) return; |
| for (let i = 0; i < eventPath.length; i++) { |
| if (eventPath[i] === this) { |
| return; |
| } |
| } |
| this.setFocus(false); |
| this.activeQueryId = 0; |
| }; |
| |
| /** |
| * Commits the suggestion, optionally firing the commit event. |
| * |
| * @param silent Allows for silent committing of an |
| * autocomplete suggestion in order to handle cases like tab-to-complete |
| * without firing the commit event. |
| */ |
| async _commit(silent?: boolean) { |
| // 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) { |
| const dataSet = this.selected.dataset; |
| // index property cannot be null for the data-set |
| if (dataSet) { |
| const index = Number(dataSet['index']!); |
| if (isNaN(index)) return; |
| this.setText(this.suggestions[index]?.name || ''); |
| } |
| } else { |
| this.clear(); |
| } |
| } |
| |
| this.resetQueryOutput(); |
| // we need willUpdate to send text-changed event before we can send the |
| // 'commit' event |
| await this.updateComplete; |
| if (!silent) { |
| fire(this, 'commit', {value}); |
| } |
| } |
| |
| // resetQueryOutput, setSuggestions and setQueryStatus insure that suggestions |
| // and queryStatus are never set at the same time. |
| private resetQueryOutput() { |
| this.suggestions = []; |
| this.queryStatus = undefined; |
| } |
| |
| private setSuggestions(suggestions: AutocompleteSuggestion[]) { |
| this.suggestions = suggestions; |
| this.queryStatus = undefined; |
| } |
| |
| private setQueryStatus(queryStatus: AutocompleteQueryStatus) { |
| this.suggestions = []; |
| this.queryStatus = queryStatus; |
| } |
| } |
| |
| /** |
| * Often gr-autocomplete is used for BranchName, RepoName, etc... |
| * GrTypedAutocomplete allows to define more precise typing in templates. |
| * For example, instead of |
| * $: { |
| * branchSelect: GrAutocomplete |
| * } |
| * you can write |
| * $: { |
| * branchSelect: GrTypedAutocomplete<BranchName> |
| * } |
| * And later user $.branchSelect.text without type conversion to BranchName. |
| */ |
| export interface GrTypedAutocomplete< |
| T extends PropertyType<GrAutocomplete, 'text'> |
| > extends GrAutocomplete { |
| text: T; |
| value: T; |
| query?: AutocompleteQuery<T>; |
| } |