xref: /aosp_15_r20/external/perfetto/ui/src/frontend/omnibox.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
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