Bug 1257613 - Add an API to open context menus from an HTML document; f=jdescottes draft
authorBrian Grinstead <bgrinstead@mozilla.com>
Fri, 29 Apr 2016 16:21:48 -0700
changeset 358038 0a3e30cf183bc83ed6148a729143c734555ce814
parent 358036 a6688f332ebcb7bc6c634e0bfc03c73fff6f42c6
child 519763 5978ac5a1cd754df5b41754969741f146bceee2e
push id16910
push userbgrinstead@mozilla.com
push dateFri, 29 Apr 2016 23:21:56 +0000
bugs1257613
milestone49.0a1
Bug 1257613 - Add an API to open context menus from an HTML document; f=jdescottes MozReview-Commit-ID: 4j9d5k3Ut1f
devtools/client/framework/menu-item.js
devtools/client/framework/menu.js
devtools/client/framework/moz.build
devtools/client/framework/test/browser.ini
devtools/client/framework/test/browser_menu_api.js
new file mode 100644
--- /dev/null
+++ b/devtools/client/framework/menu-item.js
@@ -0,0 +1,58 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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/. */
+
+/**
+ * A partial implementation of the MenuItem API provided by electron:
+ * https://github.com/electron/electron/blob/master/docs/api/menu-item.md.
+ *
+ * Missing features:
+ *   - id String - Unique within a single menu. If defined then it can be used
+ *                 as a reference to this item by the position attribute.
+ *   - role String - Define the action of the menu item; when specified the
+ *                   click property will be ignored
+ *   - sublabel String
+ *   - accelerator Accelerator
+ *   - icon NativeImage
+ *   - visible Boolean - If false, the menu item will be entirely hidden.
+ *   - position String - This field allows fine-grained definition of the
+ *                       specific location within a given menu.
+ *
+ * Implemented features:
+ *  @param Object options
+ *    Function click
+ *      Will be called with click(menuItem, browserWindow) when the menu item is clicked
+ *    String type
+ *      Can be normal, separator, submenu, checkbox or radio
+ *    String label
+ *      Boolean enabled
+ *    If false, the menu item will be greyed out and unclickable.
+ *      Boolean checked
+ *    Should only be specified for checkbox or radio type menu items.
+ *      Menu submenu
+ *    Should be specified for submenu type menu items. If submenu is specified, the type: 'submenu' can be omitted. If the value is not a Menu then it will be automatically converted to one using Menu.buildFromTemplate.
+ *
+ */
+function MenuItem({
+    accesskey = null,
+    checked = false,
+    click = () => {},
+    disabled = false,
+    label = "",
+    id = null,
+    submenu = null,
+    type = "normal",
+} = { }) {
+  this.accesskey = accesskey;
+  this.checked = checked;
+  this.click = click;
+  this.disabled = disabled;
+  this.id = id;
+  this.label = label;
+  this.submenu = submenu;
+  this.type = type;
+}
+
+module.exports = MenuItem;
new file mode 100644
--- /dev/null
+++ b/devtools/client/framework/menu.js
@@ -0,0 +1,149 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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/. */
+
+const MenuItem = require("./menu-item");
+const EventEmitter = require("devtools/shared/event-emitter");
+
+/**
+ * A partial implementation of the Menu API provided by electron:
+ * https://github.com/electron/electron/blob/master/docs/api/menu.md.
+ *
+ * Extra features:
+ *  - Emits an 'open' and 'close' event when the menu is opened/closed
+
+ * @param String id (non standard)
+ *        Needed so tests can confirm the XUL implementation is working
+ */
+function Menu({id=null} = {}) {
+  this.menuitems = [];
+  this.id = id;
+
+  Object.defineProperty(this, "items", {
+    get() {
+      return this.menuitems;
+    }
+  });
+
+  EventEmitter.decorate(this);
+}
+
+/**
+ * Add an item to the end of the Menu
+ *
+ * @param {MenuItem} menuItem
+ */
+Menu.prototype.append = function(menuItem) {
+  this.menuitems.push(menuItem);
+};
+
+/**
+ * Add an item to a specified position in the menu
+ *
+ * @param {int} pos
+ * @param {MenuItem} menuItem
+ */
+Menu.prototype.insert = function(pos, menuItem) {
+  throw "Not implemented";
+};
+
+/**
+ * Show the Menu at a specified location on the screen
+ *
+ * Missing features:
+ *   - browserWindow - BrowserWindow (optional) - Default is null.
+ *   - positioningItem Number - (optional) OS X
+ *
+ * @param {int} screenX
+ * @param {int} screenY
+ * @param Toolbox toolbox (non standard)
+ *        Needed so we in which window to inject XUL
+ */
+Menu.prototype.popup = function(screenX, screenY, toolbox) {
+  let doc = toolbox.doc;
+  let popup = doc.createElement("menupopup");
+  popup.setAttribute("menu-api", "true");
+
+  if (this.id) {
+    popup.id = this.id;
+  }
+  this._createMenuItems(popup);
+
+  // Remove the menu from the DOM once it's hidden.
+  popup.addEventListener("popuphidden", (e) => {
+    if (e.target === popup) {
+      popup.remove();
+      this.emit("close");
+    }
+  });
+
+  popup.addEventListener("popupshown", (e) => {
+    if (e.target === popup) {
+      this.emit("open");
+    }
+  });
+
+  doc.querySelector("popupset").appendChild(popup);
+  popup.openPopupAtScreen(screenX, screenY, true);
+};
+
+Menu.prototype._createMenuItems = function(parent) {
+  let doc = parent.ownerDocument;
+  this.menuitems.forEach(item => {
+    if (item.submenu) {
+      let menupopup = doc.createElement("menupopup");
+      item.submenu._createMenuItems(menupopup);
+
+      let menu = doc.createElement("menu");
+      menu.appendChild(menupopup);
+      menu.setAttribute("label", item.label);
+      parent.appendChild(menu);
+    } else if (item.type === "separator") {
+      let menusep = doc.createElement("menuseparator");
+      parent.appendChild(menusep);
+    } else {
+      let menuitem = doc.createElement("menuitem");
+      menuitem.setAttribute("label", item.label);
+      menuitem.addEventListener("command", () => {
+        item.click();
+      });
+
+      if (item.type === "checkbox") {
+        menuitem.setAttribute("type", "checkbox");
+      }
+      if (item.type === "radio") {
+        menuitem.setAttribute("type", "radio");
+      }
+      if (item.disabled) {
+        menuitem.setAttribute("disabled", "true");
+      }
+      if (item.checked) {
+        menuitem.setAttribute("checked", "true");
+      }
+      if (item.accesskey) {
+        menuitem.setAttribute("accesskey", item.accesskey);
+      }
+      if (item.id) {
+        menuitem.id = item.id;
+      }
+
+      parent.appendChild(menuitem);
+    }
+  });
+};
+
+Menu.setApplicationMenu = () => {
+  throw "Not implemented";
+};
+
+Menu.sendActionToFirstResponder = () => {
+  throw "Not implemented";
+};
+
+Menu.buildFromTemplate = () => {
+  throw "Not implemented";
+};
+
+module.exports = Menu;
--- a/devtools/client/framework/moz.build
+++ b/devtools/client/framework/moz.build
@@ -11,16 +11,18 @@ TEST_HARNESS_FILES.xpcshell.devtools.cli
 
 DevToolsModules(
     'about-devtools-toolbox.js',
     'attach-thread.js',
     'browser-menus.js',
     'devtools-browser.js',
     'devtools.js',
     'gDevTools.jsm',
+    'menu-item.js',
+    'menu.js',
     'selection.js',
     'sidebar.js',
     'source-location.js',
     'target-from-url.js',
     'target.js',
     'toolbox-highlighter-utils.js',
     'toolbox-hosts.js',
     'toolbox-options.js',
--- a/devtools/client/framework/test/browser.ini
+++ b/devtools/client/framework/test/browser.ini
@@ -24,16 +24,17 @@ support-files =
 [browser_browser_toolbox_debugger.js]
 [browser_devtools_api.js]
 [browser_devtools_api_destroy.js]
 [browser_dynamic_tool_enabling.js]
 [browser_ignore_toolbox_network_requests.js]
 [browser_keybindings_01.js]
 [browser_keybindings_02.js]
 [browser_keybindings_03.js]
+[browser_menu_api.js]
 [browser_new_activation_workflow.js]
 [browser_source-location-01.js]
 [browser_source-location-02.js]
 [browser_target_from_url.js]
 [browser_target_events.js]
 [browser_target_remote.js]
 [browser_target_support.js]
 [browser_toolbox_custom_host.js]
new file mode 100644
--- /dev/null
+++ b/devtools/client/framework/test/browser_menu_api.js
@@ -0,0 +1,161 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that the Menu API works
+
+const URL = "data:text/html;charset=utf8,test page for menu api";
+const Menu = require("devtools/client/framework/menu");
+const MenuItem = require("devtools/client/framework/menu-item");
+
+add_task(function*() {
+  info("Create a test tab and open the toolbox");
+  let tab = yield addTab(URL);
+  let target = TargetFactory.forTab(tab);
+  let toolbox = yield gDevTools.showToolbox(target, "webconsole");
+
+  yield testMenuItems();
+  yield testMenuPopup(toolbox);
+  yield testSubmenu(toolbox);
+});
+
+function* testMenuItems() {
+  let menu = new Menu();
+  let menuItem1 = new MenuItem();
+  let menuItem2 = new MenuItem();
+
+  menu.append(menuItem1);
+  menu.append(menuItem2);
+
+  is(menu.items.length, 2, "Correct number of 'items'");
+  is(menu.items[0], menuItem1, "Correct reference to MenuItem");
+  is(menu.items[1], menuItem2, "Correct reference to MenuItem");
+}
+
+function* testMenuPopup(toolbox) {
+  let clickFired = false;
+
+  let menu = new Menu({
+    id: "menu-popup",
+  });
+  menu.append(new MenuItem({ type: "separator" }));
+
+  let MENU_ITEMS = [
+    new MenuItem({
+      id: "menu-item-1",
+      label: "Normal Item",
+      click: () => {
+        info("Click callback has fired for menu item");
+        clickFired = true;
+      },
+    }),
+    new MenuItem({
+      label: "Checked Item",
+      type: "checkbox",
+      checked: true,
+    }),
+    new MenuItem({
+      label: "Radio Item",
+      type: "radio",
+    }),
+    new MenuItem({
+      label: "Disabled Item",
+      disabled: true,
+    }),
+  ];
+
+  for (let item of MENU_ITEMS) {
+    menu.append(item);
+  }
+
+  menu.popup(0, 0, toolbox);
+
+  ok(toolbox.doc.querySelector("#menu-popup"), "A popup is in the DOM");
+
+  let menuSeparators = toolbox.doc.querySelectorAll("#menu-popup > menuseparator");
+  is(menuSeparators.length, 1, "A separator is in the menu");
+
+  let menuItems = toolbox.doc.querySelectorAll("#menu-popup > menuitem");
+  is(menuItems.length, MENU_ITEMS.length, "Correct number of menuitems");
+
+  is(menuItems[0].id, MENU_ITEMS[0].id, "Correct id for menuitem");
+  is(menuItems[0].getAttribute("label"), MENU_ITEMS[0].label, "Correct label");
+
+  is(menuItems[1].getAttribute("label"), MENU_ITEMS[1].label, "Correct label");
+  is(menuItems[1].getAttribute("type"), "checkbox", "Correct type attribute");
+  is(menuItems[1].getAttribute("checked"), "true", "Has checked attribute");
+
+  is(menuItems[2].getAttribute("label"), MENU_ITEMS[2].label, "Correct label");
+  is(menuItems[2].getAttribute("type"), "radio", "Correct type attribute");
+  ok(!menuItems[2].hasAttribute("checked"), "Doesn't have checked attribute");
+
+  is(menuItems[3].getAttribute("label"), MENU_ITEMS[3].label, "Correct label");
+  is(menuItems[3].getAttribute("disabled"), "true", "disabled attribute menuitem");
+
+  yield once(menu, "open");
+  let closed = once(menu, "close");
+  EventUtils.synthesizeMouseAtCenter(menuItems[0], {}, toolbox.doc.defaultView);
+  yield closed;
+  ok(clickFired, "Click has fired");
+
+  ok(!toolbox.doc.querySelector("#menu-popup"), "The popup is removed from the DOM");
+}
+
+function* testSubmenu(toolbox) {
+  let clickFired = false;
+  let menu = new Menu({
+    id: "menu-popup",
+  });
+  let submenu = new Menu({
+    id: "submenu-popup",
+  });
+  submenu.append(new MenuItem({
+    label: "Submenu item",
+    click: () => {
+      info("Click callback has fired for submenu item");
+      clickFired = true;
+    },
+  }));
+  menu.append(new MenuItem({
+    label: "Submenu parent",
+    submenu: submenu,
+  }));
+
+  menu.popup(0, 0, toolbox);
+  ok(toolbox.doc.querySelector("#menu-popup"), "A popup is in the DOM");
+  is(toolbox.doc.querySelectorAll("#menu-popup > menuitem").length, 0, "No menuitem children");
+
+  let menus = toolbox.doc.querySelectorAll("#menu-popup > menu");
+  is(menus.length, 1, "Correct number of menus");
+  is(menus[0].getAttribute("label"), "Submenu parent", "Correct label for menus");
+
+  let subMenuItems = menus[0].querySelectorAll("menupopup > menuitem");
+  is(subMenuItems.length, 1, "Correct number of submenu items");
+  is(subMenuItems[0].getAttribute("label"), "Submenu item", "Correct label");
+
+  yield once(menu, "open");
+  let closed = once(menu, "close");
+
+  info("Using keyboard navigation to open, close, and reopen the submenu");
+  let shown = once(menus[0], "popupshown");
+  EventUtils.synthesizeKey("VK_DOWN", {});
+  EventUtils.synthesizeKey("VK_RIGHT", {});
+  yield shown;
+
+  let hidden = once(menus[0], "popuphidden");
+  EventUtils.synthesizeKey("VK_LEFT", {});
+  yield hidden;
+
+  shown = once(menus[0], "popupshown");
+  EventUtils.synthesizeKey("VK_RIGHT", {});
+  yield shown;
+
+  info("Clicking the submenu item");
+  EventUtils.synthesizeMouseAtCenter(subMenuItems[0], {}, toolbox.doc.defaultView);
+
+  yield closed;
+  ok(clickFired, "Click has fired");
+}