summaryrefslogtreecommitdiffstats
path: root/browser/components/firefoxview/fxview-tab-list.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/firefoxview/fxview-tab-list.mjs')
-rw-r--r--browser/components/firefoxview/fxview-tab-list.mjs919
1 files changed, 919 insertions, 0 deletions
diff --git a/browser/components/firefoxview/fxview-tab-list.mjs b/browser/components/firefoxview/fxview-tab-list.mjs
new file mode 100644
index 0000000000..055540722a
--- /dev/null
+++ b/browser/components/firefoxview/fxview-tab-list.mjs
@@ -0,0 +1,919 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import {
+ classMap,
+ html,
+ ifDefined,
+ repeat,
+ styleMap,
+ when,
+} from "chrome://global/content/vendor/lit.all.mjs";
+import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
+import { escapeRegExp } from "./helpers.mjs";
+
+const NOW_THRESHOLD_MS = 91000;
+const FXVIEW_ROW_HEIGHT_PX = 32;
+const lazy = {};
+let XPCOMUtils;
+
+if (!window.IS_STORYBOOK) {
+ XPCOMUtils = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+ ).XPCOMUtils;
+ XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "virtualListEnabledPref",
+ "browser.firefox-view.virtual-list.enabled"
+ );
+ ChromeUtils.defineLazyGetter(lazy, "relativeTimeFormat", () => {
+ return new Services.intl.RelativeTimeFormat(undefined, {
+ style: "narrow",
+ });
+ });
+
+ ChromeUtils.defineESModuleGetters(lazy, {
+ BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs",
+ });
+}
+
+/**
+ * A list of clickable tab items
+ *
+ * @property {boolean} compactRows - Whether to hide the URL and date/time for each tab.
+ * @property {string} dateTimeFormat - Expected format for date and/or time
+ * @property {string} hasPopup - The aria-haspopup attribute for the secondary action, if required
+ * @property {number} maxTabsLength - The max number of tabs for the list
+ * @property {Array} tabItems - Items to show in the tab list
+ * @property {string} searchQuery - The query string to highlight, if provided.
+ */
+export default class FxviewTabList extends MozLitElement {
+ constructor() {
+ super();
+ window.MozXULElement.insertFTLIfNeeded("toolkit/branding/brandings.ftl");
+ window.MozXULElement.insertFTLIfNeeded("browser/fxviewTabList.ftl");
+ this.activeIndex = 0;
+ this.currentActiveElementId = "fxview-tab-row-main";
+ this.hasPopup = null;
+ this.dateTimeFormat = "relative";
+ this.maxTabsLength = 25;
+ this.tabItems = [];
+ this.compactRows = false;
+ this.updatesPaused = true;
+ this.#register();
+ }
+
+ static properties = {
+ activeIndex: { type: Number },
+ compactRows: { type: Boolean },
+ currentActiveElementId: { type: String },
+ dateTimeFormat: { type: String },
+ hasPopup: { type: String },
+ maxTabsLength: { type: Number },
+ tabItems: { type: Array },
+ updatesPaused: { type: Boolean },
+ searchQuery: { type: String },
+ };
+
+ static queries = {
+ rowEls: { all: "fxview-tab-row" },
+ rootVirtualListEl: "virtual-list",
+ };
+
+ willUpdate(changes) {
+ this.activeIndex = Math.min(
+ Math.max(this.activeIndex, 0),
+ this.tabItems.length - 1
+ );
+
+ if (changes.has("dateTimeFormat") || changes.has("updatesPaused")) {
+ this.clearIntervalTimer();
+ if (
+ !this.updatesPaused &&
+ this.dateTimeFormat == "relative" &&
+ !window.IS_STORYBOOK
+ ) {
+ this.startIntervalTimer();
+ this.onIntervalUpdate();
+ }
+ }
+
+ if (this.maxTabsLength > 0) {
+ // Can set maxTabsLength to -1 to have no max
+ this.tabItems = this.tabItems.slice(0, this.maxTabsLength);
+ }
+ }
+
+ startIntervalTimer() {
+ this.clearIntervalTimer();
+ this.intervalID = setInterval(
+ () => this.onIntervalUpdate(),
+ this.timeMsPref
+ );
+ }
+
+ clearIntervalTimer() {
+ if (this.intervalID) {
+ clearInterval(this.intervalID);
+ delete this.intervalID;
+ }
+ }
+
+ #register() {
+ if (!window.IS_STORYBOOK) {
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "timeMsPref",
+ "browser.tabs.firefox-view.updateTimeMs",
+ NOW_THRESHOLD_MS,
+ (prefName, oldVal, newVal) => {
+ this.clearIntervalTimer();
+ if (!this.isConnected) {
+ return;
+ }
+ this.startIntervalTimer();
+ this.requestUpdate();
+ }
+ );
+ }
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ if (
+ !this.updatesPaused &&
+ this.dateTimeFormat === "relative" &&
+ !window.IS_STORYBOOK
+ ) {
+ this.startIntervalTimer();
+ }
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ this.clearIntervalTimer();
+ }
+
+ async getUpdateComplete() {
+ await super.getUpdateComplete();
+ await Promise.all(Array.from(this.rowEls).map(item => item.updateComplete));
+ }
+
+ onIntervalUpdate() {
+ this.requestUpdate();
+ Array.from(this.rowEls).forEach(fxviewTabRow =>
+ fxviewTabRow.requestUpdate()
+ );
+ }
+
+ /**
+ * Focuses the expected element (either the link or button) within fxview-tab-row
+ * The currently focused/active element ID within a row is stored in this.currentActiveElementId
+ */
+ handleFocusElementInRow(e) {
+ let fxviewTabRow = e.target;
+ if (e.code == "ArrowUp") {
+ // Focus either the link or button of the previous row based on this.currentActiveElementId
+ e.preventDefault();
+ this.focusPrevRow();
+ } else if (e.code == "ArrowDown") {
+ // Focus either the link or button of the next row based on this.currentActiveElementId
+ e.preventDefault();
+ this.focusNextRow();
+ } else if (e.code == "ArrowRight") {
+ // Focus either the link or the button in the current row and
+ // set this.currentActiveElementId to that element's ID
+ e.preventDefault();
+ if (document.dir == "rtl") {
+ if (
+ (fxviewTabRow.soundPlaying || fxviewTabRow.muted) &&
+ this.currentActiveElementId === "fxview-tab-row-secondary-button"
+ ) {
+ this.currentActiveElementId = fxviewTabRow.focusMediaButton();
+ } else {
+ this.currentActiveElementId = fxviewTabRow.focusLink();
+ }
+ } else if (
+ (fxviewTabRow.soundPlaying || fxviewTabRow.muted) &&
+ this.currentActiveElementId === "fxview-tab-row-main"
+ ) {
+ this.currentActiveElementId = fxviewTabRow.focusMediaButton();
+ } else {
+ this.currentActiveElementId = fxviewTabRow.focusButton();
+ }
+ } else if (e.code == "ArrowLeft") {
+ // Focus either the link or the button in the current row and
+ // set this.currentActiveElementId to that element's ID
+ e.preventDefault();
+ if (document.dir == "rtl") {
+ if (
+ (fxviewTabRow.soundPlaying || fxviewTabRow.muted) &&
+ this.currentActiveElementId === "fxview-tab-row-main"
+ ) {
+ this.currentActiveElementId = fxviewTabRow.focusMediaButton();
+ } else {
+ this.currentActiveElementId = fxviewTabRow.focusButton();
+ }
+ } else if (
+ (fxviewTabRow.soundPlaying || fxviewTabRow.muted) &&
+ this.currentActiveElementId === "fxview-tab-row-secondary-button"
+ ) {
+ this.currentActiveElementId = fxviewTabRow.focusMediaButton();
+ } else {
+ this.currentActiveElementId = fxviewTabRow.focusLink();
+ }
+ }
+ }
+
+ focusPrevRow() {
+ this.focusIndex(this.activeIndex - 1);
+ }
+
+ focusNextRow() {
+ this.focusIndex(this.activeIndex + 1);
+ }
+
+ async focusIndex(index) {
+ // Focus link or button of item
+ if (lazy.virtualListEnabledPref) {
+ let row = this.rootVirtualListEl.getItem(index);
+ if (!row) {
+ return;
+ }
+ let subList = this.rootVirtualListEl.getSubListForItem(index);
+ if (!subList) {
+ return;
+ }
+ this.activeIndex = index;
+
+ // In Bug 1866845, these manual updates to the sublists should be removed
+ // and scrollIntoView() should also be iterated on so that we aren't constantly
+ // moving the focused item to the center of the viewport
+ for (const sublist of Array.from(this.rootVirtualListEl.children)) {
+ await sublist.requestUpdate();
+ await sublist.updateComplete;
+ }
+ row.scrollIntoView({ block: "center" });
+ row.focus();
+ } else if (index >= 0 && index < this.rowEls?.length) {
+ this.rowEls[index].focus();
+ this.activeIndex = index;
+ }
+ }
+
+ shouldUpdate(changes) {
+ if (changes.has("updatesPaused")) {
+ if (this.updatesPaused) {
+ this.clearIntervalTimer();
+ }
+ }
+ return !this.updatesPaused;
+ }
+
+ itemTemplate = (tabItem, i) => {
+ let time;
+ if (tabItem.time || tabItem.closedAt) {
+ let stringTime = (tabItem.time || tabItem.closedAt).toString();
+ // Different APIs return time in different units, so we use
+ // the length to decide if it's milliseconds or nanoseconds.
+ if (stringTime.length === 16) {
+ time = (tabItem.time || tabItem.closedAt) / 1000;
+ } else {
+ time = tabItem.time || tabItem.closedAt;
+ }
+ }
+ return html`
+ <fxview-tab-row
+ exportparts="secondary-button"
+ ?active=${i == this.activeIndex}
+ ?compact=${this.compactRows}
+ .hasPopup=${this.hasPopup}
+ .containerObj=${tabItem.containerObj}
+ .currentActiveElementId=${this.currentActiveElementId}
+ .dateTimeFormat=${this.dateTimeFormat}
+ .favicon=${tabItem.icon}
+ .isBookmark=${ifDefined(tabItem.isBookmark)}
+ .muted=${ifDefined(tabItem.muted)}
+ .pinned=${ifDefined(tabItem.pinned)}
+ .primaryL10nId=${tabItem.primaryL10nId}
+ .primaryL10nArgs=${ifDefined(tabItem.primaryL10nArgs)}
+ role="listitem"
+ .secondaryL10nId=${tabItem.secondaryL10nId}
+ .secondaryL10nArgs=${ifDefined(tabItem.secondaryL10nArgs)}
+ .attention=${ifDefined(tabItem.attention)}
+ .soundPlaying=${ifDefined(tabItem.soundPlaying)}
+ .sourceClosedId=${ifDefined(tabItem.sourceClosedId)}
+ .sourceWindowId=${ifDefined(tabItem.sourceWindowId)}
+ .closedId=${ifDefined(tabItem.closedId || tabItem.closedId)}
+ .searchQuery=${ifDefined(this.searchQuery)}
+ .tabElement=${ifDefined(tabItem.tabElement)}
+ .time=${ifDefined(time)}
+ .timeMsPref=${ifDefined(this.timeMsPref)}
+ .title=${tabItem.title}
+ .titleChanged=${ifDefined(tabItem.titleChanged)}
+ .url=${tabItem.url}
+ ></fxview-tab-row>
+ `;
+ };
+
+ render() {
+ if (this.searchQuery && this.tabItems.length === 0) {
+ return this.#emptySearchResultsTemplate();
+ }
+ return html`
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/firefoxview/fxview-tab-list.css"
+ />
+ <div
+ id="fxview-tab-list"
+ class="fxview-tab-list"
+ role="list"
+ @keydown=${this.handleFocusElementInRow}
+ >
+ ${when(
+ lazy.virtualListEnabledPref,
+ () => html`
+ <virtual-list
+ .activeIndex=${this.activeIndex}
+ .items=${this.tabItems}
+ .template=${this.itemTemplate}
+ ></virtual-list>
+ `
+ )}
+ ${when(
+ !lazy.virtualListEnabledPref,
+ () => html`
+ ${this.tabItems.map((tabItem, i) => this.itemTemplate(tabItem, i))}
+ `
+ )}
+ </div>
+ <slot name="menu"></slot>
+ `;
+ }
+
+ #emptySearchResultsTemplate() {
+ return html` <fxview-empty-state
+ class="search-results"
+ headerLabel="firefoxview-search-results-empty"
+ .headerArgs=${{ query: this.searchQuery }}
+ isInnerCard
+ >
+ </fxview-empty-state>`;
+ }
+}
+customElements.define("fxview-tab-list", FxviewTabList);
+
+/**
+ * A tab item that displays favicon, title, url, and time of last access
+ *
+ * @property {boolean} active - Should current item have focus on keydown
+ * @property {boolean} compact - Whether to hide the URL and date/time for this tab.
+ * @property {object} containerObj - Info about an open tab's container if within one
+ * @property {string} currentActiveElementId - ID of currently focused element within each tab item
+ * @property {string} dateTimeFormat - Expected format for date and/or time
+ * @property {string} hasPopup - The aria-haspopup attribute for the secondary action, if required
+ * @property {boolean} isBookmark - Whether an open tab is bookmarked
+ * @property {number} closedId - The tab ID for when the tab item was closed.
+ * @property {number} sourceClosedId - The closedId of the closed window its from if applicable
+ * @property {number} sourceWindowId - The sessionstore id of the window its from if applicable
+ * @property {string} favicon - The favicon for the tab item.
+ * @property {boolean} muted - Whether an open tab is muted
+ * @property {boolean} pinned - Whether an open tab is pinned
+ * @property {string} primaryL10nId - The l10n id used for the primary action element
+ * @property {string} primaryL10nArgs - The l10n args used for the primary action element
+ * @property {string} secondaryL10nId - The l10n id used for the secondary action button
+ * @property {string} secondaryL10nArgs - The l10n args used for the secondary action element
+ * @property {boolean} attention - Whether to show a notification dot
+ * @property {boolean} soundPlaying - Whether an open tab has soundPlaying
+ * @property {object} tabElement - The MozTabbrowserTab element for the tab item.
+ * @property {number} time - The timestamp for when the tab was last accessed.
+ * @property {string} title - The title for the tab item.
+ * @property {boolean} titleChanged - Whether the title has changed for an open tab
+ * @property {string} url - The url for the tab item.
+ * @property {number} timeMsPref - The frequency in milliseconds of updates to relative time
+ * @property {string} searchQuery - The query string to highlight, if provided.
+ */
+export class FxviewTabRow extends MozLitElement {
+ constructor() {
+ super();
+ this.active = false;
+ this.currentActiveElementId = "fxview-tab-row-main";
+ }
+
+ static properties = {
+ active: { type: Boolean },
+ compact: { type: Boolean },
+ containerObj: { type: Object },
+ currentActiveElementId: { type: String },
+ dateTimeFormat: { type: String },
+ favicon: { type: String },
+ hasPopup: { type: String },
+ isBookmark: { type: Boolean },
+ muted: { type: Boolean },
+ pinned: { type: Boolean },
+ primaryL10nId: { type: String },
+ primaryL10nArgs: { type: String },
+ secondaryL10nId: { type: String },
+ secondaryL10nArgs: { type: String },
+ soundPlaying: { type: Boolean },
+ closedId: { type: Number },
+ sourceClosedId: { type: Number },
+ sourceWindowId: { type: String },
+ tabElement: { type: Object },
+ time: { type: Number },
+ title: { type: String },
+ titleChanged: { type: Boolean },
+ attention: { type: Boolean },
+ timeMsPref: { type: Number },
+ url: { type: String },
+ searchQuery: { type: String },
+ };
+
+ static queries = {
+ mainEl: ".fxview-tab-row-main",
+ buttonEl: "#fxview-tab-row-secondary-button:not([hidden])",
+ mediaButtonEl: "#fxview-tab-row-media-button",
+ };
+
+ get currentFocusable() {
+ let focusItem = this.renderRoot.getElementById(this.currentActiveElementId);
+ if (!focusItem) {
+ focusItem = this.renderRoot.getElementById("fxview-tab-row-main");
+ }
+ return focusItem;
+ }
+
+ focus() {
+ this.currentFocusable.focus();
+ }
+
+ focusButton() {
+ this.buttonEl.focus();
+ return this.buttonEl.id;
+ }
+
+ focusMediaButton() {
+ this.mediaButtonEl.focus();
+ return this.mediaButtonEl.id;
+ }
+
+ focusLink() {
+ this.mainEl.focus();
+ return this.mainEl.id;
+ }
+
+ dateFluentArgs(timestamp, dateTimeFormat) {
+ if (dateTimeFormat === "date" || dateTimeFormat === "dateTime") {
+ return JSON.stringify({ date: timestamp });
+ }
+ return null;
+ }
+
+ dateFluentId(timestamp, dateTimeFormat, _nowThresholdMs = NOW_THRESHOLD_MS) {
+ if (!timestamp) {
+ return null;
+ }
+ if (dateTimeFormat === "relative") {
+ const elapsed = Date.now() - timestamp;
+ if (elapsed <= _nowThresholdMs || !lazy.relativeTimeFormat) {
+ // Use a different string for very recent timestamps
+ return "fxviewtabrow-just-now-timestamp";
+ }
+ return null;
+ } else if (dateTimeFormat === "date" || dateTimeFormat === "dateTime") {
+ return "fxviewtabrow-date";
+ }
+ return null;
+ }
+
+ relativeTime(timestamp, dateTimeFormat, _nowThresholdMs = NOW_THRESHOLD_MS) {
+ if (dateTimeFormat === "relative") {
+ const elapsed = Date.now() - timestamp;
+ if (elapsed > _nowThresholdMs && lazy.relativeTimeFormat) {
+ return lazy.relativeTimeFormat.formatBestUnit(new Date(timestamp));
+ }
+ }
+ return null;
+ }
+
+ timeFluentId(dateTimeFormat) {
+ if (dateTimeFormat === "time" || dateTimeFormat === "dateTime") {
+ return "fxviewtabrow-time";
+ }
+ return null;
+ }
+
+ formatURIForDisplay(uriString) {
+ return !window.IS_STORYBOOK
+ ? lazy.BrowserUtils.formatURIStringForDisplay(uriString)
+ : uriString;
+ }
+
+ getImageUrl(icon, targetURI) {
+ if (window.IS_STORYBOOK) {
+ return `chrome://global/skin/icons/defaultFavicon.svg`;
+ }
+ if (!icon) {
+ if (targetURI?.startsWith("moz-extension")) {
+ return "chrome://mozapps/skin/extensions/extension.svg";
+ }
+ return `chrome://global/skin/icons/defaultFavicon.svg`;
+ }
+ // If the icon is not for website (doesn't begin with http), we
+ // display it directly. Otherwise we go through the page-icon
+ // protocol to try to get a cached version. We don't load
+ // favicons directly.
+ if (icon.startsWith("http")) {
+ return `page-icon:${targetURI}`;
+ }
+ return icon;
+ }
+
+ getContainerClasses() {
+ let containerClasses = ["fxview-tab-row-container-indicator", "icon"];
+ if (this.containerObj) {
+ let { icon, color } = this.containerObj;
+ containerClasses.push(`identity-icon-${icon}`);
+ containerClasses.push(`identity-color-${color}`);
+ }
+ return containerClasses;
+ }
+
+ primaryActionHandler(event) {
+ if (
+ (event.type == "click" && !event.altKey) ||
+ (event.type == "keydown" && event.code == "Enter") ||
+ (event.type == "keydown" && event.code == "Space")
+ ) {
+ event.preventDefault();
+ if (!window.IS_STORYBOOK) {
+ this.dispatchEvent(
+ new CustomEvent("fxview-tab-list-primary-action", {
+ bubbles: true,
+ composed: true,
+ detail: { originalEvent: event, item: this },
+ })
+ );
+ }
+ }
+ }
+
+ secondaryActionHandler(event) {
+ if (
+ (event.type == "click" && event.detail && !event.altKey) ||
+ // detail=0 is from keyboard
+ (event.type == "click" && !event.detail)
+ ) {
+ event.preventDefault();
+ this.dispatchEvent(
+ new CustomEvent("fxview-tab-list-secondary-action", {
+ bubbles: true,
+ composed: true,
+ detail: { originalEvent: event, item: this },
+ })
+ );
+ }
+ }
+
+ muteOrUnmuteTab() {
+ this.tabElement.toggleMuteAudio();
+ this.muted = !this.muted;
+ }
+
+ render() {
+ const title = this.title;
+ const relativeString = this.relativeTime(
+ this.time,
+ this.dateTimeFormat,
+ !window.IS_STORYBOOK ? this.timeMsPref : NOW_THRESHOLD_MS
+ );
+ const dateString = this.dateFluentId(
+ this.time,
+ this.dateTimeFormat,
+ !window.IS_STORYBOOK ? this.timeMsPref : NOW_THRESHOLD_MS
+ );
+ const dateArgs = this.dateFluentArgs(this.time, this.dateTimeFormat);
+ const timeString = this.timeFluentId(this.dateTimeFormat);
+ const time = this.time;
+ const timeArgs = JSON.stringify({ time });
+ return html`
+ ${when(
+ this.containerObj,
+ () => html`
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/usercontext/usercontext.css"
+ />
+ `
+ )}
+ <link
+ rel="stylesheet"
+ href="chrome://global/skin/in-content/common.css"
+ />
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/firefoxview/fxview-tab-row.css"
+ />
+ <a
+ href=${ifDefined(this.url)}
+ class="fxview-tab-row-main"
+ id="fxview-tab-row-main"
+ tabindex=${this.active &&
+ this.currentActiveElementId === "fxview-tab-row-main"
+ ? "0"
+ : "-1"}
+ data-l10n-id=${ifDefined(this.primaryL10nId)}
+ data-l10n-args=${ifDefined(this.primaryL10nArgs)}
+ @click=${this.primaryActionHandler}
+ @keydown=${this.primaryActionHandler}
+ >
+ <span
+ class="${classMap({
+ "fxview-tab-row-favicon-wrapper": true,
+ bookmark: this.isBookmark && !this.attention,
+ notification: this.pinned
+ ? this.attention || this.titleChanged
+ : this.attention,
+ soundplaying: this.soundPlaying && !this.muted && this.pinned,
+ muted: this.muted && this.pinned,
+ })}"
+ >
+ <span
+ class="fxview-tab-row-favicon icon"
+ id="fxview-tab-row-favicon"
+ style=${styleMap({
+ backgroundImage: `url(${this.getImageUrl(
+ this.favicon,
+ this.url
+ )})`,
+ })}
+ ></span>
+ </span>
+ <span
+ class="fxview-tab-row-title text-truncated-ellipsis"
+ id="fxview-tab-row-title"
+ dir="auto"
+ >
+ ${when(
+ this.searchQuery,
+ () => this.#highlightSearchMatches(this.searchQuery, title),
+ () => title
+ )}
+ </span>
+ <span class=${this.getContainerClasses().join(" ")}></span>
+ <span
+ class="fxview-tab-row-url text-truncated-ellipsis"
+ id="fxview-tab-row-url"
+ ?hidden=${this.compact}
+ >
+ ${when(
+ this.searchQuery,
+ () =>
+ this.#highlightSearchMatches(
+ this.searchQuery,
+ this.formatURIForDisplay(this.url)
+ ),
+ () => this.formatURIForDisplay(this.url)
+ )}
+ </span>
+ <span
+ class="fxview-tab-row-date"
+ id="fxview-tab-row-date"
+ ?hidden=${this.compact}
+ >
+ <span
+ ?hidden=${relativeString || !dateString}
+ data-l10n-id=${ifDefined(dateString)}
+ data-l10n-args=${ifDefined(dateArgs)}
+ ></span>
+ <span ?hidden=${!relativeString}>${relativeString}</span>
+ </span>
+ <span
+ class="fxview-tab-row-time"
+ id="fxview-tab-row-time"
+ ?hidden=${this.compact || !timeString}
+ data-timestamp=${ifDefined(this.time)}
+ data-l10n-id=${ifDefined(timeString)}
+ data-l10n-args=${ifDefined(timeArgs)}
+ >
+ </span>
+ </a>
+ ${when(
+ (this.soundPlaying || this.muted) && !this.pinned,
+ () => html`<button
+ class=fxview-tab-row-button ghost-button icon-button semi-transparent"
+ id="fxview-tab-row-media-button"
+ data-l10n-id=${
+ this.muted
+ ? "fxviewtabrow-unmute-tab-button"
+ : "fxviewtabrow-mute-tab-button"
+ }
+ data-l10n-args=${JSON.stringify({ tabTitle: title })}
+ muted=${ifDefined(this.muted)}
+ soundplaying=${this.soundPlaying && !this.muted}
+ @click=${this.muteOrUnmuteTab}
+ tabindex="${
+ this.active &&
+ this.currentActiveElementId === "fxview-tab-row-media-button"
+ ? "0"
+ : "-1"
+ }"
+ ></button>`,
+ () => html`<span></span>`
+ )}
+ ${when(
+ this.secondaryL10nId && this.secondaryActionHandler,
+ () => html`<button
+ class="fxview-tab-row-button ghost-button icon-button semi-transparent"
+ id="fxview-tab-row-secondary-button"
+ part="secondary-button"
+ data-l10n-id=${this.secondaryL10nId}
+ data-l10n-args=${ifDefined(this.secondaryL10nArgs)}
+ aria-haspopup=${ifDefined(this.hasPopup)}
+ @click=${this.secondaryActionHandler}
+ tabindex="${this.active &&
+ this.currentActiveElementId === "fxview-tab-row-secondary-button"
+ ? "0"
+ : "-1"}"
+ ></button>`
+ )}
+ `;
+ }
+
+ /**
+ * Find all matches of query within the given string, and compute the result
+ * to be rendered.
+ *
+ * @param {string} query
+ * @param {string} string
+ */
+ #highlightSearchMatches(query, string) {
+ const fragments = [];
+ const regex = RegExp(escapeRegExp(query), "dgi");
+ let prevIndexEnd = 0;
+ let result;
+ while ((result = regex.exec(string)) !== null) {
+ const [indexStart, indexEnd] = result.indices[0];
+ fragments.push(string.substring(prevIndexEnd, indexStart));
+ fragments.push(
+ html`<strong>${string.substring(indexStart, indexEnd)}</strong>`
+ );
+ prevIndexEnd = regex.lastIndex;
+ }
+ fragments.push(string.substring(prevIndexEnd));
+ return fragments;
+ }
+}
+
+customElements.define("fxview-tab-row", FxviewTabRow);
+
+export class VirtualList extends MozLitElement {
+ static properties = {
+ items: { type: Array },
+ template: { type: Function },
+ activeIndex: { type: Number },
+ itemOffset: { type: Number },
+ maxRenderCountEstimate: { type: Number, state: true },
+ itemHeightEstimate: { type: Number, state: true },
+ isAlwaysVisible: { type: Boolean },
+ isVisible: { type: Boolean, state: true },
+ isSubList: { type: Boolean },
+ };
+
+ createRenderRoot() {
+ return this;
+ }
+
+ constructor() {
+ super();
+ this.activeIndex = 0;
+ this.itemOffset = 0;
+ this.items = [];
+ this.subListItems = [];
+ this.itemHeightEstimate = FXVIEW_ROW_HEIGHT_PX;
+ this.maxRenderCountEstimate = Math.max(
+ 40,
+ 2 * Math.ceil(window.innerHeight / this.itemHeightEstimate)
+ );
+ this.isSubList = false;
+ this.isVisible = false;
+ this.intersectionObserver = new IntersectionObserver(
+ ([entry]) => (this.isVisible = entry.isIntersecting),
+ { root: this.ownerDocument }
+ );
+ this.resizeObserver = new ResizeObserver(([entry]) => {
+ if (entry.contentRect?.height > 0) {
+ // Update properties on top-level virtual-list
+ this.parentElement.itemHeightEstimate = entry.contentRect.height;
+ this.parentElement.maxRenderCountEstimate = Math.max(
+ 40,
+ 2 * Math.ceil(window.innerHeight / this.itemHeightEstimate)
+ );
+ }
+ });
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ this.intersectionObserver.disconnect();
+ this.resizeObserver.disconnect();
+ }
+
+ triggerIntersectionObserver() {
+ this.intersectionObserver.unobserve(this);
+ this.intersectionObserver.observe(this);
+ }
+
+ getSubListForItem(index) {
+ if (this.isSubList) {
+ throw new Error("Cannot get sublist for item");
+ }
+ return this.children[parseInt(index / this.maxRenderCountEstimate, 10)];
+ }
+
+ getItem(index) {
+ if (!this.isSubList) {
+ return this.getSubListForItem(index)?.getItem(
+ index % this.maxRenderCountEstimate
+ );
+ }
+ return this.children[index];
+ }
+
+ willUpdate(changedProperties) {
+ if (changedProperties.has("items") && !this.isSubList) {
+ this.subListItems = [];
+ for (let i = 0; i < this.items.length; i += this.maxRenderCountEstimate) {
+ this.subListItems.push(
+ this.items.slice(i, i + this.maxRenderCountEstimate)
+ );
+ }
+ this.triggerIntersectionObserver();
+ }
+ }
+
+ recalculateAfterWindowResize() {
+ this.maxRenderCountEstimate = Math.max(
+ 40,
+ 2 * Math.ceil(window.innerHeight / this.itemHeightEstimate)
+ );
+ }
+
+ firstUpdated() {
+ this.intersectionObserver.observe(this);
+ if (this.isSubList && this.children[0]) {
+ this.resizeObserver.observe(this.children[0]);
+ }
+ }
+
+ updated(changedProperties) {
+ this.updateListHeight(changedProperties);
+ }
+
+ updateListHeight(changedProperties) {
+ if (
+ changedProperties.has("isAlwaysVisible") ||
+ changedProperties.has("isVisible")
+ ) {
+ this.style.height =
+ this.isAlwaysVisible || this.isVisible
+ ? "auto"
+ : `${this.items.length * this.itemHeightEstimate}px`;
+ }
+ }
+
+ get renderItems() {
+ return this.isSubList ? this.items : this.subListItems;
+ }
+
+ subListTemplate = (data, i) => {
+ return html`<virtual-list
+ .template=${this.template}
+ .items=${data}
+ .itemHeightEstimate=${this.itemHeightEstimate}
+ .itemOffset=${i * this.maxRenderCountEstimate}
+ .isAlwaysVisible=${i ==
+ parseInt(this.activeIndex / this.maxRenderCountEstimate, 10)}
+ isSubList
+ ></virtual-list>`;
+ };
+
+ itemTemplate = (data, i) => this.template(data, this.itemOffset + i);
+
+ render() {
+ if (this.isAlwaysVisible || this.isVisible) {
+ return html`
+ ${repeat(
+ this.renderItems,
+ (data, i) => i,
+ this.isSubList ? this.itemTemplate : this.subListTemplate
+ )}
+ `;
+ }
+ return "";
+ }
+}
+
+customElements.define("virtual-list", VirtualList);