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