Bug 1944627 - update sidebar button checked state for non-revamped sidebar cases...
[gecko.git] / browser / components / urlbar / UrlbarView.sys.mjs
blob2da764ed85f4ec724a93bfd8ecc978b40836001d
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
6 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
8 const lazy = {};
10 ChromeUtils.defineESModuleGetters(lazy, {
11   BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs",
12   ContextualIdentityService:
13     "resource://gre/modules/ContextualIdentityService.sys.mjs",
14   L10nCache: "resource:///modules/UrlbarUtils.sys.mjs",
15   ObjectUtils: "resource://gre/modules/ObjectUtils.sys.mjs",
16   UrlbarProviderOpenTabs: "resource:///modules/UrlbarProviderOpenTabs.sys.mjs",
17   UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
18   UrlbarProviderGlobalActions:
19     "resource:///modules/UrlbarProviderGlobalActions.sys.mjs",
20   UrlbarProviderQuickSuggest:
21     "resource:///modules/UrlbarProviderQuickSuggest.sys.mjs",
22   UrlbarProviderRecentSearches:
23     "resource:///modules/UrlbarProviderRecentSearches.sys.mjs",
24   UrlbarProviderTopSites: "resource:///modules/UrlbarProviderTopSites.sys.mjs",
25   UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.sys.mjs",
26   UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs",
27   UrlbarSearchOneOffs: "resource:///modules/UrlbarSearchOneOffs.sys.mjs",
28   UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs",
29   UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs",
30 });
32 XPCOMUtils.defineLazyServiceGetter(
33   lazy,
34   "styleSheetService",
35   "@mozilla.org/content/style-sheet-service;1",
36   "nsIStyleSheetService"
39 // Query selector for selectable elements in results.
40 const SELECTABLE_ELEMENT_SELECTOR = "[role=button], [selectable]";
41 const KEYBOARD_SELECTABLE_ELEMENT_SELECTOR =
42   "[role=button]:not([keyboard-inaccessible]), [selectable]";
44 const ZERO_PREFIX_HISTOGRAM_DWELL_TIME = "FX_URLBAR_ZERO_PREFIX_DWELL_TIME_MS";
46 const RESULT_MENU_COMMANDS = {
47   DISMISS: "dismiss",
48   HELP: "help",
49   MANAGE: "manage",
52 const getBoundsWithoutFlushing = element =>
53   element.ownerGlobal.windowUtils.getBoundsWithoutFlushing(element);
55 // Used to get a unique id to use for row elements, it wraps at 9999, that
56 // should be plenty for our needs.
57 let gUniqueIdSerial = 1;
58 function getUniqueId(prefix) {
59   return prefix + (gUniqueIdSerial++ % 9999);
62 /**
63  * Receives and displays address bar autocomplete results.
64  */
65 export class UrlbarView {
66   // Stale rows are removed on a timer with this timeout.
67   static removeStaleRowsTimeout = 400;
69   /**
70    * @param {UrlbarInput} input
71    *   The UrlbarInput instance belonging to this UrlbarView instance.
72    */
73   constructor(input) {
74     this.input = input;
75     this.panel = input.panel;
76     this.controller = input.controller;
77     this.document = this.panel.ownerDocument;
78     this.window = this.document.defaultView;
80     this.#rows = this.panel.querySelector(".urlbarView-results");
81     this.resultMenu = this.panel.querySelector(".urlbarView-result-menu");
82     this.#resultMenuCommands = new WeakMap();
84     this.#rows.addEventListener("mousedown", this);
86     // For the horizontal fade-out effect, set the overflow attribute on result
87     // rows when they overflow.
88     this.#rows.addEventListener("overflow", this);
89     this.#rows.addEventListener("underflow", this);
91     this.resultMenu.addEventListener("command", this);
92     this.resultMenu.addEventListener("popupshowing", this);
94     // `noresults` is used to style the one-offs without their usual top border
95     // when no results are present.
96     this.panel.setAttribute("noresults", "true");
98     this.controller.setView(this);
99     this.controller.addQueryListener(this);
100     // This is used by autoOpen to avoid flickering results when reopening
101     // previously abandoned searches.
102     this.queryContextCache = new QueryContextCache(5);
104     // We cache l10n strings to avoid Fluent's async lookup.
105     this.#l10nCache = new lazy.L10nCache(this.document.l10n);
107     for (let viewTemplate of UrlbarView.dynamicViewTemplatesByName.values()) {
108       if (viewTemplate.stylesheet) {
109         addDynamicStylesheet(this.window, viewTemplate.stylesheet);
110       }
111     }
112   }
114   get oneOffSearchButtons() {
115     if (!this.#oneOffSearchButtons) {
116       this.#oneOffSearchButtons = new lazy.UrlbarSearchOneOffs(this);
117       this.#oneOffSearchButtons.addEventListener(
118         "SelectedOneOffButtonChanged",
119         this
120       );
121     }
122     return this.#oneOffSearchButtons;
123   }
125   /**
126    * Whether the panel is open.
127    *
128    * @returns {boolean}
129    */
130   get isOpen() {
131     return this.input.hasAttribute("open");
132   }
134   get allowEmptySelection() {
135     let { heuristicResult } = this.#queryContext || {};
136     return !heuristicResult || !this.#shouldShowHeuristic(heuristicResult);
137   }
139   get selectedRowIndex() {
140     if (!this.isOpen) {
141       return -1;
142     }
144     let selectedRow = this.#getSelectedRow();
146     if (!selectedRow) {
147       return -1;
148     }
150     return selectedRow.result.rowIndex;
151   }
153   set selectedRowIndex(val) {
154     if (!this.isOpen) {
155       throw new Error(
156         "UrlbarView: Cannot select an item if the view isn't open."
157       );
158     }
160     if (val < 0) {
161       this.#selectElement(null);
162       return;
163     }
165     let items = Array.from(this.#rows.children).filter(r =>
166       this.#isElementVisible(r)
167     );
168     if (val >= items.length) {
169       throw new Error(`UrlbarView: Index ${val} is out of bounds.`);
170     }
172     // Select the first selectable element inside the row. If it doesn't
173     // contain a selectable element, clear the selection.
174     let row = items[val];
175     let element = this.#getNextSelectableElement(row);
176     if (this.#getRowFromElement(element) != row) {
177       element = null;
178     }
180     this.#selectElement(element);
181   }
183   get selectedElementIndex() {
184     if (!this.isOpen || !this.#selectedElement) {
185       return -1;
186     }
188     return this.#selectedElement.elementIndex;
189   }
191   /**
192    * @returns {UrlbarResult}
193    *   The currently selected result.
194    */
195   get selectedResult() {
196     if (!this.isOpen) {
197       return null;
198     }
200     return this.#getSelectedRow()?.result;
201   }
203   /**
204    * @returns {Element}
205    *   The currently selected element.
206    */
207   get selectedElement() {
208     if (!this.isOpen) {
209       return null;
210     }
212     return this.#selectedElement;
213   }
215   /**
216    * @returns {boolean}
217    *   Whether the SPACE key should activate the selected element (if any)
218    *   instead of adding to the input value.
219    */
220   shouldSpaceActivateSelectedElement() {
221     // We want SPACE to activate buttons only.
222     if (this.selectedElement?.getAttribute("role") != "button") {
223       return false;
224     }
225     // Make sure the input field is empty, otherwise the user might want to add
226     // a space to the current search string. As it stands, selecting a button
227     // should always clear the input field, so this is just an extra safeguard.
228     if (this.input.value) {
229       return false;
230     }
231     return true;
232   }
234   /**
235    * Clears selection, regardless of view status.
236    */
237   clearSelection() {
238     this.#selectElement(null, { updateInput: false });
239   }
241   /**
242    * @returns {number}
243    *   The number of visible results in the view.  Note that this may be larger
244    *   than the number of results in the current query context since the view
245    *   may be showing stale results.
246    */
247   get visibleRowCount() {
248     let sum = 0;
249     for (let row of this.#rows.children) {
250       sum += Number(this.#isElementVisible(row));
251     }
252     return sum;
253   }
255   /**
256    * Returns the result of the row containing the given element, or the result
257    * of the element if it itself is a row.
258    *
259    * @param {Element} element
260    *   An element in the view.
261    * @returns {UrlbarResult}
262    *   The result of the element's row.
263    */
264   getResultFromElement(element) {
265     return element?.classList.contains("urlbarView-result-menuitem")
266       ? this.#resultMenuResult
267       : this.#getRowFromElement(element)?.result;
268   }
270   /**
271    * @param {number} index
272    *   The index from which to fetch the result.
273    * @returns {UrlbarResult}
274    *   The result at `index`. Null if the view is closed or if there are no
275    *   results.
276    */
277   getResultAtIndex(index) {
278     if (
279       !this.isOpen ||
280       !this.#rows.children.length ||
281       index >= this.#rows.children.length
282     ) {
283       return null;
284     }
286     return this.#rows.children[index].result;
287   }
289   /**
290    * @param {UrlbarResult} result A result.
291    * @returns {boolean} True if the given result is selected.
292    */
293   resultIsSelected(result) {
294     if (this.selectedRowIndex < 0) {
295       return false;
296     }
298     return result.rowIndex == this.selectedRowIndex;
299   }
301   /**
302    * Moves the view selection forward or backward.
303    *
304    * @param {number} amount
305    *   The number of steps to move.
306    * @param {object} options Options object
307    * @param {boolean} [options.reverse]
308    *   Set to true to select the previous item. By default the next item
309    *   will be selected.
310    * @param {boolean} [options.userPressedTab]
311    *   Set to true if the user pressed Tab to select a result. Default false.
312    */
313   selectBy(amount, { reverse = false, userPressedTab = false } = {}) {
314     if (!this.isOpen) {
315       throw new Error(
316         "UrlbarView: Cannot select an item if the view isn't open."
317       );
318     }
320     // Freeze results as the user is interacting with them, unless we are
321     // deferring events while waiting for critical results.
322     if (!this.input.eventBufferer.isDeferringEvents) {
323       this.controller.cancelQuery();
324     }
326     if (!userPressedTab) {
327       let { selectedRowIndex } = this;
328       let end = this.visibleRowCount - 1;
329       if (selectedRowIndex == -1) {
330         this.selectedRowIndex = reverse ? end : 0;
331         return;
332       }
333       let endReached = selectedRowIndex == (reverse ? 0 : end);
334       if (endReached) {
335         if (this.allowEmptySelection) {
336           this.#selectElement(null);
337         } else {
338           this.selectedRowIndex = reverse ? end : 0;
339         }
340         return;
341       }
343       let index = Math.min(end, selectedRowIndex + amount * (reverse ? -1 : 1));
344       // When navigating with arrow keys we skip rows that contain
345       // global actions.
346       if (
347         this.#rows.children[index]?.result.providerName ==
348           lazy.UrlbarProviderGlobalActions.name &&
349         this.#rows.children.length > 2
350       ) {
351         index = index + (reverse ? -1 : 1);
352       }
353       this.selectedRowIndex = Math.max(0, index);
354       return;
355     }
357     // Tab key handling below.
359     // Do not set aria-activedescendant if the user is moving to a
360     // tab-to-search result with the Tab key. If
361     // accessibility.tabToSearch.announceResults is set, the tab-to-search
362     // result was announced to the user as they typed. We don't set
363     // aria-activedescendant so the user doesn't think they have to press
364     // Enter to enter search mode. See bug 1647929.
365     const isSkippableTabToSearchAnnounce = selectedElt => {
366       let result = this.getResultFromElement(selectedElt);
367       let skipAnnouncement =
368         result?.providerName == "TabToSearch" &&
369         !this.#announceTabToSearchOnSelection &&
370         lazy.UrlbarPrefs.get("accessibility.tabToSearch.announceResults");
371       if (skipAnnouncement) {
372         // Once we skip setting aria-activedescendant once, we should not skip
373         // it again if the user returns to that result.
374         this.#announceTabToSearchOnSelection = true;
375       }
376       return skipAnnouncement;
377     };
379     let selectedElement = this.#selectedElement;
381     // We cache the first and last rows since they will not change while
382     // selectBy is running.
383     let firstSelectableElement = this.getFirstSelectableElement();
384     // getLastSelectableElement will not return an element that is over
385     // maxResults and thus may be hidden and not selectable.
386     let lastSelectableElement = this.getLastSelectableElement();
388     if (!selectedElement) {
389       selectedElement = reverse
390         ? lastSelectableElement
391         : firstSelectableElement;
392       this.#selectElement(selectedElement, {
393         setAccessibleFocus: !isSkippableTabToSearchAnnounce(selectedElement),
394       });
395       return;
396     }
397     let endReached = reverse
398       ? selectedElement == firstSelectableElement
399       : selectedElement == lastSelectableElement;
400     if (endReached) {
401       if (this.allowEmptySelection) {
402         selectedElement = null;
403       } else {
404         selectedElement = reverse
405           ? lastSelectableElement
406           : firstSelectableElement;
407       }
408       this.#selectElement(selectedElement, {
409         setAccessibleFocus: !isSkippableTabToSearchAnnounce(selectedElement),
410       });
411       return;
412     }
414     while (amount-- > 0) {
415       let next = reverse
416         ? this.#getPreviousSelectableElement(selectedElement)
417         : this.#getNextSelectableElement(selectedElement);
418       if (!next) {
419         break;
420       }
421       selectedElement = next;
422     }
423     this.#selectElement(selectedElement, {
424       setAccessibleFocus: !isSkippableTabToSearchAnnounce(selectedElement),
425     });
426   }
428   async acknowledgeFeedback(result) {
429     let row = this.#rows.children[result.rowIndex];
430     if (!row) {
431       return;
432     }
434     let l10n = { id: "firefox-suggest-feedback-acknowledgment" };
435     await this.#l10nCache.ensure(l10n);
436     if (row.result != result) {
437       return;
438     }
440     let { value } = this.#l10nCache.get(l10n);
441     row.setAttribute("feedback-acknowledgment", value);
442     this.window.A11yUtils.announce({
443       raw: value,
444       source: row._content.closest("[role=option]"),
445     });
446   }
448   /**
449    * Replaces the given result's row with a dismissal-acknowledgment tip.
450    *
451    * @param {UrlbarResult} result
452    *   The result that was dismissed.
453    * @param {object} titleL10n
454    *   The localization object shown as dismissed feedback.
455    */
456   #acknowledgeDismissal(result, titleL10n) {
457     let row = this.#rows.children[result.rowIndex];
458     if (!row || row.result != result) {
459       return;
460     }
462     // The row is no longer selectable. It's necessary to clear the selection
463     // before replacing the row because replacement will likely create a new
464     // `urlbarView-row-inner`, which will interfere with the ability of
465     // `#selectElement()` to clear the old selection after replacement, below.
466     let isSelected = this.#getSelectedRow() == row;
467     if (isSelected) {
468       this.#selectElement(null, { updateInput: false });
469     }
470     this.#setRowSelectable(row, false);
472     // Replace the row with a dismissal acknowledgment tip.
473     let tip = Object.assign(
474       new lazy.UrlbarResult(
475         lazy.UrlbarUtils.RESULT_TYPE.TIP,
476         lazy.UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL,
477         {
478           type: "dismissalAcknowledgment",
479           titleL10n,
480           buttons: [{ l10n: { id: "urlbar-search-tips-confirm-short" } }],
481           icon: "chrome://branding/content/icon32.png",
482         }
483       ),
484       { rowLabel: this.#rowLabel(row) }
485     );
486     this.#updateRow(row, tip);
487     this.#updateIndices();
489     // If the row was selected, move the selection to the tip button.
490     if (isSelected) {
491       this.#selectElement(this.#getNextSelectableElement(row), {
492         updateInput: false,
493       });
494     }
495   }
497   removeAccessibleFocus() {
498     this.#setAccessibleFocus(null);
499   }
501   clear() {
502     this.#rows.textContent = "";
503     this.panel.setAttribute("noresults", "true");
504     this.clearSelection();
505     this.visibleResults = [];
506   }
508   /**
509    * Hide the popup that shows the Urlbar results. The popup is still
510    * considered "open", this will not trigger abandonment telemetry
511    * but will not be shown to the user.
512    */
513   hideTemporarily() {
514     this.panel.toggleAttribute("hide-temporarily", true);
515   }
517   /**
518    * Show the Urlbar results popup after being hidden by
519    * `hideTemporarily`
520    */
521   restoreVisibility() {
522     this.panel.toggleAttribute("hide-temporarily", false);
523   }
525   /**
526    * Closes the view, cancelling the query if necessary.
527    *
528    * @param {object} options Options object
529    * @param {boolean} [options.elementPicked]
530    *   True if the view is being closed because a result was picked.
531    * @param {boolean} [options.showFocusBorder]
532    *   True if the Urlbar focus border should be shown after the view is closed.
533    */
534   close({ elementPicked = false, showFocusBorder = true } = {}) {
535     this.controller.cancelQuery();
536     // We do not show the focus border when an element is picked because we'd
537     // flash it just before the input is blurred. The focus border is removed
538     // in UrlbarInput._on_blur.
539     if (!elementPicked && showFocusBorder) {
540       this.input.removeAttribute("suppress-focus-border");
541     }
543     if (!this.isOpen) {
544       return;
545     }
547     this.#inputWidthOnLastClose = getBoundsWithoutFlushing(
548       this.input.textbox
549     ).width;
551     // We exit search mode preview on close since the result previewing it is
552     // implicitly unselected.
553     if (this.input.searchMode?.isPreview) {
554       this.input.searchMode = null;
555       this.window.gBrowser.userTypedValue = null;
556     }
558     this.resultMenu.hidePopup();
559     this.removeAccessibleFocus();
560     this.input.inputField.setAttribute("aria-expanded", "false");
561     this.#openPanelInstance = null;
562     this.#previousTabToSearchEngine = null;
564     this.input.removeAttribute("open");
565     this.input.endLayoutExtend();
567     // Search Tips can open the view without the Urlbar being focused. If the
568     // tip is ignored (e.g. the page content is clicked or the window loses
569     // focus) we should discard the telemetry event created when the view was
570     // opened.
571     if (!this.input.focused && !elementPicked) {
572       this.controller.engagementEvent.discard();
573       this.controller.engagementEvent.record(null, {});
574     }
576     this.window.removeEventListener("resize", this);
577     this.window.removeEventListener("blur", this);
579     this.controller.notify(this.controller.NOTIFICATIONS.VIEW_CLOSE);
581     // Revoke icon blob URLs that were created while the view was open.
582     if (this.#blobUrlsByResultUrl) {
583       for (let blobUrl of this.#blobUrlsByResultUrl.values()) {
584         URL.revokeObjectURL(blobUrl);
585       }
586       this.#blobUrlsByResultUrl.clear();
587     }
589     if (this.#isShowingZeroPrefix) {
590       if (elementPicked) {
591         Glean.urlbarZeroprefix.engagement.add(1);
592       } else {
593         Glean.urlbarZeroprefix.abandonment.add(1);
594       }
595       this.#setIsShowingZeroPrefix(false);
596     }
597   }
599   /**
600    * This can be used to open the view automatically as a consequence of
601    * specific user actions. For Top Sites searches (without a search string)
602    * the view is opened only for mouse or keyboard interactions.
603    * If the user abandoned a search (there is a search string) the view is
604    * reopened, and we try to use cached results to reduce flickering, then a new
605    * query is started to refresh results.
606    *
607    * @param {object} options Options object
608    * @param {Event} options.event The event associated with the call to autoOpen.
609    * @param {boolean} [options.suppressFocusBorder] If true, we hide the focus border
610    *        when the panel is opened. This is true by default to avoid flashing
611    *        the border when the unfocused address bar is clicked.
612    * @returns {boolean} Whether the view was opened.
613    */
614   autoOpen({ event, suppressFocusBorder = true }) {
615     if (this.#pickSearchTipIfPresent(event)) {
616       return false;
617     }
619     if (!event) {
620       return false;
621     }
623     let queryOptions = { event };
624     if (
625       !this.input.value ||
626       this.input.getAttribute("pageproxystate") == "valid"
627     ) {
628       if (!this.isOpen && ["mousedown", "command"].includes(event.type)) {
629         // Try to reuse the cached top-sites context. If it's not cached, then
630         // there will be a gap of time between when the input is focused and
631         // when the view opens that can be perceived as flicker.
632         if (!this.input.searchMode && this.queryContextCache.topSitesContext) {
633           this.onQueryResults(this.queryContextCache.topSitesContext);
634         }
635         this.input.startQuery(queryOptions);
636         if (suppressFocusBorder) {
637           this.input.toggleAttribute("suppress-focus-border", true);
638         }
639         return true;
640       }
641       return false;
642     }
644     // Reopen abandoned searches only if the input is focused.
645     if (!this.input.focused) {
646       return false;
647     }
649     // Tab switch is the only case where we requery if the view is open, because
650     // switching tabs doesn't necessarily close the view.
651     if (this.isOpen && event.type != "tabswitch") {
652       return false;
653     }
655     // We can reuse the current rows as they are if the input value and width
656     // haven't changed since the view was closed. The width check is related to
657     // row overflow: If we reuse the current rows, overflow and underflow events
658     // won't fire even if the view's width has changed and there are rows that
659     // do actually overflow or underflow. That means previously overflowed rows
660     // may unnecessarily show the overflow gradient, for example.
661     if (
662       this.#rows.firstElementChild &&
663       this.#queryContext.searchString == this.input.value &&
664       this.#inputWidthOnLastClose ==
665         getBoundsWithoutFlushing(this.input.textbox).width
666     ) {
667       // We can reuse the current rows.
668       queryOptions.allowAutofill = this.#queryContext.allowAutofill;
669     } else {
670       // To reduce flickering, try to reuse a cached UrlbarQueryContext. The
671       // overflow problem is addressed in this case because `onQueryResults()`
672       // starts the regular view-update process, during which the overflow state
673       // is reset on all rows.
674       let cachedQueryContext = this.queryContextCache.get(this.input.value);
675       if (cachedQueryContext) {
676         this.onQueryResults(cachedQueryContext);
677       }
678     }
680     // Disable autofill when search terms persist, as users are likely refining
681     // their search rather than navigating to a website matching the search
682     // term. If they do want to navigate directly, users can modify their
683     // search, which resets persistence and re-enables autofill.
684     let state = this.input.getBrowserState(
685       this.window.gBrowser.selectedBrowser
686     );
687     if (state.persist?.shouldPersist) {
688       queryOptions.allowAutofill = false;
689     }
691     this.controller.engagementEvent.discard();
692     queryOptions.searchString = this.input.value;
693     queryOptions.autofillIgnoresSelection = true;
694     queryOptions.event.interactionType = "returned";
696     // A search tip can be cached in results if it was shown but ignored
697     // by the user. Don't open the panel if a search tip is present or it
698     // will cause a flicker since it'll be quickly overwritten (Bug 1812261).
699     if (
700       this.#queryContext?.results?.length &&
701       this.#queryContext.results[0].type != lazy.UrlbarUtils.RESULT_TYPE.TIP
702     ) {
703       this.#openPanel();
704     }
706     // If we had cached results, this will just refresh them, avoiding results
707     // flicker, otherwise there may be some noise.
708     this.input.startQuery(queryOptions);
709     if (suppressFocusBorder) {
710       this.input.toggleAttribute("suppress-focus-border", true);
711     }
712     return true;
713   }
715   // UrlbarController listener methods.
716   onQueryStarted(queryContext) {
717     this.#queryWasCancelled = false;
718     this.#queryUpdatedResults = false;
719     this.#openPanelInstance = null;
720     if (!queryContext.searchString) {
721       this.#previousTabToSearchEngine = null;
722     }
723     this.#startRemoveStaleRowsTimer();
725     // Cache l10n strings so they're available when we update the view as
726     // results arrive. This is a no-op for strings that are already cached.
727     // `#cacheL10nStrings` is async but we don't await it because doing so would
728     // require view updates to be async. Instead we just opportunistically cache
729     // and if there's a cache miss we fall back to `l10n.setAttributes`.
730     this.#cacheL10nStrings();
731   }
733   onQueryCancelled() {
734     this.#queryWasCancelled = true;
735     this.#cancelRemoveStaleRowsTimer();
736   }
738   onQueryFinished(queryContext) {
739     this.#cancelRemoveStaleRowsTimer();
740     if (this.#queryWasCancelled) {
741       return;
742     }
744     // At this point the query finished successfully. If it returned some
745     // results, remove stale rows. Otherwise remove all rows.
746     if (this.#queryUpdatedResults) {
747       this.#removeStaleRows();
748     } else {
749       this.clear();
750     }
752     // Now that the view has finished updating for this query, call
753     // `#setIsShowingZeroPrefix()`.
754     this.#setIsShowingZeroPrefix(!queryContext.searchString);
756     // If the query returned results, we're done.
757     if (this.#queryUpdatedResults) {
758       return;
759     }
761     // If search mode isn't active, close the view.
762     if (!this.input.searchMode) {
763       this.close();
764       return;
765     }
767     // Search mode is active.  If the one-offs should be shown, make sure they
768     // are enabled and show the view.
769     let openPanelInstance = (this.#openPanelInstance = {});
770     this.oneOffSearchButtons.willHide().then(willHide => {
771       if (!willHide && openPanelInstance == this.#openPanelInstance) {
772         this.oneOffSearchButtons.enable(true);
773         this.#openPanel();
774       }
775     });
776   }
778   onQueryResults(queryContext) {
779     this.queryContextCache.put(queryContext);
780     this.#queryContext = queryContext;
782     if (!this.isOpen) {
783       this.clear();
784     }
785     this.#queryUpdatedResults = true;
786     this.#updateResults();
788     let firstResult = queryContext.results[0];
790     if (queryContext.lastResultCount == 0) {
791       // Clear the selection when we get a new set of results.
792       this.#selectElement(null, {
793         updateInput: false,
794       });
796       // Show the one-off search buttons unless any of the following are true:
797       //  * The first result is a search tip
798       //  * The search string is empty
799       //  * The search string starts with an `@` or a search restriction
800       //    character
801       this.oneOffSearchButtons.enable(
802         (firstResult.providerName != "UrlbarProviderSearchTips" ||
803           queryContext.trimmedSearchString) &&
804           queryContext.trimmedSearchString[0] != "@" &&
805           (queryContext.trimmedSearchString[0] !=
806             lazy.UrlbarTokenizer.RESTRICT.SEARCH ||
807             queryContext.trimmedSearchString.length != 1)
808       );
809     }
811     if (!this.#selectedElement && !this.oneOffSearchButtons.selectedButton) {
812       if (firstResult.heuristic) {
813         // Select the heuristic result.  The heuristic may not be the first
814         // result added, which is why we do this check here when each result is
815         // added and not above.
816         if (this.#shouldShowHeuristic(firstResult)) {
817           this.#selectElement(this.getFirstSelectableElement(), {
818             updateInput: false,
819             setAccessibleFocus:
820               this.controller._userSelectionBehavior == "arrow",
821           });
822         } else {
823           this.input.setResultForCurrentValue(firstResult);
824         }
825       } else if (
826         firstResult.payload.providesSearchMode &&
827         queryContext.trimmedSearchString != "@"
828       ) {
829         // Filtered keyword offer results can be in the first position but not
830         // be heuristic results. We do this so the user can press Tab to select
831         // them, resembling tab-to-search. In that case, the input value is
832         // still associated with the first result.
833         this.input.setResultForCurrentValue(firstResult);
834       }
835     }
837     // Announce tab-to-search results to screen readers as the user types.
838     // Check to make sure we don't announce the same engine multiple times in
839     // a row.
840     let secondResult = queryContext.results[1];
841     if (
842       secondResult?.providerName == "TabToSearch" &&
843       lazy.UrlbarPrefs.get("accessibility.tabToSearch.announceResults") &&
844       this.#previousTabToSearchEngine != secondResult.payload.engine
845     ) {
846       let engine = secondResult.payload.engine;
847       this.window.A11yUtils.announce({
848         id: secondResult.payload.isGeneralPurposeEngine
849           ? "urlbar-result-action-before-tabtosearch-web"
850           : "urlbar-result-action-before-tabtosearch-other",
851         args: { engine },
852       });
853       this.#previousTabToSearchEngine = engine;
854       // Do not set aria-activedescendant when the user tabs to the result
855       // because we already announced it.
856       this.#announceTabToSearchOnSelection = false;
857     }
859     // If we update the selected element, a new unique ID is generated for it.
860     // We need to ensure that aria-activedescendant reflects this new ID.
861     if (this.#selectedElement && !this.oneOffSearchButtons.selectedButton) {
862       let aadID = this.input.inputField.getAttribute("aria-activedescendant");
863       if (aadID && !this.document.getElementById(aadID)) {
864         this.#setAccessibleFocus(this.#selectedElement);
865       }
866     }
868     this.#openPanel();
870     if (firstResult.heuristic) {
871       // The heuristic result may be a search alias result, so apply formatting
872       // if necessary.  Conversely, the heuristic result of the previous query
873       // may have been an alias, so remove formatting if necessary.
874       this.input.formatValue();
875     }
877     if (queryContext.deferUserSelectionProviders.size) {
878       // DeferUserSelectionProviders block user selection until the result is
879       // shown, so it's the view's duty to remove them.
880       // Doing it sooner, like when the results are added by the provider,
881       // would not suffice because there's still a delay before those results
882       // reach the view.
883       queryContext.results.forEach(r => {
884         queryContext.deferUserSelectionProviders.delete(r.providerName);
885       });
886     }
887   }
889   /**
890    * Handles removing a result from the view when it is removed from the query,
891    * and attempts to select the new result on the same row.
892    *
893    * This assumes that the result rows are in index order.
894    *
895    * @param {number} index The index of the result that has been removed.
896    */
897   onQueryResultRemoved(index) {
898     let rowToRemove = this.#rows.children[index];
900     let { result } = rowToRemove;
901     if (result.acknowledgeDismissalL10n) {
902       // Replace the result's row with a dismissal acknowledgment tip.
903       this.#acknowledgeDismissal(result, result.acknowledgeDismissalL10n);
904       return;
905     }
907     let updateSelection = rowToRemove == this.#getSelectedRow();
908     rowToRemove.remove();
909     this.#updateIndices();
911     if (!updateSelection) {
912       return;
913     }
914     // Select the row at the same index, if possible.
915     let newSelectionIndex = index;
916     if (index >= this.#queryContext.results.length) {
917       newSelectionIndex = this.#queryContext.results.length - 1;
918     }
919     if (newSelectionIndex >= 0) {
920       this.selectedRowIndex = newSelectionIndex;
921     }
922   }
924   openResultMenu(result, anchor) {
925     this.#resultMenuResult = result;
927     if (AppConstants.platform == "macosx") {
928       // `openPopup(anchor)` doesn't use a native context menu, which is very
929       // noticeable on Mac. Use `openPopup()` with x and y coords instead. See
930       // bug 1831760 and bug 1710459.
931       let rect = getBoundsWithoutFlushing(anchor);
932       rect = this.window.windowUtils.toScreenRectInCSSUnits(
933         rect.x,
934         rect.y,
935         rect.width,
936         rect.height
937       );
938       this.resultMenu.openPopup(null, {
939         x: rect.x,
940         y: rect.y + rect.height,
941       });
942     } else {
943       this.resultMenu.openPopup(anchor, "bottomright topright");
944     }
946     anchor.toggleAttribute("open", true);
947     let listener = event => {
948       if (event.target == this.resultMenu) {
949         anchor.removeAttribute("open");
950         this.resultMenu.removeEventListener("popuphidden", listener);
951       }
952     };
953     this.resultMenu.addEventListener("popuphidden", listener);
954   }
956   /**
957    * Clears the result menu commands cache, removing the cached commands for all
958    * results. This is useful when the commands for one or more results change
959    * while the results remain in the view.
960    */
961   invalidateResultMenuCommands() {
962     this.#resultMenuCommands = new WeakMap();
963   }
965   /**
966    * Passes DOM events for the view to the on_<event type> methods.
967    *
968    * @param {Event} event
969    *   DOM event from the <view>.
970    */
971   handleEvent(event) {
972     let methodName = "on_" + event.type;
973     if (methodName in this) {
974       this[methodName](event);
975     } else {
976       throw new Error("Unrecognized UrlbarView event: " + event.type);
977     }
978   }
980   static dynamicViewTemplatesByName = new Map();
982   /**
983    * Registers the view template for a dynamic result type.  A view template is
984    * a plain object that describes the DOM subtree for a dynamic result type.
985    * When a dynamic result is shown in the urlbar view, its type's view template
986    * is used to construct the part of the view that represents the result.
987    *
988    * The specified view template will be available to the urlbars in all current
989    * and future browser windows until it is unregistered.  A given dynamic
990    * result type has at most one view template.  If this method is called for a
991    * dynamic result type more than once, the view template in the last call
992    * overrides those in previous calls.
993    *
994    * @param {string} name
995    *   The view template will be registered for the dynamic result type with
996    *   this name.
997    * @param {object} viewTemplate
998    *   This object describes the DOM subtree for the given dynamic result type.
999    *   It should be a tree-like nested structure with each object in the nesting
1000    *   representing a DOM element to be created.  This tree-like structure is
1001    *   achieved using the `children` property described below.  Each object in
1002    *   the structure may include the following properties:
1003    *
1004    *   {string} name
1005    *     The name of the object.  It is required for all objects in the
1006    *     structure except the root object and serves two important functions:
1007    *     (1) The element created for the object will automatically have a class
1008    *         named `urlbarView-dynamic-${dynamicType}-${name}`, where
1009    *         `dynamicType` is the name of the dynamic result type.  The element
1010    *         will also automatically have an attribute "name" whose value is
1011    *         this name.  The class and attribute allow the element to be styled
1012    *         in CSS.
1013    *     (2) The name is used when updating the view.  See
1014    *         UrlbarProvider.getViewUpdate().
1015    *     Names must be unique within a view template, but they don't need to be
1016    *     globally unique.  i.e., two different view templates can use the same
1017    *     names, and other DOM elements can use the same names in their IDs and
1018    *     classes.  The name also suffixes the dynamic element's ID: an element
1019    *     with name `data` will get the ID `urlbarView-row-{unique number}-data`.
1020    *     If there is no name provided for the root element, the root element
1021    *     will not get an ID.
1022    *   {string} tag
1023    *     The tag name of the object.  It is required for all objects in the
1024    *     structure except the root object and declares the kind of element that
1025    *     will be created for the object: span, div, img, etc.
1026    *   {object} [attributes]
1027    *     An optional mapping from attribute names to values.  For each
1028    *     name-value pair, an attribute is added to the element created for the
1029    *     object. The `id` attribute is reserved and cannot be set by the
1030    *     provider. Element IDs are passed back to the provider in getViewUpdate
1031    *     if they are needed.
1032    *   {array} [children]
1033    *     An optional list of children.  Each item in the array must be an object
1034    *     as described here.  For each item, a child element as described by the
1035    *     item is created and added to the element created for the parent object.
1036    *   {array} [classList]
1037    *     An optional list of classes.  Each class will be added to the element
1038    *     created for the object by calling element.classList.add().
1039    *   {boolean} [overflowable]
1040    *     If true, the element's overflow status will be tracked in order to
1041    *     fade it out when needed.
1042    *   {string} [stylesheet]
1043    *     An optional stylesheet URL.  This property is valid only on the root
1044    *     object in the structure.  The stylesheet will be loaded in all browser
1045    *     windows so that the dynamic result type view may be styled.
1046    */
1047   static addDynamicViewTemplate(name, viewTemplate) {
1048     this.dynamicViewTemplatesByName.set(name, viewTemplate);
1049     if (viewTemplate.stylesheet) {
1050       for (let window of lazy.BrowserWindowTracker.orderedWindows) {
1051         addDynamicStylesheet(window, viewTemplate.stylesheet);
1052       }
1053     }
1054   }
1056   /**
1057    * Unregisters the view template for a dynamic result type.
1058    *
1059    * @param {string} name
1060    *   The view template will be unregistered for the dynamic result type with
1061    *   this name.
1062    */
1063   static removeDynamicViewTemplate(name) {
1064     let viewTemplate = this.dynamicViewTemplatesByName.get(name);
1065     if (!viewTemplate) {
1066       return;
1067     }
1068     this.dynamicViewTemplatesByName.delete(name);
1069     if (viewTemplate.stylesheet) {
1070       for (let window of lazy.BrowserWindowTracker.orderedWindows) {
1071         removeDynamicStylesheet(window, viewTemplate.stylesheet);
1072       }
1073     }
1074   }
1076   // Private properties and methods below.
1077   #announceTabToSearchOnSelection;
1078   #blobUrlsByResultUrl = null;
1079   #inputWidthOnLastClose = 0;
1080   #l10nCache;
1081   #mousedownSelectedElement;
1082   #openPanelInstance;
1083   #oneOffSearchButtons;
1084   #previousTabToSearchEngine;
1085   #queryContext;
1086   #queryUpdatedResults;
1087   #queryWasCancelled;
1088   #removeStaleRowsTimer;
1089   #resultMenuResult;
1090   #resultMenuCommands;
1091   #rows;
1092   #rawSelectedElement;
1093   #zeroPrefixStopwatchInstance = null;
1095   /**
1096    * #rawSelectedElement may be disconnected from the DOM (e.g. it was remove()d)
1097    * but we want a connected #selectedElement usually. We don't use a WeakRef
1098    * because it would depend too much on GC timing.
1099    *
1100    * @returns {DOMElement} the selected element.
1101    */
1102   get #selectedElement() {
1103     return this.#rawSelectedElement?.isConnected
1104       ? this.#rawSelectedElement
1105       : null;
1106   }
1108   #createElement(name) {
1109     return this.document.createElementNS("http://www.w3.org/1999/xhtml", name);
1110   }
1112   #openPanel() {
1113     if (this.isOpen) {
1114       return;
1115     }
1116     this.controller.userSelectionBehavior = "none";
1118     this.panel.removeAttribute("action-override");
1120     this.#enableOrDisableRowWrap();
1122     this.input.inputField.setAttribute("aria-expanded", "true");
1124     this.input.toggleAttribute("suppress-focus-border", true);
1125     this.input.toggleAttribute("open", true);
1126     this.input.startLayoutExtend();
1128     this.window.addEventListener("resize", this);
1129     this.window.addEventListener("blur", this);
1131     this.controller.notify(this.controller.NOTIFICATIONS.VIEW_OPEN);
1133     if (lazy.UrlbarPrefs.get("closeOtherPanelsOnOpen")) {
1134       this.window.docShell.treeOwner
1135         .QueryInterface(Ci.nsIInterfaceRequestor)
1136         .getInterface(Ci.nsIAppWindow)
1137         .rollupAllPopups();
1138     }
1139   }
1141   #shouldShowHeuristic(result) {
1142     if (!result?.heuristic) {
1143       throw new Error("A heuristic result must be given");
1144     }
1145     return (
1146       !lazy.UrlbarPrefs.get("experimental.hideHeuristic") ||
1147       result.type == lazy.UrlbarUtils.RESULT_TYPE.TIP
1148     );
1149   }
1151   /**
1152    * Whether a result is a search suggestion.
1153    *
1154    * @param {UrlbarResult} result The result to examine.
1155    * @returns {boolean} Whether the result is a search suggestion.
1156    */
1157   #resultIsSearchSuggestion(result) {
1158     return Boolean(
1159       result &&
1160         result.type == lazy.UrlbarUtils.RESULT_TYPE.SEARCH &&
1161         result.payload.suggestion
1162     );
1163   }
1165   /**
1166    * Checks whether the given row index can be update to the result we want
1167    * to apply. This is used in #updateResults to avoid flickering of results, by
1168    * reusing existing rows.
1169    *
1170    * @param {number} rowIndex Index of the row to examine.
1171    * @param {UrlbarResult} result The result we'd like to apply.
1172    * @param {boolean} seenSearchSuggestion Whether the view update has
1173    *        encountered an existing row with a search suggestion result.
1174    * @returns {boolean} Whether the row can be updated to this result.
1175    */
1176   #rowCanUpdateToResult(rowIndex, result, seenSearchSuggestion) {
1177     // The heuristic result must always be current, thus it's always compatible.
1178     // Note that the `updateResults` code, when updating the selection, relies
1179     // on the fact the heuristic is the first selectable row.
1180     if (result.heuristic) {
1181       return true;
1182     }
1183     let row = this.#rows.children[rowIndex];
1184     // Don't replace a suggestedIndex result with a non-suggestedIndex result
1185     // or vice versa.
1186     if (result.hasSuggestedIndex != row.result.hasSuggestedIndex) {
1187       return false;
1188     }
1189     // Don't replace a suggestedIndex result with another suggestedIndex
1190     // result if the suggestedIndex values are different.
1191     if (
1192       result.hasSuggestedIndex &&
1193       result.suggestedIndex != row.result.suggestedIndex
1194     ) {
1195       return false;
1196     }
1197     // To avoid flickering results while typing, don't try to reuse results from
1198     // different providers.
1199     // For example user types "moz", provider A returns results much earlier
1200     // than provider B, but results from provider B stabilize in the view at the
1201     // end of the search. Typing the next letter "i" results from the faster
1202     // provider A would temporarily replace old results from provider B, just
1203     // to be replaced as soon as provider B returns its results.
1204     if (result.providerName != row.result.providerName) {
1205       return false;
1206     }
1207     let resultIsSearchSuggestion = this.#resultIsSearchSuggestion(result);
1208     // If the row is same type, just update it.
1209     if (
1210       resultIsSearchSuggestion == this.#resultIsSearchSuggestion(row.result)
1211     ) {
1212       return true;
1213     }
1214     // If the row has a different type, update it if we are in a compatible
1215     // index range.
1216     // In practice we don't want to overwrite a search suggestion with a non
1217     // search suggestion, but we allow the opposite.
1218     return resultIsSearchSuggestion && seenSearchSuggestion;
1219   }
1221   #updateResults() {
1222     // TODO: For now this just compares search suggestions to the rest, in the
1223     // future we should make it support any type of result. Or, even better,
1224     // results should be grouped, thus we can directly update groups.
1226     // Discard tentative exposures. This is analogous to marking the
1227     // hypothetical hidden rows of hidden-exposure results as stale.
1228     this.controller.engagementEvent.discardTentativeExposures();
1230     // Walk rows and find an insertion index for results. To avoid flicker, we
1231     // skip rows until we find one compatible with the result we want to apply.
1232     // If we couldn't find a compatible range, we'll just update.
1233     let results = this.#queryContext.results;
1234     if (results[0]?.heuristic && !this.#shouldShowHeuristic(results[0])) {
1235       // Exclude the heuristic.
1236       results = results.slice(1);
1237     }
1238     let rowIndex = 0;
1239     let resultIndex = 0;
1240     let visibleSpanCount = 0;
1241     let seenMisplacedResult = false;
1242     let seenSearchSuggestion = false;
1244     // We can have more rows than the visible ones.
1245     for (
1246       ;
1247       rowIndex < this.#rows.children.length && resultIndex < results.length;
1248       ++rowIndex
1249     ) {
1250       let row = this.#rows.children[rowIndex];
1251       if (this.#isElementVisible(row)) {
1252         visibleSpanCount += lazy.UrlbarUtils.getSpanForResult(row.result);
1253       }
1254       // Continue updating rows as long as we haven't encountered a new
1255       // suggestedIndex result that couldn't replace a current result.
1256       if (!seenMisplacedResult) {
1257         let result = results[resultIndex];
1258         seenSearchSuggestion =
1259           seenSearchSuggestion ||
1260           (!row.result.heuristic && this.#resultIsSearchSuggestion(row.result));
1261         if (
1262           this.#rowCanUpdateToResult(rowIndex, result, seenSearchSuggestion)
1263         ) {
1264           // We can replace the row's current result with the new one.
1265           if (result.isHiddenExposure) {
1266             this.controller.engagementEvent.addExposure(
1267               result,
1268               this.#queryContext
1269             );
1270           } else {
1271             this.#updateRow(row, result);
1272           }
1273           resultIndex++;
1274           continue;
1275         }
1276         if (
1277           (result.hasSuggestedIndex || row.result.hasSuggestedIndex) &&
1278           !result.isHiddenExposure
1279         ) {
1280           seenMisplacedResult = true;
1281         }
1282       }
1283       row.setAttribute("stale", "true");
1284     }
1286     // Mark all the remaining rows as stale and update the visible span count.
1287     // We include stale rows in the count because we should never show more than
1288     // maxResults spans at one time.  Later we'll remove stale rows and unhide
1289     // excess non-stale rows.
1290     for (; rowIndex < this.#rows.children.length; ++rowIndex) {
1291       let row = this.#rows.children[rowIndex];
1292       row.setAttribute("stale", "true");
1293       if (this.#isElementVisible(row)) {
1294         visibleSpanCount += lazy.UrlbarUtils.getSpanForResult(row.result);
1295       }
1296     }
1298     // Add remaining results, if we have fewer rows than results.
1299     for (; resultIndex < results.length; ++resultIndex) {
1300       let result = results[resultIndex];
1301       if (
1302         !seenMisplacedResult &&
1303         result.hasSuggestedIndex &&
1304         !result.isHiddenExposure
1305       ) {
1306         if (result.isSuggestedIndexRelativeToGroup) {
1307           // We can't know at this point what the right index of a group-
1308           // relative suggestedIndex result will be. To avoid all all possible
1309           // flicker, don't make it (and all rows after it) visible until stale
1310           // rows are removed.
1311           seenMisplacedResult = true;
1312         } else {
1313           // We need to check whether the new suggestedIndex result will end up
1314           // at its right index if we append it here. The "right" index is the
1315           // final index the result will occupy once the update is done and all
1316           // stale rows have been removed. We could use a more flexible
1317           // definition, but we use this strict one in order to avoid all
1318           // perceived flicker and movement of suggestedIndex results. Once
1319           // stale rows are removed, the final number of rows in the view will
1320           // be the new result count, so we base our arithmetic here on it.
1321           let finalIndex =
1322             result.suggestedIndex >= 0
1323               ? Math.min(results.length - 1, result.suggestedIndex)
1324               : Math.max(0, results.length + result.suggestedIndex);
1325           if (this.#rows.children.length != finalIndex) {
1326             seenMisplacedResult = true;
1327           }
1328         }
1329       }
1330       let newSpanCount =
1331         visibleSpanCount +
1332         lazy.UrlbarUtils.getSpanForResult(result, {
1333           includeHiddenExposures: true,
1334         });
1335       let canBeVisible =
1336         newSpanCount <= this.#queryContext.maxResults && !seenMisplacedResult;
1337       if (result.isHiddenExposure) {
1338         if (canBeVisible) {
1339           this.controller.engagementEvent.addExposure(
1340             result,
1341             this.#queryContext
1342           );
1343         } else {
1344           // Add a tentative exposure: The hypothetical row for this
1345           // hidden-exposure result can't be visible now, but as long as it were
1346           // not marked stale in a later update, it would be shown when stale
1347           // rows are removed.
1348           this.controller.engagementEvent.addTentativeExposure(
1349             result,
1350             this.#queryContext
1351           );
1352         }
1353         continue;
1354       }
1355       let row = this.#createRow();
1356       this.#updateRow(row, result);
1357       if (canBeVisible) {
1358         visibleSpanCount = newSpanCount;
1359       } else {
1360         // The new row must be hidden at first because the view is already
1361         // showing maxResults spans, or we encountered a new suggestedIndex
1362         // result that couldn't be placed in the right spot. We'll show it when
1363         // stale rows are removed.
1364         this.#setRowVisibility(row, false);
1365       }
1366       this.#rows.appendChild(row);
1367     }
1369     this.#updateIndices();
1370   }
1372   #createRow() {
1373     let item = this.#createElement("div");
1374     item.className = "urlbarView-row";
1375     item._elements = new Map();
1376     item._buttons = new Map();
1378     // A note about row selection. Any element in a row that can be selected
1379     // will have the `selectable` attribute set on it. For typical rows, the
1380     // selectable element is not the `.urlbarView-row` itself but rather the
1381     // `.urlbarView-row-inner` inside it. That's because the `.urlbarView-row`
1382     // also contains the row's buttons, which should not be selected when the
1383     // main part of the row -- `.urlbarView-row-inner` -- is selected.
1384     //
1385     // Since it's the row itself and not the row-inner that is a child of the
1386     // `role=listbox` element (the rows container, `this.#rows`), screen readers
1387     // will not automatically recognize the row-inner as a listbox option. To
1388     // compensate, we set `role=option` on the row-inner and `role=presentation`
1389     // on the row itself so that screen readers ignore it.
1390     item.setAttribute("role", "presentation");
1392     // These are used to cleanup result specific entities when row contents are
1393     // cleared to reuse the row for a different result.
1394     item._sharedAttributes = new Set(
1395       [...item.attributes].map(v => v.name).concat(["stale", "id"])
1396     );
1397     item._sharedClassList = new Set(item.classList);
1399     return item;
1400   }
1402   #createRowContent(item) {
1403     // The url is the only element that can wrap, thus all the other elements
1404     // are child of noWrap.
1405     let noWrap = this.#createElement("span");
1406     noWrap.className = "urlbarView-no-wrap";
1407     item._content.appendChild(noWrap);
1409     let favicon = this.#createElement("img");
1410     favicon.className = "urlbarView-favicon";
1411     noWrap.appendChild(favicon);
1412     item._elements.set("favicon", favicon);
1414     let typeIcon = this.#createElement("span");
1415     typeIcon.className = "urlbarView-type-icon";
1416     noWrap.appendChild(typeIcon);
1418     let tailPrefix = this.#createElement("span");
1419     tailPrefix.className = "urlbarView-tail-prefix";
1420     noWrap.appendChild(tailPrefix);
1421     item._elements.set("tailPrefix", tailPrefix);
1422     // tailPrefix holds text only for alignment purposes so it should never be
1423     // read to screen readers.
1424     tailPrefix.toggleAttribute("aria-hidden", true);
1426     let tailPrefixStr = this.#createElement("span");
1427     tailPrefixStr.className = "urlbarView-tail-prefix-string";
1428     tailPrefix.appendChild(tailPrefixStr);
1429     item._elements.set("tailPrefixStr", tailPrefixStr);
1431     let tailPrefixChar = this.#createElement("span");
1432     tailPrefixChar.className = "urlbarView-tail-prefix-char";
1433     tailPrefix.appendChild(tailPrefixChar);
1434     item._elements.set("tailPrefixChar", tailPrefixChar);
1436     let title = this.#createElement("span");
1437     title.classList.add("urlbarView-title", "urlbarView-overflowable");
1438     noWrap.appendChild(title);
1439     item._elements.set("title", title);
1441     let tagsContainer = this.#createElement("span");
1442     tagsContainer.classList.add("urlbarView-tags", "urlbarView-overflowable");
1443     noWrap.appendChild(tagsContainer);
1444     item._elements.set("tagsContainer", tagsContainer);
1446     let titleSeparator = this.#createElement("span");
1447     titleSeparator.className = "urlbarView-title-separator";
1448     noWrap.appendChild(titleSeparator);
1449     item._elements.set("titleSeparator", titleSeparator);
1451     let action = this.#createElement("span");
1452     action.className = "urlbarView-action";
1453     noWrap.appendChild(action);
1454     item._elements.set("action", action);
1456     let url = this.#createElement("span");
1457     url.className = "urlbarView-url";
1458     item._content.appendChild(url);
1459     item._elements.set("url", url);
1460   }
1462   /**
1463    * @param {Element} node
1464    *   The element to set attributes on.
1465    * @param {object} attributes
1466    *   Attribute names to values mapping.  For each name-value pair, an
1467    *   attribute is set on the element, except for `null` as a value which
1468    *   signals an attribute should be removed, and `undefined` in which case
1469    *   the attribute won't be set nor removed. The `id` attribute is reserved
1470    *   and cannot be set here.
1471    * @param {UrlbarResult} result
1472    *   The UrlbarResult displayed to the node. This is optional.
1473    */
1474   #setDynamicAttributes(node, attributes, result) {
1475     if (!attributes) {
1476       return;
1477     }
1478     for (let [name, value] of Object.entries(attributes)) {
1479       if (name == "id") {
1480         // IDs are managed externally to ensure they are unique.
1481         console.error(
1482           `Not setting id="${value}", as dynamic attributes may not include IDs.`
1483         );
1484         continue;
1485       }
1486       if (value === undefined) {
1487         continue;
1488       }
1489       if (value === null) {
1490         node.removeAttribute(name);
1491       } else if (typeof value == "boolean") {
1492         node.toggleAttribute(name, value);
1493       } else if (Blob.isInstance(value) && result) {
1494         node.setAttribute(name, this.#getBlobUrlForResult(result, value));
1495       } else {
1496         node.setAttribute(name, value);
1497       }
1498     }
1499   }
1501   #createRowContentForDynamicType(item, result) {
1502     let { dynamicType } = result.payload;
1503     let provider = lazy.UrlbarProvidersManager.getProvider(result.providerName);
1504     let viewTemplate =
1505       provider.getViewTemplate?.(result) ||
1506       UrlbarView.dynamicViewTemplatesByName.get(dynamicType);
1507     if (!viewTemplate) {
1508       console.error(`No viewTemplate found for ${result.providerName}`);
1509       return;
1510     }
1511     let classes = this.#buildViewForDynamicType(
1512       dynamicType,
1513       item._content,
1514       item._elements,
1515       viewTemplate
1516     );
1517     item.toggleAttribute("has-url", classes.has("urlbarView-url"));
1518     item.toggleAttribute("has-action", classes.has("urlbarView-action"));
1519     this.#setRowSelectable(item, item._content.hasAttribute("selectable"));
1520   }
1522   /**
1523    * Recursively builds a row's DOM for a dynamic result type.
1524    *
1525    * @param {string} type
1526    *   The name of the dynamic type.
1527    * @param {Element} parentNode
1528    *   The element being recursed into. Pass `row._content`
1529    *   (i.e., the row's `.urlbarView-row-inner`) to start with.
1530    * @param {Map} elementsByName
1531    *   The `row._elements` map.
1532    * @param {object} template
1533    *   The template object being recursed into. Pass the top-level template
1534    *   object to start with.
1535    * @param {Set} classes
1536    *   The CSS class names of all elements in the row's subtree are recursively
1537    *   collected in this set. Don't pass anything to start with so that the
1538    *   default argument, a new Set, is used.
1539    * @returns {Set}
1540    *   The `classes` set, which on return will contain the CSS class names of
1541    *   all elements in the row's subtree.
1542    */
1543   #buildViewForDynamicType(
1544     type,
1545     parentNode,
1546     elementsByName,
1547     template,
1548     classes = new Set()
1549   ) {
1550     // Set attributes on parentNode.
1551     this.#setDynamicAttributes(parentNode, template.attributes);
1553     // Add classes to parentNode's classList.
1554     if (template.classList) {
1555       parentNode.classList.add(...template.classList);
1556       for (let c of template.classList) {
1557         classes.add(c);
1558       }
1559     }
1560     if (template.overflowable) {
1561       parentNode.classList.add("urlbarView-overflowable");
1562     }
1563     if (template.name) {
1564       parentNode.setAttribute("name", template.name);
1565       elementsByName.set(template.name, parentNode);
1566     }
1568     // Recurse into children.
1569     for (let childTemplate of template.children || []) {
1570       let child = this.#createElement(childTemplate.tag);
1571       child.classList.add(`urlbarView-dynamic-${type}-${childTemplate.name}`);
1572       parentNode.appendChild(child);
1573       this.#buildViewForDynamicType(
1574         type,
1575         child,
1576         elementsByName,
1577         childTemplate,
1578         classes
1579       );
1580     }
1582     return classes;
1583   }
1585   #createRowContentForRichSuggestion(item) {
1586     item._content.toggleAttribute("selectable", true);
1588     let favicon = this.#createElement("img");
1589     favicon.className = "urlbarView-favicon";
1590     item._content.appendChild(favicon);
1591     item._elements.set("favicon", favicon);
1593     let body = this.#createElement("span");
1594     body.className = "urlbarView-row-body";
1595     item._content.appendChild(body);
1597     let top = this.#createElement("div");
1598     top.className = "urlbarView-row-body-top";
1599     body.appendChild(top);
1601     let noWrap = this.#createElement("div");
1602     noWrap.className = "urlbarView-row-body-top-no-wrap";
1603     top.appendChild(noWrap);
1604     item._elements.set("noWrap", noWrap);
1606     let title = this.#createElement("span");
1607     title.classList.add("urlbarView-title", "urlbarView-overflowable");
1608     noWrap.appendChild(title);
1609     item._elements.set("title", title);
1611     let titleSeparator = this.#createElement("span");
1612     titleSeparator.className = "urlbarView-title-separator";
1613     noWrap.appendChild(titleSeparator);
1614     item._elements.set("titleSeparator", titleSeparator);
1616     let action = this.#createElement("span");
1617     action.className = "urlbarView-action";
1618     noWrap.appendChild(action);
1619     item._elements.set("action", action);
1621     let url = this.#createElement("span");
1622     url.className = "urlbarView-url";
1623     top.appendChild(url);
1624     item._elements.set("url", url);
1626     let description = this.#createElement("div");
1627     description.classList.add("urlbarView-row-body-description");
1628     body.appendChild(description);
1629     item._elements.set("description", description);
1631     let bottom = this.#createElement("div");
1632     bottom.className = "urlbarView-row-body-bottom";
1633     body.appendChild(bottom);
1634     item._elements.set("bottom", bottom);
1635   }
1637   #addRowButtons(item, result) {
1638     for (let i = 0; i < result.payload.buttons?.length; i++) {
1639       this.#addRowButton(item, {
1640         name: i.toString(),
1641         ...result.payload.buttons[i],
1642       });
1643     }
1645     // TODO: `buttonText` is intended only for WebExtensions. We should remove
1646     // it and the WebExtensions urlbar API since we're no longer using it.
1647     if (result.payload.buttonText) {
1648       this.#addRowButton(item, {
1649         name: "tip",
1650         url: result.payload.buttonUrl,
1651       });
1652       item._buttons.get("tip").textContent = result.payload.buttonText;
1653     }
1655     if (this.#getResultMenuCommands(result)) {
1656       this.#addRowButton(item, {
1657         name: "menu",
1658         l10n: {
1659           id: result.showFeedbackMenu
1660             ? "urlbar-result-menu-button-feedback"
1661             : "urlbar-result-menu-button",
1662         },
1663         attributes: lazy.UrlbarPrefs.get("resultMenu.keyboardAccessible")
1664           ? null
1665           : {
1666               "keyboard-inaccessible": true,
1667             },
1668       });
1669     }
1670   }
1672   #addRowButton(item, { name, command, l10n, url, attributes }) {
1673     let button = this.#createElement("span");
1674     this.#setDynamicAttributes(button, attributes);
1675     button.id = `${item.id}-button-${name}`;
1676     button.classList.add("urlbarView-button", "urlbarView-button-" + name);
1677     button.setAttribute("role", "button");
1678     button.dataset.name = name;
1679     if (l10n) {
1680       this.#l10nCache.setElementL10n(button, l10n);
1681     }
1682     if (command) {
1683       button.dataset.command = command;
1684     }
1685     if (url) {
1686       button.dataset.url = url;
1687     }
1688     item._buttons.set(name, button);
1689     item.appendChild(button);
1690   }
1692   #createSecondaryAction(action, global = false) {
1693     let actionContainer = this.#createElement("div");
1694     actionContainer.classList.add("urlbarView-actions-container");
1696     let button = this.#createElement("span");
1697     button.classList.add("urlbarView-action-btn");
1698     if (global) {
1699       button.classList.add("urlbarView-global-action-btn");
1700     }
1701     if (action.classList) {
1702       button.classList.add(...action.classList);
1703     }
1704     button.setAttribute("role", "button");
1705     if (action.icon) {
1706       let icon = this.#createElement("img");
1707       icon.src = action.icon;
1708       button.appendChild(icon);
1709     }
1710     for (let key in action.dataset ?? {}) {
1711       button.dataset[key] = action.dataset[key];
1712     }
1713     button.dataset.action = action.key;
1714     button.dataset.providerName = action.providerName;
1716     let label = this.#createElement("span");
1717     if (action.l10nId) {
1718       this.#l10nCache.setElementL10n(label, {
1719         id: action.l10nId,
1720         args: action.l10nArgs,
1721       });
1722     } else {
1723       this.document.l10n.setAttributes(label, action.label, action.l10nArgs);
1724     }
1725     button.appendChild(label);
1726     actionContainer.appendChild(button);
1727     return actionContainer;
1728   }
1730   // eslint-disable-next-line complexity
1731   #updateRow(item, result) {
1732     let oldResult = item.result;
1733     let oldResultType = item.result?.type;
1734     let provider = lazy.UrlbarProvidersManager.getProvider(result.providerName);
1735     item.result = result;
1736     item.removeAttribute("stale");
1737     item.id = getUniqueId("urlbarView-row-");
1739     let needsNewContent =
1740       oldResultType === undefined ||
1741       (oldResultType == lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC) !=
1742         (result.type == lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC) ||
1743       (oldResultType == lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC &&
1744         result.type == lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC &&
1745         oldResult.payload.dynamicType != result.payload.dynamicType) ||
1746       // Dynamic results that implement getViewTemplate will
1747       // always need updating.
1748       provider?.getViewTemplate ||
1749       oldResult.isRichSuggestion != result.isRichSuggestion ||
1750       !!this.#getResultMenuCommands(result) != item._buttons.has("menu") ||
1751       !!oldResult.showFeedbackMenu != !!result.showFeedbackMenu ||
1752       !lazy.ObjectUtils.deepEqual(
1753         oldResult.payload.buttons,
1754         result.payload.buttons
1755       ) ||
1756       // Reusing a non-heuristic as a heuristic is risky as it may have DOM
1757       // nodes/attributes/classes that are normally not present in a heuristic
1758       // result. This may happen for example when switching from a zero-prefix
1759       // search not having a heuristic to a search string one.
1760       result.heuristic != oldResult.heuristic ||
1761       // Container switch-tab results have a more complex DOM content that is
1762       // only updated correctly by another switch-tab result.
1763       (oldResultType == lazy.UrlbarUtils.RESULT_TYPE.TAB_SWITCH &&
1764         lazy.UrlbarProviderOpenTabs.isContainerUserContextId(
1765           oldResult.payload.userContextId
1766         ) &&
1767         result.type != oldResultType) ||
1768       result.testForceNewContent;
1770     if (needsNewContent) {
1771       while (item.lastChild) {
1772         item.lastChild.remove();
1773       }
1774       item._elements.clear();
1775       item._buttons.clear();
1776       item._content = this.#createElement("span");
1777       item._content.className = "urlbarView-row-inner";
1778       item.appendChild(item._content);
1779       // Clear previously set attributes and classes that may refer to a
1780       // different result type.
1781       for (const attribute of item.attributes) {
1782         if (!item._sharedAttributes.has(attribute.name)) {
1783           item.removeAttribute(attribute.name);
1784         }
1785       }
1786       for (const className of item.classList) {
1787         if (!item._sharedClassList.has(className)) {
1788           item.classList.remove(className);
1789         }
1790       }
1791       if (item.result.type == lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC) {
1792         this.#createRowContentForDynamicType(item, result);
1793       } else if (result.isRichSuggestion) {
1794         this.#createRowContentForRichSuggestion(item, result);
1795       } else {
1796         this.#createRowContent(item, result);
1797       }
1798       this.#addRowButtons(item, result);
1799     }
1800     item._content.id = item.id + "-inner";
1802     let isFirstChild = item === this.#rows.children[0];
1803     let secAction = result.payload.action;
1804     let container = item.querySelector(".urlbarView-actions-container");
1805     item.toggleAttribute("secondary-action", !!secAction);
1806     if (secAction && !container) {
1807       item.appendChild(this.#createSecondaryAction(secAction, isFirstChild));
1808     } else if (
1809       secAction &&
1810       secAction.key != container.firstChild.dataset.action
1811     ) {
1812       item.replaceChild(
1813         this.#createSecondaryAction(secAction, isFirstChild),
1814         container
1815       );
1816     } else if (!secAction && container) {
1817       item.removeChild(container);
1818     }
1820     item.removeAttribute("feedback-acknowledgment");
1822     if (
1823       result.type == lazy.UrlbarUtils.RESULT_TYPE.SEARCH &&
1824       !result.payload.providesSearchMode &&
1825       !result.payload.inPrivateWindow
1826     ) {
1827       item.setAttribute("type", "search");
1828     } else if (result.type == lazy.UrlbarUtils.RESULT_TYPE.REMOTE_TAB) {
1829       item.setAttribute("type", "remotetab");
1830     } else if (result.type == lazy.UrlbarUtils.RESULT_TYPE.TAB_SWITCH) {
1831       item.setAttribute("type", "switchtab");
1832     } else if (result.type == lazy.UrlbarUtils.RESULT_TYPE.TIP) {
1833       item.setAttribute("type", "tip");
1834       item.setAttribute("tip-type", result.payload.type);
1836       // Due to role=button, the button and help icon can sometimes become
1837       // focused. We want to prevent that because the input should always be
1838       // focused instead. (This happens when input.search("", { focus: false })
1839       // is called, a tip is the first result but not heuristic, and the user
1840       // tabs the into the button from the navbar buttons. The input is skipped
1841       // and the focus goes straight to the tip button.)
1842       item.addEventListener("focus", () => this.input.focus(), true);
1844       if (
1845         result.providerName == "UrlbarProviderSearchTips" ||
1846         result.payload.type == "dismissalAcknowledgment"
1847       ) {
1848         // For a11y, we treat search tips as alerts. We use A11yUtils.announce
1849         // instead of role="alert" because role="alert" will only fire an alert
1850         // event when the alert (or something inside it) is the root of an
1851         // insertion. In this case, the entire tip result gets inserted into the
1852         // a11y tree as a single insertion, so no alert event would be fired.
1853         this.window.A11yUtils.announce(result.payload.titleL10n);
1854       }
1855     } else if (result.source == lazy.UrlbarUtils.RESULT_SOURCE.BOOKMARKS) {
1856       item.setAttribute("type", "bookmark");
1857     } else if (result.type == lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC) {
1858       item.setAttribute("type", "dynamic");
1859       this.#updateRowForDynamicType(item, result);
1860       return;
1861     } else if (result.providerName == "TabToSearch") {
1862       item.setAttribute("type", "tabtosearch");
1863     } else {
1864       item.setAttribute(
1865         "type",
1866         lazy.UrlbarUtils.searchEngagementTelemetryType(result)
1867       );
1868     }
1870     let favicon = item._elements.get("favicon");
1871     favicon.src = this.#iconForResult(result);
1873     let title = item._elements.get("title");
1874     this.#setResultTitle(result, title);
1876     if (result.payload.tail && result.payload.tailOffsetIndex > 0) {
1877       this.#fillTailSuggestionPrefix(item, result);
1878       title.setAttribute("aria-label", result.payload.suggestion);
1879       item.toggleAttribute("tail-suggestion", true);
1880     } else {
1881       item.removeAttribute("tail-suggestion");
1882       title.removeAttribute("aria-label");
1883     }
1885     this.#updateOverflowTooltip(title, result.title);
1887     let tagsContainer = item._elements.get("tagsContainer");
1888     if (tagsContainer) {
1889       tagsContainer.textContent = "";
1890       if (result.payload.tags && result.payload.tags.length) {
1891         tagsContainer.append(
1892           ...result.payload.tags.map((tag, i) => {
1893             const element = this.#createElement("span");
1894             element.className = "urlbarView-tag";
1895             this.#addTextContentWithHighlights(
1896               element,
1897               tag,
1898               result.payloadHighlights.tags[i]
1899             );
1900             return element;
1901           })
1902         );
1903       }
1904     }
1906     let action = item._elements.get("action");
1907     let actionSetter = null;
1908     let isVisitAction = false;
1909     let setURL = false;
1910     let isRowSelectable = true;
1911     switch (result.type) {
1912       case lazy.UrlbarUtils.RESULT_TYPE.TAB_SWITCH:
1913         // Hide chichlet when showing secondaryActions.
1914         if (!lazy.UrlbarPrefs.get("secondaryActions.switchToTab")) {
1915           actionSetter = () => {
1916             this.#setSwitchTabActionChiclet(result, action);
1917           };
1918         }
1919         setURL = true;
1920         break;
1921       case lazy.UrlbarUtils.RESULT_TYPE.REMOTE_TAB:
1922         actionSetter = () => {
1923           this.#l10nCache.removeElementL10n(action);
1924           action.textContent = result.payload.device;
1925         };
1926         setURL = true;
1927         break;
1928       case lazy.UrlbarUtils.RESULT_TYPE.SEARCH:
1929         if (result.payload.inPrivateWindow) {
1930           if (result.payload.isPrivateEngine) {
1931             actionSetter = () => {
1932               this.#l10nCache.setElementL10n(action, {
1933                 id: "urlbar-result-action-search-in-private-w-engine",
1934                 args: { engine: result.payload.engine },
1935               });
1936             };
1937           } else {
1938             actionSetter = () => {
1939               this.#l10nCache.setElementL10n(action, {
1940                 id: "urlbar-result-action-search-in-private",
1941               });
1942             };
1943           }
1944         } else if (result.providerName == "TabToSearch") {
1945           actionSetter = () => {
1946             this.#l10nCache.setElementL10n(action, {
1947               id: result.payload.isGeneralPurposeEngine
1948                 ? "urlbar-result-action-tabtosearch-web"
1949                 : "urlbar-result-action-tabtosearch-other-engine",
1950               args: { engine: result.payload.engine },
1951             });
1952           };
1953         } else if (!result.payload.providesSearchMode) {
1954           actionSetter = () => {
1955             this.#l10nCache.setElementL10n(action, {
1956               id: "urlbar-result-action-search-w-engine",
1957               args: { engine: result.payload.engine },
1958             });
1959           };
1960         }
1961         break;
1962       case lazy.UrlbarUtils.RESULT_TYPE.KEYWORD:
1963         isVisitAction = result.payload.input.trim() == result.payload.keyword;
1964         break;
1965       case lazy.UrlbarUtils.RESULT_TYPE.OMNIBOX:
1966         actionSetter = () => {
1967           this.#l10nCache.removeElementL10n(action);
1968           action.textContent = result.payload.content;
1969         };
1970         break;
1971       case lazy.UrlbarUtils.RESULT_TYPE.TIP:
1972         isRowSelectable = false;
1973         break;
1974       case lazy.UrlbarUtils.RESULT_TYPE.URL:
1975         if (result.providerName == "UrlbarProviderClipboard") {
1976           result.payload.displayUrl = "";
1977           actionSetter = () => {
1978             this.#l10nCache.setElementL10n(action, {
1979               id: "urlbar-result-action-visit-from-clipboard",
1980             });
1981           };
1982           title.toggleAttribute("is-url", true);
1984           let label = { id: "urlbar-result-action-visit-from-clipboard" };
1985           this.#l10nCache.ensure(label).then(() => {
1986             let { value } = this.#l10nCache.get(label);
1988             // We don't have to unset these attributes because, excluding heuristic results,
1989             // we never reuse results from different providers. Thus clipboard results can
1990             // only be reused by other clipboard results.
1991             title.setAttribute("aria-label", `${value}, ${title.innerText}`);
1992             action.setAttribute("aria-hidden", "true");
1993           });
1994           break;
1995         }
1996       // fall-through
1997       default:
1998         if (result.heuristic && !result.payload.title) {
1999           isVisitAction = true;
2000         } else if (
2001           (result.providerName != lazy.UrlbarProviderQuickSuggest.name ||
2002             result.payload.shouldShowUrl) &&
2003           !result.payload.providesSearchMode
2004         ) {
2005           setURL = true;
2006         }
2007         break;
2008     }
2010     this.#setRowSelectable(item, isRowSelectable);
2012     action.toggleAttribute("slide-in", result.providerName == "TabToSearch");
2014     item.toggleAttribute("pinned", !!result.payload.isPinned);
2016     let sponsored =
2017       result.payload.isSponsored &&
2018       result.type != lazy.UrlbarUtils.RESULT_TYPE.TAB_SWITCH &&
2019       result.providerName != lazy.UrlbarProviderQuickSuggest.name;
2020     item.toggleAttribute("sponsored", !!sponsored);
2021     if (sponsored) {
2022       actionSetter = () => {
2023         this.#l10nCache.setElementL10n(action, {
2024           id: "urlbar-result-action-sponsored",
2025         });
2026       };
2027     }
2029     item.toggleAttribute("rich-suggestion", !!result.isRichSuggestion);
2030     if (result.isRichSuggestion) {
2031       this.#updateRowForRichSuggestion(item, result);
2032     }
2034     item.toggleAttribute("has-url", setURL);
2035     let url = item._elements.get("url");
2036     if (setURL) {
2037       let displayedUrl = result.payload.displayUrl;
2038       let urlHighlights = result.payloadHighlights.displayUrl || [];
2039       if (lazy.UrlbarUtils.isTextDirectionRTL(displayedUrl, this.window)) {
2040         // Stripping the url prefix may change the initial text directionality,
2041         // causing parts of it to jump to the end. To prevent that we insert a
2042         // LRM character in place of the prefix.
2043         displayedUrl = "\u200e" + displayedUrl;
2044         urlHighlights = this.#offsetHighlights(urlHighlights, 1);
2045       }
2046       this.#addTextContentWithHighlights(url, displayedUrl, urlHighlights);
2047       this.#updateOverflowTooltip(url, result.payload.displayUrl);
2048     } else {
2049       url.textContent = "";
2050       this.#updateOverflowTooltip(url, "");
2051     }
2053     title.toggleAttribute("is-url", isVisitAction);
2054     if (isVisitAction) {
2055       actionSetter = () => {
2056         this.#l10nCache.setElementL10n(action, {
2057           id: "urlbar-result-action-visit",
2058         });
2059       };
2060     }
2062     item.toggleAttribute("has-action", actionSetter);
2063     if (actionSetter) {
2064       actionSetter();
2065       item._originalActionSetter = actionSetter;
2066     } else {
2067       item._originalActionSetter = () => {
2068         this.#l10nCache.removeElementL10n(action);
2069         action.textContent = "";
2070       };
2071       item._originalActionSetter();
2072     }
2074     if (!title.hasAttribute("is-url")) {
2075       title.setAttribute("dir", "auto");
2076     } else {
2077       title.removeAttribute("dir");
2078     }
2079   }
2081   #setRowSelectable(item, isRowSelectable) {
2082     item.toggleAttribute("row-selectable", isRowSelectable);
2083     item._content.toggleAttribute("selectable", isRowSelectable);
2084     if (isRowSelectable) {
2085       item._content.setAttribute("role", "option");
2086     } else {
2087       item._content.removeAttribute("role");
2088     }
2089   }
2091   #iconForResult(result, iconUrlOverride = null) {
2092     if (
2093       result.source == lazy.UrlbarUtils.RESULT_SOURCE.HISTORY &&
2094       (result.type == lazy.UrlbarUtils.RESULT_TYPE.SEARCH ||
2095         result.type == lazy.UrlbarUtils.RESULT_TYPE.KEYWORD)
2096     ) {
2097       return lazy.UrlbarUtils.ICON.HISTORY;
2098     }
2100     if (iconUrlOverride) {
2101       return iconUrlOverride;
2102     }
2104     if (result.payload.icon) {
2105       return result.payload.icon;
2106     }
2107     if (result.payload.iconBlob) {
2108       let blobUrl = this.#getBlobUrlForResult(result, result.payload.iconBlob);
2109       if (blobUrl) {
2110         return blobUrl;
2111       }
2112     }
2114     if (
2115       result.type == lazy.UrlbarUtils.RESULT_TYPE.SEARCH &&
2116       result.payload.trending
2117     ) {
2118       return lazy.UrlbarUtils.ICON.TRENDING;
2119     }
2121     if (
2122       result.type == lazy.UrlbarUtils.RESULT_TYPE.SEARCH ||
2123       result.type == lazy.UrlbarUtils.RESULT_TYPE.KEYWORD
2124     ) {
2125       return lazy.UrlbarUtils.ICON.SEARCH_GLASS;
2126     }
2128     return lazy.UrlbarUtils.ICON.DEFAULT;
2129   }
2131   #getBlobUrlForResult(result, blob) {
2132     // For some Suggest results, `url` is a value that is modified at query time
2133     // and that is potentially unique per query. For example, it might contain
2134     // timestamps or query-related search params. Those results will also have
2135     // an `originalUrl` that is the unmodified URL, and it should be used as the
2136     // map key.
2137     let resultUrl = result.payload.originalUrl || result.payload.url;
2138     if (resultUrl) {
2139       let blobUrl = this.#blobUrlsByResultUrl?.get(resultUrl);
2140       if (!blobUrl) {
2141         blobUrl = URL.createObjectURL(blob);
2142         // Since most users will not trigger results with blob icons, we
2143         // create this map lazily.
2144         this.#blobUrlsByResultUrl ||= new Map();
2145         this.#blobUrlsByResultUrl.set(resultUrl, blobUrl);
2146       }
2147       return blobUrl;
2148     }
2149     return null;
2150   }
2152   async #updateRowForDynamicType(item, result) {
2153     item.setAttribute("dynamicType", result.payload.dynamicType);
2155     let idsByName = new Map();
2156     for (let [name, node] of item._elements) {
2157       node.id = `${item.id}-${name}`;
2158       idsByName.set(name, node.id);
2159     }
2161     // Get the view update from the result's provider.
2162     let provider = lazy.UrlbarProvidersManager.getProvider(result.providerName);
2163     let viewUpdate = await provider.getViewUpdate(result, idsByName);
2164     if (item.result != result) {
2165       return;
2166     }
2168     // Update each node in the view by name.
2169     for (let [nodeName, update] of Object.entries(viewUpdate)) {
2170       let node = item.querySelector(`#${item.id}-${nodeName}`);
2171       this.#setDynamicAttributes(node, update.attributes, result);
2172       if (update.style) {
2173         for (let [styleName, value] of Object.entries(update.style)) {
2174           node.style[styleName] = value;
2175         }
2176       }
2177       if (update.l10n) {
2178         this.#l10nCache.setElementL10n(node, update.l10n);
2179       } else if (update.textContent) {
2180         this.#addTextContentWithHighlights(
2181           node,
2182           update.textContent,
2183           update.highlights
2184         );
2185       }
2186     }
2187   }
2189   #updateRowForRichSuggestion(item, result) {
2190     this.#setRowSelectable(item, true);
2192     let favicon = item._elements.get("favicon");
2193     if (result.richSuggestionIconSize) {
2194       item.setAttribute("icon-size", result.richSuggestionIconSize);
2195       favicon.setAttribute("icon-size", result.richSuggestionIconSize);
2196     } else {
2197       item.removeAttribute("icon-size");
2198       favicon.removeAttribute("icon-size");
2199     }
2201     if (result.richSuggestionIconVariation) {
2202       favicon.setAttribute(
2203         "icon-variation",
2204         result.richSuggestionIconVariation
2205       );
2206     } else {
2207       favicon.removeAttribute("icon-variation");
2208     }
2210     let description = item._elements.get("description");
2211     if (result.payload.descriptionL10n) {
2212       this.#l10nCache.setElementL10n(
2213         description,
2214         result.payload.descriptionL10n
2215       );
2216     } else {
2217       this.#l10nCache.removeElementL10n(description);
2218       if (result.payload.description) {
2219         description.textContent = result.payload.description;
2220       }
2221     }
2223     let bottom = item._elements.get("bottom");
2224     if (result.payload.bottomTextL10n) {
2225       this.#l10nCache.setElementL10n(bottom, result.payload.bottomTextL10n);
2226     } else {
2227       this.#l10nCache.removeElementL10n(bottom);
2228     }
2229   }
2231   /**
2232    * Performs a final pass over all rows in the view after a view update, stale
2233    * rows are removed, and other changes to the number of rows. Sets `rowIndex`
2234    * on each result, updates row labels, and performs other tasks that must be
2235    * deferred until all rows have been updated.
2236    */
2237   #updateIndices() {
2238     this.visibleResults = [];
2240     // `currentLabel` is the last-seen row label as we iterate through the rows.
2241     // When we encounter a label that's different from `currentLabel`, we add it
2242     // to the row and set it to `currentLabel`; we remove the labels for all
2243     // other rows, and therefore no label will appear adjacent to itself. (A
2244     // label may appear more than once, but there will be at least one different
2245     // label in between.) Each row's label is determined by `#rowLabel()`.
2246     let currentLabel;
2248     for (let i = 0; i < this.#rows.children.length; i++) {
2249       let item = this.#rows.children[i];
2250       item.result.rowIndex = i;
2252       let visible = this.#isElementVisible(item);
2253       if (visible) {
2254         if (item.result.exposureTelemetry) {
2255           this.controller.engagementEvent.addExposure(
2256             item.result,
2257             this.#queryContext
2258           );
2259         }
2260         this.visibleResults.push(item.result);
2261       }
2263       let newLabel = this.#updateRowLabel(item, visible, currentLabel);
2264       if (newLabel) {
2265         currentLabel = newLabel;
2266       }
2267     }
2269     let selectableElement = this.getFirstSelectableElement();
2270     let uiIndex = 0;
2271     while (selectableElement) {
2272       selectableElement.elementIndex = uiIndex++;
2273       selectableElement = this.#getNextSelectableElement(selectableElement);
2274     }
2276     if (this.visibleResults.length) {
2277       this.panel.removeAttribute("noresults");
2278     } else {
2279       this.panel.setAttribute("noresults", "true");
2280     }
2282     this.#rows.toggleAttribute(
2283       "actionmode",
2284       this.visibleResults[0]?.source == lazy.UrlbarUtils.RESULT_SOURCE.ACTIONS
2285     );
2286   }
2288   /**
2289    * Sets or removes the group label from a row. Designed to be called
2290    * iteratively over each row.
2291    *
2292    * @param {Element} item
2293    *   A row in the view.
2294    * @param {boolean} isVisible
2295    *   Whether the row is visible. This can be computed by the method itself,
2296    *   but it's a parameter as an optimization since the caller is expected to
2297    *   know it.
2298    * @param {object} currentLabel
2299    *   The current group label during row iteration.
2300    * @returns {object}
2301    *   If the given row should not have a label, returns null. Otherwise returns
2302    *   an l10n object for the label's l10n string: `{ id, args }`
2303    */
2304   #updateRowLabel(item, isVisible, currentLabel) {
2305     let label;
2306     if (isVisible) {
2307       label = this.#rowLabel(item, currentLabel);
2308       if (label && lazy.ObjectUtils.deepEqual(label, currentLabel)) {
2309         label = null;
2310       }
2311     }
2313     // When the row-inner is selected, screen readers won't naturally read the
2314     // label because it's a pseudo-element of the row, not the row-inner. To
2315     // compensate, for rows that have labels we add an element to the row-inner
2316     // with `aria-label` and no text content. Rows that don't have labels won't
2317     // have this element.
2318     let groupAriaLabel = item._elements.get("groupAriaLabel");
2320     if (!label) {
2321       this.#l10nCache.removeElementL10n(item, { attribute: "label" });
2322       if (groupAriaLabel) {
2323         groupAriaLabel.remove();
2324         item._elements.delete("groupAriaLabel");
2325       }
2326       return null;
2327     }
2329     this.#l10nCache.setElementL10n(item, {
2330       attribute: "label",
2331       id: label.id,
2332       args: label.args,
2333     });
2335     if (!groupAriaLabel) {
2336       groupAriaLabel = this.#createElement("span");
2337       groupAriaLabel.className = "urlbarView-group-aria-label";
2338       item._content.insertBefore(groupAriaLabel, item._content.firstChild);
2339       item._elements.set("groupAriaLabel", groupAriaLabel);
2340     }
2342     // `aria-label` must be a string, not an l10n ID, so first fetch the
2343     // localized value and then set it as the attribute. There's no relevant
2344     // aria attribute that uses l10n IDs.
2345     this.#l10nCache.ensure(label).then(() => {
2346       let message = this.#l10nCache.get(label);
2347       groupAriaLabel.setAttribute("aria-label", message?.attributes.label);
2348     });
2350     return label;
2351   }
2353   /**
2354    * Returns the group label to use for a row. Designed to be called iteratively
2355    * over each row.
2356    *
2357    * @param {Element} row
2358    *   A row in the view.
2359    * @param {object} currentLabel
2360    *   The current group label during row iteration.
2361    * @returns {object}
2362    *   If the current row should not have a label, returns null. Otherwise
2363    *   returns an l10n object for the label's l10n string: `{ id, args }`
2364    */
2365   #rowLabel(row, currentLabel) {
2366     if (!lazy.UrlbarPrefs.get("groupLabels.enabled")) {
2367       return null;
2368     }
2370     if (row.result.rowLabel) {
2371       return row.result.rowLabel;
2372     }
2374     let engineName =
2375       row.result.payload.engine || Services.search.defaultEngine.name;
2377     if (row.result.payload.trending) {
2378       return {
2379         id: "urlbar-group-trending",
2380         args: { engine: engineName },
2381       };
2382     }
2384     if (row.result.providerName == lazy.UrlbarProviderRecentSearches.name) {
2385       return { id: "urlbar-group-recent-searches" };
2386     }
2388     if (
2389       row.result.isBestMatch &&
2390       row.result.providerName == lazy.UrlbarProviderQuickSuggest.name
2391     ) {
2392       switch (row.result.payload.telemetryType) {
2393         case "adm_sponsored":
2394           if (!lazy.UrlbarPrefs.get("quickSuggestSponsoredPriority")) {
2395             return { id: "urlbar-group-sponsored" };
2396           }
2397           break;
2398         case "amo":
2399           return { id: "urlbar-group-addon" };
2400         case "mdn":
2401           return { id: "urlbar-group-mdn" };
2402         case "pocket":
2403           return { id: "urlbar-group-pocket" };
2404         case "yelp":
2405           return { id: "urlbar-group-local" };
2406       }
2407     }
2409     if (row.result.isBestMatch) {
2410       return { id: "urlbar-group-best-match" };
2411     }
2413     // Show "Shortcuts" if there's another result before that group.
2414     if (
2415       row.result.providerName == lazy.UrlbarProviderTopSites.name &&
2416       this.#queryContext.results[0].providerName !=
2417         lazy.UrlbarProviderTopSites.name
2418     ) {
2419       return { id: "urlbar-group-shortcuts" };
2420     }
2422     if (!this.#queryContext?.searchString || row.result.heuristic) {
2423       return null;
2424     }
2426     if (row.result.providerName == lazy.UrlbarProviderQuickSuggest.name) {
2427       if (
2428         row.result.payload.provider == "Weather" &&
2429         !row.result.payload.showRowLabel
2430       ) {
2431         return null;
2432       }
2433       return { id: "urlbar-group-firefox-suggest" };
2434     }
2436     switch (row.result.type) {
2437       case lazy.UrlbarUtils.RESULT_TYPE.KEYWORD:
2438       case lazy.UrlbarUtils.RESULT_TYPE.REMOTE_TAB:
2439       case lazy.UrlbarUtils.RESULT_TYPE.TAB_SWITCH:
2440       case lazy.UrlbarUtils.RESULT_TYPE.URL:
2441         return { id: "urlbar-group-firefox-suggest" };
2442       case lazy.UrlbarUtils.RESULT_TYPE.SEARCH:
2443         // Show "{ $engine } suggestions" if it's not the first label.
2444         if (currentLabel && row.result.payload.suggestion) {
2445           return {
2446             id: "urlbar-group-search-suggestions",
2447             args: { engine: engineName },
2448           };
2449         }
2450         break;
2451       case lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC:
2452         if (row.result.providerName == "quickactions") {
2453           return { id: "urlbar-group-quickactions" };
2454         }
2455         break;
2456     }
2458     return null;
2459   }
2461   #setRowVisibility(row, visible) {
2462     row.style.display = visible ? "" : "none";
2463     if (
2464       !visible &&
2465       row.result.type != lazy.UrlbarUtils.RESULT_TYPE.TIP &&
2466       row.result.type != lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC
2467     ) {
2468       // Reset the overflow state of elements that can overflow in case their
2469       // content changes while they're hidden. When making the row visible
2470       // again, we'll get new overflow events if needed.
2471       this.#setElementOverflowing(row._elements.get("title"), false);
2472       this.#setElementOverflowing(row._elements.get("url"), false);
2473       let tagsContainer = row._elements.get("tagsContainer");
2474       if (tagsContainer) {
2475         this.#setElementOverflowing(tagsContainer, false);
2476       }
2477     }
2478   }
2480   /**
2481    * Returns true if the given element and its row are both visible.
2482    *
2483    * @param {Element} element
2484    *   An element in the view.
2485    * @returns {boolean}
2486    *   True if the given element and its row are both visible.
2487    */
2488   #isElementVisible(element) {
2489     if (!element || element.style.display == "none") {
2490       return false;
2491     }
2492     let row = this.#getRowFromElement(element);
2493     return row && row.style.display != "none";
2494   }
2496   #removeStaleRows() {
2497     let row = this.#rows.lastElementChild;
2498     while (row) {
2499       let next = row.previousElementSibling;
2500       if (row.hasAttribute("stale")) {
2501         row.remove();
2502       } else {
2503         this.#setRowVisibility(row, true);
2504       }
2505       row = next;
2506     }
2507     this.#updateIndices();
2509     // Accept tentative exposures. This is analogous to unhiding the
2510     // hypothetical non-stale hidden rows of hidden-exposure results.
2511     this.controller.engagementEvent.acceptTentativeExposures();
2512   }
2514   #startRemoveStaleRowsTimer() {
2515     this.#removeStaleRowsTimer = this.window.setTimeout(() => {
2516       this.#removeStaleRowsTimer = null;
2517       this.#removeStaleRows();
2518     }, UrlbarView.removeStaleRowsTimeout);
2519   }
2521   #cancelRemoveStaleRowsTimer() {
2522     if (this.#removeStaleRowsTimer) {
2523       this.window.clearTimeout(this.#removeStaleRowsTimer);
2524       this.#removeStaleRowsTimer = null;
2525     }
2526   }
2528   #selectElement(
2529     element,
2530     { updateInput = true, setAccessibleFocus = true } = {}
2531   ) {
2532     if (element && !element.matches(KEYBOARD_SELECTABLE_ELEMENT_SELECTOR)) {
2533       throw new Error("Element is not keyboard-selectable");
2534     }
2536     if (this.#selectedElement) {
2537       this.#selectedElement.toggleAttribute("selected", false);
2538       this.#selectedElement.removeAttribute("aria-selected");
2539       this.#getSelectedRow()?.toggleAttribute("selected", false);
2540     }
2541     let row = this.#getRowFromElement(element);
2542     if (element) {
2543       element.toggleAttribute("selected", true);
2544       element.setAttribute("aria-selected", "true");
2545       if (row?.hasAttribute("row-selectable")) {
2546         row?.toggleAttribute("selected", true);
2547       }
2548     }
2550     let result = row?.result;
2551     let provider = lazy.UrlbarProvidersManager.getProvider(
2552       result?.providerName
2553     );
2554     if (provider) {
2555       provider.tryMethod("onBeforeSelection", result, element);
2556     }
2558     this.#setAccessibleFocus(setAccessibleFocus && element);
2559     this.#rawSelectedElement = element;
2561     if (updateInput) {
2562       let urlOverride = null;
2563       if (element?.classList?.contains("urlbarView-button")) {
2564         // Clear the input when a button is selected.
2565         urlOverride = "";
2566       }
2567       this.input.setValueFromResult({ result, urlOverride });
2568     } else {
2569       this.input.setResultForCurrentValue(result);
2570     }
2572     if (provider) {
2573       provider.tryMethod("onSelection", result, element);
2574     }
2575   }
2577   /**
2578    * Returns the element closest to the given element that can be
2579    * selected/picked.  If the element itself can be selected, it's returned.  If
2580    * there is no such element, null is returned.
2581    *
2582    * @param {Element} element
2583    *   An element in the view.
2584    * @param {object} [options]
2585    *   Options object.
2586    * @param {boolean} [options.byMouse]
2587    *   If true, include elements that are only selectable by mouse.
2588    * @returns {Element}
2589    *   The closest element that can be picked including the element itself, or
2590    *   null if there is no such element.
2591    */
2592   #getClosestSelectableElement(element, { byMouse = false } = {}) {
2593     let closest = element.closest(
2594       byMouse
2595         ? SELECTABLE_ELEMENT_SELECTOR
2596         : KEYBOARD_SELECTABLE_ELEMENT_SELECTOR
2597     );
2598     if (closest && this.#isElementVisible(closest)) {
2599       return closest;
2600     }
2601     // When clicking on a gap within a row or on its border or padding, treat
2602     // this as if the main part was clicked.
2603     if (
2604       element.classList.contains("urlbarView-row") &&
2605       element.hasAttribute("row-selectable")
2606     ) {
2607       return element._content;
2608     }
2609     return null;
2610   }
2612   /**
2613    * Returns true if the given element is keyboard-selectable.
2614    *
2615    * @param {Element} element
2616    *   The element to test.
2617    * @returns {boolean}
2618    *   True if the element is selectable and false if not.
2619    */
2620   #isSelectableElement(element) {
2621     return this.#getClosestSelectableElement(element) == element;
2622   }
2624   /**
2625    * Returns the first keyboard-selectable element in the view.
2626    *
2627    * @returns {Element}
2628    *   The first selectable element in the view.
2629    */
2630   getFirstSelectableElement() {
2631     let element = this.#rows.firstElementChild;
2632     if (element && !this.#isSelectableElement(element)) {
2633       element = this.#getNextSelectableElement(element);
2634     }
2635     return element;
2636   }
2638   /**
2639    * Returns the last keyboard-selectable element in the view.
2640    *
2641    * @returns {Element}
2642    *   The last selectable element in the view.
2643    */
2644   getLastSelectableElement() {
2645     let element = this.#rows.lastElementChild;
2646     if (element && !this.#isSelectableElement(element)) {
2647       element = this.#getPreviousSelectableElement(element);
2648     }
2649     return element;
2650   }
2652   /**
2653    * Returns the next keyboard-selectable element after the given element.  If
2654    * the element is the last selectable element, returns null.
2655    *
2656    * @param {Element} element
2657    *   An element in the view.
2658    * @returns {Element}
2659    *   The next selectable element after `element` or null if `element` is the
2660    *   last selectable element.
2661    */
2662   #getNextSelectableElement(element) {
2663     let row = this.#getRowFromElement(element);
2664     if (!row) {
2665       return null;
2666     }
2668     let next = row.nextElementSibling;
2669     let selectables = [
2670       ...row.querySelectorAll(KEYBOARD_SELECTABLE_ELEMENT_SELECTOR),
2671     ];
2672     if (selectables.length) {
2673       let index = selectables.indexOf(element);
2674       if (index < selectables.length - 1) {
2675         next = selectables[index + 1];
2676       }
2677     }
2679     if (next && !this.#isSelectableElement(next)) {
2680       next = this.#getNextSelectableElement(next);
2681     }
2683     return next;
2684   }
2686   /**
2687    * Returns the previous keyboard-selectable element before the given element.
2688    * If the element is the first selectable element, returns null.
2689    *
2690    * @param {Element} element
2691    *   An element in the view.
2692    * @returns {Element}
2693    *   The previous selectable element before `element` or null if `element` is
2694    *   the first selectable element.
2695    */
2696   #getPreviousSelectableElement(element) {
2697     let row = this.#getRowFromElement(element);
2698     if (!row) {
2699       return null;
2700     }
2702     let previous = row.previousElementSibling;
2703     let selectables = [
2704       ...row.querySelectorAll(KEYBOARD_SELECTABLE_ELEMENT_SELECTOR),
2705     ];
2706     if (selectables.length) {
2707       let index = selectables.indexOf(element);
2708       if (index < 0) {
2709         previous = selectables[selectables.length - 1];
2710       } else if (index > 0) {
2711         previous = selectables[index - 1];
2712       }
2713     }
2715     if (previous && !this.#isSelectableElement(previous)) {
2716       previous = this.#getPreviousSelectableElement(previous);
2717     }
2719     return previous;
2720   }
2722   /**
2723    * Returns the currently selected row. Useful when this.#selectedElement may
2724    * be a non-row element, such as a descendant element of RESULT_TYPE.TIP.
2725    *
2726    * @returns {Element}
2727    *   The currently selected row, or ancestor row of the currently selected
2728    *   item.
2729    */
2730   #getSelectedRow() {
2731     return this.#getRowFromElement(this.#selectedElement);
2732   }
2734   /**
2735    * @param {Element} element
2736    *   An element that is potentially a row or descendant of a row.
2737    * @returns {Element}
2738    *   The row containing `element`, or `element` itself if it is a row.
2739    */
2740   #getRowFromElement(element) {
2741     return element?.closest(".urlbarView-row");
2742   }
2744   #setAccessibleFocus(item) {
2745     if (item) {
2746       if (!item.id) {
2747         // Assign an id to dynamic actions as required by aria-activedescendant.
2748         item.id = getUniqueId("aria-activedescendant-target-");
2749       }
2750       this.input.inputField.setAttribute("aria-activedescendant", item.id);
2751     } else {
2752       this.input.inputField.removeAttribute("aria-activedescendant");
2753     }
2754   }
2756   /**
2757    * Sets `result`'s title in `titleNode`'s DOM.
2758    *
2759    * @param {UrlbarResult} result
2760    *   The result for which the title is being set.
2761    * @param {Element} titleNode
2762    *   The DOM node for the result's tile.
2763    */
2764   #setResultTitle(result, titleNode) {
2765     if (result.payload.titleL10n) {
2766       this.#l10nCache.setElementL10n(titleNode, result.payload.titleL10n);
2767       return;
2768     }
2770     // TODO: `text` is intended only for WebExtensions. We should remove it and
2771     // the WebExtensions urlbar API since we're no longer using it.
2772     if (result.payload.text) {
2773       titleNode.textContent = result.payload.text;
2774       return;
2775     }
2777     if (result.payload.providesSearchMode) {
2778       if (result.type == lazy.UrlbarUtils.RESULT_TYPE.RESTRICT) {
2779         let localSearchMode =
2780           result.payload.l10nRestrictKeywords[0].toLowerCase();
2781         let keywords = result.payload.l10nRestrictKeywords
2782           .map(keyword => `@${keyword.toLowerCase()}`)
2783           .join(", ");
2785         this.#l10nCache.setElementL10n(titleNode, {
2786           id: "urlbar-result-search-with-local-search-mode",
2787           args: {
2788             keywords,
2789             localSearchMode,
2790           },
2791         });
2792       } else if (
2793         result.providerName == "TokenAliasEngines" &&
2794         lazy.UrlbarPrefs.getScotchBonnetPref(
2795           "searchRestrictKeywords.featureGate"
2796         )
2797       ) {
2798         this.#l10nCache.setElementL10n(titleNode, {
2799           id: "urlbar-result-search-with-engine-keywords",
2800           args: {
2801             keywords: result.payload.keywords,
2802             engine: result.payload.engine,
2803           },
2804         });
2805       } else {
2806         // Keyword offers are the only result that require a localized title.
2807         // We localize the title instead of using the action text as a title
2808         // because some keyword offer results use both a title and action text
2809         // (e.g., tab-to-search).
2810         this.#l10nCache.setElementL10n(titleNode, {
2811           id: "urlbar-result-action-search-w-engine",
2812           args: { engine: result.payload.engine },
2813         });
2814       }
2816       return;
2817     }
2819     this.#l10nCache.removeElementL10n(titleNode);
2820     this.#addTextContentWithHighlights(
2821       titleNode,
2822       result.title,
2823       result.titleHighlights
2824     );
2825   }
2827   /**
2828    * Offsets all highlight ranges by a given amount.
2829    *
2830    * @param {Array} highlights The highlights which should be offset.
2831    * @param {int} startOffset
2832    *    The number by which we want to offset the highlights range starts.
2833    * @returns {Array} The offset highlights.
2834    */
2835   #offsetHighlights(highlights, startOffset) {
2836     return highlights.map(highlight => [
2837       highlight[0] + startOffset,
2838       highlight[1],
2839     ]);
2840   }
2842   /**
2843    * Sets the content of the 'Switch To Tab' chiclet.
2844    *
2845    * @param {UrlbarResult} result
2846    *   The result for which the userContext is being set.
2847    * @param {Element} actionNode
2848    *   The DOM node for the result's action.
2849    */
2850   #setSwitchTabActionChiclet(result, actionNode) {
2851     if (
2852       lazy.UrlbarPrefs.get("switchTabs.searchAllContainers") &&
2853       result.type == lazy.UrlbarUtils.RESULT_TYPE.TAB_SWITCH &&
2854       lazy.UrlbarProviderOpenTabs.isContainerUserContextId(
2855         result.payload.userContextId
2856       )
2857     ) {
2858       let label = lazy.ContextualIdentityService.getUserContextLabel(
2859         result.payload.userContextId
2860       ).toLowerCase();
2861       // To avoid flicker don't update the label unless necessary.
2862       if (
2863         actionNode.classList.contains("urlbarView-userContext") &&
2864         label &&
2865         actionNode.querySelector("span")?.innerText == label
2866       ) {
2867         return;
2868       }
2869       actionNode.innerHTML = "";
2870       let identity = lazy.ContextualIdentityService.getPublicIdentityFromId(
2871         result.payload.userContextId
2872       );
2873       if (identity) {
2874         actionNode.classList.add("urlbarView-userContext");
2875         if (identity.color) {
2876           actionNode.className = actionNode.className.replace(
2877             /identity-color-\w*/g,
2878             ""
2879           );
2880           actionNode.classList.add("identity-color-" + identity.color);
2881         }
2883         let textModeLabel = this.#createElement("div");
2884         textModeLabel.classList.add("urlbarView-userContext-textMode");
2886         if (label) {
2887           this.#l10nCache.setElementL10n(textModeLabel, {
2888             id: "urlbar-result-action-switch-tab-with-container",
2889             args: {
2890               container: label.toLowerCase(),
2891             },
2892           });
2893           actionNode.appendChild(textModeLabel);
2895           let iconModeLabel = this.#createElement("div");
2896           iconModeLabel.classList.add("urlbarView-userContext-iconMode");
2897           actionNode.appendChild(iconModeLabel);
2898           if (identity.icon) {
2899             let userContextIcon = this.#createElement("img");
2900             userContextIcon.classList.add("urlbarView-userContext-icon");
2901             userContextIcon.setAttribute("alt", label);
2902             userContextIcon.src =
2903               "resource://usercontext-content/" + identity.icon + ".svg";
2904             this.#l10nCache.setElementL10n(iconModeLabel, {
2905               id: "urlbar-result-action-switch-tab",
2906             });
2907             iconModeLabel.appendChild(userContextIcon);
2908           }
2909           actionNode.setAttribute("tooltiptext", label);
2910         }
2911       }
2912     } else {
2913       actionNode.classList.remove("urlbarView-userContext");
2914       // identity needs to be removed as well..
2915       actionNode
2916         .querySelectorAll(
2917           ".urlbarView-userContext-textMode, .urlbarView-userContext-iconMode"
2918         )
2919         .forEach(node => node.remove());
2921       this.#l10nCache.setElementL10n(actionNode, {
2922         id: "urlbar-result-action-switch-tab",
2923       });
2924     }
2925   }
2927   /**
2928    * Adds text content to a node, placing substrings that should be highlighted
2929    * inside <em> nodes.
2930    *
2931    * @param {Element} parentNode
2932    *   The text content will be added to this node.
2933    * @param {string} textContent
2934    *   The text content to give the node.
2935    * @param {Array} highlights
2936    *   The matches to highlight in the text.
2937    */
2938   #addTextContentWithHighlights(parentNode, textContent, highlights) {
2939     parentNode.textContent = "";
2940     if (!textContent) {
2941       return;
2942     }
2943     highlights = (highlights || []).concat([[textContent.length, 0]]);
2944     let index = 0;
2945     for (let [highlightIndex, highlightLength] of highlights) {
2946       if (highlightIndex - index > 0) {
2947         parentNode.appendChild(
2948           this.document.createTextNode(
2949             textContent.substring(index, highlightIndex)
2950           )
2951         );
2952       }
2953       if (highlightLength > 0) {
2954         let strong = this.#createElement("strong");
2955         strong.textContent = textContent.substring(
2956           highlightIndex,
2957           highlightIndex + highlightLength
2958         );
2959         parentNode.appendChild(strong);
2960       }
2961       index = highlightIndex + highlightLength;
2962     }
2963   }
2965   /**
2966    * Adds markup for a tail suggestion prefix to a row.
2967    *
2968    * @param {Element} item
2969    *   The node for the result row.
2970    * @param {UrlbarResult} result
2971    *   A UrlbarResult representing a tail suggestion.
2972    */
2973   #fillTailSuggestionPrefix(item, result) {
2974     let tailPrefixStrNode = item._elements.get("tailPrefixStr");
2975     let tailPrefixStr = result.payload.suggestion.substring(
2976       0,
2977       result.payload.tailOffsetIndex
2978     );
2979     tailPrefixStrNode.textContent = tailPrefixStr;
2981     let tailPrefixCharNode = item._elements.get("tailPrefixChar");
2982     tailPrefixCharNode.textContent = result.payload.tailPrefix;
2983   }
2985   #enableOrDisableRowWrap() {
2986     let wrap = getBoundsWithoutFlushing(this.input.textbox).width < 650;
2987     this.#rows.toggleAttribute("wrap", wrap);
2988     this.oneOffSearchButtons.container.toggleAttribute("wrap", wrap);
2989   }
2991   /**
2992    * @param {Element} element
2993    *   The element
2994    * @returns {boolean}
2995    *   Whether we track this element's overflow status in order to fade it out
2996    *   and add a tooltip when needed.
2997    */
2998   #canElementOverflow(element) {
2999     let { classList } = element;
3000     return (
3001       classList.contains("urlbarView-overflowable") ||
3002       classList.contains("urlbarView-url")
3003     );
3004   }
3006   /**
3007    * Marks an element as overflowing or not overflowing.
3008    *
3009    * @param {Element} element
3010    *   The element
3011    * @param {boolean} overflowing
3012    *   Whether the element is overflowing
3013    */
3014   #setElementOverflowing(element, overflowing) {
3015     element.toggleAttribute("overflow", overflowing);
3016     this.#updateOverflowTooltip(element);
3017   }
3019   /**
3020    * Sets an overflowing element's tooltip, or removes the tooltip if the
3021    * element isn't overflowing. Also optionally updates the string that should
3022    * be used as the tooltip in case of overflow.
3023    *
3024    * @param {Element} element
3025    *   The element
3026    * @param {string} [tooltip]
3027    *   The string that should be used in the tooltip. This will be stored and
3028    *   re-used next time the element overflows.
3029    */
3030   #updateOverflowTooltip(element, tooltip) {
3031     if (typeof tooltip == "string") {
3032       element._tooltip = tooltip;
3033     }
3034     if (element.hasAttribute("overflow") && element._tooltip) {
3035       element.setAttribute("title", element._tooltip);
3036     } else {
3037       element.removeAttribute("title");
3038     }
3039   }
3041   /**
3042    * If the view is open and showing a single search tip, this method picks it
3043    * and closes the view.  This counts as an engagement, so this method should
3044    * only be called due to user interaction.
3045    *
3046    * @param {event} event
3047    *   The user-initiated event for the interaction.  Should not be null.
3048    * @returns {boolean}
3049    *   True if this method picked a tip, false otherwise.
3050    */
3051   #pickSearchTipIfPresent(event) {
3052     if (
3053       !this.isOpen ||
3054       !this.#queryContext ||
3055       this.#queryContext.results.length != 1
3056     ) {
3057       return false;
3058     }
3059     let result = this.#queryContext.results[0];
3060     if (result.type != lazy.UrlbarUtils.RESULT_TYPE.TIP) {
3061       return false;
3062     }
3063     let buttons = this.#rows.firstElementChild._buttons;
3064     let tipButton = buttons.get("tip") || buttons.get("0");
3065     if (!tipButton) {
3066       throw new Error("Expected a tip button");
3067     }
3068     this.input.pickElement(tipButton, event);
3069     return true;
3070   }
3072   /**
3073    * Caches some l10n strings used by the view. Strings that are already cached
3074    * are not cached again.
3075    *
3076    * Note:
3077    *   Currently strings are never evicted from the cache, so do not cache
3078    *   strings whose arguments include the search string or other values that
3079    *   can cause the cache to grow unbounded. Suitable strings include those
3080    *   without arguments or those whose arguments depend on a small set of
3081    *   static values like search engine names.
3082    */
3083   async #cacheL10nStrings() {
3084     let idArgs = [
3085       ...this.#cacheL10nIDArgsForSearchService(),
3086       { id: "urlbar-result-action-search-bookmarks" },
3087       { id: "urlbar-result-action-search-history" },
3088       { id: "urlbar-result-action-search-in-private" },
3089       { id: "urlbar-result-action-search-tabs" },
3090       { id: "urlbar-result-action-switch-tab" },
3091       { id: "urlbar-result-action-visit" },
3092       { id: "urlbar-result-action-visit-from-clipboard" },
3093     ];
3095     let suggestSponsoredEnabled =
3096       lazy.UrlbarPrefs.get("quickSuggestEnabled") &&
3097       lazy.UrlbarPrefs.get("suggest.quicksuggest.sponsored");
3099     if (lazy.UrlbarPrefs.get("groupLabels.enabled")) {
3100       idArgs.push({ id: "urlbar-group-firefox-suggest" });
3101       idArgs.push({ id: "urlbar-group-best-match" });
3102       if (lazy.UrlbarPrefs.get("quickSuggestEnabled")) {
3103         if (lazy.UrlbarPrefs.get("addonsFeatureGate")) {
3104           idArgs.push({ id: "urlbar-group-addon" });
3105         }
3106         if (lazy.UrlbarPrefs.get("mdn.featureGate")) {
3107           idArgs.push({ id: "urlbar-group-mdn" });
3108         }
3109         if (lazy.UrlbarPrefs.get("pocketFeatureGate")) {
3110           idArgs.push({ id: "urlbar-group-pocket" });
3111         }
3112         if (lazy.UrlbarPrefs.get("yelpFeatureGate")) {
3113           idArgs.push({ id: "urlbar-group-local" });
3114         }
3115         if (
3116           suggestSponsoredEnabled &&
3117           lazy.UrlbarPrefs.get("quickSuggestAmpTopPickCharThreshold")
3118         ) {
3119           idArgs.push({ id: "urlbar-group-sponsored" });
3120         }
3121       }
3122     }
3124     if (suggestSponsoredEnabled) {
3125       idArgs.push({ id: "urlbar-result-action-sponsored" });
3126     }
3128     await this.#l10nCache.ensureAll(idArgs);
3129   }
3131   /**
3132    * A helper for l10n string caching that returns `{ id, args }` objects for
3133    * strings that depend on the search service.
3134    *
3135    * @returns {Array}
3136    *   Array of `{ id, args }` objects, possibly empty.
3137    */
3138   #cacheL10nIDArgsForSearchService() {
3139     // The search service may not be initialized if the user opens the view very
3140     // quickly after startup. Skip caching related strings in that case. Strings
3141     // are cached opportunistically every time the view opens, so they'll be
3142     // cached soon. We could use the search service's async methods, which
3143     // internally await initialization, but that would allow previously cached
3144     // out-of-date strings to appear in the view while the async calls are
3145     // ongoing. Generally there's no reason for our string-caching paths to be
3146     // async and it may even be a bad idea (except for the final necessary
3147     // `this.#l10nCache.ensureAll()` call).
3148     if (!Services.search.hasSuccessfullyInitialized) {
3149       return [];
3150     }
3152     let idArgs = [];
3154     let { defaultEngine, defaultPrivateEngine } = Services.search;
3155     let engineNames = [defaultEngine?.name, defaultPrivateEngine?.name].filter(
3156       name => name
3157     );
3159     if (defaultPrivateEngine) {
3160       idArgs.push({
3161         id: "urlbar-result-action-search-in-private-w-engine",
3162         args: { engine: defaultPrivateEngine.name },
3163       });
3164     }
3166     let engineStringIDs = [
3167       "urlbar-result-action-tabtosearch-web",
3168       "urlbar-result-action-tabtosearch-other-engine",
3169       "urlbar-result-action-search-w-engine",
3170     ];
3171     for (let id of engineStringIDs) {
3172       idArgs.push(...engineNames.map(name => ({ id, args: { engine: name } })));
3173     }
3175     if (lazy.UrlbarPrefs.get("groupLabels.enabled")) {
3176       idArgs.push(
3177         ...engineNames.map(name => ({
3178           id: "urlbar-group-search-suggestions",
3179           args: { engine: name },
3180         }))
3181       );
3182     }
3184     return idArgs;
3185   }
3187   get #isShowingZeroPrefix() {
3188     return !!this.#zeroPrefixStopwatchInstance;
3189   }
3191   #setIsShowingZeroPrefix(isShowing) {
3192     if (!!isShowing == !!this.#zeroPrefixStopwatchInstance) {
3193       return;
3194     }
3196     if (!isShowing) {
3197       TelemetryStopwatch.finish(
3198         ZERO_PREFIX_HISTOGRAM_DWELL_TIME,
3199         this.#zeroPrefixStopwatchInstance
3200       );
3201       this.#zeroPrefixStopwatchInstance = null;
3202       return;
3203     }
3205     this.#zeroPrefixStopwatchInstance = {};
3206     TelemetryStopwatch.start(
3207       ZERO_PREFIX_HISTOGRAM_DWELL_TIME,
3208       this.#zeroPrefixStopwatchInstance
3209     );
3211     Glean.urlbarZeroprefix.exposure.add(1);
3212   }
3214   /**
3215    * @param {UrlbarResult} result
3216    *   The result to get menu commands for.
3217    * @returns {Array}
3218    *   Array of menu commands available for the result, null if there are none.
3219    */
3220   #getResultMenuCommands(result) {
3221     if (this.#resultMenuCommands.has(result)) {
3222       return this.#resultMenuCommands.get(result);
3223     }
3225     let commands = lazy.UrlbarProvidersManager.getProvider(
3226       result.providerName
3227     )?.tryMethod("getResultCommands", result);
3228     if (commands) {
3229       this.#resultMenuCommands.set(result, commands);
3230       return commands;
3231     }
3233     commands = [];
3234     if (result.payload.isBlockable) {
3235       commands.push({
3236         name: RESULT_MENU_COMMANDS.DISMISS,
3237         l10n: result.payload.blockL10n,
3238       });
3239     }
3240     if (result.payload.helpUrl) {
3241       commands.push({
3242         name: RESULT_MENU_COMMANDS.HELP,
3243         l10n: result.payload.helpL10n || {
3244           id: "urlbar-result-menu-learn-more",
3245         },
3246       });
3247     }
3248     if (result.payload.isManageable) {
3249       commands.push({
3250         name: RESULT_MENU_COMMANDS.MANAGE,
3251         l10n: {
3252           id: "urlbar-result-menu-manage-firefox-suggest",
3253         },
3254       });
3255     }
3257     let rv = commands.length ? commands : null;
3258     this.#resultMenuCommands.set(result, rv);
3259     return rv;
3260   }
3262   #populateResultMenu(
3263     menupopup = this.resultMenu,
3264     commands = this.#getResultMenuCommands(this.#resultMenuResult)
3265   ) {
3266     menupopup.textContent = "";
3267     for (let data of commands) {
3268       if (data.children) {
3269         let popup = this.document.createXULElement("menupopup");
3270         this.#populateResultMenu(popup, data.children);
3271         let menu = this.document.createXULElement("menu");
3272         this.#l10nCache.setElementL10n(menu, data.l10n);
3273         menu.appendChild(popup);
3274         menupopup.appendChild(menu);
3275         continue;
3276       }
3277       if (data.name == "separator") {
3278         menupopup.appendChild(this.document.createXULElement("menuseparator"));
3279         continue;
3280       }
3281       let menuitem = this.document.createXULElement("menuitem");
3282       menuitem.dataset.command = data.name;
3283       menuitem.classList.add("urlbarView-result-menuitem");
3284       this.#l10nCache.setElementL10n(menuitem, data.l10n);
3285       menupopup.appendChild(menuitem);
3286     }
3287   }
3289   // Event handlers below.
3291   on_SelectedOneOffButtonChanged() {
3292     if (!this.isOpen || !this.#queryContext) {
3293       return;
3294     }
3296     let engine = this.oneOffSearchButtons.selectedButton?.engine;
3297     let source = this.oneOffSearchButtons.selectedButton?.source;
3298     let icon = this.oneOffSearchButtons.selectedButton?.image;
3300     let localSearchMode;
3301     if (source) {
3302       localSearchMode = lazy.UrlbarUtils.LOCAL_SEARCH_MODES.find(
3303         m => m.source == source
3304       );
3305     }
3307     for (let item of this.#rows.children) {
3308       let result = item.result;
3310       let isPrivateSearchWithoutPrivateEngine =
3311         result.payload.inPrivateWindow && !result.payload.isPrivateEngine;
3312       let isSearchHistory =
3313         result.type == lazy.UrlbarUtils.RESULT_TYPE.SEARCH &&
3314         result.source == lazy.UrlbarUtils.RESULT_SOURCE.HISTORY;
3315       let isSearchSuggestion = result.payload.suggestion && !isSearchHistory;
3317       // For one-off buttons having a source, we update the action for the
3318       // heuristic result, or for any non-heuristic that is a remote search
3319       // suggestion or a private search with no private engine.
3320       if (
3321         !result.heuristic &&
3322         !isSearchSuggestion &&
3323         !isPrivateSearchWithoutPrivateEngine
3324       ) {
3325         continue;
3326       }
3328       // If there is no selected button and we are in full search mode, it is
3329       // because the user just confirmed a one-off button, thus starting a new
3330       // query. Don't change the heuristic result because it would be
3331       // immediately replaced with the search mode heuristic, causing flicker.
3332       if (
3333         result.heuristic &&
3334         !engine &&
3335         !localSearchMode &&
3336         this.input.searchMode &&
3337         !this.input.searchMode.isPreview
3338       ) {
3339         continue;
3340       }
3342       let action = item._elements.get("action");
3343       let favicon = item._elements.get("favicon");
3344       let title = item._elements.get("title");
3346       // If a one-off button is the only selection, force the heuristic result
3347       // to show its action text, so the engine name is visible.
3348       if (
3349         result.heuristic &&
3350         !this.selectedElement &&
3351         (localSearchMode || engine)
3352       ) {
3353         item.setAttribute("show-action-text", "true");
3354       } else {
3355         item.removeAttribute("show-action-text");
3356       }
3358       // If an engine is selected, update search results to use that engine.
3359       // Otherwise, restore their original engines.
3360       if (result.type == lazy.UrlbarUtils.RESULT_TYPE.SEARCH) {
3361         if (engine) {
3362           if (!result.payload.originalEngine) {
3363             result.payload.originalEngine = result.payload.engine;
3364           }
3365           result.payload.engine = engine.name;
3366         } else if (result.payload.originalEngine) {
3367           result.payload.engine = result.payload.originalEngine;
3368           delete result.payload.originalEngine;
3369         }
3370       }
3372       // If the result is the heuristic and a one-off is selected (i.e.,
3373       // localSearchMode || engine), then restyle it to look like a search
3374       // result; otherwise, remove such styling. For restyled results, we
3375       // override the usual result-picking behaviour in UrlbarInput.pickResult.
3376       if (result.heuristic) {
3377         title.textContent =
3378           localSearchMode || engine
3379             ? this.#queryContext.searchString
3380             : result.title;
3382         // Set the restyled-search attribute so the action text and title
3383         // separator are shown or hidden via CSS as appropriate.
3384         if (localSearchMode || engine) {
3385           item.setAttribute("restyled-search", "true");
3386         } else {
3387           item.removeAttribute("restyled-search");
3388         }
3389       }
3391       // Update result action text.
3392       if (localSearchMode) {
3393         // Update the result action text for a local one-off.
3394         let name = lazy.UrlbarUtils.getResultSourceName(localSearchMode.source);
3395         this.#l10nCache.setElementL10n(action, {
3396           id: `urlbar-result-action-search-${name}`,
3397         });
3398         if (result.heuristic) {
3399           item.setAttribute("source", name);
3400         }
3401       } else if (engine && !result.payload.inPrivateWindow) {
3402         // Update the result action text for an engine one-off.
3403         this.#l10nCache.setElementL10n(action, {
3404           id: "urlbar-result-action-search-w-engine",
3405           args: { engine: engine.name },
3406         });
3407       } else {
3408         // No one-off is selected. If we replaced the action while a one-off
3409         // button was selected, it should be restored.
3410         if (item._originalActionSetter) {
3411           item._originalActionSetter();
3412           if (result.heuristic) {
3413             favicon.src = result.payload.icon || lazy.UrlbarUtils.ICON.DEFAULT;
3414           }
3415         } else {
3416           console.error("An item is missing the action setter");
3417         }
3418         item.removeAttribute("source");
3419       }
3421       // Update result favicons.
3422       let iconOverride = localSearchMode?.icon;
3423       // If the icon is the default one-off search placeholder, assume we
3424       // don't have an icon for the engine.
3425       if (
3426         !iconOverride &&
3427         icon != "chrome://browser/skin/search-engine-placeholder.png"
3428       ) {
3429         iconOverride = icon;
3430       }
3431       if (!iconOverride && (localSearchMode || engine)) {
3432         // For one-offs without an icon, do not allow restyled URL results to
3433         // use their own icons.
3434         iconOverride = lazy.UrlbarUtils.ICON.SEARCH_GLASS;
3435       }
3436       if (
3437         result.heuristic ||
3438         (result.payload.inPrivateWindow && !result.payload.isPrivateEngine)
3439       ) {
3440         // If we just changed the engine from the original engine and it had an
3441         // icon, then make sure the result now uses the new engine's icon or
3442         // failing that the default icon.  If we changed it back to the original
3443         // engine, go back to the original or default icon.
3444         favicon.src = this.#iconForResult(result, iconOverride);
3445       }
3446     }
3447   }
3449   on_blur() {
3450     // If the view is open without the input being focused, it will not close
3451     // automatically when the window loses focus. We might be in this state
3452     // after a Search Tip is shown on an engine homepage.
3453     if (!lazy.UrlbarPrefs.get("ui.popup.disable_autohide")) {
3454       this.close();
3455     }
3456   }
3458   on_mousedown(event) {
3459     if (event.button == 2) {
3460       // Ignore right clicks.
3461       return;
3462     }
3464     let element = this.#getClosestSelectableElement(event.target, {
3465       byMouse: true,
3466     });
3467     if (!element) {
3468       // Ignore clicks on elements that can't be selected/picked.
3469       return;
3470     }
3472     this.window.top.addEventListener("mouseup", this);
3474     // Select the element and open a speculative connection unless it's a
3475     // button. Buttons are special in the two ways listed below. Some buttons
3476     // may be exceptions to these two criteria, but to provide a consistent UX
3477     // and avoid complexity, we apply this logic to all of them.
3478     //
3479     // (1) Some buttons do not close the view when clicked, like the block and
3480     // menu buttons. Clicking these buttons should not have any side effects in
3481     // the view or input beyond their primary purpose. For example, the block
3482     // button should remove the row but it should not change the input value or
3483     // page proxy state, and ideally it shouldn't change the input's selection
3484     // or caret either. It probably also shouldn't change the view's selection
3485     // (if the removed row isn't selected), but that may be more debatable.
3486     //
3487     // It may be possible to select buttons on mousedown and then clear the
3488     // selection on mouseup as usual while meeting these requirements. However,
3489     // it's not simple because clearing the selection has surprising side
3490     // effects in the input like the ones mentioned above.
3491     //
3492     // (2) Most buttons don't have URLs, so there's nothing to speculatively
3493     // connect to. If a button does have a URL, it's typically different from
3494     // the primary URL of its related result, so it's not critical to open a
3495     // speculative connection anyway.
3496     if (!element.classList.contains("urlbarView-button")) {
3497       this.#mousedownSelectedElement = element;
3498       this.#selectElement(element, { updateInput: false });
3499       this.controller.speculativeConnect(
3500         this.selectedResult,
3501         this.#queryContext,
3502         "mousedown"
3503       );
3504     }
3505   }
3507   on_mouseup(event) {
3508     if (event.button == 2) {
3509       // Ignore right clicks.
3510       return;
3511     }
3513     this.window.top.removeEventListener("mouseup", this);
3515     // When mouseup outside of browser, as the target will not be element,
3516     // ignore it.
3517     let element =
3518       event.target.nodeType === event.target.ELEMENT_NODE
3519         ? this.#getClosestSelectableElement(event.target, { byMouse: true })
3520         : null;
3521     if (element) {
3522       this.input.pickElement(element, event);
3523     }
3525     // If the element that was selected on mousedown is still in the view, clear
3526     // the selection. Do it after calling `pickElement()` above since code that
3527     // reacts to picks may assume the selected element is the picked element.
3528     //
3529     // If the element is no longer in the view, then it must be because its row
3530     // was removed in response to the pick. If the element was not a button, we
3531     // selected it on mousedown and then `onQueryResultRemoved()` selected the
3532     // next row; we shouldn't unselect it here. If the element was a button,
3533     // then we didn't select anything on mousedown; clearing the selection seems
3534     // like it would be harmless, but it has side effects in the input we want
3535     // to avoid (see `on_mousedown()`).
3536     if (this.#mousedownSelectedElement?.isConnected) {
3537       this.clearSelection();
3538     }
3539     this.#mousedownSelectedElement = null;
3540   }
3542   #isRelevantOverflowEvent(event) {
3543     // We're interested only in the horizontal axis.
3544     // 0 - vertical, 1 - horizontal, 2 - both
3545     return event.detail != 0;
3546   }
3548   on_overflow(event) {
3549     if (
3550       this.#isRelevantOverflowEvent(event) &&
3551       this.#canElementOverflow(event.target)
3552     ) {
3553       this.#setElementOverflowing(event.target, true);
3554     }
3555   }
3557   on_underflow(event) {
3558     if (
3559       this.#isRelevantOverflowEvent(event) &&
3560       this.#canElementOverflow(event.target)
3561     ) {
3562       this.#setElementOverflowing(event.target, false);
3563     }
3564   }
3566   on_resize() {
3567     this.#enableOrDisableRowWrap();
3568   }
3570   on_command(event) {
3571     if (event.currentTarget == this.resultMenu) {
3572       let result = this.#resultMenuResult;
3573       this.#resultMenuResult = null;
3574       let menuitem = event.target;
3575       switch (menuitem.dataset.command) {
3576         case RESULT_MENU_COMMANDS.HELP:
3577           menuitem.dataset.url =
3578             result.payload.helpUrl ||
3579             Services.urlFormatter.formatURLPref("app.support.baseURL") +
3580               "awesome-bar-result-menu";
3581           break;
3582       }
3583       this.input.pickResult(result, event, menuitem);
3584     }
3585   }
3587   on_popupshowing(event) {
3588     if (event.target == this.resultMenu) {
3589       this.#populateResultMenu();
3590     }
3591   }
3595  * Implements a QueryContext cache, working as a circular buffer, when a new
3596  * entry is added at the top, the last item is remove from the bottom.
3597  */
3598 class QueryContextCache {
3599   #cache;
3600   #size;
3601   #topSitesContext;
3602   #topSitesListener;
3604   /**
3605    * Constructor.
3606    *
3607    * @param {number} size The number of entries to keep in the cache.
3608    */
3609   constructor(size) {
3610     this.#size = size;
3611     this.#cache = [];
3613     // We store the top-sites context separately since it will often be needed
3614     // and therefore shouldn't be evicted except when the top sites change.
3615     this.#topSitesContext = null;
3616     this.#topSitesListener = () => (this.#topSitesContext = null);
3617     lazy.UrlbarProviderTopSites.addTopSitesListener(this.#topSitesListener);
3618   }
3620   /**
3621    * @returns {number} The number of entries to keep in the cache.
3622    */
3623   get size() {
3624     return this.#size;
3625   }
3627   /**
3628    * @returns {UrlbarQueryContext} The cached top-sites context or null if none.
3629    */
3630   get topSitesContext() {
3631     return this.#topSitesContext;
3632   }
3634   /**
3635    * Adds a new entry to the cache.
3636    *
3637    * @param {UrlbarQueryContext} queryContext The UrlbarQueryContext to add.
3638    * Note: QueryContexts without results are ignored and not added. Contexts
3639    *       with an empty searchString that are not the top-sites context are
3640    *       also ignored.
3641    */
3642   put(queryContext) {
3643     if (!queryContext.results.length) {
3644       return;
3645     }
3647     let searchString = queryContext.searchString;
3648     if (!searchString) {
3649       // Cache the context if it's the top-sites context. An empty search string
3650       // doesn't necessarily imply top sites since there are other queries that
3651       // use it too, like search mode. If any result is from the top-sites
3652       // provider, assume the context is top sites.
3653       if (
3654         queryContext.results?.some(
3655           r => r.providerName == lazy.UrlbarProviderTopSites.name
3656         )
3657       ) {
3658         this.#topSitesContext = queryContext;
3659       }
3660       return;
3661     }
3663     let index = this.#cache.findIndex(e => e.searchString == searchString);
3664     if (index != -1) {
3665       if (this.#cache[index] == queryContext) {
3666         return;
3667       }
3668       this.#cache.splice(index, 1);
3669     }
3670     if (this.#cache.unshift(queryContext) > this.size) {
3671       this.#cache.length = this.size;
3672     }
3673   }
3675   get(searchString) {
3676     return this.#cache.find(e => e.searchString == searchString);
3677   }
3681  * Adds a dynamic result type stylesheet to a specified window.
3683  * @param {Window} window
3684  *   The window to which to add the stylesheet.
3685  * @param {string} stylesheetURL
3686  *   The stylesheet's URL.
3687  */
3688 async function addDynamicStylesheet(window, stylesheetURL) {
3689   // Try-catch all of these so that failing to load a stylesheet doesn't break
3690   // callers and possibly the urlbar.  If a stylesheet does fail to load, the
3691   // dynamic results that depend on it will appear broken, but at least we
3692   // won't break the whole urlbar.
3693   try {
3694     let uri = Services.io.newURI(stylesheetURL);
3695     let sheet = await lazy.styleSheetService.preloadSheetAsync(
3696       uri,
3697       Ci.nsIStyleSheetService.AGENT_SHEET
3698     );
3699     window.windowUtils.addSheet(sheet, Ci.nsIDOMWindowUtils.AGENT_SHEET);
3700   } catch (ex) {
3701     console.error("Error adding dynamic stylesheet:", ex);
3702   }
3706  * Removes a dynamic result type stylesheet from the view's window.
3708  * @param {Window} window
3709  *   The window from which to remove the stylesheet.
3710  * @param {string} stylesheetURL
3711  *   The stylesheet's URL.
3712  */
3713 function removeDynamicStylesheet(window, stylesheetURL) {
3714   // Try-catch for the same reason as desribed in addDynamicStylesheet.
3715   try {
3716     window.windowUtils.removeSheetUsingURIString(
3717       stylesheetURL,
3718       Ci.nsIDOMWindowUtils.AGENT_SHEET
3719     );
3720   } catch (ex) {
3721     console.error("Error removing dynamic stylesheet:", ex);
3722   }