Source code

Revision control

Copy as Markdown

Other Tools

/* 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,
styleMap,
when,
import {
FxviewTabListBase,
FxviewTabRowBase,
// eslint-disable-next-line import/no-unassigned-import
const lazy = {};
let XPCOMUtils;
XPCOMUtils = ChromeUtils.importESModule(
).XPCOMUtils;
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"virtualListEnabledPref",
"browser.firefox-view.virtual-list.enabled"
);
/**
* A list of clickable tab items
*
* @property {boolean} pinnedTabsGridView - Whether to show pinned tabs in a grid view
*/
export class OpenTabsTabList extends FxviewTabListBase {
constructor() {
super();
this.pinnedTabsGridView = false;
this.pinnedTabs = [];
this.unpinnedTabs = [];
}
static properties = {
pinnedTabsGridView: { type: Boolean },
};
static queries = {
...FxviewTabListBase.queries,
rowEls: {
all: "opentabs-tab-row",
},
};
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") {
this.startIntervalTimer();
this.onIntervalUpdate();
}
}
// Move pinned tabs to the beginning of the list
if (this.pinnedTabsGridView) {
// Can set maxTabsLength to -1 to have no max
this.unpinnedTabs = this.tabItems.filter(
tab => !tab.indicators.includes("pinned")
);
this.pinnedTabs = this.tabItems.filter(tab =>
tab.indicators.includes("pinned")
);
if (this.maxTabsLength > 0) {
this.unpinnedTabs = this.unpinnedTabs.slice(0, this.maxTabsLength);
}
this.tabItems = [...this.pinnedTabs, ...this.unpinnedTabs];
} else if (this.maxTabsLength > 0) {
this.tabItems = this.tabItems.slice(0, this.maxTabsLength);
}
}
/**
* 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();
if (
(this.pinnedTabsGridView &&
this.activeIndex >= this.pinnedTabs.length) ||
!this.pinnedTabsGridView
) {
this.focusPrevRow();
}
} else if (e.code == "ArrowDown") {
// Focus either the link or button of the next row based on this.currentActiveElementId
e.preventDefault();
if (
this.pinnedTabsGridView &&
this.activeIndex < this.pinnedTabs.length
) {
this.focusIndex(this.pinnedTabs.length);
} else {
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") {
fxviewTabRow.moveFocusLeft();
} else {
fxviewTabRow.moveFocusRight();
}
} 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") {
fxviewTabRow.moveFocusRight();
} else {
fxviewTabRow.moveFocusLeft();
}
}
}
async focusIndex(index) {
// Focus link or button of item
if (
((this.pinnedTabsGridView && index > this.pinnedTabs.length) ||
!this.pinnedTabsGridView) &&
lazy.virtualListEnabledPref
) {
let row = this.rootVirtualListEl.getItem(index - this.pinnedTabs.length);
if (!row) {
return;
}
let subList = this.rootVirtualListEl.getSubListForItem(
index - this.pinnedTabs.length
);
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;
}
}
#getTabListWrapperClasses() {
let wrapperClasses = ["fxview-tab-list"];
let tabsToCheck = this.pinnedTabsGridView
? this.unpinnedTabs
: this.tabItems;
if (tabsToCheck.some(tab => tab.containerObj)) {
wrapperClasses.push(`hasContainerTab`);
}
return wrapperClasses;
}
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`<opentabs-tab-row
?active=${i == this.activeIndex}
class=${classMap({
pinned:
this.pinnedTabsGridView && tabItem.indicators?.includes("pinned"),
})}
.currentActiveElementId=${this.currentActiveElementId}
.favicon=${tabItem.icon}
.compact=${this.compactRows}
.containerObj=${ifDefined(tabItem.containerObj)}
.indicators=${tabItem.indicators}
.pinnedTabsGridView=${ifDefined(this.pinnedTabsGridView)}
.primaryL10nId=${tabItem.primaryL10nId}
.primaryL10nArgs=${ifDefined(tabItem.primaryL10nArgs)}
.secondaryL10nId=${tabItem.secondaryL10nId}
.secondaryL10nArgs=${ifDefined(tabItem.secondaryL10nArgs)}
.tertiaryL10nId=${ifDefined(tabItem.tertiaryL10nId)}
.tertiaryL10nArgs=${ifDefined(tabItem.tertiaryL10nArgs)}
.secondaryActionClass=${this.secondaryActionClass}
.tertiaryActionClass=${ifDefined(this.tertiaryActionClass)}
.sourceClosedId=${ifDefined(tabItem.sourceClosedId)}
.sourceWindowId=${ifDefined(tabItem.sourceWindowId)}
.closedId=${ifDefined(tabItem.closedId || tabItem.closedId)}
role=${tabItem.pinned && this.pinnedTabsGridView ? "tab" : "listitem"}
.tabElement=${ifDefined(tabItem.tabElement)}
.time=${ifDefined(time)}
.title=${tabItem.title}
.url=${tabItem.url}
.searchQuery=${ifDefined(this.searchQuery)}
.timeMsPref=${ifDefined(this.timeMsPref)}
.hasPopup=${this.hasPopup}
.dateTimeFormat=${this.dateTimeFormat}
></opentabs-tab-row>`;
};
render() {
if (this.searchQuery && this.tabItems.length === 0) {
return this.emptySearchResultsTemplate();
}
return html`
${this.stylesheets()}
<link
rel="stylesheet"
/>
${when(
this.pinnedTabsGridView && this.pinnedTabs.length,
() => html`
<div
id="fxview-tab-list"
class="fxview-tab-list pinned"
data-l10n-id="firefoxview-pinned-tabs"
role="tablist"
@keydown=${this.handleFocusElementInRow}
>
${this.pinnedTabs.map((tabItem, i) =>
this.customItemTemplate
? this.customItemTemplate(tabItem, i)
: this.itemTemplate(tabItem, i)
)}
</div>
`
)}
<div
id="fxview-tab-list"
class=${this.#getTabListWrapperClasses().join(" ")}
data-l10n-id="firefoxview-tabs"
role="list"
@keydown=${this.handleFocusElementInRow}
>
${when(
lazy.virtualListEnabledPref,
() => html`
<virtual-list
.activeIndex=${this.activeIndex}
.pinnedTabsIndexOffset=${this.pinnedTabsGridView
? this.pinnedTabs.length
: 0}
.items=${this.pinnedTabsGridView
? this.unpinnedTabs
: this.tabItems}
.template=${this.itemTemplate}
></virtual-list>
`,
() =>
html`${this.tabItems.map((tabItem, i) =>
this.itemTemplate(tabItem, i)
)}`
)}
</div>
<slot name="menu"></slot>
`;
}
}
customElements.define("opentabs-tab-list", OpenTabsTabList);
/**
* A tab item that displays favicon, title, url, and time of last access
*
* @property {object} containerObj - Info about an open tab's container if within one
* @property {string} indicators - An array of tab indicators if any are present
* @property {boolean} pinnedTabsGridView - Whether the show pinned tabs in a grid view
*/
export class OpenTabsTabRow extends FxviewTabRowBase {
constructor() {
super();
this.indicators = [];
this.pinnedTabsGridView = false;
}
static properties = {
...FxviewTabRowBase.properties,
containerObj: { type: Object },
indicators: { type: Array },
pinnedTabsGridView: { type: Boolean },
};
static queries = {
...FxviewTabRowBase.queries,
mediaButtonEl: "#fxview-tab-row-media-button",
pinnedTabButtonEl: "moz-button#fxview-tab-row-main",
};
connectedCallback() {
super.connectedCallback();
this.addEventListener("keydown", this.handleKeydown);
}
disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener("keydown", this.handleKeydown);
}
handleKeydown(e) {
if (
this.active &&
this.pinnedTabsGridView &&
this.indicators?.includes("pinned") &&
e.key === "m" &&
e.ctrlKey
) {
this.muteOrUnmuteTab();
}
}
moveFocusRight() {
let tabList = this.getRootNode().host;
if (this.pinnedTabsGridView && this.indicators?.includes("pinned")) {
tabList.focusNextRow();
} else if (
(this.indicators?.includes("soundplaying") ||
this.indicators?.includes("muted")) &&
this.currentActiveElementId === "fxview-tab-row-main"
) {
this.focusMediaButton();
} else if (
this.currentActiveElementId === "fxview-tab-row-media-button" ||
this.currentActiveElementId === "fxview-tab-row-main"
) {
this.focusSecondaryButton();
} else if (
this.tertiaryButtonEl &&
this.currentActiveElementId === "fxview-tab-row-secondary-button"
) {
this.focusTertiaryButton();
}
}
moveFocusLeft() {
let tabList = this.getRootNode().host;
if (
this.pinnedTabsGridView &&
(this.indicators?.includes("pinned") ||
(tabList.currentActiveElementId === "fxview-tab-row-main" &&
tabList.activeIndex === tabList.pinnedTabs.length))
) {
tabList.focusPrevRow();
} else if (
tabList.currentActiveElementId === "fxview-tab-row-tertiary-button"
) {
this.focusSecondaryButton();
} else if (
(this.indicators?.includes("soundplaying") ||
this.indicators?.includes("muted")) &&
tabList.currentActiveElementId === "fxview-tab-row-secondary-button"
) {
this.focusMediaButton();
} else {
this.focusLink();
}
}
focusMediaButton() {
let tabList = this.getRootNode().host;
this.mediaButtonEl.focus();
tabList.currentActiveElementId = this.mediaButtonEl.id;
}
#secondaryActionHandler(event) {
if (
(this.pinnedTabsGridView &&
this.indicators?.includes("pinned") &&
event.type == "contextmenu") ||
(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 },
})
);
}
}
#faviconTemplate() {
return html`<span
class="${classMap({
"fxview-tab-row-favicon-wrapper": true,
pinned: this.indicators?.includes("pinned"),
pinnedOnNewTab: this.indicators?.includes("pinnedOnNewTab"),
attention: this.indicators?.includes("attention"),
bookmark: this.indicators?.includes("bookmark"),
})}"
>
<span
class="fxview-tab-row-favicon icon"
id="fxview-tab-row-favicon"
style=${styleMap({
backgroundImage: `url(${this.getImageUrl(this.favicon, this.url)})`,
})}
></span>
${when(
this.pinnedTabsGridView &&
this.indicators?.includes("pinned") &&
(this.indicators?.includes("muted") ||
this.indicators?.includes("soundplaying")),
() => html`
<button
class="fxview-tab-row-pinned-media-button"
id="fxview-tab-row-media-button"
tabindex="-1"
data-l10n-id=${this.indicators?.includes("muted")
? "fxviewtabrow-unmute-tab-button-no-context"
: "fxviewtabrow-mute-tab-button-no-context"}
muted=${this.indicators?.includes("muted")}
soundplaying=${this.indicators?.includes("soundplaying") &&
!this.indicators?.includes("muted")}
@click=${this.muteOrUnmuteTab}
></button>
`
)}
</span>`;
}
#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;
}
muteOrUnmuteTab(e) {
e?.preventDefault();
// If the tab has no sound playing, the mute/unmute button will be removed when toggled.
// We should move the focus to the right in that case. This does not apply to pinned tabs
// on the Open Tabs page.
let shouldMoveFocus =
(!this.pinnedTabsGridView ||
(!this.indicators.includes("pinned") && this.pinnedTabsGridView)) &&
this.mediaButtonEl &&
!this.indicators.includes("soundplaying") &&
this.currentActiveElementId === "fxview-tab-row-media-button";
// detail=0 is from keyboard
if (e?.type == "click" && !e?.detail && shouldMoveFocus) {
if (document.dir == "rtl") {
this.moveFocusLeft();
} else {
this.moveFocusRight();
}
}
this.tabElement.toggleMuteAudio();
}
#mediaButtonTemplate() {
return html`${when(
this.indicators?.includes("soundplaying") ||
this.indicators?.includes("muted"),
() => html`<moz-button
type="icon ghost"
class="fxview-tab-row-button"
id="fxview-tab-row-media-button"
data-l10n-id=${this.indicators?.includes("muted")
? "fxviewtabrow-unmute-tab-button-no-context"
: "fxviewtabrow-mute-tab-button-no-context"}
muted=${this.indicators?.includes("muted")}
soundplaying=${this.indicators?.includes("soundplaying") &&
!this.indicators?.includes("muted")}
@click=${this.muteOrUnmuteTab}
tabindex="${this.active &&
this.currentActiveElementId === "fxview-tab-row-media-button"
? "0"
: "-1"}"
></moz-button>`,
() => html`<span></span>`
)}`;
}
#containerIndicatorTemplate() {
let tabList = this.getRootNode().host;
let tabsToCheck = tabList.pinnedTabsGridView
? tabList.unpinnedTabs
: tabList.tabItems;
return html`${when(
tabsToCheck.some(tab => tab.containerObj),
() => html`<span class=${this.#getContainerClasses().join(" ")}></span>`
)}`;
}
#pinnedTabItemTemplate() {
return html`
<moz-button
type="icon ghost"
id="fxview-tab-row-main"
aria-haspopup=${ifDefined(this.hasPopup)}
data-l10n-id=${ifDefined(this.primaryL10nId)}
data-l10n-args=${ifDefined(this.primaryL10nArgs)}
tabindex=${this.active &&
this.currentActiveElementId === "fxview-tab-row-main"
? "0"
: "-1"}
role="tab"
@click=${this.primaryActionHandler}
@keydown=${this.primaryActionHandler}
@contextmenu=${this.#secondaryActionHandler}
>
${this.#faviconTemplate()}
</moz-button>
`;
}
#unpinnedTabItemTemplate() {
return html`<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}
title=${!this.primaryL10nId ? this.url : null}
>
${this.#faviconTemplate()} ${this.titleTemplate()}
${when(
!this.compact,
() => html`${this.#containerIndicatorTemplate()} ${this.urlTemplate()}
${this.dateTemplate()} ${this.timeTemplate()}`
)}
</a>
${this.#mediaButtonTemplate()} ${this.secondaryButtonTemplate()}
${this.tertiaryButtonTemplate()}`;
}
render() {
return html`
${this.stylesheets()}
<link
rel="stylesheet"
/>
${when(
this.containerObj,
() => html`
<link
rel="stylesheet"
/>
`
)}
${when(
this.pinnedTabsGridView && this.indicators?.includes("pinned"),
this.#pinnedTabItemTemplate.bind(this),
this.#unpinnedTabItemTemplate.bind(this)
)}
`;
}
}
customElements.define("opentabs-tab-row", OpenTabsTabRow);