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 }