diff options
Diffstat (limited to 'toolkit/content/widgets/tabbox.js')
-rw-r--r-- | toolkit/content/widgets/tabbox.js | 884 |
1 files changed, 884 insertions, 0 deletions
diff --git a/toolkit/content/widgets/tabbox.js b/toolkit/content/widgets/tabbox.js new file mode 100644 index 0000000000..997e8413f2 --- /dev/null +++ b/toolkit/content/widgets/tabbox.js @@ -0,0 +1,884 @@ +/* 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, { + ShortcutUtils: "resource://gre/modules/ShortcutUtils.sys.mjs", + }); + + class MozTabbox extends MozXULElement { + constructor() { + super(); + this._handleMetaAltArrows = AppConstants.platform == "macosx"; + this.disconnectedCallback = this.disconnectedCallback.bind(this); + } + + connectedCallback() { + Services.els.addSystemEventListener(document, "keydown", this, false); + window.addEventListener("unload", this.disconnectedCallback, { + once: true, + }); + } + + disconnectedCallback() { + window.removeEventListener("unload", this.disconnectedCallback); + Services.els.removeSystemEventListener(document, "keydown", this, false); + } + + 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: + 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: + Services.telemetry.keyedScalarAdd( + "browser.ui.interaction.keyboard", + "ctrl-tab", + 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; + } + + let parent = this.parentNode; + while (parent) { + if (parent.localName == "tabbox") { + break; + } + parent = parent.parentNode; + } + + return (this._tabbox = parent); + } + + /** + * 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); + 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.parentNode.ariaFocusedItem = null; + + if (this == this.parentNode.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.parentNode.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.closest("tabs")._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); + } + } + + on_keydown(event) { + if (event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) { + return; + } + switch (event.keyCode) { + case KeyEvent.DOM_VK_LEFT: { + let direction = window.getComputedStyle(this.parentNode).direction; + this.container.advanceSelectedTab( + direction == "ltr" ? -1 : 1, + this.arrowKeysShouldWrap + ); + event.preventDefault(); + break; + } + + case KeyEvent.DOM_VK_RIGHT: { + let direction = window.getComputedStyle(this.parentNode).direction; + this.container.advanceSelectedTab( + direction == "ltr" ? 1 : -1, + this.arrowKeysShouldWrap + ); + event.preventDefault(); + break; + } + + case KeyEvent.DOM_VK_UP: + this.container.advanceSelectedTab(-1, this.arrowKeysShouldWrap); + event.preventDefault(); + break; + + case KeyEvent.DOM_VK_DOWN: + this.container.advanceSelectedTab(1, this.arrowKeysShouldWrap); + event.preventDefault(); + break; + + case KeyEvent.DOM_VK_HOME: + this.container._selectNewTab(this.container.allTabs[0]); + event.preventDefault(); + break; + + case KeyEvent.DOM_VK_END: { + let { allTabs } = this.container; + this.container._selectNewTab(allTabs[allTabs.length - 1], -1); + event.preventDefault(); + break; + } + } + } + + set value(val) { + this.setAttribute("value", val); + } + + get value() { + return this.getAttribute("value"); + } + + get control() { + var parent = this.parentNode; + return parent.localName == "tabs" ? parent : null; + } + + 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"); + } + } + + set linkedPanel(val) { + this.setAttribute("linkedpanel", val); + } + + get linkedPanel() { + return this.getAttribute("linkedpanel"); + } + }; + + MozXULElement.implementCustomInterface(MozElements.MozTab, [ + Ci.nsIDOMXULSelectControlItemElement, + ]); + customElements.define("tab", MozElements.MozTab); + + class TabsBase extends MozElements.BaseControl { + constructor() { + super(); + + this.addEventListener("DOMMouseScroll", event => { + if (Services.prefs.getBoolPref("toolkit.tabbox.switchByScrolling")) { + if (event.detail > 0) { + this.advanceSelectedTab(1, false); + } else { + this.advanceSelectedTab(-1, false); + } + event.stopPropagation(); + } + }); + } + + // to be called from derived class connectedCallback + baseConnect() { + this._tabbox = null; + this.ACTIVE_DESCENDANT_ID = + "keyboard-focused-tab-" + 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; + } + + 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; + } + } + + get selectedIndex() { + const tabs = this.allTabs; + for (var i = 0; i < tabs.length; i++) { + if (tabs[i].selected) { + return i; + } + } + return -1; + } + + 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); + } + } + + get selectedItem() { + const tabs = this.allTabs; + for (var i = 0; i < tabs.length; i++) { + if (tabs[i].selected) { + return tabs[i]; + } + } + return null; + } + + get ariaFocusedIndex() { + const tabs = this.allTabs; + for (var i = 0; i < tabs.length; i++) { + if (tabs[i].id == this.ACTIVE_DESCENDANT_ID) { + return i; + } + } + return -1; + } + + set ariaFocusedItem(val) { + let setNewItem = val && this.getIndexOfItem(val) != -1; + let clearExistingItem = this.ariaFocusedItem && (!val || setNewItem); + if (clearExistingItem) { + let ariaFocusedItem = this.ariaFocusedItem; + ariaFocusedItem.classList.remove("keyboard-focused-tab"); + ariaFocusedItem.id = ""; + this.selectedItem.removeAttribute("aria-activedescendant"); + let evt = new CustomEvent("AriaFocus"); + this.selectedItem.dispatchEvent(evt); + } + + if (setNewItem) { + this.ariaFocusedItem = null; + val.id = this.ACTIVE_DESCENDANT_ID; + val.classList.add("keyboard-focused-tab"); + this.selectedItem.setAttribute( + "aria-activedescendant", + this.ACTIVE_DESCENDANT_ID + ); + let evt = new CustomEvent("AriaFocus"); + val.dispatchEvent(evt); + } + } + + 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]; + } + + getIndexOfItem(item) { + return Array.prototype.indexOf.call(this.allTabs, item); + } + + getItemAtIndex(index) { + return this.allTabs[index] || null; + } + + /** + * Find an adjacent tab. + * + * @param {Node} startTab A <tab> element to start searching from. + * @param {Number} opts.direction 1 to search forward, -1 to search backward. + * @param {Boolean} opts.wrap If true, wrap around if the search reaches + * the end (or beginning) of the tab strip. + * @param {Boolean} opts.startWithAdjacent + * 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 {Boolean} opts.advance If false, start searching with startTab. If + * true, start searching with an adjacent tab. + * @param {Function} opts.filter A function to select which tabs to return. + * + * @return {Node | null} The next <tab> element or, if none exists, null. + */ + findNextTab(startTab, opts = {}) { + let { + direction = 1, + wrap = false, + startWithAdjacent = true, + filter = tab => 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; + } + } + } + + _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(aTab) { + return true; + } + + advanceSelectedTab(aDir, aWrap) { + let startTab = this.ariaFocusedItem || 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.hidden); + } else { + newTab = this.allTabs.findLast(tab => !tab.hidden); + } + } else { + newTab = this.findNextTab(startTab, { + direction: aDir, + wrap: aWrap, + }); + } + + 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); +} |