Bug 1257613 - Add an API to open context menus from an HTML document; f=jdescottes
MozReview-Commit-ID: 4j9d5k3Ut1f
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");
+}