UrlbarView.jsm 73 KB
Newer Older
1
2
3
4
5
6
7
8
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

"use strict";

var EXPORTED_SYMBOLS = ["UrlbarView"];

9
10
11
const { XPCOMUtils } = ChromeUtils.import(
  "resource://gre/modules/XPCOMUtils.jsm"
);
12
XPCOMUtils.defineLazyModuleGetters(this, {
13
14
  BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm",
  Services: "resource://gre/modules/Services.jsm",
15
  UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm",
16
  UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.jsm",
17
  UrlbarSearchOneOffs: "resource:///modules/UrlbarSearchOneOffs.jsm",
18
  UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.jsm",
19
20
21
  UrlbarUtils: "resource:///modules/UrlbarUtils.jsm",
});

22
23
24
25
26
27
28
XPCOMUtils.defineLazyServiceGetter(
  this,
  "styleSheetService",
  "@mozilla.org/content/style-sheet-service;1",
  "nsIStyleSheetService"
);

29
30
31
32
// Stale rows are removed on a timer with this timeout.  Tests can override this
// by setting UrlbarView.removeStaleRowsTimeout.
const DEFAULT_REMOVE_STALE_ROWS_TIMEOUT = 400;

33
34
35
// Query selector for selectable elements in tip and dynamic results.
const SELECTABLE_ELEMENT_SELECTOR = "[role=button], [selectable=true]";

36
37
38
const getBoundsWithoutFlushing = element =>
  element.ownerGlobal.windowUtils.getBoundsWithoutFlushing(element);

39
40
41
42
43
44
45
// Used to get a unique id to use for row elements, it wraps at 9999, that
// should be plenty for our needs.
let gUniqueIdSerial = 1;
function getUniqueId(prefix) {
  return prefix + (gUniqueIdSerial++ % 9999);
}

46
47
48
49
50
/**
 * Receives and displays address bar autocomplete results.
 */
class UrlbarView {
  /**
51
   * @param {UrlbarInput} input
52
53
   *   The UrlbarInput instance belonging to this UrlbarView instance.
   */
54
55
56
57
58
  constructor(input) {
    this.input = input;
    this.panel = input.panel;
    this.controller = input.controller;
    this.document = this.panel.ownerDocument;
59
60
61
    this.window = this.document.defaultView;

    this._mainContainer = this.panel.querySelector(".urlbarView-body-inner");
62
    this._rows = this.panel.querySelector(".urlbarView-results");
63

64
    this._rows.addEventListener("mousedown", this);
65
    this._rows.addEventListener("mouseup", this);
66

67
68
    // For the horizontal fade-out effect, set the overflow attribute on result
    // rows when they overflow.
69
70
    this._rows.addEventListener("overflow", this);
    this._rows.addEventListener("underflow", this);
71

72
73
74
75
    // `noresults` is used to style the one-offs without their usual top border
    // when no results are present.
    this.panel.setAttribute("noresults", "true");

76
    this.controller.setView(this);
77
    this.controller.addQueryListener(this);
78
79
80
    // This is used by autoOpen to avoid flickering results when reopening
    // previously abandoned searches.
    this._queryContextCache = new QueryContextCache(5);
81
82
83

    for (let viewTemplate of UrlbarView.dynamicViewTemplatesByName.values()) {
      if (viewTemplate.stylesheet) {
84
        addDynamicStylesheet(this.window, viewTemplate.stylesheet);
85
86
      }
    }
87
88
  }

89
  get oneOffSearchButtons() {
90
    if (!this._oneOffSearchButtons) {
91
      this._oneOffSearchButtons = new UrlbarSearchOneOffs(this);
92
93
94
95
      this._oneOffSearchButtons.addEventListener(
        "SelectedOneOffButtonChanged",
        this
      );
96
97
    }
    return this._oneOffSearchButtons;
98
99
  }

100
101
102
103
104
105
106
107
108
109
  /**
   * @returns {boolean}
   *   Whether the update2 one-offs are used.
   */
  get oneOffsRefresh() {
    return (
      UrlbarPrefs.get("update2") && UrlbarPrefs.get("update2.oneOffsRefresh")
    );
  }

110
111
112
113
114
  /**
   * @returns {boolean}
   *   Whether the panel is open.
   */
  get isOpen() {
115
    return this.input.hasAttribute("open");
116
117
  }

118
  get allowEmptySelection() {
119
120
121
122
123
    return !(
      this._queryContext &&
      this._queryContext.results[0] &&
      this._queryContext.results[0].heuristic
    );
124
125
  }

126
127
128
129
130
131
132
133
  get selectedRowIndex() {
    if (!this.isOpen) {
      return -1;
    }

    let selectedRow = this._getSelectedRow();

    if (!selectedRow) {
134
135
      return -1;
    }
136

137
    return selectedRow.result.rowIndex;
138
139
  }

140
  set selectedRowIndex(val) {
141
    if (!this.isOpen) {
142
143
144
      throw new Error(
        "UrlbarView: Cannot select an item if the view isn't open."
      );
145
146
147
    }

    if (val < 0) {
148
      this._selectElement(null);
149
150
151
      return val;
    }

152
    let items = Array.from(this._rows.children).filter(r =>
153
      this._isElementVisible(r)
154
    );
155
156
157
    if (val >= items.length) {
      throw new Error(`UrlbarView: Index ${val} is out of bounds.`);
    }
158
    this._selectElement(items[val]);
159
160
161
    return val;
  }

162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
  get selectedElementIndex() {
    if (!this.isOpen || !this._selectedElement) {
      return -1;
    }

    return this._selectedElement.elementIndex;
  }

  set selectedElementIndex(val) {
    if (!this.isOpen) {
      throw new Error(
        "UrlbarView: Cannot select an item if the view isn't open."
      );
    }

    if (val < 0) {
      this._selectElement(null);
      return val;
    }

    let selectableElement = this._getFirstSelectableElement();
    while (selectableElement && selectableElement.elementIndex != val) {
      selectableElement = this._getNextSelectableElement(selectableElement);
    }

    if (!selectableElement) {
      throw new Error(`UrlbarView: Index ${val} is out of bounds.`);
    }

    this._selectElement(selectableElement);
    return val;
  }

195
  /**
196
   * @returns {UrlbarResult}
197
198
199
   *   The currently selected result.
   */
  get selectedResult() {
200
201
202
203
204
205
206
    if (!this.isOpen) {
      return null;
    }

    let selectedRow = this._getSelectedRow();

    if (!selectedRow) {
207
208
      return null;
    }
209
210

    return selectedRow.result;
211
212
  }

213
214
215
216
217
218
219
220
221
222
223
224
  /**
   * @returns {Element}
   *   The currently selected element.
   */
  get selectedElement() {
    if (!this.isOpen) {
      return null;
    }

    return this._selectedElement;
  }

225
226
227
228
229
230
231
  /**
   * Clears selection, regardless of view status.
   */
  clearSelection() {
    this._selectElement(null, { updateInput: false });
  }

232
233
234
235
236
237
  /**
   * @returns {number}
   *   The number of visible results in the view.  Note that this may be larger
   *   than the number of results in the current query context since the view
   *   may be showing stale results.
   */
238
  get visibleRowCount() {
239
240
    let sum = 0;
    for (let row of this._rows.children) {
241
      sum += Number(this._isElementVisible(row));
242
243
    }
    return sum;
244
245
  }

246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
  /**
   * @returns {number}
   *   The number of selectable elements in the view.
   */
  get visibleElementCount() {
    let sum = 0;
    let element = this._getFirstSelectableElement();
    while (element) {
      if (this._isElementVisible(element)) {
        sum++;
      }
      element = this._getNextSelectableElement(element);
    }
    return sum;
  }

262
  /**
263
264
265
   * Returns the result of the row containing the given element, or the result
   * of the element if it itself is a row.
   *
266
267
268
   * @param {Element} element
   *   An element in the view.
   * @returns {UrlbarResult}
269
   *   The result of the element's row.
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
   */
  getResultFromElement(element) {
    if (!this.isOpen) {
      return null;
    }

    let row = this._getRowFromElement(element);

    if (!row) {
      return null;
    }

    return row.result;
  }

285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
  /**
   * @param {number} index
   *   The index from which to fetch the result.
   * @returns {UrlbarResult}
   *   The result at `index`. Null if the view is closed or if there are no
   *   results.
   */
  getResultAtIndex(index) {
    if (
      !this.isOpen ||
      !this._rows.children.length ||
      index >= this._rows.children.length
    ) {
      return null;
    }

    return this._rows.children[index].result;
  }

304
305
306
307
308
309
310
311
312
313
314
315
  /**
   * Returns the element closest to the given element that can be
   * selected/picked.  If the element itself can be selected, it's returned.  If
   * there is no such element, null is returned.
   *
   * @param {Element} element
   *   An element in the view.
   * @returns {Element}
   *   The closest element that can be picked including the element itself, or
   *   null if there is no such element.
   */
  getClosestSelectableElement(element) {
316
317
    let row = element.closest(".urlbarView-row");
    if (!row) {
318
319
      return null;
    }
320
321
322
323
324
325
326
327
    let closest = row;
    if (
      row.result.type == UrlbarUtils.RESULT_TYPE.TIP ||
      row.result.type == UrlbarUtils.RESULT_TYPE.DYNAMIC
    ) {
      closest = element.closest(SELECTABLE_ELEMENT_SELECTOR);
    }
    return this._isElementVisible(closest) ? closest : null;
328
329
  }

330
331
332
333
334
335
336
337
338
339
340
341
  /**
   * @param {UrlbarResult} result A result.
   * @returns {boolean} True if the given result is selected.
   */
  resultIsSelected(result) {
    if (this.selectedRowIndex < 0) {
      return false;
    }

    return result.rowIndex == this.selectedRowIndex;
  }

342
  /**
343
   * Moves the view selection forward or backward.
344
   *
345
346
   * @param {number} amount
   *   The number of steps to move.
347
348
349
   * @param {boolean} options.reverse
   *   Set to true to select the previous item. By default the next item
   *   will be selected.
350
351
   * @param {boolean} options.userPressedTab
   *   Set to true if the user pressed Tab to select a result. Default false.
352
   */
353
  selectBy(amount, { reverse = false, userPressedTab = false } = {}) {
354
    if (!this.isOpen) {
355
356
357
      throw new Error(
        "UrlbarView: Cannot select an item if the view isn't open."
      );
358
359
    }

360
361
362
363
364
365
    // Do not set aria-activedescendant if the user is moving to a
    // tab-to-search result with the Tab key. If
    // accessibility.tabToSearch.announceResults is set, the tab-to-search
    // result was announced to the user as they typed. We don't set
    // aria-activedescendant so the user doesn't think they have to press
    // Enter to enter search mode. See bug 1647929.
366
367
    const isSkippableTabToSearchAnnounce = selectedElt => {
      let skipAnnouncement =
368
        selectedElt?.result?.providerName == "TabToSearch" &&
369
        !this._announceTabToSearchOnSelection &&
370
        userPressedTab &&
371
372
373
374
375
376
377
378
        UrlbarPrefs.get("accessibility.tabToSearch.announceResults");
      if (skipAnnouncement) {
        // Once we skip setting aria-activedescendant once, we should not skip
        // it again if the user returns to that result.
        this._announceTabToSearchOnSelection = true;
      }
      return skipAnnouncement;
    };
379

380
381
382
383
384
    // Freeze results as the user is interacting with them, unless we are
    // deferring events while waiting for critical results.
    if (!this.input.eventBufferer.isDeferringEvents) {
      this.controller.cancelQuery();
    }
385

386
    let selectedElement = this._selectedElement;
387

388
389
390
391
392
393
    // We cache the first and last rows since they will not change while
    // selectBy is running.
    let firstSelectableElement = this._getFirstSelectableElement();
    // _getLastSelectableElement will not return an element that is over
    // maxResults and thus may be hidden and not selectable.
    let lastSelectableElement = this._getLastSelectableElement();
394

395
    if (!selectedElement) {
396
397
398
399
400
401
      selectedElement = reverse
        ? lastSelectableElement
        : firstSelectableElement;
      this._selectElement(selectedElement, {
        setAccessibleFocus: !isSkippableTabToSearchAnnounce(selectedElement),
      });
402
403
      return;
    }
404
    let endReached = reverse
405
406
      ? selectedElement == firstSelectableElement
      : selectedElement == lastSelectableElement;
407
408
    if (endReached) {
      if (this.allowEmptySelection) {
409
        selectedElement = null;
410
      } else {
411
412
413
        selectedElement = reverse
          ? lastSelectableElement
          : firstSelectableElement;
414
      }
415
416
417
      this._selectElement(selectedElement, {
        setAccessibleFocus: !isSkippableTabToSearchAnnounce(selectedElement),
      });
418
419
420
421
      return;
    }

    while (amount-- > 0) {
422
423
424
      let next = reverse
        ? this._getPreviousSelectableElement(selectedElement)
        : this._getNextSelectableElement(selectedElement);
425
426
427
      if (!next) {
        break;
      }
428
      if (!this._isElementVisible(next)) {
429
430
        continue;
      }
431
      selectedElement = next;
432
    }
433
434
435
    this._selectElement(selectedElement, {
      setAccessibleFocus: !isSkippableTabToSearchAnnounce(selectedElement),
    });
436
437
  }

438
439
440
441
  removeAccessibleFocus() {
    this._setAccessibleFocus(null);
  }

442
443
  clear() {
    this._rows.textContent = "";
444
    this.panel.setAttribute("noresults", "true");
445
    this.clearSelection();
446
447
  }

448
  /**
449
   * Closes the view, cancelling the query if necessary.
450
451
   * @param {boolean} [elementPicked]
   *   True if the view is being closed because a result was picked.
452
   */
453
  close(elementPicked = false) {
454
    this.controller.cancelQuery();
455
456
457
458
459

    if (!this.isOpen) {
      return;
    }

460
461
462
463
464
465
    // We exit search mode preview on close since the result previewing it is
    // implicitly unselected.
    if (this.input.searchMode?.isPreview) {
      this.input.searchMode = null;
    }

466
467
    this.removeAccessibleFocus();
    this.input.inputField.setAttribute("aria-expanded", "false");
468
    this._openPanelInstance = null;
469
    this._previousTabToSearchEngine = null;
470

471
    this.input.removeAttribute("open");
472
    this.input.endLayoutExtend();
473

474
475
476
477
478
    // Search Tips can open the view without the Urlbar being focused. If the
    // tip is ignored (e.g. the page content is clicked or the window loses
    // focus) we should discard the telemetry event created when the view was
    // opened.
    if (!this.input.focused && !elementPicked) {
479
480
      this.controller.engagementEvent.discard();
      this.controller.engagementEvent.record(null, {});
481
482
    }

483
    this.window.removeEventListener("resize", this);
484
    this.window.removeEventListener("blur", this);
485
486

    this.controller.notify(this.controller.NOTIFICATIONS.VIEW_CLOSE);
487
488
  }

489
490
491
492
493
494
495
496
497
498
499
500
  /**
   * This can be used to open the view automatically as a consequence of
   * specific user actions. For Top Sites searches (without a search string)
   * the view is opened only for mouse or keyboard interactions.
   * If the user abandoned a search (there is a search string) the view is
   * reopened, and we try to use cached results to reduce flickering, then a new
   * query is started to refresh results.
   * @param {Event} queryOptions Options to use when starting a new query. The
   *        event property is mandatory for proper telemetry tracking.
   * @returns {boolean} Whether the view was opened.
   */
  autoOpen(queryOptions = {}) {
501
502
503
504
    if (this._pickSearchTipIfPresent(queryOptions.event)) {
      return false;
    }

505
    if (!queryOptions.event) {
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
      return false;
    }

    if (
      !this.input.value ||
      this.input.getAttribute("pageproxystate") == "valid"
    ) {
      if (
        !this.isOpen &&
        ["mousedown", "command"].includes(queryOptions.event.type)
      ) {
        this.input.startQuery(queryOptions);
        return true;
      }
      return false;
    }

    // Reopen abandoned searches only if the input is focused.
524
    if (!this.input.focused) {
525
526
527
      return false;
    }

528
529
530
531
532
533
    // Tab switch is the only case where we requery if the view is open, because
    // switching tabs doesn't necessarily close the view.
    if (this.isOpen && queryOptions.event.type != "tabswitch") {
      return false;
    }

534
535
    if (
      this._rows.firstElementChild &&
536
      this._queryContext.searchString == this.input.value
537
    ) {
538
539
540
541
542
543
544
      // We can reuse the current results.
      queryOptions.allowAutofill = this._queryContext.allowAutofill;
    } else {
      // To reduce results flickering, try to reuse a cached UrlbarQueryContext.
      let cachedQueryContext = this._queryContextCache.get(this.input.value);
      if (cachedQueryContext) {
        this.onQueryResults(cachedQueryContext);
545
      }
546
    }
547
548
549
550
551

    this.controller.engagementEvent.discard();
    queryOptions.searchString = this.input.value;
    queryOptions.autofillIgnoresSelection = true;
    queryOptions.event.interactionType = "returned";
552

553
554
555
556
557
558
559
    if (
      this._queryContext &&
      this._queryContext.results &&
      this._queryContext.results.length
    ) {
      this._openPanel();
    }
560

561
562
563
564
    // If we had cached results, this will just refresh them, avoiding results
    // flicker, otherwise there may be some noise.
    this.input.startQuery(queryOptions);
    return true;
565
566
  }

567
568
  // UrlbarController listener methods.
  onQueryStarted(queryContext) {
569
    this._queryWasCancelled = false;
570
    this._queryUpdatedResults = false;
571
    this._openPanelInstance = null;
572
573
574
    if (!queryContext.searchString) {
      this._previousTabToSearchEngine = null;
    }
575
    this._startRemoveStaleRowsTimer();
576
577
  }

578
  onQueryCancelled(queryContext) {
579
    this._queryWasCancelled = true;
580
    this._cancelRemoveStaleRowsTimer();
581
582
583
  }

  onQueryFinished(queryContext) {
584
    this._cancelRemoveStaleRowsTimer();
585
586
    if (this._queryWasCancelled) {
      return;
587
    }
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603

    // If the query finished and it returned some results, remove stale rows.
    if (this._queryUpdatedResults) {
      this._removeStaleRows();
      return;
    }

    // The query didn't return any results.  Clear the view.
    this.clear();

    // If search mode isn't active, close the view.
    if (!this.input.searchMode) {
      this.close();
      return;
    }

604
605
606
607
608
609
610
611
612
    // Search mode is active.  If the one-offs should be shown, make sure they
    // are enabled and show the view.
    let openPanelInstance = (this._openPanelInstance = {});
    this.oneOffSearchButtons.willHide().then(willHide => {
      if (!willHide && openPanelInstance == this._openPanelInstance) {
        this.oneOffSearchButtons.enable(true);
        this._openPanel();
      }
    });
613
614
  }

615
  onQueryResults(queryContext) {
616
    this._queryContextCache.put(queryContext);
617
    this._queryContext = queryContext;
618

619
620
621
    if (!this.isOpen) {
      this.clear();
    }
622
    this._queryUpdatedResults = true;
623
    this._updateResults(queryContext);
624

625
626
    let firstResult = queryContext.results[0];

627
    if (queryContext.lastResultCount == 0) {
628
629
630
631
632
      // Clear the selection when we get a new set of results.
      this._selectElement(null, {
        updateInput: false,
      });

633
634
635
636
637
      // Show the one-off search buttons unless any of the following are true:
      //
      // * The update 2 refresh is enabled but the first result is a search tip
      // * The update 2 refresh is disabled and the search string is empty
      // * The search string starts with an `@` or search restriction character
638
      this.oneOffSearchButtons.enable(
639
640
        ((this.oneOffsRefresh &&
          firstResult.providerName != "UrlbarProviderSearchTips") ||
641
642
643
644
645
          queryContext.trimmedSearchString) &&
          queryContext.trimmedSearchString[0] != "@" &&
          (queryContext.trimmedSearchString[0] !=
            UrlbarTokenizer.RESTRICT.SEARCH ||
            queryContext.trimmedSearchString.length != 1)
646
      );
647
    }
648

649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
    if (!this.selectedElement && !this.oneOffSearchButtons.selectedButton) {
      if (firstResult.heuristic) {
        // Select the heuristic result.  The heuristic may not be the first result
        // added, which is why we do this check here when each result is added and
        // not above.
        this._selectElement(this._getFirstSelectableElement(), {
          updateInput: false,
          setAccessibleFocus: this.controller._userSelectionBehavior == "arrow",
        });
      } else if (
        UrlbarPrefs.get("update2") &&
        firstResult.payload.keywordOffer == UrlbarUtils.KEYWORD_OFFER.SHOW &&
        queryContext.trimmedSearchString != "@"
      ) {
        // Filtered keyword offer results can be in the first position but not
        // be heuristic results. We do this so the user can press Tab to select
        // them, resembling tab-to-search. In that case, the input value is
        // still associated with the first result.
        this.input.setResultForCurrentValue(firstResult);
      }
669
670
    }

671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
    // Announce tab-to-search results to screen readers as the user types.
    // Check to make sure we don't announce the same engine multiple times in
    // a row.
    let secondResult = queryContext.results[1];
    if (
      secondResult?.providerName == "TabToSearch" &&
      UrlbarPrefs.get("accessibility.tabToSearch.announceResults") &&
      this._previousTabToSearchEngine != secondResult.payload.engine
    ) {
      let engine = secondResult.payload.engine;
      this.window.A11yUtils.announce({
        id: UrlbarUtils.WEB_ENGINE_NAMES.has(engine)
          ? "urlbar-result-action-before-tabtosearch-web"
          : "urlbar-result-action-before-tabtosearch-other",
        args: { engine },
      });
      this._previousTabToSearchEngine = engine;
688
689
690
      // Do not set aria-activedescendant when the user tabs to the result
      // because we already announced it.
      this._announceTabToSearchOnSelection = false;
691
692
    }

693
694
695
696
697
698
699
700
701
    // If we update the selected element, a new unique ID is generated for it.
    // We need to ensure that aria-activedescendant reflects this new ID.
    if (this.selectedElement && !this.oneOffSearchButtons.selectedButton) {
      let aadID = this.input.inputField.getAttribute("aria-activedescendant");
      if (aadID && !this.document.getElementById(aadID)) {
        this._setAccessibleFocus(this.selectedElement);
      }
    }

702
    this._openPanel();
703

704
    if (firstResult.heuristic) {
705
706
707
      // The heuristic result may be a search alias result, so apply formatting
      // if necessary.  Conversely, the heuristic result of the previous query
      // may have been an alias, so remove formatting if necessary.
708
709
      this.input.formatValue();
    }
710
711
712
713
714
715
716
717
718
719
720

    if (queryContext.deferUserSelectionProviders.size) {
      // DeferUserSelectionProviders block user selection until the result is
      // shown, so it's the view's duty to remove them.
      // Doing it sooner, like when the results are added by the provider,
      // would not suffice because there's still a delay before those results
      // reach the view.
      queryContext.results.forEach(r => {
        queryContext.deferUserSelectionProviders.delete(r.providerName);
      });
    }
721
722
  }

723
724
725
726
727
728
729
730
731
732
733
734
  /**
   * Handles removing a result from the view when it is removed from the query,
   * and attempts to select the new result on the same row.
   *
   * This assumes that the result rows are in index order.
   *
   * @param {number} index The index of the result that has been removed.
   */
  onQueryResultRemoved(index) {
    let rowToRemove = this._rows.children[index];
    rowToRemove.remove();

735
736
    this._updateIndices();

737
    if (rowToRemove != this._getSelectedRow()) {
738
739
740
741
742
743
744
745
746
      return;
    }

    // Select the row at the same index, if possible.
    let newSelectionIndex = index;
    if (index >= this._queryContext.results.length) {
      newSelectionIndex = this._queryContext.results.length - 1;
    }
    if (newSelectionIndex >= 0) {
747
      this.selectedRowIndex = newSelectionIndex;
748
749
750
    }
  }

751
752
753
754
755
756
757
758
759
760
761
762
763
764
  /**
   * Passes DOM events for the view to the _on_<event type> methods.
   * @param {Event} event
   *   DOM event from the <view>.
   */
  handleEvent(event) {
    let methodName = "_on_" + event.type;
    if (methodName in this) {
      this[methodName](event);
    } else {
      throw new Error("Unrecognized UrlbarView event: " + event.type);
    }
  }

765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
  static dynamicViewTemplatesByName = new Map();

  /**
   * Registers the view template for a dynamic result type.  A view template is
   * a plain object that describes the DOM subtree for a dynamic result type.
   * When a dynamic result is shown in the urlbar view, its type's view template
   * is used to construct the part of the view that represents the result.
   *
   * The specified view template will be available to the urlbars in all current
   * and future browser windows until it is unregistered.  A given dynamic
   * result type has at most one view template.  If this method is called for a
   * dynamic result type more than once, the view template in the last call
   * overrides those in previous calls.
   *
   * @param {string} name
   *   The view template will be registered for the dynamic result type with
   *   this name.
   * @param {object} viewTemplate
   *   This object describes the DOM subtree for the given dynamic result type.
   *   It should be a tree-like nested structure with each object in the nesting
   *   representing a DOM element to be created.  This tree-like structure is
   *   achieved using the `children` property described below.  Each object in
   *   the structure may include the following properties:
   *
   *   {string} name
   *     The name of the object.  It is required for all objects in the
   *     structure except the root object and serves two important functions:
   *     (1) The element created for the object will automatically have a class
   *         named `urlbarView-dynamic-${dynamicType}-${name}`, where
   *         `dynamicType` is the name of the dynamic result type.  The element
   *         will also automatically have an attribute "name" whose value is
   *         this name.  The class and attribute allow the element to be styled
   *         in CSS.
   *     (2) The name is used when updating the view.  See
   *         UrlbarProvider.getViewUpdate().
   *     Names must be unique within a view template, but they don't need to be
   *     globally unique.  i.e., two different view templates can use the same
   *     names, and other DOM elements can use the same names in their IDs and
803
804
805
806
   *     classes.  The name also suffixes the dynamic element's ID: an element
   *     with name `data` will get the ID `urlbarView-row-{unique number}-data`.
   *     If there is no name provided for the root element, the root element
   *     will not get an ID.
807
808
809
810
811
812
813
   *   {string} tag
   *     The tag name of the object.  It is required for all objects in the
   *     structure except the root object and declares the kind of element that
   *     will be created for the object: span, div, img, etc.
   *   {object} [attributes]
   *     An optional mapping from attribute names to values.  For each
   *     name-value pair, an attribute is added to the element created for the
814
815
816
   *     object. The `id` attribute is reserved and cannot be set by the
   *     provider. Element IDs are passed back to the provider in getViewUpdate
   *     if they are needed.
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
   *   {array} [children]
   *     An optional list of children.  Each item in the array must be an object
   *     as described here.  For each item, a child element as described by the
   *     item is created and added to the element created for the parent object.
   *   {array} [classList]
   *     An optional list of classes.  Each class will be added to the element
   *     created for the object by calling element.classList.add().
   *   {string} [stylesheet]
   *     An optional stylesheet URL.  This property is valid only on the root
   *     object in the structure.  The stylesheet will be loaded in all browser
   *     windows so that the dynamic result type view may be styled.
   */
  static addDynamicViewTemplate(name, viewTemplate) {
    this.dynamicViewTemplatesByName.set(name, viewTemplate);
    if (viewTemplate.stylesheet) {
      for (let window of BrowserWindowTracker.orderedWindows) {
833
        addDynamicStylesheet(window, viewTemplate.stylesheet);
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
      }
    }
  }

  /**
   * Unregisters the view template for a dynamic result type.
   *
   * @param {string} name
   *   The view template will be unregistered for the dynamic result type with
   *   this name.
   */
  static removeDynamicViewTemplate(name) {
    let viewTemplate = this.dynamicViewTemplatesByName.get(name);
    if (!viewTemplate) {
      return;
    }
    this.dynamicViewTemplatesByName.delete(name);
    if (viewTemplate.stylesheet) {
      for (let window of BrowserWindowTracker.orderedWindows) {
853
        removeDynamicStylesheet(window, viewTemplate.stylesheet);
854
855
856
857
      }
    }
  }

858
859
860
861
862
863
  // Private methods below.

  _createElement(name) {
    return this.document.createElementNS("http://www.w3.org/1999/xhtml", name);
  }

864
  _openPanel() {
865
866
867
    if (this.isOpen) {
      return;
    }
868
    this.controller.userSelectionBehavior = "none";
869

870
    this.panel.removeAttribute("actionoverride");
871

872
873
    this._enableOrDisableRowWrap();

874
875
    this.input.inputField.setAttribute("aria-expanded", "true");

876
    this.input.setAttribute("open", "true");
877
    this.input.startLayoutExtend();
878

879
    this.window.addEventListener("resize", this);
880
    this.window.addEventListener("blur", this);
881
882

    this.controller.notify(this.controller.NOTIFICATIONS.VIEW_OPEN);
883
884
  }

885
886
887
888
889
890
  /**
   * Whether a result is a search suggestion.
   * @param {UrlbarResult} result The result to examine.
   * @returns {boolean} Whether the result is a search suggestion.
   */
  _resultIsSearchSuggestion(result) {
891
892
893
894
895
    return Boolean(
      result &&
        result.type == UrlbarUtils.RESULT_TYPE.SEARCH &&
        result.payload.suggestion
    );
896
897
898
899
900
901
902
903
904
905
906
907
  }

  /**
   * Checks whether the given row index can be update to the result we want
   * to apply. This is used in _updateResults to avoid flickering of results, by
   * reusing existing rows.
   * @param {number} rowIndex Index of the row to examine.
   * @param {UrlbarResult} result The result we'd like to apply.
   * @param {number} firstSearchSuggestionIndex Index of the first search suggestion.
   * @param {number} lastSearchSuggestionIndex Index of the last search suggestion.
   * @returns {boolean} Whether the row can be updated to this result.
   */
908
909
910
911
912
913
  _rowCanUpdateToResult(
    rowIndex,
    result,
    firstSearchSuggestionIndex,
    lastSearchSuggestionIndex
  ) {
914
915
916
917
918
919
920
    // The heuristic result must always be current, thus it's always compatible.
    if (result.heuristic) {
      return true;
    }
    let row = this._rows.children[rowIndex];
    let resultIsSearchSuggestion = this._resultIsSearchSuggestion(result);
    // If the row is same type, just update it.
921
922
923
    if (
      resultIsSearchSuggestion == this._resultIsSearchSuggestion(row.result)
    ) {
924
925
926
927
928
929
930
931
932
      return true;
    }
    // If the row has a different type, update it if we are in a compatible
    // index range.
    // In practice we don't want to overwrite a search suggestion with a non
    // search suggestion, but we allow the opposite.
    return resultIsSearchSuggestion && rowIndex >= firstSearchSuggestionIndex;
  }

933
  _updateResults(queryContext) {
934
935
936
937
938
939
940
941
942
943
944
945
946
    // TODO: For now this just compares search suggestions to the rest, in the
    // future we should make it support any type of result. Or, even better,
    // results should be grouped, thus we can directly update groups.

    // Find where are existing search suggestions.
    let firstSearchSuggestionIndex = -1;
    let lastSearchSuggestionIndex = -1;
    for (let i = 0; i < this._rows.children.length; ++i) {
      let row = this._rows.children[i];
      // Mark every row as stale, _updateRow will unmark them later.
      row.setAttribute("stale", "true");
      // Skip any row that isn't a search suggestion, or is non-visible because
      // over maxResults.
947
948
949
950
951
      if (
        row.result.heuristic ||
        i >= queryContext.maxResults ||
        !this._resultIsSearchSuggestion(row.result)
      ) {
952
953
954
955
956
957
958
959
960
961
962
        continue;
      }
      if (firstSearchSuggestionIndex == -1) {
        firstSearchSuggestionIndex = i;
      }
      lastSearchSuggestionIndex = i;
    }

    // Walk rows and find an insertion index for results. To avoid flicker, we
    // skip rows until we find one compatible with the result we want to apply.
    // If we couldn't find a compatible range, we'll just update.
963
    let results = queryContext.results;
964
965
    let resultIndex = 0;
    // We can have more rows than the visible ones.
966
967
968
969
970
    for (
      let rowIndex = 0;
      rowIndex < this._rows.children.length && resultIndex < results.length;
      ++rowIndex
    ) {
971
972
      let row = this._rows.children[rowIndex];
      let result = results[resultIndex];
973
974
975
976
977
978
979
980
      if (
        this._rowCanUpdateToResult(
          rowIndex,
          result,
          firstSearchSuggestionIndex,
          lastSearchSuggestionIndex
        )
      ) {
981
982
        this._updateRow(row, result);
        resultIndex++;
983
984
      }
    }
985
986
    // Add remaining results, if we have fewer rows than results.
    for (; resultIndex < results.length; ++resultIndex) {
987
      let row = this._createRow();
988
989
990
991
      this._updateRow(row, results[resultIndex]);
      // Due to stale rows, we may have more rows than maxResults, thus we must
      // hide them, and we'll revert this when stale rows are removed.
      if (this._rows.children.length >= queryContext.maxResults) {
992
        this._setRowVisibility(row, false);
993
      }
994
995
      this._rows.appendChild(row);
    }
996
997

    this._updateIndices();
998
999
  }

1000
  _createRow() {