• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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