| // Copyright 2021 The LUCI Authors. |
| // |
| // 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. |
| |
| import { css, customElement, html, TemplateResult } from 'lit-element'; |
| import { classMap } from 'lit-html/directives/class-map'; |
| import { styleMap } from 'lit-html/directives/style-map'; |
| import { computed, observable, reaction } from 'mobx'; |
| |
| import { MiloBaseElement } from './milo_base'; |
| |
| export type Suggestion = SuggestionEntry | SuggestionHeader; |
| |
| export interface SuggestionEntry { |
| readonly isHeader?: false; |
| readonly value: string; |
| // If display is undefined, value is used. |
| readonly display?: string | TemplateResult; |
| readonly explanation: string | TemplateResult; |
| } |
| |
| export interface SuggestionHeader { |
| readonly isHeader: true; |
| readonly value?: ''; |
| readonly display: string | TemplateResult; |
| readonly explanation?: ''; |
| } |
| |
| /** |
| * An input box that supports auto-complete dropdown. |
| */ |
| @customElement('milo-auto-complete') |
| export class AutoCompleteElement extends MiloBaseElement { |
| @observable.ref value = ''; |
| @observable.ref placeHolder = ''; |
| @observable.ref suggestions: readonly Suggestion[] = []; |
| |
| onValueUpdate = (_newVal: string) => {}; |
| onSuggestionSelected = (_suggestion: SuggestionEntry) => {}; |
| |
| focus() { |
| this.inputBox.focus(); |
| } |
| |
| // -1 means nothing is selected. |
| @observable.ref private selectedIndex = -1; |
| @observable.ref private showSuggestions = false; |
| @observable.ref private focused = false; |
| |
| private get inputBox() { |
| return this.shadowRoot!.getElementById('input-box')!; |
| } |
| private get dropdownContainer() { |
| return this.shadowRoot!.getElementById('dropdown-container')!; |
| } |
| @computed private get hint() { |
| if (this.focused && this.suggestions.length > 0) { |
| if (this.showSuggestions) { |
| return 'Use ↑ and ↓ to select, ⏎ to confirm, esc to dismiss suggestions'; |
| } else { |
| return 'Press ↓ to see suggestions'; |
| } |
| } |
| return this.placeHolder; |
| } |
| |
| protected updated() { |
| this.shadowRoot!.querySelector('.dropdown-item.selected')?.scrollIntoView({ block: 'nearest' }); |
| } |
| |
| connectedCallback() { |
| super.connectedCallback(); |
| |
| // Reset suggestion state when suggestions are updated. |
| this.addDisposer( |
| reaction( |
| () => this.suggestions, |
| () => { |
| this.selectedIndex = -1; |
| if (this.value !== '') { |
| this.showSuggestions = true; |
| } |
| } |
| ) |
| ); |
| |
| document.addEventListener('click', this.externalClickHandler); |
| } |
| |
| disconnectedCallback() { |
| document.removeEventListener('click', this.externalClickHandler); |
| super.disconnectedCallback(); |
| } |
| |
| private clearSuggestion() { |
| this.showSuggestions = false; |
| this.selectedIndex = -1; |
| } |
| |
| private externalClickHandler = (e: MouseEvent) => { |
| // If user clicks on other elements, dismiss the dropdown. |
| if (!e.composedPath().some((t) => t === this.inputBox || t === this.dropdownContainer)) { |
| this.clearSuggestion(); |
| } |
| }; |
| |
| private renderSuggestion(suggestion: Suggestion, suggestionIndex: number) { |
| if (suggestion.isHeader) { |
| return html` |
| <tr class="dropdown-item header"> |
| <td colspan="2">${suggestion.display}</td> |
| </tr> |
| `; |
| } |
| return html` |
| <tr |
| class=${classMap({ 'dropdown-item': true, selected: suggestionIndex === this.selectedIndex })} |
| @mouseover=${() => (this.selectedIndex = suggestionIndex)} |
| @click=${() => { |
| this.onSuggestionSelected(this.suggestions[this.selectedIndex] as SuggestionEntry); |
| this.focus(); |
| }} |
| > |
| <td>${suggestion.display ?? suggestion.value}</td> |
| <td>${suggestion.explanation}</td> |
| </tr> |
| `; |
| } |
| |
| protected render() { |
| return html` |
| <div> |
| <slot name="pre-icon"><span></span></slot> |
| <input |
| id="input-box" |
| placeholder=${this.hint} |
| .value=${this.value} |
| @input=${(e: InputEvent) => this.onValueUpdate((e.target as HTMLInputElement).value)} |
| @focus=${() => (this.focused = true)} |
| @blur=${() => (this.focused = false)} |
| @keydown=${(e: KeyboardEvent) => { |
| switch (e.code) { |
| case 'ArrowDown': |
| if (!this.showSuggestions) { |
| this.showSuggestions = true; |
| } |
| // Select the next suggestion entry. |
| for (let nextIndex = this.selectedIndex + 1; nextIndex < this.suggestions.length; ++nextIndex) { |
| if (!this.suggestions[nextIndex].isHeader) { |
| this.selectedIndex = nextIndex; |
| break; |
| } |
| } |
| break; |
| case 'ArrowUp': |
| // Select the previous suggestion entry. |
| for (let nextIndex = this.selectedIndex - 1; nextIndex >= 0; --nextIndex) { |
| if (!this.suggestions[nextIndex].isHeader) { |
| this.selectedIndex = nextIndex; |
| break; |
| } |
| } |
| break; |
| case 'Escape': |
| this.clearSuggestion(); |
| break; |
| case 'Enter': |
| if (this.selectedIndex !== -1) { |
| this.onSuggestionSelected(this.suggestions[this.selectedIndex] as SuggestionEntry); |
| } else if (this.value !== '' && !this.value.endsWith(' ')) { |
| // Complete the current sub-query if it's not already completed. |
| this.onValueUpdate(this.value + ' '); |
| } |
| this.clearSuggestion(); |
| break; |
| default: |
| return; |
| } |
| e.preventDefault(); |
| }} |
| /> |
| <slot name="post-icon"><span></span></slot> |
| <div |
| id="dropdown-container" |
| style=${styleMap({ display: this.showSuggestions && this.suggestions.length > 0 ? '' : 'none' })} |
| > |
| <table id="dropdown"> |
| ${this.suggestions.map((suggestion, i) => this.renderSuggestion(suggestion, i))} |
| </table> |
| </div> |
| </div> |
| `; |
| } |
| |
| static styles = css` |
| :host > div { |
| display: inline-grid; |
| grid-template-columns: auto 1fr auto; |
| position: relative; |
| box-sizing: border-box; |
| width: 100%; |
| border: 1px solid var(--divider-color); |
| border-radius: 0.25rem; |
| transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; |
| } |
| |
| :host > div:focus-within { |
| outline: Highlight auto 1px; |
| outline: -webkit-focus-ring-color auto 1px; |
| } |
| |
| #input-box { |
| display: inline-block; |
| width: 100%; |
| height: 28px; |
| box-sizing: border-box; |
| padding: 0.3rem 0.5rem; |
| font-size: 1rem; |
| border: none; |
| text-overflow: ellipsis; |
| background: transparent; |
| } |
| input:focus { |
| outline: none; |
| } |
| |
| #dropdown-container { |
| position: absolute; |
| top: 30px; |
| border: 1px solid var(--divider-color); |
| border-radius: 0.25rem; |
| background: white; |
| color: var(--active-color); |
| padding: 2px; |
| z-index: 999; |
| max-height: 200px; |
| overflow-y: auto; |
| } |
| #dropdown { |
| border-spacing: 0 1px; |
| } |
| |
| .dropdown-item.header { |
| color: var(--default-text-color); |
| } |
| .dropdown-item > td { |
| white-space: nowrap; |
| overflow: hidden; |
| } |
| .dropdown-item > td:first-child { |
| padding-right: 50px; |
| } |
| .dropdown-item.selected { |
| border-color: var(--light-active-color); |
| background-color: var(--light-active-color); |
| } |
| `; |
| } |