summaryrefslogtreecommitdiffstats
path: root/toolkit/content/widgets/menu.js
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--toolkit/content/widgets/menu.js489
1 files changed, 489 insertions, 0 deletions
diff --git a/toolkit/content/widgets/menu.js b/toolkit/content/widgets/menu.js
new file mode 100644
index 0000000000..18d9c21f4d
--- /dev/null
+++ b/toolkit/content/widgets/menu.js
@@ -0,0 +1,489 @@
+/* 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 all XUL windows. Wrap in a block to prevent
+// leaking to window scope.
+{
+ let imports = {};
+ ChromeUtils.defineESModuleGetters(imports, {
+ ShortcutUtils: "resource://gre/modules/ShortcutUtils.sys.mjs",
+ });
+
+ const MozMenuItemBaseMixin = Base => {
+ class MozMenuItemBase extends MozElements.BaseTextMixin(Base) {
+ // nsIDOMXULSelectControlItemElement
+ set value(val) {
+ this.setAttribute("value", val);
+ }
+ get value() {
+ return this.getAttribute("value");
+ }
+
+ // nsIDOMXULSelectControlItemElement
+ get selected() {
+ return this.getAttribute("selected") == "true";
+ }
+
+ // nsIDOMXULSelectControlItemElement
+ get control() {
+ var parent = this.parentNode;
+ // Return the parent if it is a menu or menulist.
+ if (parent && XULMenuElement.isInstance(parent.parentNode)) {
+ return parent.parentNode;
+ }
+ return null;
+ }
+
+ // nsIDOMXULContainerItemElement
+ get parentContainer() {
+ for (var parent = this.parentNode; parent; parent = parent.parentNode) {
+ if (XULMenuElement.isInstance(parent)) {
+ return parent;
+ }
+ }
+ return null;
+ }
+ }
+ MozXULElement.implementCustomInterface(MozMenuItemBase, [
+ Ci.nsIDOMXULSelectControlItemElement,
+ Ci.nsIDOMXULContainerItemElement,
+ ]);
+ return MozMenuItemBase;
+ };
+
+ const MozMenuBaseMixin = Base => {
+ class MozMenuBase extends MozMenuItemBaseMixin(Base) {
+ set open(val) {
+ this.openMenu(val);
+ }
+
+ get open() {
+ return this.hasAttribute("open");
+ }
+
+ get itemCount() {
+ var menupopup = this.menupopup;
+ return menupopup ? menupopup.children.length : 0;
+ }
+
+ get menupopup() {
+ const XUL_NS =
+ "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+
+ for (
+ var child = this.firstElementChild;
+ child;
+ child = child.nextElementSibling
+ ) {
+ if (child.namespaceURI == XUL_NS && child.localName == "menupopup") {
+ return child;
+ }
+ }
+ return null;
+ }
+
+ appendItem(aLabel, aValue) {
+ var menupopup = this.menupopup;
+ if (!menupopup) {
+ menupopup = this.ownerDocument.createXULElement("menupopup");
+ this.appendChild(menupopup);
+ }
+
+ var menuitem = this.ownerDocument.createXULElement("menuitem");
+ menuitem.setAttribute("label", aLabel);
+ menuitem.setAttribute("value", aValue);
+
+ return menupopup.appendChild(menuitem);
+ }
+
+ getIndexOfItem(aItem) {
+ var menupopup = this.menupopup;
+ if (menupopup) {
+ var items = menupopup.children;
+ var length = items.length;
+ for (var index = 0; index < length; ++index) {
+ if (items[index] == aItem) {
+ return index;
+ }
+ }
+ }
+ return -1;
+ }
+
+ getItemAtIndex(aIndex) {
+ var menupopup = this.menupopup;
+ if (!menupopup || aIndex < 0 || aIndex >= menupopup.children.length) {
+ return null;
+ }
+
+ return menupopup.children[aIndex];
+ }
+ }
+ MozXULElement.implementCustomInterface(MozMenuBase, [
+ Ci.nsIDOMXULContainerElement,
+ ]);
+ return MozMenuBase;
+ };
+
+ // The <menucaption> element is used for rendering <html:optgroup> inside of <html:select>,
+ // See SelectParentHelper.jsm.
+ class MozMenuCaption extends MozMenuBaseMixin(MozXULElement) {
+ static get inheritedAttributes() {
+ return {
+ ".menu-iconic-left": "selected,disabled,checked",
+ ".menu-iconic-icon": "src=image,validate,src",
+ ".menu-iconic-text": "value=label,crop,highlightable",
+ ".menu-iconic-highlightable-text": "text=label,crop,highlightable",
+ };
+ }
+
+ connectedCallback() {
+ this.textContent = "";
+ this.appendChild(
+ MozXULElement.parseXULToFragment(`
+ <hbox class="menu-iconic-left" align="center" pack="center" aria-hidden="true">
+ <image class="menu-iconic-icon" aria-hidden="true"></image>
+ </hbox>
+ <label class="menu-iconic-text" flex="1" crop="end" aria-hidden="true"></label>
+ <label class="menu-iconic-highlightable-text" crop="end" aria-hidden="true"></label>
+ `)
+ );
+ this.initializeAttributeInheritance();
+ }
+ }
+
+ customElements.define("menucaption", MozMenuCaption);
+
+ // In general, wait to render menus and menuitems inside menupopups
+ // until they are going to be visible:
+ window.addEventListener(
+ "popupshowing",
+ e => {
+ if (e.originalTarget.ownerDocument != document) {
+ return;
+ }
+ e.originalTarget.setAttribute("hasbeenopened", "true");
+ for (let el of e.originalTarget.querySelectorAll("menuitem, menu")) {
+ el.render();
+ }
+ },
+ { capture: true }
+ );
+
+ class MozMenuItem extends MozMenuItemBaseMixin(MozXULElement) {
+ static get observedAttributes() {
+ return super.observedAttributes.concat("acceltext", "key");
+ }
+
+ attributeChangedCallback(name, oldValue, newValue) {
+ if (name == "acceltext") {
+ if (this._ignoreAccelTextChange) {
+ this._ignoreAccelTextChange = false;
+ } else {
+ this._accelTextIsDerived = false;
+ this._computeAccelTextFromKeyIfNeeded();
+ }
+ }
+ if (name == "key") {
+ this._computeAccelTextFromKeyIfNeeded();
+ }
+ super.attributeChangedCallback(name, oldValue, newValue);
+ }
+
+ static get inheritedAttributes() {
+ return {
+ ".menu-iconic-text": "value=label,crop,accesskey,highlightable",
+ ".menu-text": "value=label,crop,accesskey,highlightable",
+ ".menu-iconic-highlightable-text":
+ "text=label,crop,accesskey,highlightable",
+ ".menu-iconic-left": "selected,_moz-menuactive,disabled,checked",
+ ".menu-iconic-icon":
+ "src=image,validate,triggeringprincipal=iconloadingprincipal",
+ ".menu-iconic-accel": "value=acceltext",
+ ".menu-accel": "value=acceltext",
+ };
+ }
+
+ static get iconicNoAccelFragment() {
+ // Add aria-hidden="true" on all DOM, since XULMenuAccessible handles accessibility here.
+ let frag = document.importNode(
+ MozXULElement.parseXULToFragment(`
+ <hbox class="menu-iconic-left" align="center" pack="center" aria-hidden="true">
+ <image class="menu-iconic-icon"/>
+ </hbox>
+ <label class="menu-iconic-text" flex="1" crop="end" aria-hidden="true"/>
+ <label class="menu-iconic-highlightable-text" crop="end" aria-hidden="true"/>
+ `),
+ true
+ );
+ Object.defineProperty(this, "iconicNoAccelFragment", { value: frag });
+ return frag;
+ }
+
+ static get iconicFragment() {
+ let frag = document.importNode(
+ MozXULElement.parseXULToFragment(`
+ <hbox class="menu-iconic-left" align="center" pack="center" aria-hidden="true">
+ <image class="menu-iconic-icon"/>
+ </hbox>
+ <label class="menu-iconic-text" flex="1" crop="end" aria-hidden="true"/>
+ <label class="menu-iconic-highlightable-text" crop="end" aria-hidden="true"/>
+ <hbox class="menu-accel-container" aria-hidden="true">
+ <label class="menu-iconic-accel"/>
+ </hbox>
+ `),
+ true
+ );
+ Object.defineProperty(this, "iconicFragment", { value: frag });
+ return frag;
+ }
+
+ static get plainFragment() {
+ let frag = document.importNode(
+ MozXULElement.parseXULToFragment(`
+ <label class="menu-text" crop="end" aria-hidden="true"/>
+ <hbox class="menu-accel-container" aria-hidden="true">
+ <label class="menu-accel"/>
+ </hbox>
+ `),
+ true
+ );
+ Object.defineProperty(this, "plainFragment", { value: frag });
+ return frag;
+ }
+
+ get isIconic() {
+ let type = this.getAttribute("type");
+ return (
+ type == "checkbox" ||
+ type == "radio" ||
+ this.classList.contains("menuitem-iconic")
+ );
+ }
+
+ get isMenulistChild() {
+ return this.matches("menulist > menupopup > menuitem");
+ }
+
+ get isInHiddenMenupopup() {
+ return this.matches("menupopup:not([hasbeenopened]) menuitem");
+ }
+
+ _computeAccelTextFromKeyIfNeeded() {
+ if (!this._accelTextIsDerived && this.getAttribute("acceltext")) {
+ return;
+ }
+ let accelText = (() => {
+ if (!document.contains(this)) {
+ return null;
+ }
+ let keyId = this.getAttribute("key");
+ if (!keyId) {
+ return null;
+ }
+ let key = document.getElementById(keyId);
+ if (!key) {
+ console.error(
+ `Key ${keyId} of menuitem ${this.getAttribute("label")} ` +
+ `could not be found`
+ );
+ return null;
+ }
+ return imports.ShortcutUtils.prettifyShortcut(key);
+ })();
+
+ this._accelTextIsDerived = true;
+ // We need to ignore the next attribute change callback for acceltext, in
+ // order to not reenter here.
+ this._ignoreAccelTextChange = true;
+ if (accelText) {
+ this.setAttribute("acceltext", accelText);
+ } else {
+ this.removeAttribute("acceltext");
+ }
+ }
+
+ render() {
+ if (this.renderedOnce) {
+ return;
+ }
+ this.renderedOnce = true;
+ this.textContent = "";
+ if (this.isMenulistChild) {
+ this.append(this.constructor.iconicNoAccelFragment.cloneNode(true));
+ } else if (this.isIconic) {
+ this.append(this.constructor.iconicFragment.cloneNode(true));
+ } else {
+ this.append(this.constructor.plainFragment.cloneNode(true));
+ }
+
+ this._computeAccelTextFromKeyIfNeeded();
+ this.initializeAttributeInheritance();
+ }
+
+ connectedCallback() {
+ if (this.renderedOnce) {
+ this._computeAccelTextFromKeyIfNeeded();
+ }
+ // Eagerly render if we are being inserted into a menulist (since we likely need to
+ // size it), or into an already-opened menupopup (since we are already visible).
+ // Checking isConnectedAndReady is an optimization that will let us quickly skip
+ // non-menulists that are being connected during parse.
+ if (
+ this.isMenulistChild ||
+ (this.isConnectedAndReady && !this.isInHiddenMenupopup)
+ ) {
+ this.render();
+ }
+ }
+ }
+
+ customElements.define("menuitem", MozMenuItem);
+
+ const isHiddenWindow =
+ document.documentURI == "chrome://browser/content/hiddenWindowMac.xhtml";
+
+ class MozMenu extends MozMenuBaseMixin(
+ MozElements.MozElementMixin(XULMenuElement)
+ ) {
+ static get inheritedAttributes() {
+ return {
+ ".menubar-text": "value=label,accesskey,crop",
+ ".menu-iconic-text": "value=label,accesskey,crop,highlightable",
+ ".menu-text": "value=label,accesskey,crop",
+ ".menu-iconic-highlightable-text":
+ "text=label,crop,accesskey,highlightable",
+ ".menubar-left": "src=image",
+ ".menu-iconic-icon":
+ "src=image,triggeringprincipal=iconloadingprincipal,validate",
+ ".menu-iconic-accel": "value=acceltext",
+ ".menu-right": "_moz-menuactive,disabled",
+ ".menu-accel": "value=acceltext",
+ };
+ }
+
+ get needsEagerRender() {
+ return (
+ this.isMenubarChild || this.isMenulistChild || !this.isInHiddenMenupopup
+ );
+ }
+
+ get isMenubarChild() {
+ return this.matches("menubar > menu");
+ }
+
+ get isMenulistChild() {
+ return this.matches("menulist > menupopup > menu");
+ }
+
+ get isInHiddenMenupopup() {
+ return this.matches("menupopup:not([hasbeenopened]) menu");
+ }
+
+ get isIconic() {
+ return this.classList.contains("menu-iconic");
+ }
+
+ get fragment() {
+ let { isMenubarChild, isIconic } = this;
+ let fragment = null;
+ // Add aria-hidden="true" on all DOM, since XULMenuAccessible handles accessibility here.
+ if (isMenubarChild && isIconic) {
+ if (!MozMenu.menubarIconicFrag) {
+ MozMenu.menubarIconicFrag = MozXULElement.parseXULToFragment(`
+ <image class="menubar-left" aria-hidden="true"/>
+ <label class="menubar-text" crop="end" aria-hidden="true"/>
+ `);
+ }
+ fragment = document.importNode(MozMenu.menubarIconicFrag, true);
+ }
+ if (isMenubarChild && !isIconic) {
+ if (!MozMenu.menubarFrag) {
+ MozMenu.menubarFrag = MozXULElement.parseXULToFragment(`
+ <label class="menubar-text" crop="end" aria-hidden="true"/>
+ `);
+ }
+ fragment = document.importNode(MozMenu.menubarFrag, true);
+ }
+ if (!isMenubarChild && isIconic) {
+ if (!MozMenu.normalIconicFrag) {
+ MozMenu.normalIconicFrag = MozXULElement.parseXULToFragment(`
+ <hbox class="menu-iconic-left" align="center" pack="center" aria-hidden="true">
+ <image class="menu-iconic-icon"/>
+ </hbox>
+ <label class="menu-iconic-text" flex="1" crop="end" aria-hidden="true"/>
+ <label class="menu-iconic-highlightable-text" crop="end" aria-hidden="true"/>
+ <hbox class="menu-accel-container" anonid="accel" aria-hidden="true">
+ <label class="menu-iconic-accel"/>
+ </hbox>
+ <hbox align="center" class="menu-right" aria-hidden="true">
+ <image/>
+ </hbox>
+ `);
+ }
+
+ fragment = document.importNode(MozMenu.normalIconicFrag, true);
+ }
+ if (!isMenubarChild && !isIconic) {
+ if (!MozMenu.normalFrag) {
+ MozMenu.normalFrag = MozXULElement.parseXULToFragment(`
+ <label class="menu-text" crop="end" aria-hidden="true"/>
+ <hbox class="menu-accel-container" anonid="accel" aria-hidden="true">
+ <label class="menu-accel"/>
+ </hbox>
+ <hbox align="center" class="menu-right" aria-hidden="true">
+ <image/>
+ </hbox>
+ `);
+ }
+
+ fragment = document.importNode(MozMenu.normalFrag, true);
+ }
+ return fragment;
+ }
+
+ render() {
+ // There are 2 main types of menus:
+ // (1) direct descendant of a menubar
+ // (2) all other menus
+ // There is also an "iconic" variation of (1) and (2) based on the class.
+ // To make this as simple as possible, we don't support menus being changed from one
+ // of these types to another after the initial DOM connection. It'd be possible to make
+ // this work by keeping track of the markup we prepend and then removing / re-prepending
+ // during a change, but it's not a feature we use anywhere currently.
+ if (this.renderedOnce) {
+ return;
+ }
+ this.renderedOnce = true;
+
+ // There will be a <menupopup /> already. Don't clear it out, just put our markup before it.
+ this.prepend(this.fragment);
+ this.initializeAttributeInheritance();
+ }
+
+ connectedCallback() {
+ // On OSX we will have a bunch of menus in the hidden window. They get converted
+ // into native menus based on the host attributes, so the inner DOM doesn't need
+ // to be created.
+ if (isHiddenWindow) {
+ return;
+ }
+
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+
+ // Wait until we are going to be visible or required for sizing a popup.
+ if (!this.needsEagerRender) {
+ return;
+ }
+
+ this.render();
+ }
+ }
+
+ customElements.define("menu", MozMenu);
+}