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
"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, {
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(
"tabs"
).item(0);
}
get tabpanels() {
return this.getElementsByTagNameNS(
"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:
if (event.defaultPreventedByChrome) {
return;
}
// Don't check if the event was already consumed because tab
// navigation should always work for better user experience.
const { ShortcutUtils } = imports;
switch (ShortcutUtils.getSystemActionForEvent(event)) {
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 ? -1 : 1, true);
event.preventDefault();
}
break;
case ShortcutUtils.PREVIOUS_TAB:
if (this.tabs) {
this.tabs.advanceSelectedTab(-1, true);
event.preventDefault();
}
break;
case ShortcutUtils.NEXT_TAB:
if (this.tabs) {
this.tabs.advanceSelectedTab(1, true);
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 {
constructor() {
super();
this._tabbox = null;
}
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"));
}
/**
* 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;
}
}
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);
}
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");
if (stopwatchid) {
TelemetryStopwatch.start(stopwatchid);
}
// 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) {
TelemetryStopwatch.finish(stopwatchid);
}
}
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.getAttribute("selected") == "true";
}
set _selected(val) {
if (val) {
this.setAttribute("selected", "true");
this.setAttribute("visuallyselected", "true");
} else {
this.removeAttribute("selected");
this.removeAttribute("visuallyselected");
}
}
/** @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.arrowKeysShouldWrap = AppConstants.platform == "macosx";
this.addEventListener("DOMMouseScroll", event => {
if (Services.prefs.getBoolPref("toolkit.tabbox.switchByScrolling")) {
if (event.detail > 0) {
this.advanceSelectedTab(DIRECTION_FORWARD, false);
} else {
this.advanceSelectedTab(DIRECTION_BACKWARD, false);
}
event.stopPropagation();
}
});
this.addEventListener("keydown", this);
}
// 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].getAttribute("selected") == "true") {
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];
}
/**
* @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
if (document.activeElement == this.selectedItem) {
switch (event.keyCode) {
case KeyEvent.DOM_VK_LEFT: {
this.advanceSelectedTab(
this.#getDirection() == "ltr"
? DIRECTION_BACKWARD
: DIRECTION_FORWARD,
this.arrowKeysShouldWrap
);
event.preventDefault();
break;
}
case KeyEvent.DOM_VK_RIGHT: {
this.advanceSelectedTab(
this.#getDirection() == "ltr"
? DIRECTION_FORWARD
: DIRECTION_BACKWARD,
this.arrowKeysShouldWrap
);
event.preventDefault();
break;
}
case KeyEvent.DOM_VK_UP:
this.advanceSelectedTab(
DIRECTION_BACKWARD,
this.arrowKeysShouldWrap
);
event.preventDefault();
break;
case KeyEvent.DOM_VK_DOWN:
this.advanceSelectedTab(
DIRECTION_FORWARD,
this.arrowKeysShouldWrap
);
event.preventDefault();
break;
case KeyEvent.DOM_VK_HOME:
this._selectNewTab(this.allTabs.at(0), DIRECTION_FORWARD);
event.preventDefault();
break;
case KeyEvent.DOM_VK_END: {
this._selectNewTab(this.allTabs.at(-1), DIRECTION_BACKWARD);
event.preventDefault();
break;
}
}
}
}
/**
* @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;
}
/**
* @param {-1|1} [aDir]
* @param {boolean} [aWrap]
*/
advanceSelectedTab(aDir, aWrap) {
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);
}
}
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" flex="1"/>`
);
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);
}