opentabs-tab-list.mjs (18856B)
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 styleMap, 10 when, 11 } from "chrome://global/content/vendor/lit.all.mjs"; 12 import { 13 FxviewTabListBase, 14 FxviewTabRowBase, 15 } from "chrome://browser/content/firefoxview/fxview-tab-list.mjs"; 16 // eslint-disable-next-line import/no-unassigned-import 17 import "chrome://global/content/elements/moz-button.mjs"; 18 19 const lazy = {}; 20 let XPCOMUtils; 21 22 XPCOMUtils = ChromeUtils.importESModule( 23 "resource://gre/modules/XPCOMUtils.sys.mjs" 24 ).XPCOMUtils; 25 XPCOMUtils.defineLazyPreferenceGetter( 26 lazy, 27 "virtualListEnabledPref", 28 "browser.firefox-view.virtual-list.enabled" 29 ); 30 31 /** 32 * A list of clickable tab items 33 * 34 * @property {boolean} pinnedTabsGridView - Whether to show pinned tabs in a grid view 35 */ 36 37 export class OpenTabsTabList extends FxviewTabListBase { 38 constructor() { 39 super(); 40 this.pinnedTabsGridView = false; 41 this.pinnedTabs = []; 42 this.unpinnedTabs = []; 43 } 44 45 static properties = { 46 pinnedTabsGridView: { type: Boolean }, 47 }; 48 49 static queries = { 50 ...FxviewTabListBase.queries, 51 rowEls: { 52 all: "opentabs-tab-row", 53 }, 54 }; 55 56 willUpdate(changes) { 57 this.activeIndex = Math.min( 58 Math.max(this.activeIndex, 0), 59 this.tabItems.length - 1 60 ); 61 62 if (changes.has("dateTimeFormat") || changes.has("updatesPaused")) { 63 this.clearIntervalTimer(); 64 if (!this.updatesPaused && this.dateTimeFormat == "relative") { 65 this.startIntervalTimer(); 66 this.onIntervalUpdate(); 67 } 68 } 69 70 // Move pinned tabs to the beginning of the list 71 if (this.pinnedTabsGridView) { 72 // Can set maxTabsLength to -1 to have no max 73 this.unpinnedTabs = this.tabItems.filter( 74 tab => !tab.indicators.includes("pinned") 75 ); 76 this.pinnedTabs = this.tabItems.filter(tab => 77 tab.indicators.includes("pinned") 78 ); 79 if (this.maxTabsLength > 0) { 80 this.unpinnedTabs = this.unpinnedTabs.slice(0, this.maxTabsLength); 81 } 82 this.tabItems = [...this.pinnedTabs, ...this.unpinnedTabs]; 83 } else if (this.maxTabsLength > 0) { 84 this.tabItems = this.tabItems.slice(0, this.maxTabsLength); 85 } 86 } 87 88 /** 89 * Focuses the expected element (either the link or button) within fxview-tab-row 90 * The currently focused/active element ID within a row is stored in this.currentActiveElementId 91 */ 92 handleFocusElementInRow(e) { 93 let fxviewTabRow = e.target; 94 if (e.code == "ArrowUp") { 95 // Focus either the link or button of the previous row based on this.currentActiveElementId 96 e.preventDefault(); 97 if ( 98 (this.pinnedTabsGridView && 99 this.activeIndex >= this.pinnedTabs.length) || 100 !this.pinnedTabsGridView 101 ) { 102 this.focusPrevRow(); 103 } 104 } else if (e.code == "ArrowDown") { 105 // Focus either the link or button of the next row based on this.currentActiveElementId 106 e.preventDefault(); 107 if ( 108 this.pinnedTabsGridView && 109 this.activeIndex < this.pinnedTabs.length 110 ) { 111 this.focusIndex(this.pinnedTabs.length); 112 } else { 113 this.focusNextRow(); 114 } 115 } else if (e.code == "ArrowRight") { 116 // Focus either the link or the button in the current row and 117 // set this.currentActiveElementId to that element's ID 118 e.preventDefault(); 119 if (document.dir == "rtl") { 120 fxviewTabRow.moveFocusLeft(); 121 } else { 122 fxviewTabRow.moveFocusRight(); 123 } 124 } else if (e.code == "ArrowLeft") { 125 // Focus either the link or the button in the current row and 126 // set this.currentActiveElementId to that element's ID 127 e.preventDefault(); 128 if (document.dir == "rtl") { 129 fxviewTabRow.moveFocusRight(); 130 } else { 131 fxviewTabRow.moveFocusLeft(); 132 } 133 } 134 } 135 136 async focusIndex(index) { 137 // Focus link or button of item 138 if ( 139 ((this.pinnedTabsGridView && index > this.pinnedTabs.length) || 140 !this.pinnedTabsGridView) && 141 lazy.virtualListEnabledPref 142 ) { 143 let row = this.rootVirtualListEl.getItem(index - this.pinnedTabs.length); 144 if (!row) { 145 return; 146 } 147 let subList = this.rootVirtualListEl.getSubListForItem( 148 index - this.pinnedTabs.length 149 ); 150 if (!subList) { 151 return; 152 } 153 this.activeIndex = index; 154 155 // In Bug 1866845, these manual updates to the sublists should be removed 156 // and scrollIntoView() should also be iterated on so that we aren't constantly 157 // moving the focused item to the center of the viewport 158 for (const sublist of Array.from(this.rootVirtualListEl.children)) { 159 await sublist.requestUpdate(); 160 await sublist.updateComplete; 161 } 162 row.scrollIntoView({ block: "center" }); 163 row.focus(); 164 } else if (index >= 0 && index < this.rowEls?.length) { 165 this.rowEls[index].focus(); 166 this.activeIndex = index; 167 } 168 } 169 170 #getTabListWrapperClasses() { 171 let wrapperClasses = ["fxview-tab-list"]; 172 let tabsToCheck = this.pinnedTabsGridView 173 ? this.unpinnedTabs 174 : this.tabItems; 175 if (tabsToCheck.some(tab => tab.containerObj)) { 176 wrapperClasses.push(`hasContainerTab`); 177 } 178 return wrapperClasses; 179 } 180 181 itemTemplate = (tabItem, i) => { 182 let time; 183 if (tabItem.time || tabItem.closedAt) { 184 let stringTime = (tabItem.time || tabItem.closedAt).toString(); 185 // Different APIs return time in different units, so we use 186 // the length to decide if it's milliseconds or nanoseconds. 187 if (stringTime.length === 16) { 188 time = (tabItem.time || tabItem.closedAt) / 1000; 189 } else { 190 time = tabItem.time || tabItem.closedAt; 191 } 192 } 193 194 return html`<opentabs-tab-row 195 ?active=${i == this.activeIndex} 196 class=${classMap({ 197 pinned: 198 this.pinnedTabsGridView && tabItem.indicators?.includes("pinned"), 199 })} 200 .currentActiveElementId=${this.currentActiveElementId} 201 .favicon=${tabItem.icon} 202 .compact=${this.compactRows} 203 .containerObj=${ifDefined(tabItem.containerObj)} 204 .indicators=${tabItem.indicators} 205 .pinnedTabsGridView=${ifDefined(this.pinnedTabsGridView)} 206 .primaryL10nId=${tabItem.primaryL10nId} 207 .primaryL10nArgs=${ifDefined(tabItem.primaryL10nArgs)} 208 .secondaryL10nId=${tabItem.secondaryL10nId} 209 .secondaryL10nArgs=${ifDefined(tabItem.secondaryL10nArgs)} 210 .tertiaryL10nId=${ifDefined(tabItem.tertiaryL10nId)} 211 .tertiaryL10nArgs=${ifDefined(tabItem.tertiaryL10nArgs)} 212 .secondaryActionClass=${this.secondaryActionClass} 213 .tertiaryActionClass=${ifDefined(this.tertiaryActionClass)} 214 .sourceClosedId=${ifDefined(tabItem.sourceClosedId)} 215 .sourceWindowId=${ifDefined(tabItem.sourceWindowId)} 216 .closedId=${ifDefined(tabItem.closedId || tabItem.closedId)} 217 role=${tabItem.pinned && this.pinnedTabsGridView ? "tab" : "listitem"} 218 .tabElement=${ifDefined(tabItem.tabElement)} 219 .time=${ifDefined(time)} 220 .title=${tabItem.title} 221 .url=${tabItem.url} 222 .searchQuery=${ifDefined(this.searchQuery)} 223 .timeMsPref=${ifDefined(this.timeMsPref)} 224 .hasPopup=${this.hasPopup} 225 .dateTimeFormat=${this.dateTimeFormat} 226 ></opentabs-tab-row>`; 227 }; 228 229 render() { 230 if (this.searchQuery && this.tabItems.length === 0) { 231 return this.emptySearchResultsTemplate(); 232 } 233 return html` 234 ${this.stylesheets()} 235 <link 236 rel="stylesheet" 237 href="chrome://browser/content/firefoxview/opentabs-tab-list.css" 238 /> 239 ${when( 240 this.pinnedTabsGridView && this.pinnedTabs.length, 241 () => html` 242 <div 243 id="fxview-tab-list" 244 class="fxview-tab-list pinned" 245 data-l10n-id="firefoxview-pinned-tabs" 246 role="tablist" 247 @keydown=${this.handleFocusElementInRow} 248 > 249 ${this.pinnedTabs.map((tabItem, i) => 250 this.customItemTemplate 251 ? this.customItemTemplate(tabItem, i) 252 : this.itemTemplate(tabItem, i) 253 )} 254 </div> 255 ` 256 )} 257 <div 258 id="fxview-tab-list" 259 class=${this.#getTabListWrapperClasses().join(" ")} 260 data-l10n-id="firefoxview-tabs" 261 role="list" 262 @keydown=${this.handleFocusElementInRow} 263 > 264 ${when( 265 lazy.virtualListEnabledPref, 266 () => html` 267 <virtual-list 268 .activeIndex=${this.activeIndex} 269 .pinnedTabsIndexOffset=${this.pinnedTabsGridView 270 ? this.pinnedTabs.length 271 : 0} 272 .items=${this.pinnedTabsGridView 273 ? this.unpinnedTabs 274 : this.tabItems} 275 .template=${this.itemTemplate} 276 ></virtual-list> 277 `, 278 () => 279 html`${this.tabItems.map((tabItem, i) => 280 this.itemTemplate(tabItem, i) 281 )}` 282 )} 283 </div> 284 <slot name="menu"></slot> 285 `; 286 } 287 } 288 customElements.define("opentabs-tab-list", OpenTabsTabList); 289 290 /** 291 * A tab item that displays favicon, title, url, and time of last access 292 * 293 * @property {object} containerObj - Info about an open tab's container if within one 294 * @property {string} indicators - An array of tab indicators if any are present 295 * @property {boolean} pinnedTabsGridView - Whether the show pinned tabs in a grid view 296 */ 297 298 export class OpenTabsTabRow extends FxviewTabRowBase { 299 constructor() { 300 super(); 301 this.indicators = []; 302 this.pinnedTabsGridView = false; 303 } 304 305 static properties = { 306 ...FxviewTabRowBase.properties, 307 containerObj: { type: Object }, 308 indicators: { type: Array }, 309 pinnedTabsGridView: { type: Boolean }, 310 }; 311 312 static queries = { 313 ...FxviewTabRowBase.queries, 314 mediaButtonEl: "#fxview-tab-row-media-button", 315 pinnedTabButtonEl: "moz-button#fxview-tab-row-main", 316 }; 317 318 connectedCallback() { 319 super.connectedCallback(); 320 this.addEventListener("keydown", this.handleKeydown); 321 } 322 323 disconnectedCallback() { 324 super.disconnectedCallback(); 325 this.removeEventListener("keydown", this.handleKeydown); 326 } 327 328 handleKeydown(e) { 329 if ( 330 this.active && 331 this.pinnedTabsGridView && 332 this.indicators?.includes("pinned") && 333 e.key === "m" && 334 e.ctrlKey 335 ) { 336 this.muteOrUnmuteTab(); 337 } 338 } 339 340 moveFocusRight() { 341 let tabList = this.getRootNode().host; 342 if (this.pinnedTabsGridView && this.indicators?.includes("pinned")) { 343 tabList.focusNextRow(); 344 } else if ( 345 (this.indicators?.includes("soundplaying") || 346 this.indicators?.includes("muted")) && 347 this.currentActiveElementId === "fxview-tab-row-main" 348 ) { 349 this.focusMediaButton(); 350 } else if ( 351 this.currentActiveElementId === "fxview-tab-row-media-button" || 352 this.currentActiveElementId === "fxview-tab-row-main" 353 ) { 354 this.focusSecondaryButton(); 355 } else if ( 356 this.tertiaryButtonEl && 357 this.currentActiveElementId === "fxview-tab-row-secondary-button" 358 ) { 359 this.focusTertiaryButton(); 360 } 361 } 362 363 moveFocusLeft() { 364 let tabList = this.getRootNode().host; 365 if ( 366 this.pinnedTabsGridView && 367 (this.indicators?.includes("pinned") || 368 (tabList.currentActiveElementId === "fxview-tab-row-main" && 369 tabList.activeIndex === tabList.pinnedTabs.length)) 370 ) { 371 tabList.focusPrevRow(); 372 } else if ( 373 tabList.currentActiveElementId === "fxview-tab-row-tertiary-button" 374 ) { 375 this.focusSecondaryButton(); 376 } else if ( 377 (this.indicators?.includes("soundplaying") || 378 this.indicators?.includes("muted")) && 379 tabList.currentActiveElementId === "fxview-tab-row-secondary-button" 380 ) { 381 this.focusMediaButton(); 382 } else { 383 this.focusLink(); 384 } 385 } 386 387 focusMediaButton() { 388 let tabList = this.getRootNode().host; 389 this.mediaButtonEl.focus(); 390 tabList.currentActiveElementId = this.mediaButtonEl.id; 391 } 392 393 #secondaryActionHandler(event) { 394 if ( 395 (this.pinnedTabsGridView && 396 this.indicators?.includes("pinned") && 397 event.type == "contextmenu") || 398 (event.type == "click" && event.detail && !event.altKey) || 399 // detail=0 is from keyboard 400 (event.type == "click" && !event.detail) 401 ) { 402 event.preventDefault(); 403 this.dispatchEvent( 404 new CustomEvent("fxview-tab-list-secondary-action", { 405 bubbles: true, 406 composed: true, 407 detail: { originalEvent: event, item: this }, 408 }) 409 ); 410 } 411 } 412 413 #faviconTemplate() { 414 return html`<span 415 class=${classMap({ 416 "fxview-tab-row-favicon-wrapper": true, 417 pinned: this.indicators?.includes("pinned"), 418 pinnedOnNewTab: this.indicators?.includes("pinnedOnNewTab"), 419 attention: this.indicators?.includes("attention"), 420 bookmark: this.indicators?.includes("bookmark"), 421 })} 422 > 423 <span 424 class="fxview-tab-row-favicon icon" 425 id="fxview-tab-row-favicon" 426 style=${styleMap({ 427 backgroundImage: `url(${this.getImageUrl(this.favicon, this.url)})`, 428 })} 429 ></span> 430 ${when( 431 this.pinnedTabsGridView && 432 this.indicators?.includes("pinned") && 433 (this.indicators?.includes("muted") || 434 this.indicators?.includes("soundplaying")), 435 () => html` 436 <button 437 class="fxview-tab-row-pinned-media-button" 438 id="fxview-tab-row-media-button" 439 tabindex="-1" 440 data-l10n-id=${this.indicators?.includes("muted") 441 ? "fxviewtabrow-unmute-tab-button-no-context" 442 : "fxviewtabrow-mute-tab-button-no-context"} 443 muted=${this.indicators?.includes("muted")} 444 soundplaying=${this.indicators?.includes("soundplaying") && 445 !this.indicators?.includes("muted")} 446 @click=${this.muteOrUnmuteTab} 447 ></button> 448 ` 449 )} 450 </span>`; 451 } 452 453 #getContainerClasses() { 454 let containerClasses = ["fxview-tab-row-container-indicator", "icon"]; 455 if (this.containerObj) { 456 let { icon, color } = this.containerObj; 457 containerClasses.push(`identity-icon-${icon}`); 458 containerClasses.push(`identity-color-${color}`); 459 } 460 return containerClasses; 461 } 462 463 muteOrUnmuteTab(e) { 464 e?.preventDefault(); 465 // If the tab has no sound playing, the mute/unmute button will be removed when toggled. 466 // We should move the focus to the right in that case. This does not apply to pinned tabs 467 // on the Open Tabs page. 468 let shouldMoveFocus = 469 (!this.pinnedTabsGridView || 470 (!this.indicators.includes("pinned") && this.pinnedTabsGridView)) && 471 this.mediaButtonEl && 472 !this.indicators.includes("soundplaying") && 473 this.currentActiveElementId === "fxview-tab-row-media-button"; 474 475 // detail=0 is from keyboard 476 if (e?.type == "click" && !e?.detail && shouldMoveFocus) { 477 if (document.dir == "rtl") { 478 this.moveFocusLeft(); 479 } else { 480 this.moveFocusRight(); 481 } 482 } 483 this.tabElement.toggleMuteAudio(); 484 } 485 486 #mediaButtonTemplate() { 487 return html`${when( 488 this.indicators?.includes("soundplaying") || 489 this.indicators?.includes("muted"), 490 () => 491 html`<moz-button 492 type="icon ghost" 493 class="fxview-tab-row-button" 494 id="fxview-tab-row-media-button" 495 data-l10n-id=${this.indicators?.includes("muted") 496 ? "fxviewtabrow-unmute-tab-button-no-context" 497 : "fxviewtabrow-mute-tab-button-no-context"} 498 muted=${this.indicators?.includes("muted")} 499 soundplaying=${this.indicators?.includes("soundplaying") && 500 !this.indicators?.includes("muted")} 501 @click=${this.muteOrUnmuteTab} 502 tabindex=${this.active && 503 this.currentActiveElementId === "fxview-tab-row-media-button" 504 ? "0" 505 : "-1"} 506 ></moz-button>`, 507 () => html`<span></span>` 508 )}`; 509 } 510 511 #containerIndicatorTemplate() { 512 let tabList = this.getRootNode().host; 513 let tabsToCheck = tabList.pinnedTabsGridView 514 ? tabList.unpinnedTabs 515 : tabList.tabItems; 516 return html`${when( 517 tabsToCheck.some(tab => tab.containerObj), 518 () => html`<span class=${this.#getContainerClasses().join(" ")}></span>` 519 )}`; 520 } 521 522 #pinnedTabItemTemplate() { 523 return html` 524 <moz-button 525 type="icon ghost" 526 id="fxview-tab-row-main" 527 aria-haspopup=${ifDefined(this.hasPopup)} 528 data-l10n-id=${ifDefined(this.primaryL10nId)} 529 data-l10n-args=${ifDefined(this.primaryL10nArgs)} 530 tabindex=${this.active && 531 this.currentActiveElementId === "fxview-tab-row-main" 532 ? "0" 533 : "-1"} 534 role="tab" 535 @click=${this.primaryActionHandler} 536 @keydown=${this.primaryActionHandler} 537 @contextmenu=${this.#secondaryActionHandler} 538 > 539 ${this.#faviconTemplate()} 540 </moz-button> 541 `; 542 } 543 544 #unpinnedTabItemTemplate() { 545 return html`<a 546 href=${ifDefined(this.url)} 547 class="fxview-tab-row-main" 548 id="fxview-tab-row-main" 549 tabindex=${this.active && 550 this.currentActiveElementId === "fxview-tab-row-main" 551 ? "0" 552 : "-1"} 553 data-l10n-id=${ifDefined(this.primaryL10nId)} 554 data-l10n-args=${ifDefined(this.primaryL10nArgs)} 555 @click=${this.primaryActionHandler} 556 @keydown=${this.primaryActionHandler} 557 title=${!this.primaryL10nId ? this.url : null} 558 > 559 ${this.#faviconTemplate()} ${this.titleTemplate()} 560 ${when( 561 !this.compact, 562 () => 563 html`${this.#containerIndicatorTemplate()} ${this.urlTemplate()} 564 ${this.dateTemplate()} ${this.timeTemplate()}` 565 )} 566 </a> 567 ${this.#mediaButtonTemplate()} ${this.secondaryButtonTemplate()} 568 ${this.tertiaryButtonTemplate()}`; 569 } 570 571 render() { 572 return html` 573 ${this.stylesheets()} 574 <link 575 rel="stylesheet" 576 href="chrome://browser/content/firefoxview/opentabs-tab-row.css" 577 /> 578 ${when( 579 this.containerObj, 580 () => html` 581 <link 582 rel="stylesheet" 583 href="chrome://browser/content/usercontext/usercontext.css" 584 /> 585 ` 586 )} 587 ${when( 588 this.pinnedTabsGridView && this.indicators?.includes("pinned"), 589 this.#pinnedTabItemTemplate.bind(this), 590 this.#unpinnedTabItemTemplate.bind(this) 591 )} 592 `; 593 } 594 } 595 customElements.define("opentabs-tab-row", OpenTabsTabRow);