go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/ui/src/common/components/auto_complete/auto_complete.ts (about)

     1  // Copyright 2021 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  import { css, html, TemplateResult } from 'lit';
    16  import { customElement } from 'lit/decorators.js';
    17  import { classMap } from 'lit/directives/class-map.js';
    18  import { styleMap } from 'lit/directives/style-map.js';
    19  import { action, computed, makeObservable, observable, reaction } from 'mobx';
    20  
    21  import { MobxExtLitElement } from '@/generic_libs/components/lit_mobx_ext';
    22  
    23  export type Suggestion = SuggestionEntry | SuggestionHeader;
    24  
    25  export interface SuggestionEntry {
    26    readonly isHeader?: false;
    27    readonly value: string;
    28    // If display is undefined, value is used.
    29    readonly display?: string | TemplateResult;
    30    readonly explanation: string | TemplateResult;
    31  }
    32  
    33  export interface SuggestionHeader {
    34    readonly isHeader: true;
    35    readonly value?: '';
    36    readonly display: string | TemplateResult;
    37    readonly explanation?: '';
    38  }
    39  
    40  /**
    41   * An input box that supports auto-complete dropdown.
    42   */
    43  @customElement('milo-auto-complete')
    44  export class AutoCompleteElement extends MobxExtLitElement {
    45    @observable.ref value = '';
    46    @observable.ref placeHolder = '';
    47    @observable.ref suggestions: readonly Suggestion[] = [];
    48  
    49    /**
    50     * Highlight the input box for a short period of time when
    51     * 1. this.highlight is true when first rendered, and
    52     * 2. this.value is not empty when first rendered.
    53     */
    54    @observable.ref highlight = false;
    55  
    56    onValueUpdate = (_newVal: string) => {
    57      /* do nothing by default */
    58    };
    59    onSuggestionSelected = (_suggestion: SuggestionEntry) => {
    60      /* do nothing by default */
    61    };
    62    onComplete = () => {
    63      /* do nothing by default */
    64    };
    65  
    66    focus() {
    67      this.inputBox.focus();
    68    }
    69  
    70    // -1 means nothing is selected.
    71    @observable.ref private selectedIndex = -1;
    72    @observable.ref private showSuggestions = false;
    73    @observable.ref private focused = false;
    74  
    75    private get inputBox() {
    76      return this.shadowRoot!.getElementById('input-box')!;
    77    }
    78    private get dropdownContainer() {
    79      return this.shadowRoot!.getElementById('dropdown-container')!;
    80    }
    81    @computed private get hint() {
    82      if (this.focused && this.suggestions.length > 0) {
    83        if (this.showSuggestions) {
    84          return 'Use ↑ and ↓ to select, ⏎ to confirm, esc to dismiss suggestions';
    85        } else {
    86          return 'Press ↓ to see suggestions';
    87        }
    88      }
    89      return this.placeHolder;
    90    }
    91  
    92    constructor() {
    93      super();
    94      makeObservable(this);
    95    }
    96  
    97    protected updated() {
    98      this.shadowRoot!.querySelector('.dropdown-item.selected')?.scrollIntoView({
    99        block: 'nearest',
   100      });
   101    }
   102  
   103    protected firstUpdated() {
   104      if (this.highlight && this.value) {
   105        this.style.setProperty('animation', 'highlight 2s');
   106      }
   107    }
   108  
   109    connectedCallback() {
   110      super.connectedCallback();
   111  
   112      // Reset suggestion state when suggestions are updated.
   113      this.addDisposer(
   114        reaction(
   115          () => this.suggestions,
   116          () => {
   117            this.selectedIndex = -1;
   118            if (this.value !== '') {
   119              action(() => (this.showSuggestions = true))();
   120            }
   121          },
   122        ),
   123      );
   124  
   125      document.addEventListener('click', this.externalClickHandler);
   126    }
   127  
   128    disconnectedCallback() {
   129      document.removeEventListener('click', this.externalClickHandler);
   130      super.disconnectedCallback();
   131    }
   132  
   133    @action private clearSuggestion() {
   134      this.showSuggestions = false;
   135      this.selectedIndex = -1;
   136    }
   137  
   138    private externalClickHandler = (e: MouseEvent) => {
   139      // If user clicks on other elements, dismiss the dropdown.
   140      if (
   141        !e
   142          .composedPath()
   143          .some((t) => t === this.inputBox || t === this.dropdownContainer)
   144      ) {
   145        this.clearSuggestion();
   146      }
   147    };
   148  
   149    private renderSuggestion(suggestion: Suggestion, suggestionIndex: number) {
   150      if (suggestion.isHeader) {
   151        return html`
   152          <tr class="dropdown-item header">
   153            <td colspan="2">${suggestion.display}</td>
   154          </tr>
   155        `;
   156      }
   157      return html`
   158        <tr
   159          class=${classMap({
   160            'dropdown-item': true,
   161            selected: suggestionIndex === this.selectedIndex,
   162          })}
   163          @mouseover=${() => (this.selectedIndex = suggestionIndex)}
   164          @click=${() => {
   165            this.onSuggestionSelected(
   166              this.suggestions[this.selectedIndex] as SuggestionEntry,
   167            );
   168            this.focus();
   169          }}
   170        >
   171          <td>${suggestion.display ?? suggestion.value}</td>
   172          <td>${suggestion.explanation}</td>
   173        </tr>
   174      `;
   175    }
   176  
   177    protected render() {
   178      return html`
   179        <div>
   180          <slot name="pre-icon"><span></span></slot>
   181          <input
   182            id="input-box"
   183            placeholder=${this.hint}
   184            .value=${this.value}
   185            @input=${(e: InputEvent) =>
   186              this.onValueUpdate((e.target as HTMLInputElement).value)}
   187            @focus=${() => (this.focused = true)}
   188            @blur=${() => (this.focused = false)}
   189            @keydown=${(e: KeyboardEvent) => {
   190              switch (e.code) {
   191                case 'ArrowDown':
   192                  if (!this.showSuggestions) {
   193                    this.showSuggestions = true;
   194                  }
   195                  // Select the next suggestion entry.
   196                  for (
   197                    let nextIndex = this.selectedIndex + 1;
   198                    nextIndex < this.suggestions.length;
   199                    ++nextIndex
   200                  ) {
   201                    if (!this.suggestions[nextIndex].isHeader) {
   202                      action(() => (this.selectedIndex = nextIndex))();
   203                      break;
   204                    }
   205                  }
   206                  break;
   207                case 'ArrowUp':
   208                  // Select the previous suggestion entry.
   209                  for (
   210                    let nextIndex = this.selectedIndex - 1;
   211                    nextIndex >= 0;
   212                    --nextIndex
   213                  ) {
   214                    if (!this.suggestions[nextIndex].isHeader) {
   215                      action(() => (this.selectedIndex = nextIndex))();
   216                      break;
   217                    }
   218                  }
   219                  break;
   220                case 'Escape':
   221                  this.clearSuggestion();
   222                  break;
   223                case 'Enter':
   224                  if (this.selectedIndex !== -1) {
   225                    this.onSuggestionSelected(
   226                      this.suggestions[this.selectedIndex] as SuggestionEntry,
   227                    );
   228                  } else {
   229                    if (this.value !== '' && !this.value.endsWith(' ')) {
   230                      // Complete the current sub-query if it's not already completed.
   231                      this.onValueUpdate(this.value + ' ');
   232                    }
   233                    this.onComplete();
   234                  }
   235                  this.clearSuggestion();
   236                  break;
   237                default:
   238                  return;
   239              }
   240              e.preventDefault();
   241            }}
   242          />
   243          <slot name="post-icon"><span></span></slot>
   244          <div
   245            id="dropdown-container"
   246            style=${styleMap({
   247              display:
   248                this.showSuggestions && this.suggestions.length > 0 ? '' : 'none',
   249            })}
   250          >
   251            <table id="dropdown">
   252              ${this.suggestions.map((suggestion, i) =>
   253                this.renderSuggestion(suggestion, i),
   254              )}
   255            </table>
   256          </div>
   257        </div>
   258      `;
   259    }
   260  
   261    static styles = css`
   262      :host {
   263        display: inline-block;
   264        width: 100%;
   265      }
   266  
   267      :host > div {
   268        display: inline-grid;
   269        grid-template-columns: auto 1fr auto;
   270        position: relative;
   271        box-sizing: border-box;
   272        width: 100%;
   273        border: 1px solid var(--divider-color);
   274        border-radius: 0.25rem;
   275        transition:
   276          border-color 0.15s ease-in-out,
   277          box-shadow 0.15s ease-in-out;
   278      }
   279  
   280      :host > div:focus-within {
   281        outline: Highlight auto 1px;
   282        outline: -webkit-focus-ring-color auto 1px;
   283      }
   284  
   285      #input-box {
   286        display: inline-block;
   287        width: 100%;
   288        height: 28px;
   289        box-sizing: border-box;
   290        padding: 0.3rem 0.5rem;
   291        font-size: 1rem;
   292        border: none;
   293        text-overflow: ellipsis;
   294        background: transparent;
   295      }
   296      input:focus {
   297        outline: none;
   298      }
   299  
   300      #dropdown-container {
   301        position: absolute;
   302        top: 30px;
   303        width: 100%;
   304        border: 1px solid var(--divider-color);
   305        border-radius: 0.25rem;
   306        background: white;
   307        color: var(--active-color);
   308        padding: 2px;
   309        z-index: 999;
   310        max-height: 200px;
   311        overflow-y: auto;
   312      }
   313      #dropdown {
   314        border-spacing: 0 1px;
   315        table-layout: fixed;
   316        width: 100%;
   317        word-break: break-word;
   318      }
   319  
   320      .dropdown-item.header {
   321        color: var(--default-text-color);
   322      }
   323      .dropdown-item > td {
   324        overflow: hidden;
   325      }
   326      .dropdown-item > td:first-child {
   327        padding-right: 10px;
   328      }
   329      .dropdown-item.selected {
   330        border-color: var(--light-active-color);
   331        background-color: var(--light-active-color);
   332      }
   333    `;
   334  }