blob: c28bb886adbfe1cc90c1545075985836a49085db [file] [log] [blame]
// 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);
}
`;
}