1// Copyright (C) 2023 The Android Open Source Project 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 15import m from 'mithril'; 16 17import {classNames} from '../base/classnames'; 18import {FuzzySegment} from '../base/fuzzy'; 19import {isString} from '../base/object_utils'; 20import {exists} from '../base/utils'; 21import {raf} from '../core/raf_scheduler'; 22import {EmptyState} from '../widgets/empty_state'; 23import {KeycapGlyph} from '../widgets/hotkey_glyphs'; 24import {Popup} from '../widgets/popup'; 25 26interface OmniboxOptionRowAttrs { 27 // Human readable display name for the option. 28 // This can either be a simple string, or a list of fuzzy segments in which 29 // case highlighting will be applied to the matching segments. 30 displayName: FuzzySegment[] | string; 31 32 // Highlight this option. 33 highlighted: boolean; 34 35 // Arbitrary components to put on the right hand side of the option. 36 rightContent?: m.Children; 37 38 // Some tag to place on the right (to the left of the right content). 39 label?: string; 40 41 // Additional attrs forwarded to the underlying element. 42 // eslint-disable-next-line @typescript-eslint/no-explicit-any 43 [htmlAttrs: string]: any; 44} 45 46class OmniboxOptionRow implements m.ClassComponent<OmniboxOptionRowAttrs> { 47 private highlightedBefore = false; 48 49 view({attrs}: m.Vnode<OmniboxOptionRowAttrs>): void | m.Children { 50 const {displayName, highlighted, rightContent, label, ...htmlAttrs} = attrs; 51 return m( 52 'li', 53 { 54 class: classNames(highlighted && 'pf-highlighted'), 55 ...htmlAttrs, 56 }, 57 m('span.pf-title', this.renderTitle(displayName)), 58 label && m('span.pf-tag', label), 59 rightContent, 60 ); 61 } 62 63 private renderTitle(title: FuzzySegment[] | string): m.Children { 64 if (isString(title)) { 65 return title; 66 } else { 67 return title.map(({matching, value}) => { 68 return matching ? m('b', value) : value; 69 }); 70 } 71 } 72 73 onupdate({attrs, dom}: m.VnodeDOM<OmniboxOptionRowAttrs, this>) { 74 if (this.highlightedBefore !== attrs.highlighted) { 75 if (attrs.highlighted) { 76 dom.scrollIntoView({block: 'nearest'}); 77 } 78 this.highlightedBefore = attrs.highlighted; 79 } 80 } 81} 82 83// Omnibox option. 84export interface OmniboxOption { 85 // The value to place into the omnibox. This is what's returned in onSubmit. 86 key: string; 87 88 // Display name provided as a string or a list of fuzzy segments to enable 89 // fuzzy match highlighting. 90 displayName: FuzzySegment[] | string; 91 92 // Some tag to place on the right (to the left of the right content). 93 tag?: string; 94 95 // Arbitrary components to put on the right hand side of the option. 96 rightContent?: m.Children; 97} 98 99export interface OmniboxAttrs { 100 // Current value of the omnibox input. 101 value: string; 102 103 // What to show when value is blank. 104 placeholder?: string; 105 106 // Called when the text changes. 107 onInput?: (value: string, previousValue: string) => void; 108 109 // Class or list of classes to append to the Omnibox element. 110 extraClasses?: string | string[]; 111 112 // Called on close. 113 onClose?: () => void; 114 115 // Dropdown items to show. If none are supplied, the omnibox runs in free text 116 // mode, where anyt text can be input. Otherwise, onSubmit will always be 117 // called with one of the options. 118 // Options are provided in groups called categories. If the category has a 119 // name the name will be listed at the top of the group rendered with a little 120 // divider as well. 121 options?: OmniboxOption[]; 122 123 // Called when the user expresses the intent to "execute" the thing. 124 onSubmit?: (value: string, mod: boolean, shift: boolean) => void; 125 126 // Called when the user hits backspace when the field is empty. 127 onGoBack?: () => void; 128 129 // When true, disable and grey-out the omnibox's input. 130 readonly?: boolean; 131 132 // Ref to use on the input - useful for extracing this element from the DOM. 133 inputRef?: string; 134 135 // Whether to close when the user presses Enter. Default = false. 136 closeOnSubmit?: boolean; 137 138 // Whether to close the omnibox (i.e. call the |onClose| handler) when we 139 // click outside the omnibox or its dropdown. Default = false. 140 closeOnOutsideClick?: boolean; 141 142 // Some content to place into the right hand side of the after the input. 143 rightContent?: m.Children; 144 145 // If we have options, this value indicates the index of the option which 146 // is currently highlighted. 147 selectedOptionIndex?: number; 148 149 // Callback for when the user pressed up/down, expressing a desire to change 150 // the |selectedOptionIndex|. 151 onSelectedOptionChanged?: (index: number) => void; 152} 153 154export class Omnibox implements m.ClassComponent<OmniboxAttrs> { 155 private popupElement?: HTMLElement; 156 private dom?: Element; 157 private attrs?: OmniboxAttrs; 158 159 view({attrs}: m.Vnode<OmniboxAttrs>): m.Children { 160 const { 161 value, 162 placeholder, 163 extraClasses, 164 onInput = () => {}, 165 onSubmit = () => {}, 166 onGoBack = () => {}, 167 inputRef = 'omnibox', 168 options, 169 closeOnSubmit = false, 170 rightContent, 171 selectedOptionIndex = 0, 172 } = attrs; 173 174 return m( 175 Popup, 176 { 177 onPopupMount: (dom: HTMLElement) => (this.popupElement = dom), 178 onPopupUnMount: (_dom: HTMLElement) => (this.popupElement = undefined), 179 isOpen: exists(options), 180 showArrow: false, 181 matchWidth: true, 182 offset: 2, 183 trigger: m( 184 '.omnibox', 185 { 186 class: classNames(extraClasses), 187 }, 188 m('input', { 189 ref: inputRef, 190 value, 191 placeholder, 192 oninput: (e: Event) => { 193 onInput((e.target as HTMLInputElement).value, value); 194 }, 195 onkeydown: (e: KeyboardEvent) => { 196 if (e.key === 'Backspace' && value === '') { 197 onGoBack(); 198 } else if (e.key === 'Escape') { 199 e.preventDefault(); 200 this.close(attrs); 201 } 202 203 if (options) { 204 if (e.key === 'ArrowUp') { 205 e.preventDefault(); 206 this.highlightPreviousOption(attrs); 207 } else if (e.key === 'ArrowDown') { 208 e.preventDefault(); 209 this.highlightNextOption(attrs); 210 } else if (e.key === 'Enter') { 211 e.preventDefault(); 212 213 const option = options[selectedOptionIndex]; 214 // Return values from indexing arrays can be undefined. 215 // We should enable noUncheckedIndexedAccess in 216 // tsconfig.json. 217 /* eslint-disable 218 @typescript-eslint/strict-boolean-expressions */ 219 if (option) { 220 /* eslint-enable */ 221 closeOnSubmit && this.close(attrs); 222 223 const mod = e.metaKey || e.ctrlKey; 224 const shift = e.shiftKey; 225 onSubmit(option.key, mod, shift); 226 } 227 } 228 } else { 229 if (e.key === 'Enter') { 230 e.preventDefault(); 231 232 closeOnSubmit && this.close(attrs); 233 234 const mod = e.metaKey || e.ctrlKey; 235 const shift = e.shiftKey; 236 onSubmit(value, mod, shift); 237 } 238 } 239 }, 240 }), 241 rightContent, 242 ), 243 }, 244 options && this.renderDropdown(attrs), 245 ); 246 } 247 248 private renderDropdown(attrs: OmniboxAttrs): m.Children { 249 const {options} = attrs; 250 251 if (!options) return null; 252 253 if (options.length === 0) { 254 return m(EmptyState, {title: 'No matching options...'}); 255 } else { 256 return m( 257 '.pf-omnibox-dropdown', 258 this.renderOptionsContainer(attrs, options), 259 this.renderFooter(), 260 ); 261 } 262 } 263 264 private renderFooter() { 265 return m( 266 '.pf-omnibox-dropdown-footer', 267 m( 268 'section', 269 m(KeycapGlyph, {keyValue: 'ArrowUp'}), 270 m(KeycapGlyph, {keyValue: 'ArrowDown'}), 271 'to navigate', 272 ), 273 m('section', m(KeycapGlyph, {keyValue: 'Enter'}), 'to use'), 274 m('section', m(KeycapGlyph, {keyValue: 'Escape'}), 'to dismiss'), 275 ); 276 } 277 278 private renderOptionsContainer( 279 attrs: OmniboxAttrs, 280 options: OmniboxOption[], 281 ): m.Children { 282 const { 283 onClose = () => {}, 284 onSubmit = () => {}, 285 closeOnSubmit = false, 286 selectedOptionIndex, 287 } = attrs; 288 289 const opts = options.map(({displayName, key, rightContent, tag}, index) => { 290 return m(OmniboxOptionRow, { 291 key, 292 label: tag, 293 displayName: displayName, 294 highlighted: index === selectedOptionIndex, 295 onclick: () => { 296 closeOnSubmit && onClose(); 297 onSubmit(key, false, false); 298 }, 299 rightContent, 300 }); 301 }); 302 303 return m('ul.pf-omnibox-options-container', opts); 304 } 305 306 oncreate({attrs, dom}: m.VnodeDOM<OmniboxAttrs, this>) { 307 this.attrs = attrs; 308 this.dom = dom; 309 const {closeOnOutsideClick} = attrs; 310 if (closeOnOutsideClick) { 311 document.addEventListener('mousedown', this.onMouseDown); 312 } 313 } 314 315 onupdate({attrs, dom}: m.VnodeDOM<OmniboxAttrs, this>) { 316 this.attrs = attrs; 317 this.dom = dom; 318 const {closeOnOutsideClick} = attrs; 319 if (closeOnOutsideClick) { 320 document.addEventListener('mousedown', this.onMouseDown); 321 } else { 322 document.removeEventListener('mousedown', this.onMouseDown); 323 } 324 } 325 326 onremove(_: m.VnodeDOM<OmniboxAttrs, this>) { 327 this.attrs = undefined; 328 this.dom = undefined; 329 document.removeEventListener('mousedown', this.onMouseDown); 330 } 331 332 private onMouseDown = (e: Event) => { 333 // Don't close if the click was within ourselves or our popup. 334 if (e.target instanceof Node) { 335 if (this.popupElement && this.popupElement.contains(e.target)) { 336 return; 337 } 338 if (this.dom && this.dom.contains(e.target)) return; 339 } 340 if (this.attrs) { 341 this.close(this.attrs); 342 } 343 }; 344 345 private close(attrs: OmniboxAttrs): void { 346 const {onClose = () => {}} = attrs; 347 raf.scheduleFullRedraw(); 348 onClose(); 349 } 350 351 private highlightPreviousOption(attrs: OmniboxAttrs) { 352 const {selectedOptionIndex = 0, onSelectedOptionChanged = () => {}} = attrs; 353 354 onSelectedOptionChanged(Math.max(0, selectedOptionIndex - 1)); 355 } 356 357 private highlightNextOption(attrs: OmniboxAttrs) { 358 const { 359 selectedOptionIndex = 0, 360 onSelectedOptionChanged = () => {}, 361 options = [], 362 } = attrs; 363 364 const max = options.length - 1; 365 onSelectedOptionChanged(Math.min(max, selectedOptionIndex + 1)); 366 } 367} 368