fxview-tab-list.mjs (29419B)
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 { 6 classMap, 7 html, 8 ifDefined, 9 repeat, 10 styleMap, 11 when, 12 } from "chrome://global/content/vendor/lit.all.mjs"; 13 import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; 14 import { escapeRegExp } from "./search-helpers.mjs"; 15 // eslint-disable-next-line import/no-unassigned-import 16 import "chrome://global/content/elements/moz-button.mjs"; 17 18 const NOW_THRESHOLD_MS = 91000; 19 const FXVIEW_ROW_HEIGHT_PX = 32; 20 const lazy = {}; 21 let XPCOMUtils; 22 23 if (!window.IS_STORYBOOK) { 24 XPCOMUtils = ChromeUtils.importESModule( 25 "resource://gre/modules/XPCOMUtils.sys.mjs" 26 ).XPCOMUtils; 27 XPCOMUtils.defineLazyPreferenceGetter( 28 lazy, 29 "virtualListEnabledPref", 30 "browser.firefox-view.virtual-list.enabled" 31 ); 32 ChromeUtils.defineLazyGetter(lazy, "relativeTimeFormat", () => { 33 return new Services.intl.RelativeTimeFormat(undefined, { 34 style: "narrow", 35 }); 36 }); 37 38 ChromeUtils.defineESModuleGetters(lazy, { 39 BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", 40 }); 41 } 42 43 /** 44 * A list of clickable tab items 45 * 46 * @property {boolean} compactRows - Whether to hide the URL and date/time for each tab. 47 * @property {string} dateTimeFormat - Expected format for date and/or time 48 * @property {string} hasPopup - The aria-haspopup attribute for the secondary action, if required 49 * @property {number} maxTabsLength - The max number of tabs for the list 50 * @property {Array} tabItems - Items to show in the tab list 51 * @property {string} searchQuery - The query string to highlight, if provided. 52 * @property {string} secondaryActionClass - The class used to style the secondary action element 53 * @property {string} tertiaryActionClass - The class used to style the tertiary action element 54 */ 55 export class FxviewTabListBase extends MozLitElement { 56 constructor() { 57 super(); 58 window.MozXULElement.insertFTLIfNeeded("toolkit/branding/brandings.ftl"); 59 window.MozXULElement.insertFTLIfNeeded("browser/fxviewTabList.ftl"); 60 this.activeIndex = 0; 61 this.currentActiveElementId = "fxview-tab-row-main"; 62 this.hasPopup = null; 63 this.dateTimeFormat = "relative"; 64 this.maxTabsLength = 25; 65 this.tabItems = []; 66 this.compactRows = false; 67 this.updatesPaused = true; 68 this.#register(); 69 } 70 71 static properties = { 72 activeIndex: { type: Number }, 73 compactRows: { type: Boolean }, 74 currentActiveElementId: { type: String }, 75 dateTimeFormat: { type: String }, 76 hasPopup: { type: String }, 77 maxTabsLength: { type: Number }, 78 tabItems: { type: Array }, 79 updatesPaused: { type: Boolean }, 80 searchQuery: { type: String }, 81 secondaryActionClass: { type: String }, 82 tertiaryActionClass: { type: String }, 83 }; 84 85 static queries = { 86 emptyState: "fxview-empty-state", 87 rowEls: { 88 all: "fxview-tab-row", 89 }, 90 rootVirtualListEl: "virtual-list", 91 }; 92 93 willUpdate(changes) { 94 this.activeIndex = Math.min( 95 Math.max(this.activeIndex, 0), 96 this.tabItems.length - 1 97 ); 98 99 if (changes.has("dateTimeFormat") || changes.has("updatesPaused")) { 100 this.clearIntervalTimer(); 101 if ( 102 !this.updatesPaused && 103 this.dateTimeFormat == "relative" && 104 !window.IS_STORYBOOK 105 ) { 106 this.startIntervalTimer(); 107 this.onIntervalUpdate(); 108 } 109 } 110 111 if (this.maxTabsLength > 0) { 112 this.tabItems = this.tabItems.slice(0, this.maxTabsLength); 113 } 114 } 115 116 startIntervalTimer() { 117 this.clearIntervalTimer(); 118 this.intervalID = setInterval( 119 () => this.onIntervalUpdate(), 120 this.timeMsPref 121 ); 122 } 123 124 clearIntervalTimer() { 125 if (this.intervalID) { 126 clearInterval(this.intervalID); 127 delete this.intervalID; 128 } 129 } 130 131 #register() { 132 if (!window.IS_STORYBOOK) { 133 XPCOMUtils.defineLazyPreferenceGetter( 134 this, 135 "timeMsPref", 136 "browser.tabs.firefox-view.updateTimeMs", 137 NOW_THRESHOLD_MS, 138 () => { 139 this.clearIntervalTimer(); 140 if (!this.isConnected) { 141 return; 142 } 143 this.startIntervalTimer(); 144 this.requestUpdate(); 145 } 146 ); 147 } 148 } 149 150 connectedCallback() { 151 super.connectedCallback(); 152 if ( 153 !this.updatesPaused && 154 this.dateTimeFormat === "relative" && 155 !window.IS_STORYBOOK 156 ) { 157 this.startIntervalTimer(); 158 } 159 } 160 161 disconnectedCallback() { 162 super.disconnectedCallback(); 163 this.clearIntervalTimer(); 164 } 165 166 async getUpdateComplete() { 167 await super.getUpdateComplete(); 168 await Promise.all(Array.from(this.rowEls).map(item => item.updateComplete)); 169 } 170 171 onIntervalUpdate() { 172 this.requestUpdate(); 173 Array.from(this.rowEls).forEach(fxviewTabRow => 174 fxviewTabRow.requestUpdate() 175 ); 176 } 177 178 /** 179 * Focuses the expected element (either the link or button) within fxview-tab-row 180 * The currently focused/active element ID within a row is stored in this.currentActiveElementId 181 */ 182 handleFocusElementInRow(e) { 183 let fxviewTabRow = e.target; 184 if (e.code == "ArrowUp") { 185 // Focus either the link or button of the previous row based on this.currentActiveElementId 186 e.preventDefault(); 187 this.focusPrevRow(); 188 } else if (e.code == "ArrowDown") { 189 // Focus either the link or button of the next row based on this.currentActiveElementId 190 e.preventDefault(); 191 this.focusNextRow(); 192 } else if (e.code == "ArrowRight") { 193 // Focus either the link or the button in the current row and 194 // set this.currentActiveElementId to that element's ID 195 e.preventDefault(); 196 if (document.dir == "rtl") { 197 fxviewTabRow.moveFocusLeft(); 198 } else { 199 fxviewTabRow.moveFocusRight(); 200 } 201 } else if (e.code == "ArrowLeft") { 202 // Focus either the link or the button in the current row and 203 // set this.currentActiveElementId to that element's ID 204 e.preventDefault(); 205 if (document.dir == "rtl") { 206 fxviewTabRow.moveFocusRight(); 207 } else { 208 fxviewTabRow.moveFocusLeft(); 209 } 210 } 211 } 212 213 focusPrevRow() { 214 this.focusIndex(this.activeIndex - 1); 215 } 216 217 focusNextRow() { 218 this.focusIndex(this.activeIndex + 1); 219 } 220 221 async focusIndex(index) { 222 // Focus link or button of item 223 if (lazy.virtualListEnabledPref) { 224 let row = this.rootVirtualListEl.getItem(index); 225 if (!row) { 226 return; 227 } 228 let subList = this.rootVirtualListEl.getSubListForItem(index); 229 if (!subList) { 230 return; 231 } 232 this.activeIndex = index; 233 234 // In Bug 1866845, these manual updates to the sublists should be removed 235 // and scrollIntoView() should also be iterated on so that we aren't constantly 236 // moving the focused item to the center of the viewport 237 await this.requestVirtualListUpdate(); 238 row.scrollIntoView({ block: "center" }); 239 row.focus(); 240 } else if (index >= 0 && index < this.rowEls?.length) { 241 this.rowEls[index].focus(); 242 this.activeIndex = index; 243 } 244 } 245 246 async requestVirtualListUpdate() { 247 for (const sublist of this.rootVirtualListEl.children) { 248 await sublist.requestUpdate(); 249 await sublist.updateComplete; 250 } 251 } 252 253 shouldUpdate(changes) { 254 if (changes.has("updatesPaused")) { 255 if (this.updatesPaused) { 256 this.clearIntervalTimer(); 257 } 258 } 259 return !this.updatesPaused; 260 } 261 262 itemTemplate = (tabItem, i) => { 263 let time; 264 if (tabItem.time || tabItem.closedAt) { 265 let stringTime = (tabItem.time || tabItem.closedAt).toString(); 266 // Different APIs return time in different units, so we use 267 // the length to decide if it's milliseconds or nanoseconds. 268 if (stringTime.length === 16) { 269 time = (tabItem.time || tabItem.closedAt) / 1000; 270 } else { 271 time = tabItem.time || tabItem.closedAt; 272 } 273 } 274 275 return html` 276 <fxview-tab-row 277 ?active=${i == this.activeIndex} 278 ?compact=${this.compactRows} 279 .currentActiveElementId=${this.currentActiveElementId} 280 .favicon=${tabItem.icon} 281 .primaryL10nId=${tabItem.primaryL10nId} 282 .primaryL10nArgs=${tabItem.primaryL10nArgs} 283 .secondaryL10nId=${tabItem.secondaryL10nId} 284 .secondaryL10nArgs=${tabItem.secondaryL10nArgs} 285 .tertiaryL10nId=${tabItem.tertiaryL10nId} 286 .tertiaryL10nArgs=${tabItem.tertiaryL10nArgs} 287 .secondaryActionClass=${this.secondaryActionClass} 288 .tertiaryActionClass=${this.tertiaryActionClass} 289 .sourceClosedId=${tabItem.sourceClosedId} 290 .sourceWindowId=${tabItem.sourceWindowId} 291 .closedId=${tabItem.closedId || tabItem.closedId} 292 role="listitem" 293 .tabElement=${tabItem.tabElement} 294 .time=${time} 295 .title=${tabItem.title} 296 .url=${tabItem.url} 297 .searchQuery=${this.searchQuery} 298 .timeMsPref=${this.timeMsPref} 299 .hasPopup=${this.hasPopup} 300 .dateTimeFormat=${this.dateTimeFormat} 301 ></fxview-tab-row> 302 `; 303 }; 304 305 stylesheets() { 306 return html`<link 307 rel="stylesheet" 308 href="chrome://browser/content/firefoxview/fxview-tab-list.css" 309 />`; 310 } 311 312 render() { 313 if (this.searchQuery && !this.tabItems.length) { 314 return this.emptySearchResultsTemplate(); 315 } 316 return html` 317 ${this.stylesheets()} 318 <div 319 id="fxview-tab-list" 320 class="fxview-tab-list" 321 data-l10n-id="firefoxview-tabs" 322 role="list" 323 @keydown=${this.handleFocusElementInRow} 324 > 325 ${when( 326 lazy.virtualListEnabledPref, 327 () => html` 328 <virtual-list 329 .activeIndex=${this.activeIndex} 330 .items=${this.tabItems} 331 .template=${this.itemTemplate} 332 ></virtual-list> 333 `, 334 () => 335 html`${this.tabItems.map((tabItem, i) => 336 this.itemTemplate(tabItem, i) 337 )}` 338 )} 339 </div> 340 <slot name="menu"></slot> 341 `; 342 } 343 344 emptySearchResultsTemplate() { 345 return html` <fxview-empty-state 346 class="search-results" 347 headerLabel="firefoxview-search-results-empty" 348 .headerArgs=${{ query: this.searchQuery }} 349 isInnerCard 350 > 351 </fxview-empty-state>`; 352 } 353 } 354 customElements.define("fxview-tab-list", FxviewTabListBase); 355 356 /** 357 * A tab item that displays favicon, title, url, and time of last access 358 * 359 * @property {boolean} active - Should current item have focus on keydown 360 * @property {boolean} compact - Whether to hide the URL and date/time for this tab. 361 * @property {string} currentActiveElementId - ID of currently focused element within each tab item 362 * @property {string} dateTimeFormat - Expected format for date and/or time 363 * @property {string} hasPopup - The aria-haspopup attribute for the secondary action, if required 364 * @property {number} closedId - The tab ID for when the tab item was closed. 365 * @property {number} sourceClosedId - The closedId of the closed window its from if applicable 366 * @property {number} sourceWindowId - The sessionstore id of the window its from if applicable 367 * @property {string} favicon - The favicon for the tab item. 368 * @property {string} primaryL10nId - The l10n id used for the primary action element 369 * @property {string} primaryL10nArgs - The l10n args used for the primary action element 370 * @property {string} secondaryL10nId - The l10n id used for the secondary action button 371 * @property {string} secondaryL10nArgs - The l10n args used for the secondary action element 372 * @property {string} secondaryActionClass - The class used to style the secondary action element 373 * @property {string} tertiaryL10nId - The l10n id used for the tertiary action button 374 * @property {string} tertiaryL10nArgs - The l10n args used for the tertiary action element 375 * @property {string} tertiaryActionClass - The class used to style the tertiary action element 376 * @property {object} tabElement - The MozTabbrowserTab element for the tab item. 377 * @property {number} time - The timestamp for when the tab was last accessed. 378 * @property {string} title - The title for the tab item. 379 * @property {string} url - The url for the tab item. 380 * @property {number} timeMsPref - The frequency in milliseconds of updates to relative time 381 * @property {string} searchQuery - The query string to highlight, if provided. 382 */ 383 export class FxviewTabRowBase extends MozLitElement { 384 static properties = { 385 active: { type: Boolean }, 386 compact: { type: Boolean }, 387 currentActiveElementId: { type: String }, 388 dateTimeFormat: { type: String }, 389 favicon: { type: String }, 390 hasPopup: { type: String }, 391 primaryL10nId: { type: String }, 392 primaryL10nArgs: { type: String }, 393 secondaryL10nId: { type: String }, 394 secondaryL10nArgs: { type: String }, 395 secondaryActionClass: { type: String }, 396 tertiaryL10nId: { type: String }, 397 tertiaryL10nArgs: { type: String }, 398 tertiaryActionClass: { type: String }, 399 closedId: { type: Number }, 400 sourceClosedId: { type: Number }, 401 sourceWindowId: { type: String }, 402 tabElement: { type: Object }, 403 time: { type: Number }, 404 title: { type: String }, 405 timeMsPref: { type: Number }, 406 url: { type: String }, 407 uri: { type: String }, 408 searchQuery: { type: String }, 409 }; 410 411 constructor() { 412 super(); 413 this.active = false; 414 this.currentActiveElementId = "fxview-tab-row-main"; 415 } 416 417 static queries = { 418 mainEl: "#fxview-tab-row-main", 419 secondaryButtonEl: "#fxview-tab-row-secondary-button:not([hidden])", 420 tertiaryButtonEl: "#fxview-tab-row-tertiary-button", 421 }; 422 423 get currentFocusable() { 424 let focusItem = this.renderRoot.getElementById(this.currentActiveElementId); 425 if (!focusItem) { 426 focusItem = this.renderRoot.getElementById("fxview-tab-row-main"); 427 } 428 return focusItem; 429 } 430 431 connectedCallback() { 432 super.connectedCallback(); 433 this.uri = this.url; 434 } 435 436 focus() { 437 this.currentFocusable.focus(); 438 } 439 440 focusSecondaryButton() { 441 let tabList = this.getRootNode().host; 442 this.secondaryButtonEl.focus(); 443 tabList.currentActiveElementId = this.secondaryButtonEl.id; 444 } 445 446 focusTertiaryButton() { 447 let tabList = this.getRootNode().host; 448 this.tertiaryButtonEl.focus(); 449 tabList.currentActiveElementId = this.tertiaryButtonEl.id; 450 } 451 452 focusLink() { 453 let tabList = this.getRootNode().host; 454 this.mainEl.focus(); 455 tabList.currentActiveElementId = this.mainEl.id; 456 } 457 458 moveFocusRight() { 459 if (this.currentActiveElementId === "fxview-tab-row-main") { 460 this.focusSecondaryButton(); 461 } else if ( 462 this.tertiaryButtonEl && 463 this.currentActiveElementId === "fxview-tab-row-secondary-button" 464 ) { 465 this.focusTertiaryButton(); 466 } 467 } 468 469 moveFocusLeft() { 470 if (this.currentActiveElementId === "fxview-tab-row-tertiary-button") { 471 this.focusSecondaryButton(); 472 } else { 473 this.focusLink(); 474 } 475 } 476 477 dateFluentArgs(timestamp, dateTimeFormat) { 478 if (dateTimeFormat === "date" || dateTimeFormat === "dateTime") { 479 return JSON.stringify({ date: timestamp }); 480 } 481 return null; 482 } 483 484 dateFluentId(timestamp, dateTimeFormat, _nowThresholdMs = NOW_THRESHOLD_MS) { 485 if (!timestamp) { 486 return null; 487 } 488 if (dateTimeFormat === "relative") { 489 const elapsed = Date.now() - timestamp; 490 if (elapsed <= _nowThresholdMs || !lazy.relativeTimeFormat) { 491 // Use a different string for very recent timestamps 492 return "fxviewtabrow-just-now-timestamp"; 493 } 494 return null; 495 } else if (dateTimeFormat === "date" || dateTimeFormat === "dateTime") { 496 return "fxviewtabrow-date"; 497 } 498 return null; 499 } 500 501 relativeTime(timestamp, dateTimeFormat, _nowThresholdMs = NOW_THRESHOLD_MS) { 502 if (dateTimeFormat === "relative") { 503 const elapsed = Date.now() - timestamp; 504 if (elapsed > _nowThresholdMs && lazy.relativeTimeFormat) { 505 return lazy.relativeTimeFormat.formatBestUnit(new Date(timestamp)); 506 } 507 } 508 return null; 509 } 510 511 timeFluentId(dateTimeFormat) { 512 if (dateTimeFormat === "time" || dateTimeFormat === "dateTime") { 513 return "fxviewtabrow-time"; 514 } 515 return null; 516 } 517 518 formatURIForDisplay(uriString) { 519 return !window.IS_STORYBOOK 520 ? lazy.BrowserUtils.formatURIStringForDisplay(uriString, { 521 showFilenameForLocalURIs: true, 522 }) 523 : uriString; 524 } 525 526 getImageUrl(icon, targetURI) { 527 if (window.IS_STORYBOOK) { 528 return `chrome://global/skin/icons/defaultFavicon.svg`; 529 } 530 if (!icon) { 531 if (targetURI?.startsWith("moz-extension")) { 532 return "chrome://mozapps/skin/extensions/extension.svg"; 533 } 534 return `chrome://global/skin/icons/defaultFavicon.svg`; 535 } 536 // If the icon is not for website (doesn't begin with http), we 537 // display it directly. Otherwise we go through the page-icon 538 // protocol to try to get a cached version. We don't load 539 // favicons directly. 540 if (icon.startsWith("http")) { 541 return `page-icon:${targetURI}`; 542 } 543 return icon; 544 } 545 546 primaryActionHandler(event) { 547 if ( 548 (event.type == "click" && !event.altKey) || 549 (event.type == "keydown" && event.code == "Enter") || 550 (event.type == "keydown" && event.code == "Space") 551 ) { 552 event.preventDefault(); 553 if (!window.IS_STORYBOOK) { 554 this.dispatchEvent( 555 new CustomEvent("fxview-tab-list-primary-action", { 556 bubbles: true, 557 composed: true, 558 detail: { originalEvent: event, item: this }, 559 }) 560 ); 561 } 562 } 563 } 564 565 secondaryActionHandler(event) { 566 if ( 567 (event.type == "click" && event.detail && !event.altKey) || 568 // detail=0 is from keyboard 569 (event.type == "click" && !event.detail) 570 ) { 571 event.preventDefault(); 572 this.dispatchEvent( 573 new CustomEvent("fxview-tab-list-secondary-action", { 574 bubbles: true, 575 composed: true, 576 detail: { originalEvent: event, item: this }, 577 }) 578 ); 579 } 580 } 581 582 tertiaryActionHandler(event) { 583 if ( 584 (event.type == "click" && event.detail && !event.altKey) || 585 // detail=0 is from keyboard 586 (event.type == "click" && !event.detail) 587 ) { 588 event.preventDefault(); 589 this.dispatchEvent( 590 new CustomEvent("fxview-tab-list-tertiary-action", { 591 bubbles: true, 592 composed: true, 593 detail: { originalEvent: event, item: this }, 594 }) 595 ); 596 } 597 } 598 599 /** 600 * Find all matches of query within the given string, and compute the result 601 * to be rendered. 602 * 603 * @param {string} query 604 * @param {string} string 605 */ 606 highlightSearchMatches(query, string) { 607 const fragments = []; 608 const regex = RegExp(escapeRegExp(query), "dgi"); 609 let prevIndexEnd = 0; 610 let result; 611 while ((result = regex.exec(string)) !== null) { 612 const [indexStart, indexEnd] = result.indices[0]; 613 fragments.push(string.substring(prevIndexEnd, indexStart)); 614 fragments.push( 615 html`<strong>${string.substring(indexStart, indexEnd)}</strong>` 616 ); 617 prevIndexEnd = regex.lastIndex; 618 } 619 fragments.push(string.substring(prevIndexEnd)); 620 return fragments; 621 } 622 623 stylesheets() { 624 return html`<link 625 rel="stylesheet" 626 href="chrome://browser/content/firefoxview/fxview-tab-row.css" 627 />`; 628 } 629 630 faviconTemplate() { 631 return html`<span 632 class="fxview-tab-row-favicon icon" 633 id="fxview-tab-row-favicon" 634 style=${styleMap({ 635 backgroundImage: `url(${this.getImageUrl(this.favicon, this.url)})`, 636 })} 637 ></span>`; 638 } 639 640 titleTemplate() { 641 const title = this.title; 642 return html`<span 643 class="fxview-tab-row-title text-truncated-ellipsis" 644 id="fxview-tab-row-title" 645 dir="auto" 646 > 647 ${when( 648 this.searchQuery, 649 () => this.highlightSearchMatches(this.searchQuery, title), 650 () => title 651 )} 652 </span>`; 653 } 654 655 urlTemplate() { 656 return html`<span 657 class="fxview-tab-row-url text-truncated-ellipsis" 658 id="fxview-tab-row-url" 659 > 660 ${when( 661 this.searchQuery, 662 () => 663 this.highlightSearchMatches( 664 this.searchQuery, 665 this.formatURIForDisplay(this.url) 666 ), 667 () => this.formatURIForDisplay(this.url) 668 )} 669 </span>`; 670 } 671 672 dateTemplate() { 673 const relativeString = this.relativeTime( 674 this.time, 675 this.dateTimeFormat, 676 !window.IS_STORYBOOK ? this.timeMsPref : NOW_THRESHOLD_MS 677 ); 678 const dateString = this.dateFluentId( 679 this.time, 680 this.dateTimeFormat, 681 !window.IS_STORYBOOK ? this.timeMsPref : NOW_THRESHOLD_MS 682 ); 683 const dateArgs = this.dateFluentArgs(this.time, this.dateTimeFormat); 684 return html`<span class="fxview-tab-row-date" id="fxview-tab-row-date"> 685 <span 686 ?hidden=${relativeString || !dateString} 687 data-l10n-id=${ifDefined(dateString)} 688 data-l10n-args=${ifDefined(dateArgs)} 689 ></span> 690 <span ?hidden=${!relativeString}>${relativeString}</span> 691 </span>`; 692 } 693 694 timeTemplate() { 695 const timeString = this.timeFluentId(this.dateTimeFormat); 696 const time = this.time; 697 const timeArgs = JSON.stringify({ time }); 698 return html`<span 699 class="fxview-tab-row-time" 700 id="fxview-tab-row-time" 701 ?hidden=${!timeString} 702 data-timestamp=${ifDefined(this.time)} 703 data-l10n-id=${ifDefined(timeString)} 704 data-l10n-args=${ifDefined(timeArgs)} 705 > 706 </span>`; 707 } 708 709 getIconSrc(actionClass) { 710 let iconSrc; 711 switch (actionClass) { 712 case "delete-button": 713 iconSrc = "chrome://global/skin/icons/delete.svg"; 714 break; 715 case "dismiss-button": 716 iconSrc = "chrome://global/skin/icons/close.svg"; 717 break; 718 case "options-button": 719 iconSrc = "chrome://global/skin/icons/more.svg"; 720 break; 721 default: 722 iconSrc = null; 723 break; 724 } 725 return iconSrc; 726 } 727 728 secondaryButtonTemplate() { 729 return html`${when( 730 this.secondaryL10nId && this.secondaryActionHandler, 731 () => 732 html`<moz-button 733 type="icon ghost" 734 class=${classMap({ 735 "fxview-tab-row-button": true, 736 [this.secondaryActionClass]: this.secondaryActionClass, 737 })} 738 id="fxview-tab-row-secondary-button" 739 data-l10n-id=${this.secondaryL10nId} 740 data-l10n-args=${ifDefined(this.secondaryL10nArgs)} 741 aria-haspopup=${ifDefined(this.hasPopup)} 742 @click=${this.secondaryActionHandler} 743 tabindex=${this.active && 744 this.currentActiveElementId === "fxview-tab-row-secondary-button" 745 ? "0" 746 : "-1"} 747 iconSrc=${this.getIconSrc(this.secondaryActionClass)} 748 ></moz-button>` 749 )}`; 750 } 751 752 tertiaryButtonTemplate() { 753 return html`${when( 754 this.tertiaryL10nId && this.tertiaryActionHandler, 755 () => 756 html`<moz-button 757 type="icon ghost" 758 class=${classMap({ 759 "fxview-tab-row-button": true, 760 [this.tertiaryActionClass]: this.tertiaryActionClass, 761 })} 762 id="fxview-tab-row-tertiary-button" 763 data-l10n-id=${this.tertiaryL10nId} 764 data-l10n-args=${ifDefined(this.tertiaryL10nArgs)} 765 aria-haspopup=${ifDefined(this.hasPopup)} 766 @click=${this.tertiaryActionHandler} 767 tabindex=${this.active && 768 this.currentActiveElementId === "fxview-tab-row-tertiary-button" 769 ? "0" 770 : "-1"} 771 iconSrc=${this.getIconSrc(this.tertiaryActionClass)} 772 ></moz-button>` 773 )}`; 774 } 775 } 776 777 export class FxviewTabRow extends FxviewTabRowBase { 778 render() { 779 return html` 780 ${this.stylesheets()} 781 <a 782 href=${ifDefined(this.url)} 783 class="fxview-tab-row-main" 784 id="fxview-tab-row-main" 785 tabindex=${this.active && 786 this.currentActiveElementId === "fxview-tab-row-main" 787 ? "0" 788 : "-1"} 789 data-l10n-id=${ifDefined(this.primaryL10nId)} 790 data-l10n-args=${ifDefined(this.primaryL10nArgs)} 791 @click=${this.primaryActionHandler} 792 @keydown=${this.primaryActionHandler} 793 title=${!this.primaryL10nId ? this.url : null} 794 > 795 ${this.faviconTemplate()} ${this.titleTemplate()} 796 ${when( 797 !this.compact, 798 () => 799 html`${this.urlTemplate()} ${this.dateTemplate()} 800 ${this.timeTemplate()}` 801 )} 802 </a> 803 ${this.secondaryButtonTemplate()} ${this.tertiaryButtonTemplate()} 804 `; 805 } 806 } 807 808 customElements.define("fxview-tab-row", FxviewTabRow); 809 810 export class VirtualList extends MozLitElement { 811 static properties = { 812 items: { type: Array }, 813 template: { type: Function }, 814 activeIndex: { type: Number }, 815 itemOffset: { type: Number }, 816 maxRenderCountEstimate: { type: Number, state: true }, 817 itemHeightEstimate: { type: Number, state: true }, 818 isAlwaysVisible: { type: Boolean }, 819 isVisible: { type: Boolean, state: true }, 820 isSubList: { type: Boolean }, 821 pinnedTabsIndexOffset: { type: Number }, 822 }; 823 824 createRenderRoot() { 825 return this; 826 } 827 828 constructor() { 829 super(); 830 this.activeIndex = 0; 831 this.itemOffset = 0; 832 this.pinnedTabsIndexOffset = 0; 833 this.items = []; 834 this.subListItems = []; 835 this.itemHeightEstimate = FXVIEW_ROW_HEIGHT_PX; 836 this.maxRenderCountEstimate = Math.max( 837 40, 838 2 * Math.ceil(window.innerHeight / this.itemHeightEstimate) 839 ); 840 this.isSubList = false; 841 this.isVisible = false; 842 this.intersectionObserver = new IntersectionObserver( 843 ([entry]) => { 844 this.isVisible = entry.isIntersecting; 845 }, 846 { root: this.ownerDocument } 847 ); 848 this.selfResizeObserver = new ResizeObserver(() => { 849 // Trigger the intersection observer once the tab rows have rendered 850 this.triggerIntersectionObserver(); 851 }); 852 this.childResizeObserver = new ResizeObserver(([entry]) => { 853 if (entry.contentRect?.height > 0) { 854 // Update properties on top-level virtual-list 855 this.parentElement.itemHeightEstimate = entry.contentRect.height; 856 this.parentElement.maxRenderCountEstimate = Math.max( 857 40, 858 2 * Math.ceil(window.innerHeight / this.itemHeightEstimate) 859 ); 860 } 861 }); 862 } 863 864 disconnectedCallback() { 865 super.disconnectedCallback(); 866 this.intersectionObserver.disconnect(); 867 this.childResizeObserver.disconnect(); 868 this.selfResizeObserver.disconnect(); 869 } 870 871 triggerIntersectionObserver() { 872 this.intersectionObserver.unobserve(this); 873 this.intersectionObserver.observe(this); 874 } 875 876 getSubListForItem(index) { 877 if (this.isSubList) { 878 throw new Error("Cannot get sublist for item"); 879 } 880 return this.children[parseInt(index / this.maxRenderCountEstimate, 10)]; 881 } 882 883 getItem(index) { 884 if (!this.isSubList) { 885 return this.getSubListForItem(index)?.getItem( 886 index % this.maxRenderCountEstimate 887 ); 888 } 889 return this.children[index]; 890 } 891 892 willUpdate(changedProperties) { 893 if (changedProperties.has("items") && !this.isSubList) { 894 this.subListItems = []; 895 for (let i = 0; i < this.items.length; i += this.maxRenderCountEstimate) { 896 this.subListItems.push( 897 this.items.slice(i, i + this.maxRenderCountEstimate) 898 ); 899 } 900 } 901 } 902 903 recalculateAfterWindowResize() { 904 this.maxRenderCountEstimate = Math.max( 905 40, 906 2 * Math.ceil(window.innerHeight / this.itemHeightEstimate) 907 ); 908 } 909 910 firstUpdated() { 911 this.intersectionObserver.observe(this); 912 this.selfResizeObserver.observe(this); 913 if (this.isSubList && this.children[0]) { 914 this.childResizeObserver.observe(this.children[0]); 915 } 916 } 917 918 updated(changedProperties) { 919 this.updateListHeight(changedProperties); 920 if (changedProperties.has("items") && !this.isSubList) { 921 this.triggerIntersectionObserver(); 922 } 923 } 924 925 updateListHeight(changedProperties) { 926 if ( 927 changedProperties.has("isAlwaysVisible") || 928 changedProperties.has("isVisible") 929 ) { 930 this.style.height = 931 this.isAlwaysVisible || this.isVisible 932 ? "auto" 933 : `${this.items.length * this.itemHeightEstimate}px`; 934 } 935 } 936 937 get renderItems() { 938 return this.isSubList ? this.items : this.subListItems; 939 } 940 941 subListTemplate = (data, i) => { 942 return html`<virtual-list 943 .template=${this.template} 944 .items=${data} 945 .itemHeightEstimate=${this.itemHeightEstimate} 946 .itemOffset=${i * this.maxRenderCountEstimate + 947 this.pinnedTabsIndexOffset} 948 .isAlwaysVisible=${i == 949 parseInt(this.activeIndex / this.maxRenderCountEstimate, 10)} 950 isSubList 951 ></virtual-list>`; 952 }; 953 954 itemTemplate = (data, i) => 955 this.template(data, this.itemOffset + i + this.pinnedTabsIndexOffset); 956 957 render() { 958 if (this.isAlwaysVisible || this.isVisible) { 959 return html` 960 ${repeat( 961 this.renderItems, 962 (data, i) => i, 963 this.isSubList ? this.itemTemplate : this.subListTemplate 964 )} 965 `; 966 } 967 return ""; 968 } 969 } 970 customElements.define("virtual-list", VirtualList);