forked from mirrors/gecko-dev
Initial open tabs implementation in Firefox View Next. Note that this only lists out the open tabs. It does not yet provide the ability to switch to an open tab or window. See bug description for a full list of expected behaviors. [[ https://bugzilla.mozilla.org/attachment.cgi?id=9324749&action=diff | This patch ]] from @mkaply was used as a starting point. https://treeherder.mozilla.org/jobs?repo=try&revision=789cd12b31cc76c4f9532560fd10727af6b264d5 Differential Revision: https://phabricator.services.mozilla.com/D178970
511 lines
16 KiB
JavaScript
511 lines
16 KiB
JavaScript
/* 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 {
|
|
html,
|
|
ifDefined,
|
|
styleMap,
|
|
when,
|
|
} from "chrome://global/content/vendor/lit.all.mjs";
|
|
import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
|
|
|
|
const NOW_THRESHOLD_MS = 91000;
|
|
const lazy = {};
|
|
let XPCOMUtils;
|
|
|
|
if (!window.IS_STORYBOOK) {
|
|
XPCOMUtils = ChromeUtils.importESModule(
|
|
"resource://gre/modules/XPCOMUtils.sys.mjs"
|
|
).XPCOMUtils;
|
|
XPCOMUtils.defineLazyGetter(lazy, "relativeTimeFormat", () => {
|
|
return new Services.intl.RelativeTimeFormat(undefined, {
|
|
style: "narrow",
|
|
});
|
|
});
|
|
|
|
ChromeUtils.defineESModuleGetters(lazy, {
|
|
BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs",
|
|
PlacesUIUtils: "resource:///modules/PlacesUIUtils.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
|
|
*/
|
|
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.#register();
|
|
}
|
|
|
|
static properties = {
|
|
activeIndex: { type: Number },
|
|
compactRows: { type: Boolean },
|
|
currentActiveElementId: { type: String },
|
|
dateTimeFormat: { type: String },
|
|
hasPopup: { type: String },
|
|
maxTabsLength: { type: Number },
|
|
tabItems: { type: Array },
|
|
};
|
|
|
|
static queries = {
|
|
rowEls: { all: "fxview-tab-row" },
|
|
};
|
|
|
|
willUpdate(changes) {
|
|
this.activeIndex = Math.min(
|
|
Math.max(this.activeIndex, 0),
|
|
this.tabItems.length - 1
|
|
);
|
|
|
|
if (changes.has("dateTimeFormat")) {
|
|
if (this.dateTimeFormat == "relative" && !window.IS_STORYBOOK) {
|
|
this.intervalID = setInterval(
|
|
() => this.onIntervalUpdate(),
|
|
this.timeMsPref
|
|
);
|
|
} else {
|
|
clearInterval(this.intervalID);
|
|
}
|
|
}
|
|
}
|
|
|
|
#register() {
|
|
if (!window.IS_STORYBOOK) {
|
|
XPCOMUtils.defineLazyPreferenceGetter(
|
|
this,
|
|
"timeMsPref",
|
|
"browser.tabs.firefox-view.updateTimeMs",
|
|
NOW_THRESHOLD_MS,
|
|
(prefName, oldVal, newVal) => {
|
|
if (!this.isConnected) {
|
|
return;
|
|
}
|
|
clearInterval(this.intervalID);
|
|
this.intervalID = setInterval(() => {
|
|
this.onIntervalUpdate();
|
|
}, newVal);
|
|
this.requestUpdate();
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
connectedCallback() {
|
|
super.connectedCallback();
|
|
if (this.dateTimeFormat === "relative" && !window.IS_STORYBOOK) {
|
|
this.intervalID = setInterval(
|
|
() => this.onIntervalUpdate(),
|
|
this.timeMsPref
|
|
);
|
|
}
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
super.disconnectedCallback();
|
|
if (this.intervalID) {
|
|
clearInterval(this.intervalID);
|
|
}
|
|
}
|
|
|
|
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") {
|
|
this.currentActiveElementId = fxviewTabRow.focusLink();
|
|
} 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") {
|
|
this.currentActiveElementId = fxviewTabRow.focusButton();
|
|
} else {
|
|
this.currentActiveElementId = fxviewTabRow.focusLink();
|
|
}
|
|
}
|
|
}
|
|
|
|
focusPrevRow() {
|
|
// Focus link or button of item above
|
|
let previousIndex = this.activeIndex - 1;
|
|
if (previousIndex >= 0) {
|
|
this.rowEls[previousIndex].focus();
|
|
this.activeIndex = previousIndex;
|
|
}
|
|
}
|
|
|
|
focusNextRow() {
|
|
// Focus link or button of item below
|
|
let nextIndex = this.activeIndex + 1;
|
|
if (nextIndex < this.rowEls.length) {
|
|
this.rowEls[nextIndex].focus();
|
|
this.activeIndex = nextIndex;
|
|
}
|
|
}
|
|
|
|
// Use a relative URL in storybook to get faster reloads on style changes.
|
|
static stylesheetUrl = window.IS_STORYBOOK
|
|
? "./fxview-tab-list.css"
|
|
: "chrome://browser/content/firefoxview/fxview-tab-list.css";
|
|
|
|
render() {
|
|
this.tabItems = this.tabItems.slice(0, this.maxTabsLength);
|
|
const {
|
|
activeIndex,
|
|
currentActiveElementId,
|
|
dateTimeFormat,
|
|
hasPopup,
|
|
tabItems,
|
|
} = this;
|
|
return html`
|
|
<link rel="stylesheet" href=${this.constructor.stylesheetUrl} />
|
|
<div
|
|
id="fxview-tab-list"
|
|
class="fxview-tab-list"
|
|
role="list"
|
|
@keydown=${this.handleFocusElementInRow}
|
|
>
|
|
${tabItems.map(
|
|
(tabItem, i) =>
|
|
html`
|
|
<fxview-tab-row
|
|
exportparts="secondary-button"
|
|
?active=${i == activeIndex}
|
|
?compact=${this.compactRows}
|
|
.hasPopup=${hasPopup}
|
|
.currentActiveElementId=${currentActiveElementId}
|
|
.dateTimeFormat=${dateTimeFormat}
|
|
.favicon=${tabItem.icon}
|
|
.primaryL10nId=${tabItem.primaryL10nId}
|
|
.primaryL10nArgs=${ifDefined(tabItem.primaryL10nArgs)}
|
|
role="listitem"
|
|
.secondaryL10nId=${tabItem.secondaryL10nId}
|
|
.secondaryL10nArgs=${ifDefined(tabItem.secondaryL10nArgs)}
|
|
.tabid=${ifDefined(tabItem.tabid || tabItem.closedId)}
|
|
.time=${(tabItem.time || tabItem.closedAt).toString().length ===
|
|
16
|
|
? (tabItem.time || tabItem.closedAt) / 1000
|
|
: tabItem.time || tabItem.closedAt}
|
|
.timeMsPref=${ifDefined(this.timeMsPref)}
|
|
.title=${tabItem.title}
|
|
.url=${tabItem.url}
|
|
>
|
|
</fxview-tab-row>
|
|
`
|
|
)}
|
|
</div>
|
|
<slot name="menu"></slot>
|
|
`;
|
|
}
|
|
}
|
|
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 {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 {number} tabid - The tab ID for when the tab item.
|
|
* @property {string} favicon - The favicon for the tab item.
|
|
* @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 {number} time - The timestamp for when the tab was last accessed.
|
|
* @property {string} title - The title for the tab item.
|
|
* @property {string} url - The url for the tab item.
|
|
* @property {number} timeMsPref - The frequency in milliseconds of updates to relative time
|
|
*/
|
|
export class FxviewTabRow extends MozLitElement {
|
|
constructor() {
|
|
super();
|
|
this.active = false;
|
|
this.currentActiveElementId = "fxview-tab-row-main";
|
|
}
|
|
|
|
static properties = {
|
|
active: { type: Boolean },
|
|
compact: { type: Boolean },
|
|
currentActiveElementId: { type: String },
|
|
dateTimeFormat: { type: String },
|
|
favicon: { type: String },
|
|
hasPopup: { type: String },
|
|
primaryL10nId: { type: String },
|
|
primaryL10nArgs: { type: String },
|
|
secondaryL10nId: { type: String },
|
|
secondaryL10nArgs: { type: String },
|
|
tabid: { type: Number },
|
|
time: { type: Number },
|
|
title: { type: String },
|
|
timeMsPref: { type: Number },
|
|
url: { type: String },
|
|
};
|
|
|
|
static queries = {
|
|
mainEl: ".fxview-tab-row-main",
|
|
buttonEl: ".fxview-tab-row-button:not([hidden])",
|
|
};
|
|
|
|
get currentFocusable() {
|
|
return this.renderRoot.getElementById(this.currentActiveElementId);
|
|
}
|
|
|
|
connectedCallback() {
|
|
super.connectedCallback();
|
|
}
|
|
|
|
focus() {
|
|
this.currentFocusable.focus();
|
|
}
|
|
|
|
focusButton() {
|
|
this.buttonEl.focus();
|
|
return this.buttonEl.id;
|
|
}
|
|
|
|
focusLink() {
|
|
this.mainEl.focus();
|
|
return this.mainEl.id;
|
|
}
|
|
|
|
// Use a relative URL in storybook to get faster reloads on style changes.
|
|
static stylesheetUrl = window.IS_STORYBOOK
|
|
? "./fxview-tab-row.css"
|
|
: "chrome://browser/content/firefoxview/fxview-tab-row.css";
|
|
|
|
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 "";
|
|
}
|
|
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 icon
|
|
? lazy.PlacesUIUtils.getImageURL(icon)
|
|
: `page-icon:${targetURI}`;
|
|
}
|
|
return `chrome://global/skin/icons/defaultFavicon.svg`;
|
|
}
|
|
|
|
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 },
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
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`
|
|
<link
|
|
rel="stylesheet"
|
|
href="chrome://global/skin/in-content/common.css"
|
|
/>
|
|
<link rel="stylesheet" href=${this.constructor.stylesheetUrl} />
|
|
<a
|
|
href=${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=${this.primaryL10nId}
|
|
data-l10n-args=${ifDefined(this.primaryL10nArgs)}
|
|
@click=${this.primaryActionHandler}
|
|
@keydown=${this.primaryActionHandler}
|
|
>
|
|
<span
|
|
class="fxview-tab-row-favicon icon"
|
|
id="fxview-tab-row-favicon"
|
|
style=${styleMap({
|
|
backgroundImage: `url(${this.getImageUrl(this.favicon, this.url)})`,
|
|
})}
|
|
></span>
|
|
<span class="fxview-tab-row-title" id="fxview-tab-row-title">
|
|
${title}
|
|
</span>
|
|
<span
|
|
class="fxview-tab-row-url"
|
|
id="fxview-tab-row-url"
|
|
?hidden=${this.compact}
|
|
>
|
|
${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=${this.time}
|
|
data-l10n-id=${ifDefined(timeString)}
|
|
data-l10n-args=${timeArgs}
|
|
>
|
|
</span>
|
|
</a>
|
|
${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>`
|
|
)}
|
|
`;
|
|
}
|
|
}
|
|
|
|
customElements.define("fxview-tab-row", FxviewTabRow);
|