We send both events (which can be throttled if the rate is too high) and counters for raw numbers. Differential Revision: https://phabricator.services.mozilla.com/D300743
1270 lines
36 KiB
JavaScript
1270 lines
36 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/. */
|
|
|
|
"use strict";
|
|
|
|
// This is loaded into chrome windows with the subscript loader. Wrap in
|
|
// a block to prevent accidentally leaking globals onto `window`.
|
|
{
|
|
const { AppConstants } = ChromeUtils.importESModule(
|
|
"resource://gre/modules/AppConstants.sys.mjs"
|
|
);
|
|
|
|
let imports = {};
|
|
ChromeUtils.defineESModuleGetters(imports, {
|
|
DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
|
|
KeyboardLockUtils: "resource://gre/modules/KeyboardLockUtils.sys.mjs",
|
|
ShortcutUtils: "resource://gre/modules/ShortcutUtils.sys.mjs",
|
|
});
|
|
|
|
const DIRECTION_BACKWARD = -1;
|
|
const DIRECTION_FORWARD = 1;
|
|
|
|
class MozTabbox extends MozXULElement {
|
|
constructor() {
|
|
super();
|
|
this._handleMetaAltArrows = AppConstants.platform == "macosx";
|
|
this.disconnectedCallback = this.disconnectedCallback.bind(this);
|
|
}
|
|
|
|
connectedCallback() {
|
|
document.addEventListener("keydown", this, { mozSystemGroup: true });
|
|
window.addEventListener("unload", this.disconnectedCallback, {
|
|
once: true,
|
|
});
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
document.removeEventListener("keydown", this, { mozSystemGroup: true });
|
|
window.removeEventListener("unload", this.disconnectedCallback);
|
|
}
|
|
|
|
set handleCtrlTab(val) {
|
|
this.setAttribute("handleCtrlTab", val);
|
|
}
|
|
|
|
get handleCtrlTab() {
|
|
return this.getAttribute("handleCtrlTab") != "false";
|
|
}
|
|
|
|
get tabs() {
|
|
if (this.hasAttribute("tabcontainer")) {
|
|
return document.getElementById(this.getAttribute("tabcontainer"));
|
|
}
|
|
return this.getElementsByTagNameNS(
|
|
"http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul",
|
|
"tabs"
|
|
).item(0);
|
|
}
|
|
|
|
get tabpanels() {
|
|
return this.getElementsByTagNameNS(
|
|
"http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul",
|
|
"tabpanels"
|
|
).item(0);
|
|
}
|
|
|
|
set selectedIndex(val) {
|
|
let tabs = this.tabs;
|
|
if (tabs) {
|
|
tabs.selectedIndex = val;
|
|
}
|
|
this.setAttribute("selectedIndex", val);
|
|
}
|
|
|
|
get selectedIndex() {
|
|
let tabs = this.tabs;
|
|
return tabs ? tabs.selectedIndex : -1;
|
|
}
|
|
|
|
set selectedTab(val) {
|
|
if (val) {
|
|
let tabs = this.tabs;
|
|
if (tabs) {
|
|
tabs.selectedItem = val;
|
|
}
|
|
}
|
|
}
|
|
|
|
get selectedTab() {
|
|
let tabs = this.tabs;
|
|
return tabs && tabs.selectedItem;
|
|
}
|
|
|
|
set selectedPanel(val) {
|
|
if (val) {
|
|
let tabpanels = this.tabpanels;
|
|
if (tabpanels) {
|
|
tabpanels.selectedPanel = val;
|
|
}
|
|
}
|
|
}
|
|
|
|
get selectedPanel() {
|
|
let tabpanels = this.tabpanels;
|
|
return tabpanels && tabpanels.selectedPanel;
|
|
}
|
|
|
|
handleEvent(event) {
|
|
if (!event.isTrusted) {
|
|
// Don't let untrusted events mess with tabs.
|
|
return;
|
|
}
|
|
|
|
// Skip this only if something has explicitly cancelled it.
|
|
if (event.defaultCancelled) {
|
|
return;
|
|
}
|
|
|
|
// Skip if chrome code has cancelled this
|
|
// or keyboard lock & webcontent default prevented it
|
|
if (event.defaultPrevented) {
|
|
return;
|
|
}
|
|
|
|
// Don't check if the event was already consumed because tab
|
|
// navigation should always work for better user experience.
|
|
|
|
const { KeyboardLockUtils, ShortcutUtils } = imports;
|
|
|
|
const action = ShortcutUtils.getSystemActionForEvent(event);
|
|
// If we don't have an action, don't request reply.
|
|
if (
|
|
action != null &&
|
|
KeyboardLockUtils.mustWaitForKeyboardLockRequestedReply(event)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
switch (action) {
|
|
case ShortcutUtils.CYCLE_TABS:
|
|
Glean.browserUiInteraction.keyboard["ctrl-tab"].add(1);
|
|
Services.prefs.setBoolPref(
|
|
"browser.engagement.ctrlTab.has-used",
|
|
true
|
|
);
|
|
if (this.tabs && this.handleCtrlTab) {
|
|
this.tabs.advanceSelectedTab(
|
|
event.shiftKey ? DIRECTION_BACKWARD : DIRECTION_FORWARD,
|
|
true,
|
|
event
|
|
);
|
|
event.preventDefault();
|
|
}
|
|
break;
|
|
case ShortcutUtils.PREVIOUS_TAB:
|
|
if (this.tabs) {
|
|
this.tabs.advanceSelectedTab(DIRECTION_BACKWARD, true, event);
|
|
event.preventDefault();
|
|
}
|
|
break;
|
|
case ShortcutUtils.NEXT_TAB:
|
|
if (this.tabs) {
|
|
this.tabs.advanceSelectedTab(DIRECTION_FORWARD, true, event);
|
|
event.preventDefault();
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
customElements.define("tabbox", MozTabbox);
|
|
|
|
class MozDeck extends MozXULElement {
|
|
get isAsync() {
|
|
return this.getAttribute("async") == "true";
|
|
}
|
|
|
|
connectedCallback() {
|
|
if (this.delayConnectedCallback()) {
|
|
return;
|
|
}
|
|
this._selectedPanel = null;
|
|
this._inAsyncOperation = false;
|
|
|
|
let selectCurrentIndex = () => {
|
|
// Try to select the new node if any.
|
|
let index = this.selectedIndex;
|
|
let oldPanel = this._selectedPanel;
|
|
this._selectedPanel = this.children.item(index) || null;
|
|
this.updateSelectedIndex(index, oldPanel);
|
|
};
|
|
|
|
this._mutationObserver = new MutationObserver(records => {
|
|
let anyRemovals = records.some(record => !!record.removedNodes.length);
|
|
if (anyRemovals) {
|
|
// Try to keep the current selected panel in-place first.
|
|
let index = Array.from(this.children).indexOf(this._selectedPanel);
|
|
if (index != -1) {
|
|
// Try to keep the same node selected.
|
|
this.setAttribute("selectedIndex", index);
|
|
}
|
|
}
|
|
// Select the current index if needed in case mutations have made that
|
|
// available where it wasn't before.
|
|
if (!this._inAsyncOperation) {
|
|
selectCurrentIndex();
|
|
}
|
|
});
|
|
|
|
this._mutationObserver.observe(this, {
|
|
childList: true,
|
|
});
|
|
|
|
selectCurrentIndex();
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
this._mutationObserver?.disconnect();
|
|
this._mutationObserver = null;
|
|
}
|
|
|
|
updateSelectedIndex(
|
|
val,
|
|
oldPanel = this.querySelector(":scope > .deck-selected")
|
|
) {
|
|
this._inAsyncOperation = false;
|
|
if (oldPanel != this._selectedPanel) {
|
|
oldPanel?.classList.remove("deck-selected");
|
|
this._selectedPanel?.classList.add("deck-selected");
|
|
}
|
|
this.setAttribute("selectedIndex", val);
|
|
}
|
|
|
|
set selectedIndex(val) {
|
|
if (val < 0 || val >= this.children.length) {
|
|
return;
|
|
}
|
|
|
|
let oldPanel = this._selectedPanel;
|
|
this._selectedPanel = this.children[val];
|
|
|
|
this._inAsyncOperation = this.isAsync;
|
|
if (!this._inAsyncOperation) {
|
|
this.updateSelectedIndex(val, oldPanel);
|
|
}
|
|
|
|
if (this._selectedPanel != oldPanel) {
|
|
let event = document.createEvent("Events");
|
|
event.initEvent("select", true, true);
|
|
this.dispatchEvent(event);
|
|
}
|
|
}
|
|
|
|
get selectedIndex() {
|
|
let indexStr = this.getAttribute("selectedIndex");
|
|
return indexStr ? parseInt(indexStr) : 0;
|
|
}
|
|
|
|
set selectedPanel(val) {
|
|
this.selectedIndex = Array.from(this.children).indexOf(val);
|
|
}
|
|
|
|
get selectedPanel() {
|
|
return this._selectedPanel;
|
|
}
|
|
}
|
|
|
|
customElements.define("deck", MozDeck);
|
|
|
|
class MozTabpanels extends MozDeck {
|
|
/**
|
|
* Panels that are currently within an active Split View.
|
|
*
|
|
* @type {string[]}
|
|
*/
|
|
#splitViewPanels = [];
|
|
|
|
/**
|
|
* The splitter placed in between Split View panels.
|
|
*
|
|
* @type {XULElement}
|
|
*/
|
|
#splitViewSplitter = null;
|
|
#splitterWasDragging = false;
|
|
#splitterAriaUpdateTask = null;
|
|
#splitViewSplitterObserver = new MutationObserver(() => {
|
|
const splitterState = this.#splitViewSplitter.getAttribute("state");
|
|
if (splitterState === "dragging") {
|
|
this.#splitterWasDragging = true;
|
|
gBrowser.activeSplitView.resetRightPanelWidth();
|
|
} else {
|
|
const wasDragging = this.#splitterWasDragging;
|
|
this.#splitterWasDragging = false;
|
|
if (wasDragging) {
|
|
window.promiseDocumentFlushed(() =>
|
|
this.#recordSplitViewResizeTelemetry()
|
|
);
|
|
}
|
|
this.#splitterAriaUpdateTask.arm();
|
|
}
|
|
});
|
|
|
|
static #SPLIT_VIEW_PANEL_EVENTS = Object.freeze([
|
|
"click",
|
|
"mouseover",
|
|
"mouseout",
|
|
]);
|
|
|
|
constructor() {
|
|
super();
|
|
this._tabbox = null;
|
|
}
|
|
|
|
connectedCallback() {
|
|
super.connectedCallback();
|
|
this.#splitterAriaUpdateTask = new imports.DeferredTask(
|
|
() => this.updateSplitterAriaAttributes(),
|
|
0
|
|
);
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
super.disconnectedCallback();
|
|
this.#splitViewSplitterObserver.disconnect();
|
|
this.#splitterAriaUpdateTask.finalize();
|
|
}
|
|
|
|
#recordSplitViewResizeTelemetry() {
|
|
if (!this.#splitViewPanels.length) {
|
|
return;
|
|
}
|
|
|
|
const leftPanel = document.getElementById(this.#splitViewPanels[0]);
|
|
if (!leftPanel) {
|
|
return;
|
|
}
|
|
|
|
const leftWidth = leftPanel.getBoundingClientRect().width;
|
|
const totalWidth = this.getBoundingClientRect().width;
|
|
const widthPercentage = Math.round((leftWidth / totalWidth) * 100);
|
|
|
|
Glean.splitview.resize.record({ width: widthPercentage });
|
|
}
|
|
|
|
handleEvent(e) {
|
|
const browser =
|
|
e.currentTarget.tagName === "browser"
|
|
? e.currentTarget
|
|
: e.currentTarget.querySelector("browser");
|
|
let elToFocus = null;
|
|
switch (e.type) {
|
|
case "click":
|
|
if (e.target.tagName !== "browser") {
|
|
elToFocus = e.target;
|
|
}
|
|
// falls through
|
|
case "focus": {
|
|
const tab = gBrowser.getTabForBrowser(browser);
|
|
const tabstrip = this.tabbox.tabs;
|
|
tabstrip.selectedItem = tab;
|
|
break;
|
|
}
|
|
case "mouseover":
|
|
gBrowser.appendStatusPanel(browser);
|
|
break;
|
|
case "mouseout":
|
|
StatusPanel.panel.setAttribute("inactive", true);
|
|
gBrowser.appendStatusPanel();
|
|
break;
|
|
}
|
|
elToFocus?.focus();
|
|
}
|
|
|
|
get tabbox() {
|
|
// Memoize the result rather than replacing this getter, so that
|
|
// it can be reset if the parent changes.
|
|
if (this._tabbox) {
|
|
return this._tabbox;
|
|
}
|
|
|
|
return (this._tabbox = this.closest("tabbox"));
|
|
}
|
|
|
|
get splitViewSplitter() {
|
|
if (!this.#splitViewSplitter) {
|
|
this.#splitViewSplitter = this.#createSplitViewSplitter();
|
|
}
|
|
return this.#splitViewSplitter;
|
|
}
|
|
|
|
#createSplitViewSplitter() {
|
|
const splitter = document.createXULElement("splitter");
|
|
splitter.className = "split-view-splitter";
|
|
splitter.setAttribute("resizebefore", "sibling");
|
|
splitter.setAttribute("resizeafter", "none");
|
|
splitter.setAttribute("tabindex", "0");
|
|
splitter.setAttribute("role", "separator");
|
|
splitter.setAttribute("data-l10n-id", "tab-splitview-splitter");
|
|
this.#splitterWasDragging = false;
|
|
splitter.addEventListener("command", () => {
|
|
gBrowser.activeSplitView.resetRightPanelWidth();
|
|
window.promiseDocumentFlushed(() =>
|
|
this.#recordSplitViewResizeTelemetry()
|
|
);
|
|
this.#splitterAriaUpdateTask.arm();
|
|
});
|
|
this.#splitViewSplitterObserver.observe(splitter, {
|
|
attributeFilter: ["state"],
|
|
});
|
|
return splitter;
|
|
}
|
|
|
|
async updateSplitterAriaAttributes() {
|
|
// avoid triggering the splitter's creation here if it doesnt already exist
|
|
const splitter = this.#splitViewSplitter;
|
|
if (!splitter) {
|
|
return;
|
|
}
|
|
// The splitter is actively controlling the size of the left/first panel
|
|
const controlledPanel =
|
|
this.#splitViewPanels.length &&
|
|
document.getElementById(this.splitViewPanels[0]);
|
|
if (controlledPanel) {
|
|
splitter.setAttribute("aria-controls", controlledPanel.id);
|
|
|
|
// gather the min, max and current widths to update the aria attributes
|
|
const [containerWidth, currentWidth] =
|
|
await window.promiseDocumentFlushed(() => [
|
|
this.clientWidth,
|
|
controlledPanel.clientWidth,
|
|
]);
|
|
const minWidth = parseFloat(getComputedStyle(controlledPanel).minWidth);
|
|
// We can reuse the controlled panel's minWidth to calculate maxWidth as it should be
|
|
// the same as the 2nd panel in the splitview
|
|
const maxWidth = containerWidth - minWidth;
|
|
// Sometimes dragging the splitter produces a panel width attribute which exceeds
|
|
// the max width, so lets get our own measurment. This may end up at the previous
|
|
// frames width
|
|
if (controlledPanel.hasAttribute("width")) {
|
|
const storedWidth = Number(controlledPanel.getAttribute("width"));
|
|
if (storedWidth > maxWidth) {
|
|
controlledPanel.setAttribute("width", currentWidth);
|
|
controlledPanel.style.width = currentWidth + "px";
|
|
}
|
|
}
|
|
|
|
splitter.setAttribute("aria-valuemin", String(minWidth));
|
|
splitter.setAttribute("aria-valuemax", String(maxWidth));
|
|
splitter.setAttribute("aria-valuenow", String(currentWidth));
|
|
} else {
|
|
splitter.removeAttribute("aria-controls");
|
|
splitter.removeAttribute("aria-valuenow");
|
|
splitter.removeAttribute("aria-valuemin");
|
|
splitter.removeAttribute("aria-valuemax");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* nsIDOMXULRelatedElement
|
|
*/
|
|
getRelatedElement(aTabPanelElm) {
|
|
if (!aTabPanelElm) {
|
|
return null;
|
|
}
|
|
|
|
let tabboxElm = this.tabbox;
|
|
if (!tabboxElm) {
|
|
return null;
|
|
}
|
|
|
|
let tabsElm = tabboxElm.tabs;
|
|
if (!tabsElm) {
|
|
return null;
|
|
}
|
|
|
|
// Return tab element having 'linkedpanel' attribute equal to the id
|
|
// of the tab panel or the same index as the tab panel element.
|
|
let tabpanelIdx = Array.prototype.indexOf.call(
|
|
this.children,
|
|
aTabPanelElm
|
|
);
|
|
if (tabpanelIdx == -1) {
|
|
return null;
|
|
}
|
|
|
|
let tabElms = tabsElm.allTabs;
|
|
let tabElmFromIndex = tabElms[tabpanelIdx];
|
|
|
|
let tabpanelId = aTabPanelElm.id;
|
|
if (tabpanelId) {
|
|
for (let idx = 0; idx < tabElms.length; idx++) {
|
|
let tabElm = tabElms[idx];
|
|
if (tabElm.linkedPanel == tabpanelId) {
|
|
return tabElm;
|
|
}
|
|
}
|
|
}
|
|
|
|
return tabElmFromIndex;
|
|
}
|
|
|
|
set splitViewPanels(newPanels) {
|
|
for (const [i, panel] of newPanels.entries()) {
|
|
const panelEl = document.getElementById(panel);
|
|
panelEl?.classList.add("split-view-panel");
|
|
panelEl?.setAttribute("column", i);
|
|
const browser = panelEl?.querySelector("browser");
|
|
const browserContainer = panelEl?.querySelector(".browserContainer");
|
|
for (const eventType of MozTabpanels.#SPLIT_VIEW_PANEL_EVENTS) {
|
|
browserContainer?.addEventListener(eventType, this);
|
|
}
|
|
browser?.addEventListener("focus", this);
|
|
}
|
|
this.#splitViewPanels = newPanels;
|
|
this.setSplitViewActive(!!newPanels.length);
|
|
}
|
|
|
|
get splitViewPanels() {
|
|
return this.#splitViewPanels;
|
|
}
|
|
|
|
/**
|
|
* Remove split view attributes from tabs and their linked panels.
|
|
*
|
|
* @param {MozTabbrowserTab[]} tabs
|
|
*/
|
|
removeTabsFromSplitview(tabs) {
|
|
for (const tab of tabs) {
|
|
let panel = tab.linkedPanel;
|
|
const panelEl = document.getElementById(panel);
|
|
panelEl?.classList.remove("split-view-panel");
|
|
panelEl?.classList.remove("split-view-panel-active");
|
|
panelEl?.removeAttribute("column");
|
|
const browser = panelEl?.querySelector("browser");
|
|
const browserContainer = panelEl?.querySelector(".browserContainer");
|
|
|
|
for (const eventType of MozTabpanels.#SPLIT_VIEW_PANEL_EVENTS) {
|
|
browserContainer?.removeEventListener(eventType, this);
|
|
}
|
|
browser?.removeEventListener("focus", this);
|
|
const index = this.#splitViewPanels.indexOf(panel);
|
|
|
|
if (index !== -1) {
|
|
this.#splitViewPanels.splice(index, 1);
|
|
}
|
|
}
|
|
|
|
this.setSplitViewActive(!!this.#splitViewPanels.length);
|
|
}
|
|
|
|
/**
|
|
* Temporarily hide split view panels when switching to a non-split-view tab.
|
|
* Preserves the split-view-panel class and column attribute so panels re-enter
|
|
* the flex layout at their correct size when reactivated.
|
|
*
|
|
* @param {MozTabbrowserTab[]} tabs
|
|
*/
|
|
suspendSplitViewPanels(tabs) {
|
|
for (const tab of tabs) {
|
|
const panelEl = document.getElementById(tab.linkedPanel);
|
|
panelEl?.classList.remove("split-view-panel-active");
|
|
}
|
|
this.setSplitViewActive(!!this.#splitViewPanels.length);
|
|
}
|
|
|
|
/**
|
|
* Updates attributes on panels such as the blue outline for active splitview tabs,
|
|
* panel ordering and aria attributes.
|
|
*
|
|
* @param {boolean} updatedValue
|
|
*/
|
|
setSplitViewActive(updatedValue) {
|
|
const splitViewTabSelected =
|
|
gBrowser.selectedTab.splitview && updatedValue;
|
|
this.toggleAttribute("splitview", updatedValue);
|
|
this.splitViewSplitter.hidden = !splitViewTabSelected;
|
|
const selectedPanel = this.selectedPanel;
|
|
|
|
if (splitViewTabSelected) {
|
|
// Ensure panels are in the correct DOM order so that focus moves
|
|
// as expected when tabbing across a splitview
|
|
const firstPanel = document.getElementById(this.splitViewPanels[0]);
|
|
const secondPanel = document.getElementById(this.splitViewPanels[1]);
|
|
if (firstPanel && secondPanel) {
|
|
// Does secondPanel follow firstPanel? Move firstPanel before secondPanel if necessary
|
|
if (
|
|
!(
|
|
firstPanel.compareDocumentPosition(secondPanel) &
|
|
Node.DOCUMENT_POSITION_FOLLOWING
|
|
)
|
|
) {
|
|
firstPanel.parentElement.moveBefore(firstPanel, secondPanel);
|
|
}
|
|
}
|
|
// Ensure the splitter is in-between the panels
|
|
if (
|
|
firstPanel &&
|
|
firstPanel.nextElementSibling !== this.#splitViewSplitter
|
|
) {
|
|
firstPanel.after(this.#splitViewSplitter);
|
|
}
|
|
}
|
|
// Ensure that selected index stays up to date, in case the splitter
|
|
// offsets it.
|
|
this.selectedPanel = selectedPanel;
|
|
// Update aria attributes
|
|
this.#splitterAriaUpdateTask.arm();
|
|
}
|
|
}
|
|
|
|
MozXULElement.implementCustomInterface(MozTabpanels, [
|
|
Ci.nsIDOMXULRelatedElement,
|
|
]);
|
|
customElements.define("tabpanels", MozTabpanels);
|
|
|
|
MozElements.MozTab = class MozTab extends MozElements.BaseText {
|
|
static get markup() {
|
|
return `
|
|
<hbox class="tab-middle box-inherit" flex="1">
|
|
<image class="tab-icon" role="presentation"></image>
|
|
<label class="tab-text" flex="1" role="presentation"></label>
|
|
</hbox>
|
|
`;
|
|
}
|
|
|
|
constructor() {
|
|
super();
|
|
|
|
this.addEventListener("mousedown", this);
|
|
this.addEventListener("keydown", this);
|
|
|
|
this.arrowKeysShouldWrap = AppConstants.platform == "macosx";
|
|
}
|
|
|
|
static get inheritedAttributes() {
|
|
return {
|
|
".tab-middle": "align,dir,pack,orient,selected,visuallyselected",
|
|
".tab-icon": "validate,src=image",
|
|
".tab-text": "value=label,accesskey,crop,disabled",
|
|
};
|
|
}
|
|
|
|
connectedCallback() {
|
|
if (!this._initialized) {
|
|
this.textContent = "";
|
|
this.appendChild(this.constructor.fragment);
|
|
this.initializeAttributeInheritance();
|
|
this._initialized = true;
|
|
}
|
|
}
|
|
|
|
on_mousedown(event) {
|
|
if (event.button != 0 || this.disabled) {
|
|
return;
|
|
}
|
|
|
|
this.container.ariaFocusedItem = null;
|
|
|
|
if (this == this.container.selectedItem) {
|
|
// This tab is already selected and we will fall
|
|
// through to mousedown behavior which sets focus on the current tab,
|
|
// Only a click on an already selected tab should focus the tab itself.
|
|
return;
|
|
}
|
|
|
|
let stopwatchid = this.container.getAttribute("stopwatchid");
|
|
let timerId;
|
|
if (stopwatchid) {
|
|
timerId = Glean.browserTimings[stopwatchid].start();
|
|
}
|
|
|
|
// Call this before setting the 'ignorefocus' attribute because this
|
|
// will pass on focus if the formerly selected tab was focused as well.
|
|
this.container._selectNewTab(this);
|
|
|
|
var isTabFocused = false;
|
|
try {
|
|
isTabFocused = document.commandDispatcher.focusedElement == this;
|
|
} catch (e) {}
|
|
|
|
// Set '-moz-user-focus' to 'ignore' so that PostHandleEvent() can't
|
|
// focus the tab; we only want tabs to be focusable by the mouse if
|
|
// they are already focused. After a short timeout we'll reset
|
|
// '-moz-user-focus' so that tabs can be focused by keyboard again.
|
|
if (!isTabFocused) {
|
|
this.setAttribute("ignorefocus", "true");
|
|
setTimeout(tab => tab.removeAttribute("ignorefocus"), 0, this);
|
|
}
|
|
|
|
if (stopwatchid) {
|
|
Glean.browserTimings[stopwatchid].stopAndAccumulate(timerId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @returns {"ltr"|"rtl"}
|
|
*/
|
|
#getDirection() {
|
|
return window.getComputedStyle(this).direction;
|
|
}
|
|
|
|
/**
|
|
* @param {KeyEvent} event
|
|
*/
|
|
on_keydown(event) {
|
|
if (event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) {
|
|
return;
|
|
}
|
|
|
|
// Handles some keyboard interactions when the active tab is in focus.
|
|
switch (event.keyCode) {
|
|
case KeyEvent.DOM_VK_LEFT: {
|
|
this.container.advanceSelectedItem(
|
|
this.#getDirection() == "ltr"
|
|
? DIRECTION_BACKWARD
|
|
: DIRECTION_FORWARD,
|
|
this.arrowKeysShouldWrap
|
|
);
|
|
event.preventDefault();
|
|
break;
|
|
}
|
|
|
|
case KeyEvent.DOM_VK_RIGHT: {
|
|
this.container.advanceSelectedItem(
|
|
this.#getDirection() == "ltr"
|
|
? DIRECTION_FORWARD
|
|
: DIRECTION_BACKWARD,
|
|
this.arrowKeysShouldWrap
|
|
);
|
|
event.preventDefault();
|
|
break;
|
|
}
|
|
|
|
case KeyEvent.DOM_VK_UP:
|
|
this.container.advanceSelectedItem(
|
|
DIRECTION_BACKWARD,
|
|
this.arrowKeysShouldWrap
|
|
);
|
|
event.preventDefault();
|
|
break;
|
|
|
|
case KeyEvent.DOM_VK_DOWN:
|
|
this.container.advanceSelectedItem(
|
|
DIRECTION_FORWARD,
|
|
this.arrowKeysShouldWrap
|
|
);
|
|
event.preventDefault();
|
|
break;
|
|
|
|
case KeyEvent.DOM_VK_HOME:
|
|
this.container._selectNewTab(this.allTabs.at(0), DIRECTION_FORWARD);
|
|
event.preventDefault();
|
|
break;
|
|
|
|
case KeyEvent.DOM_VK_END: {
|
|
this.container._selectNewTab(this.allTabs.at(-1), DIRECTION_BACKWARD);
|
|
event.preventDefault();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
set value(val) {
|
|
this.setAttribute("value", val);
|
|
}
|
|
|
|
get value() {
|
|
return this.getAttribute("value") || "";
|
|
}
|
|
|
|
get container() {
|
|
return this.closest("tabs");
|
|
}
|
|
|
|
// nsIDOMXULSelectControlItemElement
|
|
get control() {
|
|
return this.container;
|
|
}
|
|
|
|
get selected() {
|
|
return this.hasAttribute("selected");
|
|
}
|
|
|
|
set _selected(val) {
|
|
this.toggleAttribute("selected", val);
|
|
this.toggleAttribute("visuallyselected", val);
|
|
}
|
|
|
|
/** @returns {boolean} */
|
|
get visible() {
|
|
return !this.hidden;
|
|
}
|
|
|
|
set linkedPanel(val) {
|
|
this.setAttribute("linkedpanel", val);
|
|
}
|
|
|
|
get linkedPanel() {
|
|
return this.getAttribute("linkedpanel");
|
|
}
|
|
};
|
|
|
|
MozXULElement.implementCustomInterface(MozElements.MozTab, [
|
|
Ci.nsIDOMXULSelectControlItemElement,
|
|
]);
|
|
customElements.define("tab", MozElements.MozTab);
|
|
|
|
const ARIA_FOCUSED_CLASS_NAME = "tablist-keyboard-focus";
|
|
|
|
class TabsBase extends MozElements.BaseControl {
|
|
constructor() {
|
|
super();
|
|
|
|
this.addEventListener("DOMMouseScroll", event => {
|
|
if (Services.prefs.getBoolPref("toolkit.tabbox.switchByScrolling")) {
|
|
if (event.detail > 0) {
|
|
this.advanceSelectedTab(DIRECTION_FORWARD, false, event);
|
|
} else {
|
|
this.advanceSelectedTab(DIRECTION_BACKWARD, false, event);
|
|
}
|
|
event.stopPropagation();
|
|
}
|
|
});
|
|
}
|
|
|
|
// to be called from derived class connectedCallback
|
|
baseConnect() {
|
|
this._tabbox = null;
|
|
this.ACTIVE_DESCENDANT_ID = `${ARIA_FOCUSED_CLASS_NAME}-${Math.trunc(
|
|
Math.random() * 1000000
|
|
)}`;
|
|
|
|
if (!this.hasAttribute("orient")) {
|
|
this.setAttribute("orient", "horizontal");
|
|
}
|
|
|
|
if (this.tabbox && this.tabbox.hasAttribute("selectedIndex")) {
|
|
let selectedIndex = parseInt(this.tabbox.getAttribute("selectedIndex"));
|
|
this.selectedIndex = selectedIndex > 0 ? selectedIndex : 0;
|
|
return;
|
|
}
|
|
|
|
let children = this.allTabs;
|
|
let length = children.length;
|
|
for (var i = 0; i < length; i++) {
|
|
if (children[i].hasAttribute("selected")) {
|
|
this.selectedIndex = i;
|
|
return;
|
|
}
|
|
}
|
|
|
|
var value = this.value;
|
|
if (value) {
|
|
this.value = value;
|
|
} else {
|
|
this.selectedIndex = 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* nsIDOMXULSelectControlElement
|
|
*/
|
|
get itemCount() {
|
|
return this.allTabs.length;
|
|
}
|
|
|
|
set value(val) {
|
|
this.setAttribute("value", val);
|
|
var children = this.allTabs;
|
|
for (var c = children.length - 1; c >= 0; c--) {
|
|
if (children[c].value == val) {
|
|
this.selectedIndex = c;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
get value() {
|
|
return this.getAttribute("value") || "";
|
|
}
|
|
|
|
get tabbox() {
|
|
if (!this._tabbox) {
|
|
// Memoize the result in a field rather than replacing this property,
|
|
// so that it can be reset along with the binding.
|
|
this._tabbox = this.closest("tabbox");
|
|
}
|
|
|
|
return this._tabbox;
|
|
}
|
|
|
|
/**
|
|
* @param {number} val
|
|
*/
|
|
set selectedIndex(val) {
|
|
var tab = this.getItemAtIndex(val);
|
|
if (!tab) {
|
|
return;
|
|
}
|
|
for (let otherTab of this.allTabs) {
|
|
if (otherTab != tab && otherTab.selected) {
|
|
otherTab._selected = false;
|
|
}
|
|
}
|
|
tab._selected = true;
|
|
|
|
this.setAttribute("value", tab.value);
|
|
|
|
let linkedPanel = this.getRelatedElement(tab);
|
|
if (linkedPanel) {
|
|
this.tabbox.setAttribute("selectedIndex", val);
|
|
|
|
// This will cause an onselect event to fire for the tabpanel
|
|
// element.
|
|
this.tabbox.tabpanels.selectedPanel = linkedPanel;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @returns {number}
|
|
*/
|
|
get selectedIndex() {
|
|
const tabs = this.allTabs;
|
|
for (var i = 0; i < tabs.length; i++) {
|
|
if (tabs[i].selected) {
|
|
return i;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
/**
|
|
* @param {MozTab|null} [val]
|
|
*/
|
|
set selectedItem(val) {
|
|
if (val && !val.selected) {
|
|
// The selectedIndex setter ignores invalid values
|
|
// such as -1 if |val| isn't one of our child nodes.
|
|
this.selectedIndex = this.getIndexOfItem(val);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @returns {MozTab|null}
|
|
*/
|
|
get selectedItem() {
|
|
const tabs = this.allTabs;
|
|
for (var i = 0; i < tabs.length; i++) {
|
|
if (tabs[i].selected) {
|
|
return tabs[i];
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @returns {MozTab[]}
|
|
*/
|
|
get ariaFocusableItems() {
|
|
return this.allTabs;
|
|
}
|
|
|
|
/**
|
|
* @returns {number}
|
|
*/
|
|
get ariaFocusedIndex() {
|
|
const items = this.ariaFocusableItems;
|
|
for (var i = 0; i < items.length; i++) {
|
|
if (items[i].id == this.ACTIVE_DESCENDANT_ID) {
|
|
return i;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
/**
|
|
* @param {MozTab|null} [val]
|
|
*/
|
|
set ariaFocusedItem(val) {
|
|
let setNewItem = val && this.ariaFocusableItems.includes(val);
|
|
let clearExistingItem = this.ariaFocusedItem && (!val || setNewItem);
|
|
|
|
if (clearExistingItem) {
|
|
let ariaFocusedItem = this.ariaFocusedItem;
|
|
ariaFocusedItem.classList.remove(ARIA_FOCUSED_CLASS_NAME);
|
|
ariaFocusedItem.id = "";
|
|
this.selectedItem.removeAttribute("aria-activedescendant");
|
|
let evt = new CustomEvent("AriaFocus");
|
|
this.selectedItem.dispatchEvent(evt);
|
|
}
|
|
|
|
if (setNewItem) {
|
|
val.id = this.ACTIVE_DESCENDANT_ID;
|
|
val.classList.add(ARIA_FOCUSED_CLASS_NAME);
|
|
this.selectedItem.setAttribute(
|
|
"aria-activedescendant",
|
|
this.ACTIVE_DESCENDANT_ID
|
|
);
|
|
let evt = new CustomEvent("AriaFocus");
|
|
val.dispatchEvent(evt);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @returns {MozTab|null}
|
|
*/
|
|
get ariaFocusedItem() {
|
|
return document.getElementById(this.ACTIVE_DESCENDANT_ID);
|
|
}
|
|
|
|
/**
|
|
* nsIDOMXULRelatedElement
|
|
*/
|
|
getRelatedElement(aTabElm) {
|
|
if (!aTabElm) {
|
|
return null;
|
|
}
|
|
|
|
let tabboxElm = this.tabbox;
|
|
if (!tabboxElm) {
|
|
return null;
|
|
}
|
|
|
|
let tabpanelsElm = tabboxElm.tabpanels;
|
|
if (!tabpanelsElm) {
|
|
return null;
|
|
}
|
|
|
|
// Get linked tab panel by 'linkedpanel' attribute on the given tab
|
|
// element.
|
|
let linkedPanelId = aTabElm.linkedPanel;
|
|
if (linkedPanelId) {
|
|
return this.ownerDocument.getElementById(linkedPanelId);
|
|
}
|
|
|
|
// otherwise linked tabpanel element has the same index as the given
|
|
// tab element.
|
|
let tabElmIdx = this.getIndexOfItem(aTabElm);
|
|
return tabpanelsElm.children[tabElmIdx];
|
|
}
|
|
|
|
/**
|
|
* @param {MozTab} item
|
|
* @returns {number}
|
|
*/
|
|
getIndexOfItem(item) {
|
|
return Array.prototype.indexOf.call(this.allTabs, item);
|
|
}
|
|
|
|
/**
|
|
* @param {numb} index
|
|
* @returns {MozTab|null}
|
|
*/
|
|
getItemAtIndex(index) {
|
|
return this.allTabs[index] || null;
|
|
}
|
|
|
|
/**
|
|
* Find an adjacent tab.
|
|
*
|
|
* @param {MozTab} startTab
|
|
* A `<tab>` element to start searching from.
|
|
* @param {object} opts
|
|
* @param {number} [opts.direction=1]
|
|
* 1 to search forward, -1 to search backward.
|
|
* @param {boolean} [opts.wrap=false]
|
|
* If true, wrap around if the search reaches the end (or beginning)
|
|
* of the tab strip.
|
|
* @param {boolean} [opts.startWithAdjacent=true]
|
|
* If true (which is the default), start searching from the next tab
|
|
* after (or before) `startTab`. If false, `startTab` may be returned
|
|
* if it passes the filter.
|
|
* @param {function(MozTab):boolean} [opts.filter]
|
|
* A function to select which tabs to return.
|
|
* @return {MozTab|null}
|
|
* The next `<tab>` element or, if none exists, null.
|
|
*/
|
|
findNextTab(startTab, opts = {}) {
|
|
let {
|
|
direction = 1,
|
|
wrap = false,
|
|
startWithAdjacent = true,
|
|
filter = () => true,
|
|
} = opts;
|
|
|
|
let tab = startTab;
|
|
if (!startWithAdjacent && filter(tab)) {
|
|
return tab;
|
|
}
|
|
|
|
let children = this.allTabs;
|
|
let i = children.indexOf(tab);
|
|
if (i < 0) {
|
|
return null;
|
|
}
|
|
|
|
while (true) {
|
|
i += direction;
|
|
if (wrap) {
|
|
if (i < 0) {
|
|
i = children.length - 1;
|
|
} else if (i >= children.length) {
|
|
i = 0;
|
|
}
|
|
} else if (i < 0 || i >= children.length) {
|
|
return null;
|
|
}
|
|
|
|
tab = children[i];
|
|
if (tab == startTab) {
|
|
return null;
|
|
}
|
|
if (filter(tab)) {
|
|
return tab;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {MozTab} aNewTab
|
|
* @param {-1|1} [aFallbackDir]
|
|
* @param {boolean} [aWrap]
|
|
* @returns
|
|
*/
|
|
_selectNewTab(aNewTab, aFallbackDir, aWrap) {
|
|
this.ariaFocusedItem = null;
|
|
|
|
aNewTab = this.findNextTab(aNewTab, {
|
|
direction: aFallbackDir,
|
|
wrap: aWrap,
|
|
startWithAdjacent: false,
|
|
filter: tab =>
|
|
!tab.hidden && !tab.disabled && this._canAdvanceToTab(tab),
|
|
});
|
|
|
|
var isTabFocused = false;
|
|
try {
|
|
isTabFocused =
|
|
document.commandDispatcher.focusedElement == this.selectedItem;
|
|
} catch (e) {}
|
|
this.selectedItem = aNewTab;
|
|
if (isTabFocused) {
|
|
aNewTab.focus();
|
|
} else if (this.getAttribute("setfocus") != "false") {
|
|
let selectedPanel = this.tabbox.selectedPanel;
|
|
document.commandDispatcher.advanceFocusIntoSubtree(selectedPanel);
|
|
|
|
// Make sure that the focus doesn't move outside the tabbox
|
|
if (this.tabbox) {
|
|
try {
|
|
let el = document.commandDispatcher.focusedElement;
|
|
while (el && el != this.tabbox.tabpanels) {
|
|
if (el == this.tabbox || el == selectedPanel) {
|
|
return;
|
|
}
|
|
el = el.parentNode;
|
|
}
|
|
aNewTab.focus();
|
|
} catch (e) {}
|
|
}
|
|
}
|
|
}
|
|
|
|
_canAdvanceToTab() {
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Selects the next visible tab in this list of tabs.
|
|
*
|
|
* @param {-1|1} [aDir]
|
|
* @param {boolean} [aWrap]
|
|
* @param {Event} [aEvent] The DOM event that triggered this call.
|
|
*/
|
|
// eslint-disable-next-line no-unused-vars
|
|
advanceSelectedTab(aDir, aWrap, aEvent) {
|
|
let { ariaFocusedItem } = this;
|
|
let startTab = ariaFocusedItem;
|
|
if (!ariaFocusedItem || !this.allTabs.includes(ariaFocusedItem)) {
|
|
startTab = this.selectedItem;
|
|
}
|
|
let newTab = null;
|
|
|
|
// Handle keyboard navigation for a hidden tab that can be selected, like the Firefox View tab,
|
|
// which has a random placement in this.allTabs.
|
|
if (startTab.hidden) {
|
|
if (aDir == 1) {
|
|
newTab = this.allTabs.find(tab => tab.visible);
|
|
} else {
|
|
newTab = this.allTabs.findLast(tab => tab.visible);
|
|
}
|
|
} else {
|
|
newTab = this.findNextTab(startTab, {
|
|
direction: aDir,
|
|
wrap: aWrap,
|
|
filter: tab => tab.visible,
|
|
});
|
|
}
|
|
|
|
if (newTab && newTab != startTab) {
|
|
this._selectNewTab(newTab, aDir, aWrap);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Selects the next visible item in this list of items.
|
|
*
|
|
* This provides an extension point for code to mix non-tab items inside
|
|
* of this tab list and be able to appropriately and logically advance to
|
|
* the next tab or non-tab.
|
|
*
|
|
* @param {-1|1} [aDir]
|
|
* @param {boolean} [aWrap]
|
|
*/
|
|
advanceSelectedItem(aDir, aWrap) {
|
|
this.advanceSelectedTab(aDir, aWrap);
|
|
}
|
|
|
|
appendItem(label, value) {
|
|
var tab = document.createXULElement("tab");
|
|
tab.setAttribute("label", label);
|
|
tab.setAttribute("value", value);
|
|
this.appendChild(tab);
|
|
return tab;
|
|
}
|
|
}
|
|
|
|
MozXULElement.implementCustomInterface(TabsBase, [
|
|
Ci.nsIDOMXULSelectControlElement,
|
|
Ci.nsIDOMXULRelatedElement,
|
|
]);
|
|
|
|
MozElements.TabsBase = TabsBase;
|
|
|
|
class MozTabs extends TabsBase {
|
|
connectedCallback() {
|
|
if (this.delayConnectedCallback()) {
|
|
return;
|
|
}
|
|
|
|
let start = MozXULElement.parseXULToFragment(
|
|
`<spacer class="tabs-left"/>`
|
|
);
|
|
this.insertBefore(start, this.firstChild);
|
|
|
|
let end = MozXULElement.parseXULToFragment(
|
|
`<spacer class="tabs-right"/>`
|
|
);
|
|
this.insertBefore(end, null);
|
|
|
|
this.baseConnect();
|
|
}
|
|
|
|
// Accessor for tabs. This element has spacers as the first and
|
|
// last elements and <tab>s are everything in between.
|
|
get allTabs() {
|
|
let children = Array.from(this.children);
|
|
return children.splice(1, children.length - 2);
|
|
}
|
|
|
|
appendChild(tab) {
|
|
// insert before the end spacer.
|
|
this.insertBefore(tab, this.lastChild);
|
|
}
|
|
}
|
|
|
|
customElements.define("tabs", MozTabs);
|
|
}
|