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 374063 7cd31f8bca620bd6942349de365937a8115ae46b
parent 374062 faa1661edc002da6a574b08a4b24c9a55f29e6d3
child 374064 5d2cb7fff024a1d3ed59ad2432882d158ce5ee91
push id19916
push usergkruglov@mozilla.com
push dateWed, 01 Jun 2016 19:04:24 +0000
reviewerspbro
bugs1259812
milestone49.0a1
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);
 }