Bug 1278984 - Support optional tabs in sidebar component draft
authorJulian Descottes <jdescottes@mozilla.com>
Wed, 08 Jun 2016 13:27:19 +0200
changeset 377979 2611834ad8b1057acddbbece1466726a50465d55
parent 377978 e4403d5ee20a7350822e8f7aa3a986e21c314a04
child 523446 7bd5107179a3df66cfefe9df321684b59b4d0834
push id20912
push userjdescottes@mozilla.com
push dateTue, 14 Jun 2016 11:41:50 +0000
bugs1278984
milestone50.0a1
Bug 1278984 - Support optional tabs in sidebar component Implementation of optional tabs using the XUL version of the sidebar. MozReview-Commit-ID: 80JjUmqJdgc
devtools/client/framework/sidebar.js
devtools/client/framework/test/browser.ini
devtools/client/framework/test/browser_toolbox_sidebar.js
devtools/client/framework/test/browser_toolbox_sidebar_events.js
devtools/client/framework/test/browser_toolbox_sidebar_optional_tabs.js
devtools/client/framework/test/browser_toolbox_sidebar_overflow_menu.js
devtools/client/framework/test/browser_toolbox_sidebar_tool_optional_tabs.xul
devtools/client/inspector/inspector-panel.js
devtools/client/inspector/inspector.xul
devtools/client/jar.mn
devtools/client/scratchpad/scratchpad.js
devtools/client/themes/images/close-dark.svg
devtools/client/themes/toolbars.css
devtools/client/webconsole/jsterm.js
--- a/devtools/client/framework/sidebar.js
+++ b/devtools/client/framework/sidebar.js
@@ -61,20 +61,23 @@ function ToolSidebar(tabbox, panel, uid,
   this._tabbox = tabbox;
   this._uid = uid;
   this._panelDoc = this._tabbox.ownerDocument;
   this._toolPanel = panel;
   this._options = options;
 
   this._onTabBoxOverflow = this._onTabBoxOverflow.bind(this);
   this._onTabBoxUnderflow = this._onTabBoxUnderflow.bind(this);
+  this._onTabClicked = this._onTabClicked.bind(this);
 
   try {
     this._width = Services.prefs.getIntPref("devtools.toolsidebar-width." + this._uid);
-  } catch (e) {}
+  } catch (e) {
+    // devtools.toolsidebar-width preference could not be retrieved.
+  }
 
   if (!options.disableTelemetry) {
     this._telemetry = new Telemetry();
   }
 
   this._tabbox.tabpanels.addEventListener("select", this, true);
 
   this._tabs = new Map();
@@ -95,16 +98,18 @@ function ToolSidebar(tabbox, panel, uid,
 
 exports.ToolSidebar = ToolSidebar;
 
 ToolSidebar.prototype = {
   TAB_ID_PREFIX: "sidebar-tab-",
 
   TABPANEL_ID_PREFIX: "sidebar-panel-",
 
+  MENUITEM_ID_PREFIX: "sidebar-alltabs-item-",
+
   /**
    * Add a "…" button at the end of the tabstripe that toggles a dropdown menu
    * containing the list of all tabs if any become hidden due to lack of room.
    *
    * If the ToolSidebar was created with the "showAllTabsMenu" option set to
    * true, this is already done automatically. If not, you may call this
    * function at any time to add the menu.
    */
@@ -126,35 +131,96 @@ ToolSidebar.prototype = {
     // Create the dropdown menu next to the tabs
     this._allTabsBtn = this._panelDoc.createElementNS(XULNS, "toolbarbutton");
     this._allTabsBtn.setAttribute("class", "devtools-sidebar-alltabs");
     this._allTabsBtn.setAttribute("end", "0");
     this._allTabsBtn.setAttribute("top", "0");
     this._allTabsBtn.setAttribute("width", "15");
     this._allTabsBtn.setAttribute("type", "menu");
     this._allTabsBtn.setAttribute("tooltiptext", l10n("sidebar.showAllTabs.tooltip"));
-    this._allTabsBtn.setAttribute("hidden", "true");
     allTabsContainer.appendChild(this._allTabsBtn);
 
     let menuPopup = this._panelDoc.createElementNS(XULNS, "menupopup");
     this._allTabsBtn.appendChild(menuPopup);
 
     // Listening to tabs overflow event to toggle the alltabs button
     tabs.addEventListener("overflow", this._onTabBoxOverflow, false);
     tabs.addEventListener("underflow", this._onTabBoxUnderflow, false);
+    tabs.addEventListener("click", this._onTabClicked);
 
     // Add menuitems to the alltabs menu if there are already tabs in the
     // sidebar
     for (let [id, tab] of this._tabs) {
       let selected = tab.hasAttribute("selected");
-      let item = this._addItemToAllTabsMenu(id, tab, selected);
-      item.hidden = tab.hidden;
+      let item = this._addItemToAllTabsMenu(id, tab, {selected});
+      item.hidden = tab.hidden && !tab.optional;
+    }
+
+    this.updateAllTabsButtonVisibility();
+  },
+
+  _onTabClicked: function (e) {
+    let id = e.target.getAttribute("id");
+    if (id.startsWith(this.TAB_ID_PREFIX)) {
+      id = id.split(this.TAB_ID_PREFIX).pop();
+    }
+
+    let tab = this.getTab(id);
+    if (!tab || !tab.optional) {
+      return;
+    }
+
+    let {left, top} = e.target.getBoundingClientRect();
+    let isOnCloseIcon = e.clientX > left && e.clientX < left + 12 &&
+                        e.clientY > top && e.clientY < top + 12;
+
+    if (isOnCloseIcon) {
+      this.toggleTab(false, id);
+      if (this._tabbox.selectedTab === tab) {
+        this._selectDefault();
+      }
+      this.emit("tab-closed", id);
     }
   },
 
+  _selectDefault: function (e) {
+    for (let [id, tab] of this._tabs) {
+      if (!tab.hidden) {
+        this.select(id);
+        return;
+      }
+    }
+  },
+
+  _onTabBoxUnderflow: function () {
+    this._hasOverflow = false;
+    this.updateAllTabsButtonVisibility();
+  },
+
+  _onTabBoxOverflow: function () {
+    this._hasOverflow = true;
+    this.updateAllTabsButtonVisibility();
+  },
+
+  updateAllTabsButtonVisibility: function () {
+    if (!this._allTabsBtn) {
+      return;
+    }
+
+    if (this._hasOverflow || this.hasHiddenOptionalTab()) {
+      this._allTabsBtn.removeAttribute("hidden");
+    } else {
+      this._allTabsBtn.setAttribute("hidden", "true");
+    }
+  },
+
+  hasHiddenOptionalTab: function () {
+    return [...this._tabs].some(([, tab]) => tab.hidden && tab.optional);
+  },
+
   removeAllTabsMenu: function () {
     if (!this._allTabsBtn) {
       return;
     }
 
     let tabs = this._tabbox.tabs;
 
     tabs.removeEventListener("overflow", this._onTabBoxOverflow, false);
@@ -162,77 +228,84 @@ ToolSidebar.prototype = {
 
     // Moving back the tabs as a first child of the tabbox
     this._tabbox.insertBefore(tabs, this._tabbox.tabpanels);
     this._tabbox.querySelector("stack").remove();
 
     this._allTabsBtn = null;
   },
 
-  _onTabBoxOverflow: function () {
-    this._allTabsBtn.removeAttribute("hidden");
-  },
-
-  _onTabBoxUnderflow: function () {
-    this._allTabsBtn.setAttribute("hidden", "true");
-  },
-
   /**
    * Add an item in the allTabs menu for a given tab.
    */
-  _addItemToAllTabsMenu: function (id, tab, selected = false) {
+  _addItemToAllTabsMenu: function (id, tab, {selected = false, before = ""}) {
     if (!this._allTabsBtn) {
-      return;
+      return undefined;
     }
 
     let item = this._panelDoc.createElementNS(XULNS, "menuitem");
-    item.setAttribute("id", "sidebar-alltabs-item-" + id);
+    item.setAttribute("id", this.MENUITEM_ID_PREFIX + id);
     item.setAttribute("label", tab.getAttribute("label"));
     item.setAttribute("type", "checkbox");
     if (selected) {
       item.setAttribute("checked", true);
     }
     // The auto-checking of menuitems in this menu doesn't work, so let's do
     // it manually
     item.setAttribute("autocheck", false);
 
-    this._allTabsBtn.querySelector("menupopup").appendChild(item);
+    let popup = this._allTabsBtn.querySelector("menupopup");
+    if (before) {
+      popup.insertBefore(item, this.getMenuItem(before));
+    } else {
+      popup.appendChild(item);
+    }
 
     item.addEventListener("click", () => {
       this._tabbox.selectedTab = tab;
     }, false);
 
     tab.allTabsMenuItem = item;
 
     return item;
   },
 
   /**
    * Register a tab. A tab is a document.
    * The document must have a title, which will be used as the name of the tab.
    *
-   * @param {string} tab uniq id
-   * @param {string} url
+   * @param {String} tab uniq id
+   * @param {String} url
+   * @param {Object} (optional)
+   *        - {Boolean} selected: should the tab be selected, defaults to false
+   *        - {String} before: tab id before which the new tab should be inserted,
+   *          defaults to empty string (no tab id).
    */
-  addTab: function (id, url, selected = false) {
+  addTab: function (id, url, {selected = false, before = ""} = {}) {
     let iframe = this._panelDoc.createElementNS(XULNS, "iframe");
     iframe.className = "iframe-" + id;
     iframe.setAttribute("flex", "1");
     iframe.setAttribute("src", url);
     iframe.tooltip = "aHTMLTooltip";
 
     // Creating the tab and adding it to the tabbox
     let tab = this._panelDoc.createElementNS(XULNS, "tab");
-    this._tabbox.tabs.appendChild(tab);
-    tab.setAttribute("label", ""); // Avoid showing "undefined" while the tab is loading
+    if (before) {
+      this._tabbox.tabs.insertBefore(tab, this.getTab(before));
+    } else {
+      this._tabbox.tabs.appendChild(tab);
+    }
+
+    // Avoid showing "undefined" while the tab is loading
+    tab.setAttribute("label", "");
     tab.setAttribute("id", this.TAB_ID_PREFIX + id);
     tab.setAttribute("crop", "end");
 
     // Add the tab to the allTabs menu if exists
-    let allTabsItem = this._addItemToAllTabsMenu(id, tab, selected);
+    let allTabsItem = this._addItemToAllTabsMenu(id, tab, {selected, before});
 
     let onIFrameLoaded = (event) => {
       let doc = event.target;
       let win = doc.defaultView;
       tab.setAttribute("label", doc.title);
 
       if (allTabsItem) {
         allTabsItem.setAttribute("label", doc.title);
@@ -245,17 +318,21 @@ ToolSidebar.prototype = {
       this.emit(id + "-ready");
     };
 
     iframe.addEventListener("load", onIFrameLoaded, true);
 
     let tabpanel = this._panelDoc.createElementNS(XULNS, "tabpanel");
     tabpanel.setAttribute("id", this.TABPANEL_ID_PREFIX + id);
     tabpanel.appendChild(iframe);
-    this._tabbox.tabpanels.appendChild(tabpanel);
+    if (before) {
+      this._tabbox.tabpanels.insertBefore(tabpanel, this.getTabPanel(before));
+    } else {
+      this._tabbox.tabpanels.appendChild(tabpanel);
+    }
 
     this._tooltip = this._panelDoc.createElementNS(XULNS, "tooltip");
     this._tooltip.id = "aHTMLTooltip";
     tabpanel.appendChild(this._tooltip);
     this._tooltip.page = true;
 
     tab.linkedPanel = this.TABPANEL_ID_PREFIX + id;
 
@@ -267,17 +344,17 @@ ToolSidebar.prototype = {
     }
 
     this.emit("new-tab-registered", id);
   },
 
   untitledTabsIndex: 0,
 
   /**
-   * Search for existing tabs in the markup that aren't know yet and add them.
+   * Search for existing tabs in the markup that aren't known yet and add them.
    */
   addExistingTabs: function () {
     let knownTabs = [...this._tabs.values()];
 
     for (let tab of this._tabbox.tabs.querySelectorAll("tab")) {
       if (knownTabs.indexOf(tab) !== -1) {
         continue;
       }
@@ -286,16 +363,18 @@ ToolSidebar.prototype = {
       let id = tab.getAttribute("id") || "untitled-tab-" + (this.untitledTabsIndex++);
 
       // If the existing tab contains the tab ID prefix, extract the ID of the
       // tab
       if (id.startsWith(this.TAB_ID_PREFIX)) {
         id = id.split(this.TAB_ID_PREFIX).pop();
       }
 
+      tab.optional = tab.getAttribute("optional") === "true";
+
       // Register the tab
       this._tabs.set(id, tab);
       this.emit("new-tab-registered", id);
     }
   },
 
   /**
    * Remove an existing tab.
@@ -320,16 +399,17 @@ ToolSidebar.prototype = {
 
     // Also remove the tabpanel
     let panel = this.getTabPanel(tabPanelId || tabId);
     if (panel) {
       panel.remove();
     }
 
     this._tabs.delete(tabId);
+    this.updateAllTabsButtonVisibility();
     this.emit("tab-unregistered", tabId);
   }),
 
   /**
    * Show or hide a specific tab.
    * @param {Boolean} isVisible True to show the tab/tabpanel, False to hide it.
    * @param {String} id The ID of the tab to be hidden.
    */
@@ -338,17 +418,18 @@ ToolSidebar.prototype = {
     let tab = this.getTab(id);
     if (!tab) {
       return;
     }
     tab.hidden = !isVisible;
 
     // Toggle the item in the allTabs menu.
     if (this._allTabsBtn) {
-      this._allTabsBtn.querySelector("#sidebar-alltabs-item-" + id).hidden = !isVisible;
+      this.getMenuItem(id).hidden = tab.hidden && !tab.optional;
+      this.updateAllTabsButtonVisibility();
     }
   },
 
   /**
    * Select a specific tab.
    */
   select: function (id) {
     let tab = this.getTab(id);
@@ -386,28 +467,33 @@ ToolSidebar.prototype = {
   /**
    * Returns the requested tab panel based on the id.
    * @param {String} id
    * @return {DOMNode}
    */
   getTabPanel: function (id) {
     // Search with and without the ID prefix as there might have been existing
     // tabpanels by the time the sidebar got created
-    return this._tabbox.tabpanels.querySelector("#" + this.TABPANEL_ID_PREFIX + id + ", #" + id);
+    let selector = "#" + this.TABPANEL_ID_PREFIX + id + ", #" + id;
+    return this._tabbox.tabpanels.querySelector(selector);
   },
 
   /**
    * Return the tab based on the provided id, if one was registered with this id.
    * @param {String} id
    * @return {DOMNode}
    */
   getTab: function (id) {
     return this._tabs.get(id);
   },
 
+  getMenuItem: function (id) {
+    return this._allTabsBtn.querySelector("#" + this.MENUITEM_ID_PREFIX + id);
+  },
+
   /**
    * Event handler.
    */
   handleEvent: function (event) {
     if (event.type !== "select" || this._destroyed) {
       return;
     }
 
@@ -442,16 +528,22 @@ ToolSidebar.prototype = {
     // items except the selected one.
     let tab = this._tabbox.selectedTab;
     if (tab.allTabsMenuItem) {
       for (let otherItem of this._allTabsBtn.querySelectorAll("menuitem")) {
         otherItem.removeAttribute("checked");
       }
       tab.allTabsMenuItem.setAttribute("checked", true);
     }
+    // When selecting a hidden optional tab, update the tab visibility.
+    if (tab.optional && tab.hidden) {
+      tab.hidden = false;
+      this.updateAllTabsButtonVisibility();
+    }
+    tab.scrollIntoView();
   },
 
   /**
    * Toggle sidebar's visibility state.
    */
   toggle: function () {
     if (this._tabbox.hasAttribute("hidden")) {
       this.show();
@@ -486,17 +578,18 @@ ToolSidebar.prototype = {
 
     this.emit("show");
   },
 
   /**
    * Show the sidebar.
    */
   hide: function () {
-    Services.prefs.setIntPref("devtools.toolsidebar-width." + this._uid, this._tabbox.width);
+    let width = this._tabbox.width;
+    Services.prefs.setIntPref("devtools.toolsidebar-width." + this._uid, width);
     this._tabbox.setAttribute("hidden", "true");
     this._panelDoc.activeElement.blur();
 
     this.emit("hide");
   },
 
   /**
    * Return the window containing the tab content.
@@ -504,37 +597,39 @@ ToolSidebar.prototype = {
   getWindowForTab: function (id) {
     if (!this._tabs.has(id)) {
       return null;
     }
 
     // Get the tabpanel and make sure it contains an iframe
     let panel = this.getTabPanel(id);
     if (!panel || !panel.firstChild || !panel.firstChild.contentWindow) {
-      return;
+      return null;
     }
     return panel.firstChild.contentWindow;
   },
 
   /**
    * Clean-up.
    */
   destroy: Task.async(function* () {
     if (this._destroyed) {
       return;
     }
     this._destroyed = true;
 
-    Services.prefs.setIntPref("devtools.toolsidebar-width." + this._uid, this._tabbox.width);
+    let width = this._tabbox.width;
+    Services.prefs.setIntPref("devtools.toolsidebar-width." + this._uid, width);
 
     if (this._allTabsBtn) {
       this.removeAllTabsMenu();
     }
 
     this._tabbox.tabpanels.removeEventListener("select", this, true);
+    this._tabbox.tabs.removeEventListener("click", this._onTabClicked);
 
     // Note that we check for the existence of this._tabbox.tabpanels at each
     // step as the container window may have been closed by the time one of the
     // panel's destroy promise resolves.
     while (this._tabbox.tabpanels && this._tabbox.tabpanels.hasChildNodes()) {
       let panel = this._tabbox.tabpanels.firstChild;
       let win = panel.firstChild.contentWindow;
       if (win && ("destroy" in win)) {
@@ -557,21 +652,21 @@ ToolSidebar.prototype = {
     this._tabbox = null;
     this._panelDoc = null;
     this._toolPanel = null;
   })
 };
 
 XPCOMUtils.defineLazyGetter(this, "l10n", function () {
   let bundle = Services.strings.createBundle("chrome://devtools/locale/toolbox.properties");
-  let l10n = function (aName, ...aArgs) {
+  let l10n = function (name, ...args) {
     try {
-      if (aArgs.length == 0) {
-        return bundle.GetStringFromName(aName);
-      } else {
-        return bundle.formatStringFromName(aName, aArgs, aArgs.length);
+      if (args.length == 0) {
+        return bundle.GetStringFromName(name);
       }
+      return bundle.formatStringFromName(name, args, args.length);
     } catch (ex) {
-      console.log("Error reading '" + aName + "'");
+      console.log("Error reading '" + name + "'");
+      return name;
     }
   };
   return l10n;
 });
--- a/devtools/client/framework/test/browser.ini
+++ b/devtools/client/framework/test/browser.ini
@@ -1,16 +1,17 @@
 [DEFAULT]
 tags = devtools
 subsuite = devtools
 support-files =
   browser_toolbox_options_disable_js.html
   browser_toolbox_options_disable_js_iframe.html
   browser_toolbox_options_disable_cache.sjs
   browser_toolbox_sidebar_tool.xul
+  browser_toolbox_sidebar_tool_optional_tabs.xul
   browser_toolbox_sidebar_tool_toggle_tabs.xul
   browser_toolbox_window_title_changes_page.html
   browser_toolbox_window_title_frame_select_page.html
   code_math.js
   code_ugly.js
   head.js
   shared-head.js
   shared-redux-head.js
@@ -58,16 +59,17 @@ skip-if = true # Bug 1177463 - Temporari
 # skip-if = os == "win"
 [browser_toolbox_ready.js]
 [browser_toolbox_select_event.js]
 skip-if = e10s # Bug 1069044 - destroyInspector may hang during shutdown
 [browser_toolbox_selected_tool_unavailable.js]
 [browser_toolbox_sidebar.js]
 [browser_toolbox_sidebar_events.js]
 [browser_toolbox_sidebar_existing_tabs.js]
+[browser_toolbox_sidebar_optional_tabs.js]
 [browser_toolbox_sidebar_overflow_menu.js]
 [browser_toolbox_sidebar_toggle_tabs.js]
 [browser_toolbox_split_console.js]
 [browser_toolbox_tabsswitch_shortcuts.js]
 [browser_toolbox_textbox_context_menu.js]
 [browser_toolbox_theme_registration.js]
 [browser_toolbox_toggle.js]
 [browser_toolbox_tool_ready.js]
--- a/devtools/client/framework/test/browser_toolbox_sidebar.js
+++ b/devtools/client/framework/test/browser_toolbox_sidebar.js
@@ -78,17 +78,17 @@ function test() {
       });
 
       panel.sidebar.once("tab1-selected", function (event) {
         info(event);
         tab1Selected = true;
         allTabsReady(panel);
       });
 
-      panel.sidebar.addTab("tab1", tab1URL, true);
+      panel.sidebar.addTab("tab1", tab1URL, {selected: true});
       panel.sidebar.addTab("tab2", tab2URL);
       panel.sidebar.addTab("tab3", tab3URL);
 
       panel.sidebar.show();
     }).then(null, console.error);
   });
 
   function allTabsReady(panel) {
--- a/devtools/client/framework/test/browser_toolbox_sidebar_events.js
+++ b/devtools/client/framework/test/browser_toolbox_sidebar_events.js
@@ -62,17 +62,17 @@ function test() {
         collectedEvents.push(event);
       });
 
       panel.sidebar.once("hide", function (event, id) {
         collectedEvents.push(event);
       });
 
       panel.sidebar.once("tab1-selected", () => finishUp(panel));
-      panel.sidebar.addTab("tab1", tab1URL, true);
+      panel.sidebar.addTab("tab1", tab1URL, {selected: true});
       panel.sidebar.show();
     }).then(null, console.error);
   });
 
   function finishUp(panel) {
     panel.sidebar.hide();
     panel.sidebar.destroy();
 
new file mode 100644
--- /dev/null
+++ b/devtools/client/framework/test/browser_toolbox_sidebar_optional_tabs.js
@@ -0,0 +1,85 @@
+/* -*- 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 sidebar widget supports optional tabs behaving as follows:
+// - if tab is optional and visible, it has a close icon
+// - if tab is optional and hidden, it is still listed in the all tabs menu
+// - if any of the sidebar tabs is optional and hidden, the all tabs menu button is shown
+
+const {ToolSidebar} = require("devtools/client/framework/sidebar");
+
+const testToolDefinition = {
+  id: "testTool",
+  url: CHROME_URL_ROOT + "browser_toolbox_sidebar_tool_optional_tabs.xul",
+  label: "Test Tool",
+  isTargetSupported: () => true,
+  build: (iframeWindow, toolbox) => {
+    return promise.resolve({
+      target: toolbox.target,
+      toolbox: toolbox,
+      isReady: true,
+      destroy: () => {},
+      panelDoc: iframeWindow.document,
+    });
+  }
+};
+
+add_task(function* () {
+  let tab = yield addTab("about:blank");
+
+  let target = TargetFactory.forTab(tab);
+
+  gDevTools.registerTool(testToolDefinition);
+  let toolbox = yield gDevTools.showToolbox(target, testToolDefinition.id);
+
+  let toolPanel = toolbox.getPanel(testToolDefinition.id);
+  let tabbox = toolPanel.panelDoc.getElementById("sidebar");
+
+  info("Creating the sidebar widget");
+  let sidebar = new ToolSidebar(tabbox, toolPanel, "bug1278984", {
+    showAllTabsMenu: true
+  });
+
+  info("Checking that tab2 has been registered as an optional tab.");
+  ok(sidebar.getTab("tab2"), "Existing tab2 was found");
+  ok(sidebar.getTab("tab2").optional, "tab2 is optional");
+  ok(sidebar.getTabPanel("tabpanel2"), "Existing tabpanel 2 was found");
+
+  let allTabsMenu = toolPanel.panelDoc.querySelector(".devtools-sidebar-alltabs");
+  ok(allTabsMenu, "The all-tabs menu is available");
+  is(allTabsMenu.getAttribute("hidden"), "true", "The menu is hidden for now");
+
+  info("Check that the all tabs menu is displayed after hiding an optional tab");
+  sidebar.toggleTab(false, "tab2");
+  ok(!allTabsMenu.hasAttribute("hidden"), "The menu is displayed now");
+  is(sidebar.getTab("tab2").getAttribute("hidden"), "true", "tab2 is now hidden");
+
+  info("Check that even after simulating an underflow, the button is still visible");
+  sidebar._onTabBoxUnderflow();
+  ok(!allTabsMenu.hasAttribute("hidden"), "The all-tabs menu is still visible");
+
+  info("Check that tab2 becomes visible after clicking on its menu item");
+  let tab2MenuItem = allTabsMenu.querySelector("#sidebar-alltabs-item-tab2");
+  ok(tab2MenuItem, "tab2 is available in allTabsMenu");
+  EventUtils.sendMouseEvent({type: "click"}, tab2MenuItem,
+    toolPanel.panelDoc.defaultView);
+
+  ok(!sidebar.getTab("tab2").hasAttribute("hidden"), "tab2 is now visible");
+  is(allTabsMenu.getAttribute("hidden"), "true", "The menu is now hidden");
+
+  info("Check that clicking on the tab2 close icon hides tab2");
+  // In the current XUL implementation there is no dedicated element to handle the close
+  // icon so we have to rely on a click using a hardcoded offset.
+  EventUtils.synthesizeMouse(sidebar.getTab("tab2"), 5, 5, {},
+    toolPanel.panelDoc.defaultView);
+  is(sidebar.getTab("tab2").getAttribute("hidden"), "true", "tab2 is now hidden");
+  ok(!allTabsMenu.hasAttribute("hidden"), "The menu is displayed now");
+
+  sidebar.destroy();
+  gDevTools.unregisterTool(testToolDefinition.id);
+  gBrowser.removeCurrentTab();
+});
--- a/devtools/client/framework/test/browser_toolbox_sidebar_overflow_menu.js
+++ b/devtools/client/framework/test/browser_toolbox_sidebar_overflow_menu.js
@@ -43,17 +43,17 @@ add_task(function* () {
 
   let allTabsMenu = toolPanel.panelDoc.querySelector(".devtools-sidebar-alltabs");
   ok(allTabsMenu, "The all-tabs menu is available");
   is(allTabsMenu.getAttribute("hidden"), "true", "The menu is hidden for now");
 
   info("Adding 10 tabs to the sidebar widget");
   for (let nb = 0; nb < 10; nb++) {
     let url = `data:text/html;charset=utf8,<title>tab ${nb}</title><p>Test tab ${nb}</p>`;
-    sidebar.addTab("tab" + nb, url, nb === 0);
+    sidebar.addTab("tab" + nb, url, {selected: nb === 0});
   }
 
   info("Fake an overflow event so that the all-tabs menu is visible");
   sidebar._onTabBoxOverflow();
   ok(!allTabsMenu.hasAttribute("hidden"), "The all-tabs menu is now shown");
 
   info("Select each tab, one by one");
   for (let nb = 0; nb < 10; nb++) {
new file mode 100644
--- /dev/null
+++ b/devtools/client/framework/test/browser_toolbox_sidebar_tool_optional_tabs.xul
@@ -0,0 +1,23 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+<?xml-stylesheet href="chrome://global/skin/" type="text/css"?>
+<?xml-stylesheet href="chrome://devtools/content/shared/widgets/widgets.css" type="text/css"?>
+<?xml-stylesheet href="chrome://devtools/skin/widgets.css" type="text/css"?>
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+  <hbox flex="1">
+    <description flex="1">test tool</description>
+    <splitter class="devtools-side-splitter"/>
+    <tabbox flex="1" id="sidebar" class="devtools-sidebar-tabs">
+      <tabs>
+        <tab id="tab1" label="tab 1"></tab>
+        <tab id="tab2" label="tab 2" optional="true"></tab>
+      </tabs>
+      <tabpanels flex="1">
+        <tabpanel id="tabpanel1">tab 1</tabpanel>
+        <tabpanel id="tabpanel2">tab 2</tabpanel>
+      </tabpanels>
+    </tabbox>
+  </hbox>
+</window>
--- a/devtools/client/inspector/inspector-panel.js
+++ b/devtools/client/inspector/inspector-panel.js
@@ -38,16 +38,18 @@ loader.lazyGetter(this, "strings", () =>
 });
 loader.lazyGetter(this, "toolboxStrings", () => {
   return Services.strings.createBundle("chrome://devtools/locale/toolbox.properties");
 });
 loader.lazyGetter(this, "clipboardHelper", () => {
   return Cc["@mozilla.org/widget/clipboardhelper;1"].getService(Ci.nsIClipboardHelper);
 });
 
+const ANIMATION_INSPECTOR_URL = "chrome://devtools/content/animationinspector/animation-inspector.xhtml";
+
 /**
  * Represents an open instance of the Inspector for a tab.
  * The inspector controls the breadcrumbs, the markup view, and the sidebar
  * (computed view, rule view, font view and layout view).
  *
  * Events:
  * - ready
  *      Fired when the inspector panel is opened for the first time and ready to
@@ -376,35 +378,45 @@ InspectorPanel.prototype = {
 
     if (!Services.prefs.getBoolPref("devtools.fontinspector.enabled") &&
        defaultTab == "fontinspector") {
       defaultTab = "ruleview";
     }
 
     this._setDefaultSidebar = (event, toolId) => {
       Services.prefs.setCharPref("devtools.inspector.activeSidebar", toolId);
+      if (toolId === "fontinspector") {
+        Services.prefs.setBoolPref("devtools.fontinspector.enabled", true);
+      }
+    };
+
+    this._onSidebarTabClosed = (event, toolId) => {
+      Services.prefs.setBoolPref("devtools.fontinspector.enabled", false);
     };
 
     this.sidebar.on("select", this._setDefaultSidebar);
+    this.sidebar.on("tab-closed", this._onSidebarTabClosed);
 
     this.ruleview = new RuleViewTool(this, this.panelWin);
     this.computedview = new ComputedViewTool(this, this.panelWin);
 
-    if (Services.prefs.getBoolPref("devtools.fontinspector.enabled") &&
-        this.canGetUsedFontFaces) {
+    if (this.canGetUsedFontFaces) {
       this.fontInspector = new FontInspector(this, this.panelWin);
-      this.sidebar.toggleTab(true, "fontinspector");
+      if (Services.prefs.getBoolPref("devtools.fontinspector.enabled")) {
+        this.sidebar.toggleTab(true, "fontinspector");
+      }
     }
 
     this.layoutview = new LayoutView(this, this.panelWin);
 
     if (this.target.form.animationsActor) {
-      this.sidebar.addTab("animationinspector",
-                          "chrome://devtools/content/animationinspector/animation-inspector.xhtml",
-                          defaultTab == "animationinspector");
+      this.sidebar.addTab("animationinspector", ANIMATION_INSPECTOR_URL, {
+        selected: defaultTab == "animationinspector",
+        before: "fontinspector"
+      });
     }
 
     this.sidebar.show(defaultTab);
 
     this.setupSidebarToggle();
   },
 
   /**
@@ -659,16 +671,17 @@ InspectorPanel.prototype = {
 
     let cssPropertiesDestroyer = this._cssPropertiesLoaded.then(({front}) => {
       if (front) {
         front.destroy();
       }
     });
 
     this.sidebar.off("select", this._setDefaultSidebar);
+    this.sidebar.off("tab-closed", this._onSidebarTabClosed);
     let sidebarDestroyer = this.sidebar.destroy();
     this.sidebar = null;
 
     this.addNodeButton.removeEventListener("click", this.addNode);
 
     this.nodemenu.removeEventListener("popupshowing", this._setupNodeMenu, true);
     this.nodemenu.removeEventListener("popuphiding", this._resetNodeMenu, true);
     this.breadcrumbs.destroy();
--- a/devtools/client/inspector/inspector.xul
+++ b/devtools/client/inspector/inspector.xul
@@ -176,23 +176,24 @@
     <tabbox id="inspector-sidebar" handleCtrlTab="false" class="devtools-sidebar-tabs" hidden="true">
       <tabs>
         <tab id="sidebar-tab-ruleview"
              label="&ruleViewTitle;"
              crop="end"/>
         <tab id="sidebar-tab-computedview"
              label="&computedViewTitle;"
              crop="end"/>
+        <tab id="sidebar-tab-layoutview"
+             label="&layoutViewTitle;"
+             crop="end"/>
         <tab id="sidebar-tab-fontinspector"
              label="&fontInspectorTitle;"
              crop="end"
-             hidden="true"/>
-        <tab id="sidebar-tab-layoutview"
-             label="&layoutViewTitle;"
-             crop="end"/>
+             hidden="true"
+             optional="true"/>
       </tabs>
       <tabpanels flex="1">
         <tabpanel id="sidebar-panel-ruleview" class="devtools-monospace theme-sidebar inspector-tabpanel">
           <html:div id="ruleview-toolbar-container" class="devtools-toolbar">
             <html:div id="ruleview-toolbar">
               <html:div class="devtools-searchbox">
                 <html:input id="ruleview-searchbox"
                             class="devtools-searchinput devtools-rule-searchbox"
@@ -234,51 +235,16 @@
           <html:div id="propertyContainer">
           </html:div>
 
           <html:div id="noResults" hidden="">
             &noPropertiesFound;
           </html:div>
         </tabpanel>
 
-        <tabpanel id="sidebar-panel-fontinspector" class="devtools-monospace theme-sidebar inspector-tabpanel">
-          <html:div class="devtools-toolbar">
-            <html:div class="devtools-searchbox">
-              <html:input id="font-preview-text-input"
-                          class="devtools-textinput"
-                          type="search"
-                          placeholder="&previewHint;"/>
-            </html:div>
-          </html:div>
-
-          <html:div id="font-container">
-            <html:ul id="all-fonts"></html:ul>
-            <html:button id="font-showall">&showAllFonts;</html:button>
-          </html:div>
-
-          <html:div id="font-template">
-            <html:section class="font">
-              <html:div class="font-preview-container">
-                <html:img class="font-preview"></html:img>
-              </html:div>
-              <html:div class="font-info">
-                <html:h1 class="font-name"></html:h1>
-                <html:span class="font-is-local">&system;</html:span>
-                <html:span class="font-is-remote">&remote;</html:span>
-                <html:p class="font-format-url">
-                  <html:input readonly="readonly" class="font-url"></html:input>
-                  <html:span class="font-format"></html:span>
-                </html:p>
-                <html:p class="font-css">&usedAs; "<html:span class="font-css-name"></html:span>"</html:p>
-                <html:pre class="font-css-code"></html:pre>
-              </html:div>
-            </html:section>
-          </html:div>
-        </tabpanel>
-
         <tabpanel id="sidebar-panel-layoutview" class="devtools-monospace theme-sidebar inspector-tabpanel">
           <html:div id="layout-wrapper">
             <html:div id="layout-container">
               <html:p id="layout-header">
                 <html:span id="layout-element-size"></html:span>
                 <html:section id="layout-position-group">
                   <html:button class="devtools-button" id="layout-geometry-editor" title="&geometry.button.tooltip;"></html:button>
                   <html:span id="layout-element-position"></html:span>
@@ -317,12 +283,47 @@
               </html:div>
 
               <html:div style="display: none">
                 <html:p id="layout-dummy"></html:p>
               </html:div>
             </html:div>
           </html:div>
         </tabpanel>
+
+        <tabpanel id="sidebar-panel-fontinspector" class="devtools-monospace theme-sidebar inspector-tabpanel">
+          <html:div class="devtools-toolbar">
+            <html:div class="devtools-searchbox">
+              <html:input id="font-preview-text-input"
+                          class="devtools-textinput"
+                          type="search"
+                          placeholder="&previewHint;"/>
+            </html:div>
+          </html:div>
+
+          <html:div id="font-container">
+            <html:ul id="all-fonts"></html:ul>
+            <html:button id="font-showall">&showAllFonts;</html:button>
+          </html:div>
+
+          <html:div id="font-template">
+            <html:section class="font">
+              <html:div class="font-preview-container">
+                <html:img class="font-preview"></html:img>
+              </html:div>
+              <html:div class="font-info">
+                <html:h1 class="font-name"></html:h1>
+                <html:span class="font-is-local">&system;</html:span>
+                <html:span class="font-is-remote">&remote;</html:span>
+                <html:p class="font-format-url">
+                  <html:input readonly="readonly" class="font-url"></html:input>
+                  <html:span class="font-format"></html:span>
+                </html:p>
+                <html:p class="font-css">&usedAs; "<html:span class="font-css-name"></html:span>"</html:p>
+                <html:pre class="font-css-code"></html:pre>
+              </html:div>
+            </html:section>
+          </html:div>
+        </tabpanel>
       </tabpanels>
     </tabbox>
   </box>
 </window>
--- a/devtools/client/jar.mn
+++ b/devtools/client/jar.mn
@@ -302,16 +302,17 @@ devtools.jar:
     skin/images/tool-profiler-active.svg (themes/images/tool-profiler-active.svg)
     skin/images/tool-network.svg (themes/images/tool-network.svg)
     skin/images/tool-scratchpad.svg (themes/images/tool-scratchpad.svg)
     skin/images/tool-webaudio.svg (themes/images/tool-webaudio.svg)
     skin/images/tool-memory.svg (themes/images/tool-memory.svg)
     skin/images/tool-memory-active.svg (themes/images/tool-memory-active.svg)
     skin/images/tool-dom.svg (themes/images/tool-dom.svg)
     skin/images/close.svg (themes/images/close.svg)
+    skin/images/close-dark.svg (themes/images/close-dark.svg)
     skin/images/clear.svg (themes/images/clear.svg)
     skin/images/vview-delete.png (themes/images/vview-delete.png)
     skin/images/vview-delete@2x.png (themes/images/vview-delete@2x.png)
     skin/images/vview-edit.png (themes/images/vview-edit.png)
     skin/images/vview-edit@2x.png (themes/images/vview-edit@2x.png)
     skin/images/vview-lock.png (themes/images/vview-lock.png)
     skin/images/vview-lock@2x.png (themes/images/vview-lock@2x.png)
     skin/images/vview-open-inspector.png (themes/images/vview-open-inspector.png)
--- a/devtools/client/scratchpad/scratchpad.js
+++ b/devtools/client/scratchpad/scratchpad.js
@@ -2303,17 +2303,17 @@ ScratchpadSidebar.prototype = {
       this._update(aObject).then(() => deferred.resolve());
     };
 
     if (this._sidebar.getCurrentTabID() == "variablesview") {
       onTabReady();
     }
     else {
       this._sidebar.once("variablesview-ready", onTabReady);
-      this._sidebar.addTab("variablesview", VARIABLES_VIEW_URL, true);
+      this._sidebar.addTab("variablesview", VARIABLES_VIEW_URL, {selected: true});
     }
 
     return deferred.promise;
   },
 
   /**
    * Show the sidebar.
    */
new file mode 100644
--- /dev/null
+++ b/devtools/client/themes/images/close-dark.svg
@@ -0,0 +1,6 @@
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="#393F4C">
+  <path d="M6.7 8l3.6-3.6c.2-.2.2-.5 0-.7-.2-.2-.5-.2-.7 0L6 7.3 2.4 3.7c-.2-.2-.5-.2-.7 0-.2.2-.2.5 0 .7L5.3 8l-3.6 3.6c-.2.2-.2.5 0 .7.2.2.5.2.7 0L6 8.7l3.6 3.6c.2.2.5.2.7 0 .2-.2.2-.5 0-.7L6.7 8z"/>
+</svg>
--- a/devtools/client/themes/toolbars.css
+++ b/devtools/client/themes/toolbars.css
@@ -10,16 +10,17 @@
   --searchbox-background-color: #ffee99;
   --searchbox-border-color: #ffbf00;
   --searcbox-no-match-background-color: #ffe5e5;
   --searcbox-no-match-border-color: #e52e2e;
   --magnifying-glass-image: url(images/magnifying-glass-light.png);
   --magnifying-glass-image-2x: url(images/magnifying-glass-light@2x.png);
   --tool-options-image: url(images/tool-options.svg);
   --close-button-image: url(chrome://devtools/skin/images/close.svg);
+  --close-dark-button-image: url(chrome://devtools/skin/images/close-dark.svg);
   --icon-filter: invert(1);
   --dock-bottom-image: url(chrome://devtools/skin/images/dock-bottom.svg);
   --dock-side-image: url(chrome://devtools/skin/images/dock-side.svg);
   --dock-undock-image: url(chrome://devtools/skin/images/dock-undock.svg);
   --toolbar-button-border-color: rgba(170, 170, 170, .5);
 
   /* Toolbox buttons */
   --command-paintflashing-image: url(images/command-paintflashing.svg);
@@ -41,16 +42,17 @@
   --searchbox-background-color: #4d4222;
   --searchbox-border-color: #d99f2b;
   --searcbox-no-match-background-color: #402325;
   --searcbox-no-match-border-color: #cc3d3d;
   --magnifying-glass-image: url(images/magnifying-glass.png);
   --magnifying-glass-image-2x: url(images/magnifying-glass@2x.png);
   --tool-options-image: url(images/tool-options.svg);
   --close-button-image: url(chrome://devtools/skin/images/close.svg);
+  --close-dark-button-image: url(chrome://devtools/skin/images/close.svg);
   --icon-filter: none;
   --dock-bottom-image: url(chrome://devtools/skin/images/dock-bottom.svg);
   --dock-side-image: url(chrome://devtools/skin/images/dock-side.svg);
   --dock-undock-image: url(chrome://devtools/skin/images/dock-undock.svg);
   --toolbar-button-border-color: rgba(0, 0, 0, .4);
 
   /* Toolbox buttons */
   --command-paintflashing-image: url(images/command-paintflashing.svg);
@@ -66,16 +68,17 @@
   --command-measure-image: url(images/command-measure.svg);
 }
 
 .theme-firebug {
   --magnifying-glass-image: url(images/firebug/filter.svg);
   --magnifying-glass-image-2x: url(images/firebug/filter.svg);
   --tool-options-image: url(images/firebug/tool-options.svg);
   --close-button-image: url(chrome://devtools/skin/images/firebug/close.svg);
+  --close-dark-button-image: url(chrome://devtools/skin/images/firebug/close.svg);
   --icon-filter: invert(1);
   --dock-bottom-image: url(chrome://devtools/skin/images/firebug/dock-bottom.svg);
   --dock-side-image: url(chrome://devtools/skin/images/firebug/dock-side.svg);
   --dock-undock-image: url(chrome://devtools/skin/images/firebug/dock-undock.svg);
   --toolbar-button-border-color: rgba(170, 170, 170, .5);
 
   /* Toolbox buttons */
   --command-paintflashing-image: url(images/firebug/command-paintflashing.svg);
@@ -597,16 +600,17 @@
   border-inline-start-width: 1px;
   border-style: solid;
   border-radius: 0;
   position: static;
   text-shadow: none;
 }
 
 .devtools-sidebar-tabs tabs > tab {
+  position: relative;
   border-image: linear-gradient(transparent 15%, var(--theme-splitter-color) 15%, var(--theme-splitter-color) 85%, transparent 85%) 1 1;
 }
 
 .devtools-sidebar-tabs tabs > tab[selected],
 .devtools-sidebar-tabs tabs > tab[selected] + tab {
   border-image: linear-gradient(var(--theme-splitter-color), var(--theme-splitter-color)) 1 1;
 }
 
@@ -623,16 +627,36 @@
 }
 
 .devtools-sidebar-tabs tabs > tab[selected],
 .devtools-sidebar-tabs tabs > tab[selected]:hover:active {
   color: var(--theme-selection-color);
   background: var(--theme-selection-background);
 }
 
+/* Optional toolbar item: close button. */
+
+.devtools-sidebar-tabs tabs > tab[optional="true"],
+.devtools-sidebar-tabs tabs > tab[optional="true"]:hover:active {
+  background-image: var(--close-dark-button-image);
+  background-position: 2px 1px;
+  background-size: 10px;
+  background-repeat: no-repeat;
+}
+
+.devtools-sidebar-tabs tabs > tab[selected][optional="true"],
+.devtools-sidebar-tabs tabs > tab[selected][optional="true"]:hover:active  {
+  background-image: var(--close-button-image);
+}
+
+.theme-firebug .devtools-sidebar-tabs tabs > tab[optional="true"] {
+  padding-left: 10px;
+  padding-right: 10px;
+}
+
 /* Toolbox - moved from toolbox.css.
  * Rules that apply to the global toolbox like command buttons,
  * devtools tabs, docking buttons, etc. */
 
 #toolbox-controls > button,
 #toolbox-dock-buttons > button {
   -moz-appearance: none;
   -moz-user-focus: normal;
--- a/devtools/client/webconsole/jsterm.js
+++ b/devtools/client/webconsole/jsterm.js
@@ -652,17 +652,17 @@ JSTerm.prototype = {
       if (this.sidebar.getCurrentTabID() == "variablesview") {
         onTabReady();
       } else {
         this.sidebar.once("variablesview-selected", onTabReady);
         this.sidebar.select("variablesview");
       }
     } else {
       this.sidebar.once("variablesview-ready", onTabReady);
-      this.sidebar.addTab("variablesview", VARIABLES_VIEW_URL, true);
+      this.sidebar.addTab("variablesview", VARIABLES_VIEW_URL, {selected: true});
     }
 
     return deferred.promise;
   },
 
   /**
    * The keypress event handler for the Variables View sidebar. Currently this
    * is used for removing the sidebar when Escape is pressed.