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