Bug 1259812: Replace XUL elements with XHTML in inspector breadcrumbs;r=pbro
authorSteve Melia <steve.j.melia@gmail.com>
Tue, 31 May 2016 20:29:50 +0100
changeset 338889 7cd31f8bca620bd6942349de365937a8115ae46b
parent 338888 faa1661edc002da6a574b08a4b24c9a55f29e6d3
child 338890 5d2cb7fff024a1d3ed59ad2432882d158ce5ee91
push id6249
push userjlund@mozilla.com
push dateMon, 01 Aug 2016 13:59:36 +0000
treeherdermozilla-beta@bad9d4f5bf7e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerspbro
bugs1259812
milestone49.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1259812: Replace XUL elements with XHTML in inspector breadcrumbs;r=pbro MozReview-Commit-ID: 4LQfN5GwZH5
devtools/client/inspector/breadcrumbs.js
devtools/client/inspector/inspector.xul
devtools/client/inspector/test/browser_inspector_breadcrumbs.js
devtools/client/inspector/test/browser_inspector_breadcrumbs_mutations.js
devtools/client/themes/inspector.css
--- a/devtools/client/inspector/breadcrumbs.js
+++ b/devtools/client/inspector/breadcrumbs.js
@@ -7,28 +7,306 @@
 "use strict";
 
 const {Ci} = require("chrome");
 const Services = require("Services");
 const promise = require("promise");
 const FocusManager = Services.focus;
 const {waitForTick} = require("devtools/shared/DevToolsUtils");
 
-const ENSURE_SELECTION_VISIBLE_DELAY_MS = 50;
 const ELLIPSIS = Services.prefs.getComplexValue(
     "intl.ellipsis",
     Ci.nsIPrefLocalizedString).data;
 const MAX_LABEL_LENGTH = 40;
 
+const NS_XHTML = "http://www.w3.org/1999/xhtml";
+const SCROLL_REPEAT_MS = 100;
+
+loader.lazyRequireGetter(this, "EventEmitter",
+                         "devtools/shared/event-emitter");
+
+/**
+ * Component to replicate functionality of XUL arrowscrollbox
+ * for breadcrumbs
+ *
+ * @param {Window} win The window containing the breadcrumbs
+ * @parem {DOMNode} container The element in which to put the scroll box
+ */
+function ArrowScrollBox(win, container) {
+  this.win = win;
+  this.doc = win.document;
+  this.container = container;
+  EventEmitter.decorate(this);
+  this.init();
+}
+
+ArrowScrollBox.prototype = {
+
+  /**
+   * Build the HTML, add to the DOM and start listening to
+   * events
+   */
+  init: function () {
+    this.constructHtml();
+
+    this.onUnderflow();
+
+    this.onScroll = this.onScroll.bind(this);
+    this.onStartBtnClick = this.onStartBtnClick.bind(this);
+    this.onEndBtnClick = this.onEndBtnClick.bind(this);
+    this.onStartBtnDblClick = this.onStartBtnDblClick.bind(this);
+    this.onEndBtnDblClick = this.onEndBtnDblClick.bind(this);
+    this.onUnderflow = this.onUnderflow.bind(this);
+    this.onOverflow = this.onOverflow.bind(this);
+
+    this.inner.addEventListener("scroll", this.onScroll, false);
+    this.startBtn.addEventListener("mousedown", this.onStartBtnClick, false);
+    this.endBtn.addEventListener("mousedown", this.onEndBtnClick, false);
+    this.startBtn.addEventListener("dblclick", this.onStartBtnDblClick, false);
+    this.endBtn.addEventListener("dblclick", this.onEndBtnDblClick, false);
+
+    // Overflow and underflow are moz specific events
+    this.inner.addEventListener("underflow", this.onUnderflow, false);
+    this.inner.addEventListener("overflow", this.onOverflow, false);
+  },
+
+  /**
+   * Call the given function once; then continuously
+   * while the mouse button is held
+   * @param {repeatFn} the function to repeat while the button is held
+   */
+  clickOrHold: function (repeatFn) {
+    let timer;
+    let container = this.container;
+
+    function handleClick() {
+      cancelHold();
+      repeatFn();
+    }
+
+    let window = this.win;
+    function cancelHold() {
+      window.clearTimeout(timer);
+      container.removeEventListener("mouseout", cancelHold, false);
+      container.removeEventListener("mouseup", handleClick, false);
+    }
+
+    function repeated() {
+      repeatFn();
+      timer = window.setTimeout(repeated, SCROLL_REPEAT_MS);
+    }
+
+    container.addEventListener("mouseout", cancelHold, false);
+    container.addEventListener("mouseup", handleClick, false);
+    timer = window.setTimeout(repeated, SCROLL_REPEAT_MS);
+  },
+
+  /**
+   * When start button is dbl clicked scroll to first element
+   */
+  onStartBtnDblClick: function () {
+    let children = this.inner.childNodes;
+    if (children.length < 1) {
+      return;
+    }
+
+    let element = this.inner.childNodes[0];
+    element.scrollIntoView({ block: "start", behavior: "smooth" });
+  },
+
+  /**
+   * When end button is dbl clicked scroll to last element
+   */
+  onEndBtnDblClick: function () {
+    let children = this.inner.childNodes;
+    if (children.length < 1) {
+      return;
+    }
+
+    let element = children[children.length - 1];
+    element.scrollIntoView({ block: "start", behavior: "smooth" });
+  },
+
+  /**
+   * When start arrow button is clicked scroll towards first element
+   */
+  onStartBtnClick: function () {
+    let scrollToStart = () => {
+      let element = this.getFirstInvisibleElement();
+      if (!element) {
+        return;
+      }
+
+      element.scrollIntoView({ block: "start", behavior: "smooth" });
+    };
+
+    this.clickOrHold(scrollToStart);
+  },
+
+  /**
+   * When end arrow button is clicked scroll towards last element
+   */
+  onEndBtnClick: function () {
+    let scrollToEnd = () => {
+      let element = this.getLastInvisibleElement();
+      if (!element) {
+        return;
+      }
+
+      element.scrollIntoView({ block: "end", behavior: "smooth" });
+    };
+
+    this.clickOrHold(scrollToEnd);
+  },
+
+  /**
+   * Event handler for scrolling, update the
+   * enabled/disabled status of the arrow buttons
+   */
+  onScroll: function () {
+    let first = this.getFirstInvisibleElement();
+    if (!first) {
+      this.startBtn.setAttribute("disabled", "true");
+    } else {
+      this.startBtn.removeAttribute("disabled");
+    }
+
+    let last = this.getLastInvisibleElement();
+    if (!last) {
+      this.endBtn.setAttribute("disabled", "true");
+    } else {
+      this.endBtn.removeAttribute("disabled");
+    }
+  },
+
+  /**
+   * On underflow, make the arrow buttons invisible
+   */
+  onUnderflow: function () {
+    this.startBtn.style.visibility = "collapse";
+    this.endBtn.style.visibility = "collapse";
+    this.emit("underflow");
+  },
+
+  /**
+   * On overflow, show the arrow buttons
+   */
+  onOverflow: function () {
+    this.startBtn.style.visibility = "visible";
+    this.endBtn.style.visibility = "visible";
+    this.emit("overflow");
+  },
+
+  /**
+   * Get the first (i.e. furthest left for LTR)
+   * non visible element in the scroll box
+   */
+  getFirstInvisibleElement: function () {
+    let start = this.inner.scrollLeft;
+    let end = this.inner.scrollLeft + this.inner.clientWidth;
+    let crumbs = this.inner.childNodes;
+    for (let i = crumbs.length - 1; i > -1; i--) {
+      let element = crumbs[i];
+      let elementRight = element.offsetLeft + element.offsetWidth;
+      if (element.offsetLeft < start) {
+        // edge case, check the element isn't already visible
+        if (elementRight >= end) {
+          continue;
+        }
+        return element;
+      }
+    }
+
+    return null;
+  },
+
+  /**
+   * Get the last (i.e. furthest right for LTR)
+   * non-visible element in the scroll box
+   */
+  getLastInvisibleElement: function () {
+    let end = this.inner.scrollLeft + this.inner.clientWidth;
+    let elementStart = 0;
+    for (let element of this.inner.childNodes) {
+      let elementEnd = elementStart + element.offsetWidth;
+      if (elementEnd > end) {
+        // Edge case: check the element isn't bigger than the
+        // container and thus already in view
+        if (elementStart > this.inner.scrollLeft) {
+          return element;
+        }
+      }
+
+      elementStart = elementEnd;
+    }
+
+    return null;
+  },
+
+  /**
+   * Build the HTML for the scroll box and insert it into the DOM
+   */
+  constructHtml: function () {
+    this.startBtn = this.createElement("div", "scrollbutton-up",
+                                       this.container);
+    this.createElement("div", "toolbarbutton-icon", this.startBtn);
+
+    this.createElement("div", "arrowscrollbox-overflow-start-indicator",
+                       this.container);
+    this.inner = this.createElement("div", "html-arrowscrollbox-inner",
+                                    this.container);
+    this.createElement("div", "arrowscrollbox-overflow-end-indicator",
+                       this.container);
+
+    this.endBtn = this.createElement("div", "scrollbutton-down",
+                                     this.container);
+    this.createElement("div", "toolbarbutton-icon", this.endBtn);
+  },
+
+  /**
+   * Create an XHTML element with the given class name, and append it to the
+   * parent.
+   * @param {String} tagName name of the tag to create
+   * @param {String} className class of the element
+   * @param {DOMNode} parent the parent node to which it should be appended
+   * @return {DOMNode} The new element
+   */
+  createElement: function (tagName, className, parent) {
+    let el = this.doc.createElementNS(NS_XHTML, tagName);
+    el.className = className;
+    if (parent) {
+      parent.appendChild(el);
+    }
+
+    return el;
+  },
+
+  /**
+   * Remove event handlers and clean up
+   */
+  destroy: function () {
+    this.inner.removeEventListener("scroll", this.onScroll, false);
+    this.startBtn.removeEventListener("mousedown",
+                                      this.onStartBtnClick, false);
+    this.endBtn.removeEventListener("mousedown", this.onEndBtnClick, false);
+    this.startBtn.removeEventListener("dblclick",
+                                      this.onStartBtnDblClick, false);
+    this.endBtn.removeEventListener("dblclick",
+                                    this.onRightBtnDblClick, false);
+
+    // Overflow and underflow are moz specific events
+    this.inner.removeEventListener("underflow", this.onUnderflow, false);
+    this.inner.removeEventListener("overflow", this.onOverflow, false);
+  },
+};
+
 /**
  * Display the ancestors of the current node and its children.
  * Only one "branch" of children are displayed (only one line).
  *
- * FIXME: Bug 822388 - Use the BreadcrumbsWidget in the Inspector.
- *
  * Mechanism:
  * - If no nodes displayed yet:
  *   then display the ancestor of the selected node and the selected node;
  *   else select the node;
  * - If the selected node is the last node displayed, append its first (if any).
  *
  * @param {InspectorPanel} inspector The inspector hosting this widget.
  */
@@ -43,57 +321,46 @@ function HTMLBreadcrumbs(inspector) {
 exports.HTMLBreadcrumbs = HTMLBreadcrumbs;
 
 HTMLBreadcrumbs.prototype = {
   get walker() {
     return this.inspector.walker;
   },
 
   _init: function () {
-    this.container = this.chromeDoc.getElementById("inspector-breadcrumbs");
+    this.outer = this.chromeDoc.getElementById("inspector-breadcrumbs");
+    this.arrowScrollBox = new ArrowScrollBox(
+        this.chromeWin,
+        this.outer);
+
+    this.container = this.arrowScrollBox.inner;
+    this.arrowScrollBox.on("overflow", this.scroll);
 
     // These separators are used for CSS purposes only, and are positioned
     // off screen, but displayed with -moz-element.
-    this.separators = this.chromeDoc.createElement("box");
+    this.separators = this.chromeDoc.createElementNS(NS_XHTML, "div");
     this.separators.className = "breadcrumb-separator-container";
     this.separators.innerHTML =
-                      "<box id='breadcrumb-separator-before'></box>" +
-                      "<box id='breadcrumb-separator-after'></box>" +
-                      "<box id='breadcrumb-separator-normal'></box>";
+                      "<div id='breadcrumb-separator-before'></div>" +
+                      "<div id='breadcrumb-separator-after'></div>" +
+                      "<div id='breadcrumb-separator-normal'></div>";
     this.container.parentNode.appendChild(this.separators);
 
-    this.container.addEventListener("click", this, true);
-    this.container.addEventListener("keypress", this, true);
-    this.container.addEventListener("mouseover", this, true);
-    this.container.addEventListener("mouseleave", this, true);
-    this.container.addEventListener("focus", this, true);
+    this.outer.addEventListener("click", this, true);
+    this.outer.addEventListener("keypress", this, true);
+    this.outer.addEventListener("mouseover", this, true);
+    this.outer.addEventListener("mouseleave", this, true);
+    this.outer.addEventListener("focus", this, true);
 
     // We will save a list of already displayed nodes in this array.
     this.nodeHierarchy = [];
 
     // Last selected node in nodeHierarchy.
     this.currentIndex = -1;
 
-    // By default, hide the arrows. We let the <scrollbox> show them
-    // in case of overflow.
-    this.container.removeAttribute("overflows");
-    this.container._scrollButtonUp.collapsed = true;
-    this.container._scrollButtonDown.collapsed = true;
-
-    this.onscrollboxreflow = () => {
-      if (this.container._scrollButtonDown.collapsed) {
-        this.container.removeAttribute("overflows");
-      } else {
-        this.container.setAttribute("overflows", true);
-      }
-    };
-
-    this.container.addEventListener("underflow", this.onscrollboxreflow, false);
-    this.container.addEventListener("overflow", this.onscrollboxreflow, false);
-
     this.update = this.update.bind(this);
     this.updateSelectors = this.updateSelectors.bind(this);
     this.selection.on("new-node-front", this.update);
     this.selection.on("pseudoclass", this.updateSelectors);
     this.selection.on("attribute-changed", this.updateSelectors);
     this.inspector.on("markupmutation", this.update);
     this.update();
   },
@@ -124,36 +391,34 @@ HTMLBreadcrumbs.prototype = {
     for (let pseudo of node.pseudoClassLocks) {
       text += pseudo;
     }
 
     return text;
   },
 
   /**
-   * Build <label>s that represent the node:
-   *   <label class="breadcrumbs-widget-item-tag">tagName</label>
-   *   <label class="breadcrumbs-widget-item-id">#id</label>
-   *   <label class="breadcrumbs-widget-item-classes">.class1.class2</label>
+   * Build <span>s that represent the node:
+   *   <span class="breadcrumbs-widget-item-tag">tagName</span>
+   *   <span class="breadcrumbs-widget-item-id">#id</span>
+   *   <span class="breadcrumbs-widget-item-classes">.class1.class2</span>
    * @param {NodeFront} node The node to pretty-print
    * @returns {DocumentFragment}
    */
-  prettyPrintNodeAsXUL: function (node) {
-    let fragment = this.chromeDoc.createDocumentFragment();
-
-    let tagLabel = this.chromeDoc.createElement("label");
+  prettyPrintNodeAsXHTML: function (node) {
+    let tagLabel = this.chromeDoc.createElementNS(NS_XHTML, "span");
     tagLabel.className = "breadcrumbs-widget-item-tag plain";
 
-    let idLabel = this.chromeDoc.createElement("label");
+    let idLabel = this.chromeDoc.createElementNS(NS_XHTML, "span");
     idLabel.className = "breadcrumbs-widget-item-id plain";
 
-    let classesLabel = this.chromeDoc.createElement("label");
+    let classesLabel = this.chromeDoc.createElementNS(NS_XHTML, "span");
     classesLabel.className = "breadcrumbs-widget-item-classes plain";
 
-    let pseudosLabel = this.chromeDoc.createElement("label");
+    let pseudosLabel = this.chromeDoc.createElementNS(NS_XHTML, "span");
     pseudosLabel.className = "breadcrumbs-widget-item-pseudo-classes plain";
 
     let tagText = node.displayName;
     if (node.isPseudoElement) {
       tagText = node.isBeforePseudoElement ? "::before" : "::after";
     }
     let idText = node.id ? ("#" + node.id) : "";
     let classesText = "";
@@ -182,16 +447,17 @@ HTMLBreadcrumbs.prototype = {
       classesText = classesText.substr(0, maxClassLength) + ELLIPSIS;
     }
 
     tagLabel.textContent = tagText;
     idLabel.textContent = idText;
     classesLabel.textContent = classesText;
     pseudosLabel.textContent = node.pseudoClassLocks.join("");
 
+    let fragment = this.chromeDoc.createDocumentFragment();
     fragment.appendChild(tagLabel);
     fragment.appendChild(idLabel);
     fragment.appendChild(classesLabel);
     fragment.appendChild(pseudosLabel);
 
     return fragment;
   },
 
@@ -216,29 +482,34 @@ HTMLBreadcrumbs.prototype = {
   /**
    * Focus event handler. When breadcrumbs container gets focus, if there is an
    * already selected breadcrumb, move focus to it.
    * @param {DOMEvent} event.
    */
   handleFocus: function (event) {
     let control = this.container.querySelector(
       ".breadcrumbs-widget-item[checked]");
-    if (control && control !== event.target) {
+    if (!this.suspendFocus && control && control !== event.target) {
       // If we already have a selected breadcrumb and focus target is not it,
-      // move focus to selected breadcrumb.
+      // move focus to selected breadcrumb
       event.preventDefault();
       control.focus();
     }
+    this.suspendFocus = false;
   },
 
   /**
    * On click navigate to the correct node.
    * @param {DOMEvent} event.
    */
   handleClick: function (event) {
+    // When clicking a button temporarily suspend the behaviour that refocuses
+    // the currently selected button, to prevent flicking back to that button
+    // See Bug 1272011
+    this.suspendFocus = true;
     let target = event.originalTarget;
     if (target.tagName == "button") {
       target.onBreadcrumbsClick();
     }
   },
 
   /**
    * On mouse over, highlight the corresponding content DOM Node.
@@ -320,30 +591,29 @@ HTMLBreadcrumbs.prototype = {
    * Remove nodes and clean up.
    */
   destroy: function () {
     this.selection.off("new-node-front", this.update);
     this.selection.off("pseudoclass", this.updateSelectors);
     this.selection.off("attribute-changed", this.updateSelectors);
     this.inspector.off("markupmutation", this.update);
 
-    this.container.removeEventListener("underflow",
-      this.onscrollboxreflow, false);
-    this.container.removeEventListener("overflow",
-      this.onscrollboxreflow, false);
     this.container.removeEventListener("click", this, true);
     this.container.removeEventListener("keypress", this, true);
     this.container.removeEventListener("mouseover", this, true);
     this.container.removeEventListener("mouseleave", this, true);
     this.container.removeEventListener("focus", this, true);
 
     this.empty();
     this.separators.remove();
 
-    this.onscrollboxreflow = null;
+    this.arrowScrollBox.off("overflow", this.scroll);
+    this.arrowScrollBox.destroy();
+    this.arrowScrollBox = null;
+    this.outer = null;
     this.container = null;
     this.separators = null;
     this.nodeHierarchy = null;
 
     this.isDestroyed = true;
   },
 
   /**
@@ -401,21 +671,21 @@ HTMLBreadcrumbs.prototype = {
   },
 
   /**
    * Build a button representing the node.
    * @param {NodeFront} node The node from the page.
    * @return {DOMNode} The <button> for this node.
    */
   buildButton: function (node) {
-    let button = this.chromeDoc.createElement("button");
-    button.appendChild(this.prettyPrintNodeAsXUL(node));
+    let button = this.chromeDoc.createElementNS(NS_XHTML, "button");
+    button.appendChild(this.prettyPrintNodeAsXHTML(node));
     button.className = "breadcrumbs-widget-item";
 
-    button.setAttribute("tooltiptext", this.prettyPrintNodeAsText(node));
+    button.setAttribute("title", this.prettyPrintNodeAsText(node));
 
     button.onkeypress = function onBreadcrumbsKeypress(e) {
       if (e.charCode == Ci.nsIDOMKeyEvent.DOM_VK_SPACE ||
           e.keyCode == Ci.nsIDOMKeyEvent.DOM_VK_RETURN) {
         button.click();
       }
     };
 
@@ -478,26 +748,18 @@ HTMLBreadcrumbs.prototype = {
     return -1;
   },
 
   /**
    * Ensure the selected node is visible.
    */
   scroll: function () {
     // FIXME bug 684352: make sure its immediate neighbors are visible too.
-
-    let scrollbox = this.container;
     let element = this.nodeHierarchy[this.currentIndex].button;
-
-    // Repeated calls to ensureElementIsVisible would interfere with each other
-    // and may sometimes result in incorrect scroll positions.
-    this.chromeWin.clearTimeout(this._ensureVisibleTimeout);
-    this._ensureVisibleTimeout = this.chromeWin.setTimeout(function () {
-      scrollbox.ensureElementIsVisible(element);
-    }, ENSURE_SELECTION_VISIBLE_DELAY_MS);
+    element.scrollIntoView({ block: "end", behavior: "smooth" });
   },
 
   /**
    * Update all button outputs.
    */
   updateSelectors: function () {
     if (this.isDestroyed) {
       return;
@@ -511,18 +773,18 @@ HTMLBreadcrumbs.prototype = {
       if (currentPrettyPrintText === textOutput) {
         continue;
       }
 
       // Otherwise, update the whole markup for the button.
       while (button.hasChildNodes()) {
         button.firstChild.remove();
       }
-      button.appendChild(this.prettyPrintNodeAsXUL(node));
-      button.setAttribute("tooltiptext", textOutput);
+      button.appendChild(this.prettyPrintNodeAsXHTML(node));
+      button.setAttribute("title", textOutput);
 
       this.nodeHierarchy[i].currentPrettyPrintText = textOutput;
     }
   },
 
   /**
    * Given a list of mutation changes (passed by the markupmutation event),
    * decide whether or not they are "interesting" to the current state of the
@@ -615,15 +877,15 @@ HTMLBreadcrumbs.prototype = {
       this.setCursor(idx);
     }
 
     let doneUpdating = this.inspector.updating("breadcrumbs");
 
     this.updateSelectors();
 
     // Make sure the selected node and its neighbours are visible.
-    this.scroll();
     waitForTick().then(() => {
+      this.scroll();
       this.inspector.emit("breadcrumbs-updated", this.selection.nodeFront);
       doneUpdating();
     });
   }
 };
--- a/devtools/client/inspector/inspector.xul
+++ b/devtools/client/inspector/inspector.xul
@@ -163,24 +163,19 @@
           class="devtools-searchinput"
           placeholder="&inspectorSearchHTML.label3;"/>
         <html:button id="inspector-pane-toggle"
           class="devtools-button"
           tabindex="0" />
       </html:div>
       <vbox flex="1" id="markup-box">
       </vbox>
-      <toolbar id="inspector-breadcrumbs-toolbar"
-        class="devtools-toolbar"
-        nowindowdrag="true">
-        <arrowscrollbox id="inspector-breadcrumbs"
-          class="breadcrumbs-widget-container"
-          flex="1" orient="horizontal"
-          clicktoscroll="true"/>
-      </toolbar>
+      <html:div id="inspector-breadcrumbs-toolbar" class="devtools-toolbar">
+        <html:div id="inspector-breadcrumbs" class="breadcrumbs-widget-container"/>
+      </html:div>
     </vbox>
     <splitter class="devtools-side-splitter"/>
     <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"
--- a/devtools/client/inspector/test/browser_inspector_breadcrumbs.js
+++ b/devtools/client/inspector/test/browser_inspector_breadcrumbs.js
@@ -22,17 +22,18 @@ const NODES = [
   {selector: "#i3", ids: "i3", nodeName: "article",
     title: "article#i3"},
   {selector: "clipPath", ids: "vector clip", nodeName: "clipPath",
     title: "clipPath#clip"},
 ];
 
 add_task(function* () {
   let { inspector } = yield openInspectorForURL(TEST_URI);
-  let container = inspector.panelDoc.getElementById("inspector-breadcrumbs");
+  let breadcrumbs = inspector.panelDoc.getElementById("inspector-breadcrumbs");
+  let container = breadcrumbs.querySelector(".html-arrowscrollbox-inner");
 
   for (let node of NODES) {
     info("Testing node " + node.selector);
 
     info("Selecting node and waiting for breadcrumbs to update");
     let breadcrumbsUpdated = inspector.once("breadcrumbs-updated");
     yield selectNode(node.selector, inspector);
     yield breadcrumbsUpdated;
@@ -57,17 +58,17 @@ add_task(function* () {
     let id = inspector.selection.nodeFront.id;
     is(labelId.textContent, "#" + id,
       "Node " + node.selector + ": selection matches");
 
     let labelTag = checkedButton.querySelector(".breadcrumbs-widget-item-tag");
     is(labelTag.textContent, node.nodeName,
       "Node " + node.selector + " has the expected tag name");
 
-    is(checkedButton.getAttribute("tooltiptext"), node.title,
+    is(checkedButton.getAttribute("title"), node.title,
       "Node " + node.selector + " has the expected tooltip");
   }
 
   yield testPseudoElements(inspector, container);
 });
 
 function* testPseudoElements(inspector, container) {
   info("Checking for pseudo elements");
--- a/devtools/client/inspector/test/browser_inspector_breadcrumbs_mutations.js
+++ b/devtools/client/inspector/test/browser_inspector_breadcrumbs_mutations.js
@@ -144,17 +144,18 @@ const TEST_DATA = [{
     }]);
   },
   shouldRefresh: true,
   output: ["html", "body#new-id.test-class-changed"]
 }];
 
 add_task(function* () {
   let {inspector} = yield openInspectorForURL(TEST_URI);
-  let container = inspector.panelDoc.getElementById("inspector-breadcrumbs");
+  let breadcrumbs = inspector.panelDoc.getElementById("inspector-breadcrumbs");
+  let container = breadcrumbs.querySelector(".html-arrowscrollbox-inner");
   let win = container.ownerDocument.defaultView;
 
   for (let {desc, setup, run, shouldRefresh, output} of TEST_DATA) {
     info("Running test case: " + desc);
 
     info("Listen to markupmutation events from the inspector to know when a " +
          "test case has completed");
     let onContentMutation = inspector.once("markupmutation");
--- a/devtools/client/themes/inspector.css
+++ b/devtools/client/themes/inspector.css
@@ -32,16 +32,55 @@
 .theme-firebug #inspector-searchbox {
   line-height: 17px;
 }
 
 #inspector-breadcrumbs-toolbar {
   padding: 0px;
   border-bottom-width: 0px;
   border-top-width: 1px;
+  display: block;
+  position: relative;
+}
+
+#inspector-breadcrumbs-toolbar,
+#inspector-breadcrumbs-toolbar * {
+  box-sizing: border-box;
+}
+
+#inspector-breadcrumbs {
+  display: flex;
+
+  /* Break out of the XUL flexbox, so the splitter can still shrink the
+     markup view even if the contents of the breadcrumbs are wider than
+     the new width. */
+  position: absolute;
+  top: 0;
+  left: 0;
+  bottom: 0;
+  right: 0;
+}
+
+#inspector-breadcrumbs .scrollbutton-up,
+#inspector-breadcrumbs .scrollbutton-down {
+  flex: 0;
+  display: flex;
+  align-items: center;
+}
+
+#inspector-breadcrumbs .html-arrowscrollbox-inner {
+  flex: 1;
+  display: flex;
+  overflow: hidden;
+}
+
+#inspector-breadcrumbs .breadcrumbs-widget-item {
+  white-space: nowrap;
+  flex-shrink: 0;
+  font: message-box;
 }
 
 /* Expand/collapse panel toolbar button */
 
 #inspector-pane-toggle::before {
   background-image: var(--theme-pane-collapse-image);
 }