Bug 1242694 - improving inspector markup view accessibility (semantics and keyboard). r=gl draft
authorYura Zenevich <yzenevich@mozilla.com>
Thu, 19 May 2016 16:50:23 -0400
changeset 368934 98323a24c41f2753dd6a39f0fe30bb8adca235ca
parent 368911 1806d405c8715949b39fa3a4fc142d14a60df590
child 521400 4297e40186b26e5a900cbb630f169635bbda6dcc
push id18669
push useryura.zenevich@gmail.com
push dateThu, 19 May 2016 20:50:47 +0000
reviewersgl
bugs1242694
milestone49.0a1
Bug 1242694 - improving inspector markup view accessibility (semantics and keyboard). r=gl MozReview-Commit-ID: 4EvLALR4NIv
devtools/client/inspector/markup/markup.js
devtools/client/inspector/markup/markup.xhtml
devtools/client/inspector/markup/test/browser.ini
devtools/client/inspector/markup/test/browser_markup_accessibility_focus_blur.js
devtools/client/inspector/markup/test/browser_markup_accessibility_navigation.js
devtools/client/inspector/markup/test/browser_markup_accessibility_semantics.js
devtools/client/inspector/markup/test/browser_markup_keybindings_01.js
devtools/client/inspector/markup/test/browser_markup_tag_edit_02.js
devtools/client/inspector/markup/test/browser_markup_tag_edit_03.js
devtools/client/inspector/markup/test/browser_markup_tag_edit_08.js
devtools/client/inspector/markup/test/browser_markup_tag_edit_09.js
devtools/client/inspector/markup/test/browser_markup_tag_edit_10.js
devtools/client/inspector/markup/test/browser_markup_tag_edit_11.js
devtools/client/inspector/markup/test/browser_markup_tag_edit_12.js
devtools/client/inspector/markup/test/browser_markup_textcontent_edit_01.js
devtools/client/inspector/markup/test/browser_markup_textcontent_edit_02.js
devtools/client/inspector/markup/test/head.js
devtools/client/inspector/markup/test/helper_attributes_test_runner.js
devtools/client/inspector/markup/test/helper_style_attr_test_runner.js
devtools/client/inspector/test/browser_inspector_addNode_03.js
devtools/client/inspector/test/head.js
devtools/client/shared/inplace-editor.js
devtools/client/shared/test/browser_inplace-editor-01.js
devtools/client/themes/markup.css
--- a/devtools/client/inspector/markup/markup.js
+++ b/devtools/client/inspector/markup/markup.js
@@ -126,21 +126,25 @@ function MarkupView(inspector, frame, co
   this._onNewSelection = this._onNewSelection.bind(this);
   this._onCopy = this._onCopy.bind(this);
   this._onFocus = this._onFocus.bind(this);
   this._onMouseMove = this._onMouseMove.bind(this);
   this._onMouseLeave = this._onMouseLeave.bind(this);
   this._onToolboxPickerHover = this._onToolboxPickerHover.bind(this);
   this._onCollapseAttributesPrefChange =
     this._onCollapseAttributesPrefChange.bind(this);
+  this._onBlur = this._onBlur.bind(this);
+
+  EventEmitter.decorate(this);
 
   // Listening to various events.
   this._elt.addEventListener("click", this._onMouseClick, false);
   this._elt.addEventListener("mousemove", this._onMouseMove, false);
   this._elt.addEventListener("mouseleave", this._onMouseLeave, false);
+  this._elt.addEventListener("blur", this._onBlur, true);
   this.win.addEventListener("mouseup", this._onMouseUp);
   this.win.addEventListener("copy", this._onCopy);
   this._frame.addEventListener("focus", this._onFocus, false);
   this.walker.on("mutations", this._mutationObserver);
   this.walker.on("display-change", this._onDisplayChange);
   this._inspector.selection.on("new-node-front", this._onNewSelection);
   this._inspector.toolbox.on("picker-node-hovered", this._onToolboxPickerHover);
 
@@ -149,18 +153,16 @@ function MarkupView(inspector, frame, co
 
   this._prefObserver = new PrefObserver("devtools.markup");
   this._prefObserver.on(ATTR_COLLAPSE_ENABLED_PREF,
                         this._onCollapseAttributesPrefChange);
   this._prefObserver.on(ATTR_COLLAPSE_LENGTH_PREF,
                         this._onCollapseAttributesPrefChange);
 
   this._initShortcuts();
-
-  EventEmitter.decorate(this);
 }
 
 MarkupView.prototype = {
   /**
    * How long does a node flash when it mutates (in ms).
    */
   CONTAINER_FLASHING_DURATION: 500,
 
@@ -216,16 +218,35 @@ MarkupView.prototype = {
       }
     }
     this._showContainerAsHovered(container.node);
 
     this.emit("node-hover");
   },
 
   /**
+   * If focus is moved outside of the markup view document and there is a
+   * selected container, make its contents not focusable by a keyboard.
+   */
+  _onBlur: function (event) {
+    if (!this._selectedContainer) {
+      return;
+    }
+
+    let {relatedTarget} = event;
+    if (relatedTarget && relatedTarget.ownerDocument === this.doc) {
+      return;
+    }
+
+    if (this._selectedContainer) {
+      this._selectedContainer.clearFocus();
+    }
+  },
+
+  /**
    * Executed on each mouse-move while a node is being dragged in the view.
    * Auto-scrolls the view to reveal nodes below the fold to drop the dragged
    * node in.
    */
   _autoScroll: function (event) {
     let docEl = this.doc.documentElement;
 
     if (this._autoScrollInterval) {
@@ -536,50 +557,51 @@ MarkupView.prototype = {
       // We could be destroyed by now.
       if (this._destroyer) {
         return promise.reject("markupview destroyed");
       }
 
       // Mark the node as selected.
       this.markNodeAsSelected(selection.nodeFront);
 
-      // Make sure the new selection receives focus so the keyboard can be used.
-      this.maybeFocusNewSelection();
+      // Make sure the new selection is navigated to.
+      this.maybeNavigateToNewSelection();
       return undefined;
     }).catch(e => {
       if (!this._destroyer) {
         console.error(e);
       } else {
         console.warn("Could not mark node as selected, the markup-view was " +
           "destroyed while showing the node.");
       }
     });
 
     promise.all([onShowBoxModel, onShow]).then(done);
   },
 
   /**
-   * Maybe focus the current node selection's MarkupContainer depending on why
-   * the current node got selected.
+   * Maybe make selected the current node selection's MarkupContainer depending
+   * on why the current node got selected.
    */
-  maybeFocusNewSelection: function () {
+  maybeNavigateToNewSelection: function () {
     let {reason, nodeFront} = this._inspector.selection;
 
-    // The list of reasons that should lead to focusing the node.
-    let reasonsToFocus = [
+    // The list of reasons that should lead to navigating to the node.
+    let reasonsToNavigate = [
       // If the user picked an element with the element picker.
       "picker-node-picked",
       // If the user selected an element with the browser context menu.
       "browser-context-menu",
       // If the user added a new node by clicking in the inspector toolbar.
       "node-inserted"
     ];
 
-    if (reasonsToFocus.includes(reason)) {
-      this.getContainer(nodeFront).focus();
+    if (reasonsToNavigate.includes(reason)) {
+      this.getContainer(this._rootNode).elt.focus();
+      this.navigate(this.getContainer(nodeFront));
     }
   },
 
   /**
    * Create a TreeWalker to find the next/previous
    * node for selection.
    */
   _selectionWalker: function (start) {
@@ -628,17 +650,17 @@ MarkupView.prototype = {
      "markupView.edit.key",
      "markupView.scrollInto.key"].forEach(name => {
        let key = this.strings.GetStringFromName(name);
        shortcuts.on(key, (_, event) => this._onShortcut(name, event));
      });
 
     // Process generic keys:
     ["Delete", "Backspace", "Home", "Left", "Right", "Up", "Down", "PageUp",
-     "PageDown", "Esc"].forEach(key => {
+     "PageDown", "Esc", "Enter", "Space"].forEach(key => {
        shortcuts.on(key, this._onShortcut);
      });
   },
 
   /**
    * Key shortcut listener.
    */
   _onShortcut(name, event) {
@@ -737,16 +759,27 @@ MarkupView.prototype = {
           if (!nextNode) {
             break;
           }
           selection = nextNode.container;
         }
         this.navigate(selection);
         break;
       }
+      case "Enter":
+      case "Space": {
+        if (!this._selectedContainer.canFocus) {
+          this._selectedContainer.canFocus = true;
+          this._selectedContainer.focus();
+        } else {
+          // Return early to prevent cancelling the event.
+          return;
+        }
+        break;
+      }
       case "Esc": {
         if (this.isDragging) {
           this.cancelDragging();
         } else {
           // Return early to prevent cancelling the event when not
           // dragging, to allow the split console to be toggled.
           return;
         }
@@ -837,40 +870,34 @@ MarkupView.prototype = {
    * If an editable item is focused, select its container.
    */
   _onFocus: function (event) {
     let parent = event.target;
     while (!parent.container) {
       parent = parent.parentNode;
     }
     if (parent) {
-      this.navigate(parent.container, true);
+      this.navigate(parent.container);
     }
   },
 
   /**
    * Handle a user-requested navigation to a given MarkupContainer,
    * updating the inspector's currently-selected node.
    *
    * @param  {MarkupContainer} container
    *         The container we're navigating to.
-   * @param  {Boolean} ignoreFocus
-   *         If false, keyboard focus will be moved to the container too.
    */
-  navigate: function (container, ignoreFocus) {
+  navigate: function (container) {
     if (!container) {
       return;
     }
 
     let node = container.node;
     this.markNodeAsSelected(node, "treepanel");
-
-    if (!ignoreFocus) {
-      container.focus();
-    }
   },
 
   /**
    * Make sure a node is included in the markup tool.
    *
    * @param  {NodeFront} node
    *         The node in the content document.
    * @param  {Boolean} flashNode
@@ -939,18 +966,21 @@ MarkupView.prototype = {
         // we're not viewing.
         continue;
       }
       if (type === "attributes" || type === "characterData"
         || type === "events" || type === "pseudoClassLock") {
         container.update();
       } else if (type === "childList" || type === "nativeAnonymousChildList") {
         container.childrenDirty = true;
-        // Update the children to take care of changes in the markup view DOM.
-        this._updateChildren(container, {flash: true});
+        // Update the children to take care of changes in the markup view DOM
+        // and update container (and its subtree) DOM tree depth level for
+        // accessibility where necessary.
+        this._updateChildren(container, {flash: true}).then(() =>
+          container.updateLevel());
       }
     }
 
     this._waitForChildren().then(() => {
       if (this._destroyer) {
         console.warn("Could not fully update after markup mutations, " +
           "the markup-view was destroyed while waiting for children.");
         return;
@@ -1382,23 +1412,25 @@ MarkupView.prototype = {
    *         The NodeFront to mark as selected.
    * @param  {String} reason
    *         The reason for marking the node as selected.
    * @return {Boolean} False if the node is already marked as selected, true
    *         otherwise.
    */
   markNodeAsSelected: function (node, reason) {
     let container = this.getContainer(node);
+
     if (this._selectedContainer === container) {
       return false;
     }
 
-    // Un-select the previous container.
+    // Un-select and remove focus from the previous container.
     if (this._selectedContainer) {
       this._selectedContainer.selected = false;
+      this._selectedContainer.clearFocus();
     }
 
     // Select the new container.
     this._selectedContainer = container;
     if (node) {
       this._selectedContainer.selected = true;
     }
 
@@ -1483,16 +1515,19 @@ MarkupView.prototype = {
    * @return {Promise} that will be resolved when the children are ready
    *         (which may be immediately).
    */
   _updateChildren: function (container, options) {
     let expand = options && options.expand;
     let flash = options && options.flash;
 
     container.hasChildren = container.node.hasChildren;
+    // Accessibility should either ignore empty children or semantically
+    // consider them a group.
+    container.setChildrenRole();
 
     if (!this._queuedChildUpdates) {
       this._queuedChildUpdates = new Map();
     }
 
     if (this._queuedChildUpdates.has(container)) {
       return this._queuedChildUpdates.get(container);
     }
@@ -1651,16 +1686,17 @@ MarkupView.prototype = {
     this.undo = null;
 
     this.popup.destroy();
     this.popup = null;
 
     this._elt.removeEventListener("click", this._onMouseClick, false);
     this._elt.removeEventListener("mousemove", this._onMouseMove, false);
     this._elt.removeEventListener("mouseleave", this._onMouseLeave, false);
+    this._elt.removeEventListener("blur", this._onBlur, true);
     this.win.removeEventListener("mouseup", this._onMouseUp);
     this.win.removeEventListener("copy", this._onCopy);
     this._frame.removeEventListener("focus", this._onFocus, false);
     this.walker.off("mutations", this._mutationObserver);
     this.walker.off("display-change", this._onDisplayChange);
     this._inspector.selection.off("new-node-front", this._onNewSelection);
     this._inspector.toolbox.off("picker-node-hovered",
                                 this._onToolboxPickerHover);
@@ -1788,16 +1824,22 @@ MarkupView.prototype = {
  *
  * This should not be instantiated directly, instead use one of:
  *    MarkupReadOnlyContainer
  *    MarkupTextContainer
  *    MarkupElementContainer
  */
 function MarkupContainer() { }
 
+/**
+ * Unique identifier used to set markup container node id.
+ * @type {Number}
+ */
+let markupContainerID = 0;
+
 MarkupContainer.prototype = {
   /*
    * Initialize the MarkupContainer.  Should be called while one
    * of the other contain classes is instantiated.
    *
    * @param  {MarkupView} markupView
    *         The markup view that owns this container.
    * @param  {NodeFront} node
@@ -1805,30 +1847,32 @@ MarkupContainer.prototype = {
    * @param  {String} templateID
    *         Which template to render for this container
    */
   initialize: function (markupView, node, templateID) {
     this.markup = markupView;
     this.node = node;
     this.undo = this.markup.undo;
     this.win = this.markup._frame.contentWindow;
+    this.id = "treeitem-" + markupContainerID++;
 
     // The template will fill the following properties
     this.elt = null;
     this.expander = null;
     this.tagState = null;
     this.tagLine = null;
     this.children = null;
     this.markup.template(templateID, this);
     this.elt.container = this;
 
     this._onMouseDown = this._onMouseDown.bind(this);
     this._onToggle = this._onToggle.bind(this);
     this._onMouseUp = this._onMouseUp.bind(this);
     this._onMouseMove = this._onMouseMove.bind(this);
+    this._onKeyDown = this._onKeyDown.bind(this);
 
     // Binding event listeners
     this.elt.addEventListener("mousedown", this._onMouseDown, false);
     this.win.addEventListener("mouseup", this._onMouseUp, true);
     this.win.addEventListener("mousemove", this._onMouseMove, true);
     this.elt.addEventListener("dblclick", this._onToggle, false);
     if (this.expander) {
       this.expander.addEventListener("click", this._onToggle, false);
@@ -1876,38 +1920,139 @@ MarkupContainer.prototype = {
   },
 
   set hasChildren(value) {
     this._hasChildren = value;
     this.updateExpander();
   },
 
   /**
+   * A list of all elements with tabindex that are not in container's children.
+   */
+  get focusableElms() {
+    return [...this.tagLine.querySelectorAll("[tabindex]")];
+  },
+
+  /**
+   * An indicator that the container internals are focusable.
+   */
+  get canFocus() {
+    return this._canFocus;
+  },
+
+  /**
+   * Toggle focusable state for container internals.
+   */
+  set canFocus(value) {
+    if (this._canFocus === value) {
+      return;
+    }
+
+    this._canFocus = value;
+
+    if (value) {
+      this.tagLine.addEventListener("keydown", this._onKeyDown, true);
+      this.focusableElms.forEach(elm => elm.setAttribute("tabindex", "0"));
+    } else {
+      this.tagLine.removeEventListener("keydown", this._onKeyDown, true);
+      // Exclude from tab order.
+      this.focusableElms.forEach(elm => elm.setAttribute("tabindex", "-1"));
+    }
+  },
+
+  /**
+   * If conatiner and its contents are focusable, exclude them from tab order,
+   * and, if necessary, remove focus.
+   */
+  clearFocus: function () {
+    if (!this.canFocus) {
+      return;
+    }
+
+    this.canFocus = false;
+    let doc = this.markup.doc;
+
+    if (!doc.activeElement || doc.activeElement === doc.body) {
+      return;
+    }
+
+    let parent = doc.activeElement;
+
+    while (parent && parent !== this.elt) {
+      parent = parent.parentNode;
+    }
+
+    if (parent) {
+      doc.activeElement.blur();
+    }
+  },
+
+  /**
    * True if the current node can be expanded.
    */
   get canExpand() {
     return this._hasChildren && !this.node.singleTextChild;
   },
 
   /**
    * True if this is the root <html> element and can't be collapsed.
    */
   get mustExpand() {
     return this.node._parent === this.markup.walker.rootNode;
   },
 
+  /**
+   * True if current node can be expanded and collapsed.
+   */
+  get showExpander() {
+    return this.canExpand && !this.mustExpand;
+  },
+
   updateExpander: function () {
     if (!this.expander) {
       return;
     }
 
-    if (this.canExpand && !this.mustExpand) {
+    if (this.showExpander) {
       this.expander.style.visibility = "visible";
+      // Update accessibility expanded state.
+      this.tagLine.setAttribute("aria-expanded", this.expanded);
     } else {
       this.expander.style.visibility = "hidden";
+      // No need for accessible expanded state indicator when expander is not
+      // shown.
+      this.tagLine.removeAttribute("aria-expanded");
+    }
+  },
+
+  /**
+   * If current node has no children, ignore them. Otherwise, consider them a
+   * group from the accessibility point of view.
+   */
+  setChildrenRole: function () {
+    this.children.setAttribute("role",
+      this.hasChildren ? "group" : "presentation");
+  },
+
+  /**
+   * Set an appropriate DOM tree depth level for a node and its subtree.
+   */
+  updateLevel: function () {
+    // ARIA level should already be set when container template is rendered.
+    let currentLevel = this.tagLine.getAttribute("aria-level");
+    let newLevel = this.level;
+    if (currentLevel === newLevel) {
+      // If level did not change, ignore this node and its subtree.
+      return;
+    }
+
+    this.tagLine.setAttribute("aria-level", newLevel);
+    let childContainers = this.getChildContainers();
+    if (childContainers) {
+      childContainers.forEach(container => container.updateLevel());
     }
   },
 
   /**
    * If the node has children, return the list of containers for all these
    * children.
    */
   getChildContainers: function () {
@@ -1940,59 +2085,84 @@ MarkupContainer.prototype = {
     if (value && this.elt.classList.contains("collapsed")) {
       // Expanding a node means cloning its "inline" closing tag into a new
       // tag-line that the user can interact with and showing the children.
       let closingTag = this.elt.querySelector(".close");
       if (closingTag) {
         if (!this.closeTagLine) {
           let line = this.markup.doc.createElement("div");
           line.classList.add("tag-line");
+          // Closing tag is not important for accessibility.
+          line.setAttribute("role", "presentation");
 
           let tagState = this.markup.doc.createElement("div");
           tagState.classList.add("tag-state");
           line.appendChild(tagState);
 
           line.appendChild(closingTag.cloneNode(true));
 
           flashElementOff(line);
           this.closeTagLine = line;
         }
         this.elt.appendChild(this.closeTagLine);
       }
 
       this.elt.classList.remove("collapsed");
       this.expander.setAttribute("open", "");
       this.hovered = false;
+      this.markup.emit("expanded");
     } else if (!value) {
       if (this.closeTagLine) {
         this.elt.removeChild(this.closeTagLine);
         this.closeTagLine = undefined;
       }
       this.elt.classList.add("collapsed");
       this.expander.removeAttribute("open");
+      this.markup.emit("collapsed");
+    }
+    if (this.showExpander) {
+      this.tagLine.setAttribute("aria-expanded", this.expanded);
     }
   },
 
   parentContainer: function () {
     return this.elt.parentNode ? this.elt.parentNode.container : null;
   },
 
+  /**
+   * Determine tree depth level of a given node. This is used to specify ARIA
+   * level for node tree items and to give them better semantic context.
+   */
+  get level() {
+    let level = 1;
+    let parent = this.node.parentNode();
+    while (parent && parent !== this.markup.walker.rootNode) {
+      level++;
+      parent = parent.parentNode();
+    }
+    return level;
+  },
+
   _isDragging: false,
   _dragStartY: 0,
 
   set isDragging(isDragging) {
+    let rootElt = this.markup.getContainer(this.markup._rootNode).elt;
     this._isDragging = isDragging;
     this.markup.isDragging = isDragging;
+    this.tagLine.setAttribute("aria-grabbed", isDragging);
 
     if (isDragging) {
       this.elt.classList.add("dragging");
       this.markup.doc.body.classList.add("dragging");
+      rootElt.setAttribute("aria-dropeffect", "move");
     } else {
       this.elt.classList.remove("dragging");
       this.markup.doc.body.classList.remove("dragging");
+      rootElt.setAttribute("aria-dropeffect", "none");
     }
   },
 
   get isDragging() {
     return this._isDragging;
   },
 
   /**
@@ -2005,45 +2175,121 @@ MarkupContainer.prototype = {
            !this.node.isAnonymous &&
            !this.node.isDocumentElement &&
            tagName !== "body" &&
            tagName !== "head" &&
            this.win.getSelection().isCollapsed &&
            this.node.parentNode().tagName !== null;
   },
 
+  /**
+   * Move keyboard focus to a next/previous focusable element inside container
+   * that is not part of its children (only if current focus is on first or last
+   * element).
+   *
+   * @param  {DOMNode} current  currently focused element
+   * @param  {Boolean} back     direction
+   * @return {DOMNode}          newly focused element if any
+   */
+  _wrapMoveFocus: function (current, back) {
+    let elms = this.focusableElms;
+    let next;
+    if (back) {
+      if (elms.indexOf(current) === 0) {
+        next = elms[elms.length - 1];
+        next.focus();
+      }
+    } else if (elms.indexOf(current) === elms.length - 1) {
+      next = elms[0];
+      next.focus();
+    }
+    return next;
+  },
+
+  _onKeyDown: function (event) {
+    let {target, keyCode, shiftKey} = event;
+    let isInput = this.markup._isInputOrTextarea(target);
+
+    // Ignore all keystrokes that originated in editors except for when 'Tab' is
+    // pressed.
+    if (isInput && keyCode !== event.DOM_VK_TAB) {
+      return;
+    }
+
+    switch (keyCode) {
+      case event.DOM_VK_TAB:
+        // Only handle 'Tab' if tabbable element is on the edge (first or last).
+        if (isInput) {
+          // Corresponding tabbable element is editor's next sibling.
+          let next = this._wrapMoveFocus(target.nextSibling, shiftKey);
+          if (next) {
+            event.preventDefault();
+            // Keep the editing state if possible.
+            if (next._editable) {
+              let e = this.markup.doc.createEvent("Event");
+              e.initEvent(next._trigger, true, true);
+              next.dispatchEvent(e);
+            }
+          }
+        } else {
+          let next = this._wrapMoveFocus(target, shiftKey);
+          if (next) {
+            event.preventDefault();
+          }
+        }
+        break;
+      case event.DOM_VK_ESCAPE:
+        this.clearFocus();
+        this.markup.getContainer(this.markup._rootNode).elt.focus();
+        if (this.isDragging) {
+          // Escape when dragging is handled by markup view itself.
+          return;
+        }
+        event.preventDefault();
+        break;
+      default:
+        return;
+    }
+    event.stopPropagation();
+  },
+
   _onMouseDown: function (event) {
     let {target, button, metaKey, ctrlKey} = event;
     let isLeftClick = button === 0;
     let isMiddleClick = button === 1;
     let isMetaClick = isLeftClick && (metaKey || ctrlKey);
 
     // The "show more nodes" button already has its onclick, so early return.
     if (target.nodeName === "button") {
       return;
     }
 
     // target is the MarkupContainer itself.
     this.hovered = false;
     this.markup.navigate(this);
+    // Make container tabbable descendants tabbable and focus in.
+    this.canFocus = true;
+    this.focus();
     event.stopPropagation();
 
     // Preventing the default behavior will avoid the body to gain focus on
     // mouseup (through bubbling) when clicking on a non focusable node in the
     // line. So, if the click happened outside of a focusable element, do
     // prevent the default behavior, so that the tagname or textcontent gains
     // focus.
     if (!target.closest(".editor [tabindex]")) {
       event.preventDefault();
     }
 
     // Follow attribute links if middle or meta click.
     if (isMiddleClick || isMetaClick) {
       let link = target.dataset.link;
       let type = target.dataset.type;
+      // Make container tabbable descendants not tabbable (by default).
+      this.canFocus = false;
       this.markup._inspector.followAttributeLink(type, link);
       return;
     }
 
     // Start node drag & drop (if the mouse moved, see _onMouseMove).
     if (isLeftClick && this.isDraggable()) {
       this._isPreDragging = true;
       this._dragStartY = event.pageY;
@@ -2177,17 +2423,21 @@ MarkupContainer.prototype = {
   get selected() {
     return this._selected;
   },
 
   set selected(value) {
     this.tagState.classList.remove("flash-out");
     this._selected = value;
     this.editor.selected = value;
+    // Markup tree item should have accessible selected state.
+    this.tagLine.setAttribute("aria-selected", value);
     if (this._selected) {
+      this.markup.getContainer(this.markup._rootNode).elt.setAttribute(
+        "aria-activedescendant", this.id);
       this.tagLine.setAttribute("selected", "");
       this.tagState.classList.add("theme-selected");
     } else {
       this.tagLine.removeAttribute("selected");
       this.tagState.classList.remove("theme-selected");
     }
   },
 
@@ -2206,17 +2456,18 @@ MarkupContainer.prototype = {
       this.editor.update();
     }
   },
 
   /**
    * Try to put keyboard focus on the current editor.
    */
   focus: function () {
-    let focusable = this.editor.elt.querySelector("[tabindex]");
+    // Elements with tabindex of -1 are not focusable.
+    let focusable = this.editor.elt.querySelector("[tabindex='0']");
     if (focusable) {
       focusable.focus();
     }
   },
 
   _onToggle: function (event) {
     this.markup.navigate(this);
     if (this.hasChildren) {
@@ -2228,16 +2479,17 @@ MarkupContainer.prototype = {
   /**
    * Get rid of event listeners and references, when the container is no longer
    * needed
    */
   destroy: function () {
     // Remove event listeners
     this.elt.removeEventListener("mousedown", this._onMouseDown, false);
     this.elt.removeEventListener("dblclick", this._onToggle, false);
+    this.tagLine.removeEventListener("keydown", this._onKeyDown, true);
     if (this.win) {
       this.win.removeEventListener("mouseup", this._onMouseUp, true);
       this.win.removeEventListener("mousemove", this._onMouseMove, true);
     }
 
     this.win = null;
 
     if (this.expander) {
@@ -2485,16 +2737,20 @@ MarkupElementContainer.prototype = Herit
 });
 
 /**
  * Dummy container node used for the root document element.
  */
 function RootContainer(markupView, node) {
   this.doc = markupView.doc;
   this.elt = this.doc.createElement("ul");
+  // Root container has tree semantics for accessibility.
+  this.elt.setAttribute("role", "tree");
+  this.elt.setAttribute("tabindex", "0");
+  this.elt.setAttribute("aria-dropeffect", "none");
   this.elt.container = this;
   this.children = this.elt;
   this.node = node;
   this.toString = () => "[root container]";
 }
 
 RootContainer.prototype = {
   hasChildren: true,
@@ -2509,17 +2765,27 @@ RootContainer.prototype = {
   getChildContainers: function () {
     return [...this.children.children].map(node => node.container);
   },
 
   /**
    * Set the expanded state of the container node.
    * @param  {Boolean} value
    */
-  setExpanded: function () {}
+  setExpanded: function () {},
+
+  /**
+   * Set an appropriate role of the container's children node.
+   */
+  setChildrenRole: function () {},
+
+  /**
+   * Set an appropriate DOM tree depth level for a node and its subtree.
+   */
+  updateLevel: function () {}
 };
 
 /**
  * Creates an editor for non-editable nodes.
  */
 function GenericEditor(container, node) {
   this.container = container;
   this.markup = this.container.markup;
@@ -2675,17 +2941,18 @@ function ElementEditor(container, node) 
   this.closeElt = null;
 
   // Create the main editor
   this.template("element", this);
 
   // Make the tag name editable (unless this is a remote node or
   // a document element)
   if (!node.isDocumentElement) {
-    this.tag.setAttribute("tabindex", "0");
+    // Make the tag optionally tabbable but not by default.
+    this.tag.setAttribute("tabindex", "-1");
     editableField({
       element: this.tag,
       trigger: "dblclick",
       stopOnReturn: true,
       done: this.onTagEdit.bind(this),
     });
   }
 
--- a/devtools/client/inspector/markup/markup.xhtml
+++ b/devtools/client/inspector/markup/markup.xhtml
@@ -14,88 +14,88 @@
           src="chrome://devtools/content/shared/theme-switching.js"/>
 
 </head>
 <body class="theme-body devtools-monospace" role="application">
 
 <!-- NOTE THAT WE MAKE EXTENSIVE USE OF HTML COMMENTS IN THIS FILE IN ORDER -->
 <!-- TO MAKE SPANS READABLE WHILST AVOIDING SIGNIFICANT WHITESPACE          -->
 
-  <div id="root-wrapper">
-    <div id="root"></div>
+  <div id="root-wrapper" role="presentation">
+    <div id="root" role="presentation"></div>
   </div>
   <div id="templates" style="display:none">
 
     <ul class="children">
-      <li id="template-elementcontainer" save="${elt}" class="child collapsed">
-        <div save="${tagLine}" class="tag-line"><!--
-        --><span save="${tagState}" class="tag-state"></span><!--
-        --><span save="${expander}" class="theme-twisty expander"></span><!--
+      <li id="template-elementcontainer" save="${elt}" class="child collapsed" role="presentation">
+        <div save="${tagLine}" id="${id}" class="tag-line" role="treeitem" aria-level="${level}" aria-grabbed="${isDragging}"><!--
+        --><span save="${tagState}" class="tag-state" role="presentation"></span><!--
+        --><span save="${expander}" class="theme-twisty expander" role="presentation"></span><!--
      --></div>
-        <ul save="${children}" class="children"></ul>
+        <ul save="${children}" class="children" role="group"></ul>
       </li>
 
-      <li id="template-textcontainer" save="${elt}" class="child collapsed">
-        <div save="${tagLine}" class="tag-line"><span save="${tagState}" class="tag-state"></span></div>
-        <ul save="${children}" class="children"></ul>
+      <li id="template-textcontainer" save="${elt}" class="child collapsed" role="presentation">
+        <div save="${tagLine}" id="${id}" class="tag-line" role="treeitem" aria-level="${level}" aria-grabbed="${isDragging}"><span save="${tagState}" class="tag-state" role="presentation"></span></div>
+        <ul save="${children}" class="children" role="group"></ul>
       </li>
 
-      <li id="template-readonlycontainer" save="${elt}" class="child collapsed">
-        <div save="${tagLine}" class="tag-line"><!--
-        --><span save="${tagState}" class="tag-state"></span><!--
-        --><span save="${expander}" class="theme-twisty expander"></span><!--
+      <li id="template-readonlycontainer" save="${elt}" class="child collapsed" role="presentation">
+        <div save="${tagLine}" id="${id}" class="tag-line" role="treeitem" aria-level="${level}" aria-grabbed="${isDragging}"><!--
+        --><span save="${tagState}" class="tag-state" role="presentation"></span><!--
+        --><span save="${expander}" class="theme-twisty expander" role="presentation"></span><!--
      --></div>
-        <ul save="${children}" class="children"></ul>
+        <ul save="${children}" class="children" role="group"></ul>
       </li>
 
       <li id="template-more-nodes"
           class="more-nodes devtools-class-comment"
           save="${elt}"><!--
       --><span>${showing}</span> <!--
       --><button href="#" onclick="${allButtonClick}">${showAll}</button>
       </li>
     </ul>
 
     <span id="template-generic" save="${elt}" class="editor"><span save="${tag}" class="tag"></span></span>
 
     <span id="template-element" save="${elt}" class="editor"><!--
    --><span class="open">&lt;<!--
-     --><span save="${tag}" class="tag theme-fg-color3" tabindex="0"></span><!--
+     --><span save="${tag}" class="tag theme-fg-color3" tabindex="-1"></span><!--
      --><span save="${attrList}"></span><!--
-     --><span save="${newAttr}" class="newattr" tabindex="0"></span><!--
+     --><span save="${newAttr}" class="newattr" tabindex="-1"></span><!--
      --><span class="closing-bracket">&gt;</span><!--
    --></span><!--
    --><span class="close">&lt;/<!--
      --><span save="${closeTag}" class="tag theme-fg-color3"></span><!--
      -->&gt;<!--
    --></span><!--
      --><div save="${eventNode}" class="markupview-events" data-event="true">ev</div><!--
  --></span>
 
     <span id="template-attribute"
           save="${attr}"
           data-attr="${attrName}"
           data-value="${attrValue}"
           class="attreditor"
           style="display:none"> <!--
-   --><span class="editable" save="${inner}" tabindex="0"><!--
+   --><span class="editable" save="${inner}" tabindex="-1"><!--
      --><span save="${name}" class="attr-name theme-fg-color2"></span><!--
      -->=&quot;<!--
      --><span save="${val}" class="attr-value theme-fg-color6"></span><!--
      -->&quot;<!--
    --></span><!--
  --></span>
 
     <span id="template-text" save="${elt}" class="editor text"><!--
-   --><pre save="${value}" style="display:inline-block; white-space: normal;" tabindex="0"></pre><!--
+   --><pre save="${value}" style="display:inline-block; white-space: normal;" tabindex="-1"></pre><!--
  --></span>
 
     <span id="template-comment"
           save="${elt}"
           class="editor comment theme-comment"><!--
    --><span>&lt;!--</span><!--
-   --><pre save="${value}" style="display:inline-block; white-space: normal;" tabindex="0"></pre><!--
+   --><pre save="${value}" style="display:inline-block; white-space: normal;" tabindex="-1"></pre><!--
    --><span>--&gt;</span><!--
  --></span>
 
   </div>
 </body>
 </html>
--- a/devtools/client/inspector/markup/test/browser.ini
+++ b/devtools/client/inspector/markup/test/browser.ini
@@ -45,16 +45,21 @@ support-files =
   lib_jquery_1.11.1_min.js
   lib_jquery_2.1.1_min.js
   !/devtools/client/commandline/test/helpers.js
   !/devtools/client/inspector/test/head.js
   !/devtools/client/framework/test/shared-head.js
   !/devtools/client/shared/test/test-actor.js
   !/devtools/client/shared/test/test-actor-registry.js
 
+[browser_markup_accessibility_focus_blur.js]
+skip-if = os == "mac" # Full keyboard navigation on OSX only works if Full Keyboard Access setting is set to All Control in System Keyboard Preferences
+[browser_markup_accessibility_navigation.js]
+skip-if = os == "mac" # Full keyboard navigation on OSX only works if Full Keyboard Access setting is set to All Control in System Keyboard Preferences
+[browser_markup_accessibility_semantics.js]
 [browser_markup_anonymous_01.js]
 [browser_markup_anonymous_02.js]
 skip-if = e10s # scratchpad.xul is not loading in e10s window
 [browser_markup_anonymous_03.js]
 [browser_markup_anonymous_04.js]
 [browser_markup_copy_image_data.js]
 [browser_markup_css_completion_style_attribute_01.js]
 [browser_markup_css_completion_style_attribute_02.js]
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/markup/test/browser_markup_accessibility_focus_blur.js
@@ -0,0 +1,59 @@
+/* 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/. */
+
+"use strict";
+
+// Test inspector markup view handling focus and blur when moving between markup
+// view, its root and other containers, and other parts of inspector.
+
+add_task(function* () {
+  let {inspector, testActor} = yield openInspectorForURL(
+    "data:text/html;charset=utf-8,<h1>foo</h1><span>bar</span>");
+  let markup = inspector.markup;
+  let doc = markup.doc;
+  let win = doc.defaultView;
+
+  let spanContainer = yield getContainerForSelector("span", inspector);
+  let rootContainer = markup.getContainer(markup._rootNode);
+
+  is(doc.activeElement, doc.body,
+    "Keyboard focus by default is on document body");
+
+  yield selectNode("span", inspector);
+
+  is(doc.activeElement, doc.body,
+    "Keyboard focus is still on document body");
+
+  info("Focusing on the test span node using 'Return' key");
+  // Focus on the tree element.
+  rootContainer.elt.focus();
+  EventUtils.synthesizeKey("VK_RETURN", {}, win);
+
+  is(doc.activeElement, spanContainer.editor.tag,
+    "Keyboard focus should be on tag element of focused container");
+
+  info("Focusing on search box, external to markup view document");
+  yield focusSearchBoxUsingShortcut(inspector.panelWin);
+
+  is(doc.activeElement, doc.body,
+    "Keyboard focus should be removed from focused container");
+
+  info("Selecting the test span node again");
+  yield selectNode("span", inspector);
+
+  is(doc.activeElement, doc.body,
+    "Keyboard focus should again be on document body");
+
+  info("Focusing on the test span node using 'Space' key");
+  // Focus on the tree element.
+  rootContainer.elt.focus();
+  EventUtils.synthesizeKey("VK_SPACE", {}, win);
+
+  is(doc.activeElement, spanContainer.editor.tag,
+    "Keyboard focus should again be on tag element of focused container");
+
+  yield clickOnInspectMenuItem(testActor, "h1");
+  is(doc.activeElement, rootContainer.elt,
+    "When inspect menu item is used keyboard focus should move to tree.");
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/markup/test/browser_markup_accessibility_navigation.js
@@ -0,0 +1,301 @@
+/* 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/. */
+
+"use strict";
+
+/* global getContainerForSelector, openInspectorForURL */
+
+// Test keyboard navigation accessibility of inspector's markup view.
+
+/**
+ * Test data has the format of:
+ * {
+ *   desc              {String}   description for better logging
+ *   key               {String}   key event's key
+ *   options           {?Object}  optional event data such as shiftKey, etc
+ *   focused           {String}   path to expected focused element relative to
+ *                                its container
+ *   activedescendant  {String}   path to expected aria-activedescendant element
+ *                                relative to its container
+ *   waitFor           {String}   optional event to wait for if keyboard actions
+ *                                result in asynchronous updates
+ * }
+ */
+const TESTS = [
+  {
+    desc: "Collapse body container",
+    focused: "root.elt",
+    activedescendant: "body.tagLine",
+    key: "VK_LEFT",
+    options: { },
+    waitFor: "collapsed"
+  },
+  {
+    desc: "Expand body container",
+    focused: "root.elt",
+    activedescendant: "body.tagLine",
+    key: "VK_RIGHT",
+    options: { },
+    waitFor: "expanded"
+  },
+  {
+    desc: "Select header container",
+    focused: "root.elt",
+    activedescendant: "header.tagLine",
+    key: "VK_DOWN",
+    options: { },
+    waitFor: "inspector-updated"
+  },
+  {
+    desc: "Expand header container",
+    focused: "root.elt",
+    activedescendant: "header.tagLine",
+    key: "VK_RIGHT",
+    options: { },
+    waitFor: "expanded"
+  },
+  {
+    desc: "Select text container",
+    focused: "root.elt",
+    activedescendant: "container-0.tagLine",
+    key: "VK_DOWN",
+    options: { },
+    waitFor: "inspector-updated"
+  },
+  {
+    desc: "Select header container again",
+    focused: "root.elt",
+    activedescendant: "header.tagLine",
+    key: "VK_UP",
+    options: { },
+    waitFor: "inspector-updated"
+  },
+  {
+    desc: "Collapse header container",
+    focused: "root.elt",
+    activedescendant: "header.tagLine",
+    key: "VK_LEFT",
+    options: { },
+    waitFor: "collapsed"
+  },
+  {
+    desc: "Focus on header container tag",
+    focused: "header.focusableElms.0",
+    activedescendant: "header.tagLine",
+    key: "VK_RETURN",
+    options: { }
+  },
+  {
+    desc: "Remove focus from header container tag",
+    focused: "root.elt",
+    activedescendant: "header.tagLine",
+    key: "VK_ESCAPE",
+    options: { }
+  },
+  {
+    desc: "Focus on header container tag again",
+    focused: "header.focusableElms.0",
+    activedescendant: "header.tagLine",
+    key: "VK_SPACE",
+    options: { }
+  },
+  {
+    desc: "Focus on header id attribute",
+    focused: "header.focusableElms.1",
+    activedescendant: "header.tagLine",
+    key: "VK_TAB",
+    options: { }
+  },
+  {
+    desc: "Focus on header class attribute",
+    focused: "header.focusableElms.2",
+    activedescendant: "header.tagLine",
+    key: "VK_TAB",
+    options: { }
+  },
+  {
+    desc: "Focus on header new attribute",
+    focused: "header.focusableElms.3",
+    activedescendant: "header.tagLine",
+    key: "VK_TAB",
+    options: { }
+  },
+  {
+    desc: "Circle back and focus on header tag again",
+    focused: "header.focusableElms.0",
+    activedescendant: "header.tagLine",
+    key: "VK_TAB",
+    options: { }
+  },
+  {
+    desc: "Circle back and focus on header new attribute again",
+    focused: "header.focusableElms.3",
+    activedescendant: "header.tagLine",
+    key: "VK_TAB",
+    options: { shiftKey: true }
+  },
+  {
+    desc: "Tab back and focus on header class attribute",
+    focused: "header.focusableElms.2",
+    activedescendant: "header.tagLine",
+    key: "VK_TAB",
+    options: { shiftKey: true }
+  },
+  {
+    desc: "Tab back and focus on header id attribute",
+    focused: "header.focusableElms.1",
+    activedescendant: "header.tagLine",
+    key: "VK_TAB",
+    options: { shiftKey: true }
+  },
+  {
+    desc: "Tab back and focus on header tag",
+    focused: "header.focusableElms.0",
+    activedescendant: "header.tagLine",
+    key: "VK_TAB",
+    options: { shiftKey: true }
+  },
+  {
+    desc: "Expand header container, ensure that focus is still on header tag",
+    focused: "header.focusableElms.0",
+    activedescendant: "header.tagLine",
+    key: "VK_RIGHT",
+    options: { },
+    waitFor: "expanded"
+  },
+  {
+    desc: "Activate header tag editor",
+    focused: "header.editor.tag.inplaceEditor.input",
+    activedescendant: "header.tagLine",
+    key: "VK_RETURN",
+    options: { }
+  },
+  {
+    desc: "Activate header id attribute editor",
+    focused: "header.editor.attrList.children.0.children.1.inplaceEditor.input",
+    activedescendant: "header.tagLine",
+    key: "VK_TAB",
+    options: { }
+  },
+  {
+    desc: "Deselect text in header id attribute editor",
+    focused: "header.editor.attrList.children.0.children.1.inplaceEditor.input",
+    activedescendant: "header.tagLine",
+    key: "VK_TAB",
+    options: { }
+  },
+  {
+    desc: "Activate header class attribute editor",
+    focused: "header.editor.attrList.children.1.children.1.inplaceEditor.input",
+    activedescendant: "header.tagLine",
+    key: "VK_TAB",
+    options: { }
+  },
+  {
+    desc: "Deselect text in header class attribute editor",
+    focused: "header.editor.attrList.children.1.children.1.inplaceEditor.input",
+    activedescendant: "header.tagLine",
+    key: "VK_TAB",
+    options: { }
+  },
+  {
+    desc: "Activate header new attribute editor",
+    focused: "header.editor.newAttr.inplaceEditor.input",
+    activedescendant: "header.tagLine",
+    key: "VK_TAB",
+    options: { }
+  },
+  {
+    desc: "Circle back and activate header tag editor again",
+    focused: "header.editor.tag.inplaceEditor.input",
+    activedescendant: "header.tagLine",
+    key: "VK_TAB",
+    options: { }
+  },
+  {
+    desc: "Circle back and activate header new attribute editor again",
+    focused: "header.editor.newAttr.inplaceEditor.input",
+    activedescendant: "header.tagLine",
+    key: "VK_TAB",
+    options: { shiftKey: true }
+  },
+  {
+    desc: "Exit edit mode and keep focus on header new attribute",
+    focused: "header.focusableElms.3",
+    activedescendant: "header.tagLine",
+    key: "VK_ESCAPE",
+    options: { }
+  },
+  {
+    desc: "Move the selection to body and reset focus to container tree",
+    focused: "docBody",
+    activedescendant: "body.tagLine",
+    key: "VK_UP",
+    options: { },
+    waitFor: "inspector-updated"
+  },
+];
+
+let elms = {};
+let containerID = 0;
+
+add_task(function* () {
+  let { inspector } = yield openInspectorForURL(`data:text/html;charset=utf-8,
+    <h1 id="some-id" class="some-class">foo<span>Child span<span></h1>`);
+  let markup = inspector.markup;
+  let doc = markup.doc;
+  let win = doc.defaultView;
+
+  // Record containers that are created after inspector is initialized to be
+  // useful in testing.
+  inspector.on("container-created", memorizeContainer);
+  registerCleanupFunction(() => {
+    inspector.off("container-created", memorizeContainer);
+  });
+
+  elms.docBody = doc.body;
+  elms.root = markup.getContainer(markup._rootNode);
+  elms.header = yield getContainerForSelector("h1", inspector);
+  elms.body = yield getContainerForSelector("body", inspector);
+
+  // Initial focus is on root element and active descendant should be set on
+  // body tag line.
+  testNavigationState(doc, elms.docBody, elms.body.tagLine);
+
+  // Focus on the tree element.
+  elms.root.elt.focus();
+
+  for (let {desc, waitFor, focused, activedescendant, key, options} of TESTS) {
+    info(desc);
+    let updated;
+    if (waitFor) {
+      updated = waitFor === "inspector-updated" ?
+        inspector.once(waitFor) : markup.once(waitFor);
+    } else {
+      updated = Promise.resolve();
+    }
+
+    EventUtils.synthesizeKey(key, options, win);
+    yield updated;
+    testNavigationState(doc, getElm(focused), getElm(activedescendant));
+  }
+});
+
+// Record all containers that are created dynamically into elms object.
+function memorizeContainer(event, container) {
+  elms[`container-${containerID++}`] = container;
+}
+
+// Parse and lookup an element from elms object based on dotted path.
+function getElm(path) {
+  let segments = path.split(".");
+  return segments.reduce((prev, current) => prev[current], elms);
+}
+
+function testNavigationState(doc, focused, activedescendant) {
+  let id = activedescendant.getAttribute("id");
+  is(doc.activeElement, focused, `Keyboard focus should be set to ${focused}`);
+  is(elms.root.elt.getAttribute("aria-activedescendant"), id,
+    `Active descendant should be set to ${id}`);
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/markup/test/browser_markup_accessibility_semantics.js
@@ -0,0 +1,100 @@
+/* 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/. */
+
+"use strict";
+
+// Test that inspector markup view has all expected ARIA properties set and
+// updated.
+
+const TOP_CONTAINER_LEVEL = 3;
+
+add_task(function* () {
+  let {inspector} = yield openInspectorForURL(`
+    data:text/html;charset=utf-8,
+    <h1>foo</h1>
+    <span>bar</span>
+    <ul>
+      <li></li>
+    </ul>`);
+  let markup = inspector.markup;
+  let doc = markup.doc;
+  let win = doc.defaultView;
+
+  let rootElt = markup.getContainer(markup._rootNode).elt;
+  let bodyContainer = yield getContainerForSelector("body", inspector);
+  let spanContainer = yield getContainerForSelector("span", inspector);
+  let headerContainer = yield getContainerForSelector("h1", inspector);
+  let listContainer = yield getContainerForSelector("ul", inspector);
+
+  // Focus on the tree element.
+  rootElt.focus();
+
+  // Test tree related semantics
+  is(rootElt.getAttribute("role"), "tree",
+    "Root container should have tree semantics");
+  is(rootElt.getAttribute("aria-dropeffect"), "none",
+    "By default root container's drop effect should be set to none");
+  is(rootElt.getAttribute("aria-activedescendant"),
+    bodyContainer.tagLine.getAttribute("id"),
+    "Default active descendant should be set to body");
+  is(bodyContainer.tagLine.getAttribute("aria-level"), TOP_CONTAINER_LEVEL - 1,
+    "Body container tagLine should have nested level up to date");
+  [spanContainer, headerContainer, listContainer].forEach(container => {
+    let treeitem = container.tagLine;
+    is(treeitem.getAttribute("role"), "treeitem",
+      "Child container tagLine elements should have tree item semantics");
+    is(treeitem.getAttribute("aria-level"), TOP_CONTAINER_LEVEL,
+      "Child container tagLine should have nested level up to date");
+    is(treeitem.getAttribute("aria-grabbed"), "false",
+      "Child container should be draggable but not grabbed by default");
+    is(container.children.getAttribute("role"), "group",
+      "Container with children should have its children element have group " +
+      "semantics");
+    ok(treeitem.id, "Tree item should have id assigned");
+    if (container.closeTagLine) {
+      is(container.closeTagLine.getAttribute("role"), "presentation",
+        "Ignore closing tag");
+    }
+    if (container.expander) {
+      is(container.expander.getAttribute("role"), "presentation",
+        "Ignore expander");
+    }
+  });
+
+  // Test expanding/expandable semantics
+  ok(!spanContainer.tagLine.hasAttribute("aria-expanded"),
+    "Non expandable tree items should not have aria-expanded attribute");
+  ok(!headerContainer.tagLine.hasAttribute("aria-expanded"),
+    "Non expandable tree items should not have aria-expanded attribute");
+  is(listContainer.tagLine.getAttribute("aria-expanded"), "false",
+    "Closed tree item should have aria-expanded unset");
+
+  info("Selecting and expanding list container");
+  let updated = waitForMultipleChildrenUpdates(inspector);
+  yield selectNode("ul", inspector);
+  EventUtils.synthesizeKey("VK_RIGHT", {}, win);
+  yield updated;
+
+  is(rootElt.getAttribute("aria-activedescendant"),
+    listContainer.tagLine.getAttribute("id"),
+    "Active descendant should not be set to list container tagLine");
+  is(listContainer.tagLine.getAttribute("aria-expanded"), "true",
+    "Open tree item should have aria-expanded set");
+  let listItemContainer = yield getContainerForSelector("li", inspector);
+  is(listItemContainer.tagLine.getAttribute("aria-level"),
+    TOP_CONTAINER_LEVEL + 1,
+    "Grand child container tagLine should have nested level up to date");
+  is(listItemContainer.children.getAttribute("role"), "presentation",
+    "Container with no children should have its children element ignored by " +
+    "accessibility");
+
+  info("Collapsing list container");
+  updated = waitForMultipleChildrenUpdates(inspector);
+  EventUtils.synthesizeKey("VK_LEFT", {}, win);
+  yield updated;
+
+  is(listContainer.tagLine.getAttribute("aria-expanded"), "false",
+    "Closed tree item should have aria-expanded unset");
+});
+
--- a/devtools/client/inspector/markup/test/browser_markup_keybindings_01.js
+++ b/devtools/client/inspector/markup/test/browser_markup_keybindings_01.js
@@ -9,17 +9,17 @@ requestLongerTimeout(2);
 // Tests tabbing through attributes on a node
 
 const TEST_URL = "data:text/html;charset=utf8,<div id='test' a b c d e></div>";
 
 add_task(function* () {
   let {inspector} = yield openInspectorForURL(TEST_URL);
 
   info("Focusing the tag editor of the test element");
-  let {editor} = yield getContainerForSelector("div", inspector);
+  let {editor} = yield focusNode("div", inspector);
   editor.tag.focus();
 
   info("Pressing tab and expecting to focus the ID attribute, always first");
   EventUtils.sendKey("tab", inspector.panelWin);
   checkFocusedAttribute("id");
 
   info("Hit enter to turn the attribute to edit mode");
   EventUtils.sendKey("return", inspector.panelWin);
--- a/devtools/client/inspector/markup/test/browser_markup_tag_edit_02.js
+++ b/devtools/client/inspector/markup/test/browser_markup_tag_edit_02.js
@@ -9,17 +9,17 @@
 const TEST_URL = `data:text/html,
                   <div id='test-div'>Test modifying my ID attribute</div>`;
 
 add_task(function* () {
   info("Opening the inspector on the test page");
   let {inspector, testActor} = yield openInspectorForURL(TEST_URL);
 
   info("Selecting the test node");
-  yield selectNode("#test-div", inspector);
+  yield focusNode("#test-div", inspector);
 
   info("Verify attributes, only ID should be there for now");
   yield assertAttributes("#test-div", {
     id: "test-div"
   }, testActor);
 
   info("Focus the ID attribute and change its content");
   let {editor} = yield getContainerForSelector("#test-div", inspector);
--- a/devtools/client/inspector/markup/test/browser_markup_tag_edit_03.js
+++ b/devtools/client/inspector/markup/test/browser_markup_tag_edit_03.js
@@ -10,17 +10,17 @@ const TEST_URL = `data:text/html;charset
                   <div id='retag-me'><div id='retag-me-2'></div></div>`;
 
 add_task(function* () {
   let {inspector, testActor} = yield openInspectorForURL(TEST_URL);
 
   yield inspector.markup.expandAll();
 
   info("Selecting the test node");
-  yield selectNode("#retag-me", inspector);
+  yield focusNode("#retag-me", inspector);
 
   info("Getting the markup-container for the test node");
   let container = yield getContainerForSelector("#retag-me", inspector);
   ok(container.expanded, "The container is expanded");
 
   let parentInfo = yield testActor.getNodeInfo("#retag-me");
   is(parentInfo.tagName.toLowerCase(), "div",
      "We've got #retag-me element, it's a DIV");
--- a/devtools/client/inspector/markup/test/browser_markup_tag_edit_08.js
+++ b/devtools/client/inspector/markup/test/browser_markup_tag_edit_08.js
@@ -32,17 +32,17 @@ function* testCollapsedLongAttribute(ins
   yield onMutated;
 
   yield assertAttributes("#node24", {
     id: "node24",
     "class": "",
     "data-long": LONG_ATTRIBUTE
   }, testActor);
 
-  let {editor} = yield getContainerForSelector("#node24", inspector);
+  let {editor} = yield focusNode("#node24", inspector);
   let attr = editor.attrElements.get("data-long").querySelector(".editable");
 
   // Check to make sure it has expanded after focus
   attr.focus();
   EventUtils.sendKey("return", inspector.panelWin);
   let input = inplaceEditor(attr).input;
   is(input.value, `data-long="${LONG_ATTRIBUTE}"`);
   EventUtils.sendKey("escape", inspector.panelWin);
@@ -66,17 +66,17 @@ function* testModifyInlineStyleWithQuote
   info("Modify inline style containing \"");
 
   yield assertAttributes("#node26", {
     id: "node26",
     style: 'background-image: url("moz-page-thumb://thumbnail?url=http%3A%2F%2Fwww.mozilla.org%2F");'
   }, testActor);
 
   let onMutated = inspector.once("markupmutation");
-  let {editor} = yield getContainerForSelector("#node26", inspector);
+  let {editor} = yield focusNode("#node26", inspector);
   let attr = editor.attrElements.get("style").querySelector(".editable");
 
   attr.focus();
   EventUtils.sendKey("return", inspector.panelWin);
 
   let input = inplaceEditor(attr).input;
   let value = input.value;
 
@@ -102,17 +102,17 @@ function* testEditingAttributeWithMixedQ
   info("Modify class containing \" and \'");
 
   yield assertAttributes("#node27", {
     "id": "node27",
     "class": 'Double " and single \''
   }, testActor);
 
   let onMutated = inspector.once("markupmutation");
-  let {editor} = yield getContainerForSelector("#node27", inspector);
+  let {editor} = yield focusNode("#node27", inspector);
   let attr = editor.attrElements.get("class").querySelector(".editable");
 
   attr.focus();
   EventUtils.sendKey("return", inspector.panelWin);
 
   let input = inplaceEditor(attr).input;
   let value = input.value;
 
--- a/devtools/client/inspector/markup/test/browser_markup_tag_edit_09.js
+++ b/devtools/client/inspector/markup/test/browser_markup_tag_edit_09.js
@@ -21,17 +21,17 @@ add_task(function* () {
 function* testWellformedMixedCase(inspector, testActor) {
   info("Modifying a mixed-case attribute, " +
     "expecting the attribute's case to be preserved");
 
   info("Listening to markup mutations");
   let onMutated = inspector.once("markupmutation");
 
   info("Focusing the viewBox attribute editor");
-  let {editor} = yield getContainerForSelector("svg", inspector);
+  let {editor} = yield focusNode("svg", inspector);
   let attr = editor.attrElements.get("viewBox").querySelector(".editable");
   attr.focus();
   EventUtils.sendKey("return", inspector.panelWin);
 
   info("Editing the attribute value and waiting for the mutation event");
   let input = inplaceEditor(attr).input;
   input.value = "viewBox=\"0 0 1 1\"";
   EventUtils.sendKey("return", inspector.panelWin);
@@ -47,17 +47,17 @@ function* testWellformedMixedCase(inspec
 function* testMalformedMixedCase(inspector, testActor) {
   info("Modifying a malformed, mixed-case attribute, " +
     "expecting the attribute's case to be preserved");
 
   info("Listening to markup mutations");
   let onMutated = inspector.once("markupmutation");
 
   info("Focusing the viewBox attribute editor");
-  let {editor} = yield getContainerForSelector("svg", inspector);
+  let {editor} = yield focusNode("svg", inspector);
   let attr = editor.attrElements.get("viewBox").querySelector(".editable");
   attr.focus();
   EventUtils.sendKey("return", inspector.panelWin);
 
   info("Editing the attribute value and waiting for the mutation event");
   let input = inplaceEditor(attr).input;
   input.value = "viewBox=\"<>\"";
   EventUtils.sendKey("return", inspector.panelWin);
--- a/devtools/client/inspector/markup/test/browser_markup_tag_edit_10.js
+++ b/devtools/client/inspector/markup/test/browser_markup_tag_edit_10.js
@@ -6,20 +6,19 @@
 
 // Tests that invalid tagname updates are handled correctly
 
 const TEST_URL = "data:text/html;charset=utf-8,<div></div>";
 
 add_task(function* () {
   let {inspector} = yield openInspectorForURL(TEST_URL);
   yield inspector.markup.expandAll();
-  yield selectNode("div", inspector);
 
   info("Updating the DIV tagname to an invalid value");
-  let container = yield getContainerForSelector("div", inspector);
+  let container = yield focusNode("div", inspector);
   let onCancelReselect = inspector.markup.once("canceledreselectonremoved");
   let tagEditor = container.editor.tag;
   setEditableFieldValue(tagEditor, "<<<", inspector);
   yield onCancelReselect;
   ok(true, "The markup-view emitted the canceledreselectonremoved event");
   is(inspector.selection.nodeFront, container.node,
      "The test DIV is still selected");
 
--- a/devtools/client/inspector/markup/test/browser_markup_tag_edit_11.js
+++ b/devtools/client/inspector/markup/test/browser_markup_tag_edit_11.js
@@ -15,24 +15,24 @@ add_task(function* () {
   let {inspector} = yield openInspectorForURL(TEST_URL);
 
   // Overriding the editTagName walkerActor method here to check that it isn't
   // called when blurring the tagname field.
   inspector.walker.editTagName = function () {
     isEditTagNameCalled = true;
   };
 
-  yield selectNode("div", inspector);
-  let container = yield getContainerForSelector("div", inspector);
+  let container = yield focusNode("div", inspector);
   let tagEditor = container.editor.tag;
 
   info("Blurring the tagname field");
   tagEditor.blur();
   is(isEditTagNameCalled, false, "The editTagName method wasn't called");
 
   info("Updating the tagname to uppercase");
+  yield focusNode("div", inspector);
   setEditableFieldValue(tagEditor, "DIV", inspector);
   is(isEditTagNameCalled, false, "The editTagName method wasn't called");
 
   info("Updating the tagname to a different value");
   setEditableFieldValue(tagEditor, "SPAN", inspector);
   is(isEditTagNameCalled, true, "The editTagName method was called");
 });
--- a/devtools/client/inspector/markup/test/browser_markup_tag_edit_12.js
+++ b/devtools/client/inspector/markup/test/browser_markup_tag_edit_12.js
@@ -84,15 +84,15 @@ function* editAttributeAndTab(newValue, 
   yield onEditMutation;
 }
 
 /**
  * Given a markup container, focus and turn in edit mode its first attribute
  * field.
  */
 function* activateFirstAttribute(container, inspector) {
-  let {editor} = yield getContainerForSelector(container, inspector);
+  let {editor} = yield focusNode(container, inspector);
   editor.tag.focus();
 
   // Go to "id" attribute and trigger edit mode.
   EventUtils.sendKey("tab", inspector.panelWin);
   EventUtils.sendKey("return", inspector.panelWin);
 }
--- a/devtools/client/inspector/markup/test/browser_markup_textcontent_edit_01.js
+++ b/devtools/client/inspector/markup/test/browser_markup_textcontent_edit_01.js
@@ -48,17 +48,17 @@ function* getNodeValue(selector, testAct
 
 function* editContainer(inspector, testActor,
                         {selector, newValue, oldValue, shortValue}) {
   let nodeValue = yield getNodeValue(selector, testActor);
   is(nodeValue, oldValue, "The test node's text content is correct");
 
   info("Changing the text content");
   let onMutated = inspector.once("markupmutation");
-  let container = yield getContainerForSelector(selector, inspector);
+  let container = yield focusNode(selector, inspector);
   let field = container.elt.querySelector("pre");
 
   if (shortValue) {
     is(oldValue.indexOf(
        field.textContent.substring(0, field.textContent.length - 1)),
        0,
        "The shortened value starts with the full value " + field.textContent);
     ok(oldValue.length > field.textContent.length,
--- a/devtools/client/inspector/markup/test/browser_markup_textcontent_edit_02.js
+++ b/devtools/client/inspector/markup/test/browser_markup_textcontent_edit_02.js
@@ -17,18 +17,17 @@ add_task(function* () {
   yield inspector.markup.expandAll();
   yield waitForMultipleChildrenUpdates(inspector);
 
   let nodeValue = yield getNodeValue(SELECTOR, testActor);
   let expectedValue = "line6";
   is(nodeValue, expectedValue, "The test node's text content is correct");
 
   info("Open editable field for .node6");
-  let nodeFront = yield getNodeFront(SELECTOR, inspector);
-  let container = getContainerForNodeFront(nodeFront, inspector);
+  let container = yield focusNode(SELECTOR, inspector);
   let field = container.elt.querySelector("pre");
   field.focus();
   EventUtils.sendKey("return", inspector.panelWin);
   let editor = inplaceEditor(field);
 
   info("Initially, all the input content should be selected");
   checkSelectionPositions(editor, 0, expectedValue.length);
 
--- a/devtools/client/inspector/markup/test/head.js
+++ b/devtools/client/inspector/markup/test/head.js
@@ -155,17 +155,17 @@ function setEditableFieldValue(field, va
  * @param {String} text The new attribute text to be entered (e.g. "id='test'")
  * @param {InspectorPanel} inspector The instance of InspectorPanel currently
  * loaded in the toolbox
  * @return a promise that resolves when the node has mutated
  */
 var addNewAttributes = Task.async(function* (selector, text, inspector) {
   info(`Entering text "${text}" in new attribute field for node ${selector}`);
 
-  let container = yield getContainerForSelector(selector, inspector);
+  let container = yield focusNode(selector, inspector);
   ok(container, "The container for '" + selector + "' was found");
 
   info("Listening for the markupmutation event");
   let nodeMutated = inspector.once("markupmutation");
   setEditableFieldValue(container.editor.newAttr, text, inspector);
   yield nodeMutated;
 });
 
--- a/devtools/client/inspector/markup/test/helper_attributes_test_runner.js
+++ b/devtools/client/inspector/markup/test/helper_attributes_test_runner.js
@@ -130,17 +130,17 @@ function* runEditAttributesTest(test, in
   info("Selecting the test node " + test.node);
   yield selectNode(test.node, inspector);
 
   info("Asserting that the node has the right attributes to start with");
   yield assertAttributes(test.node, test.originalAttributes, testActor);
 
   info("Editing attribute " + test.name + " with value " + test.value);
 
-  let container = yield getContainerForSelector(test.node, inspector);
+  let container = yield focusNode(test.node, inspector);
   ok(container && container.editor, "The markup-container for " + test.node +
     " was found");
 
   info("Listening for the markupmutation event");
   let nodeMutated = inspector.once("markupmutation");
   let attr = container.editor.attrElements.get(test.name)
                                           .querySelector(".editable");
   setEditableFieldValue(attr, test.value, inspector);
--- a/devtools/client/inspector/markup/test/helper_style_attr_test_runner.js
+++ b/devtools/client/inspector/markup/test/helper_style_attr_test_runner.js
@@ -26,18 +26,17 @@
  *        Array of arrays representing the characters to type for the new
  *        attribute as well as the expected state at each step
  */
 function* runStyleAttributeAutocompleteTests(inspector, testData) {
   info("Expand all markup nodes");
   yield inspector.markup.expandAll();
 
   info("Select #node14");
-  let nodeFront = yield getNodeFront("#node14", inspector);
-  let container = getContainerForNodeFront(nodeFront, inspector);
+  let container = yield focusNode("#node14", inspector);
 
   info("Focus and open the new attribute inplace-editor");
   let attr = container.editor.newAttr;
   attr.focus();
   EventUtils.sendKey("return", inspector.panelWin);
   let editor = inplaceEditor(attr);
 
   for (let i = 0; i < testData.length; i++) {
--- a/devtools/client/inspector/test/browser_inspector_addNode_03.js
+++ b/devtools/client/inspector/test/browser_inspector_addNode_03.js
@@ -1,18 +1,19 @@
 /* vim: set 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 adding nodes does work as expected: the parent gets expanded, the
-// new node gets selected and the corresponding markup-container focused.
+// new node gets selected.
 
 const TEST_URL = URL_ROOT + "doc_inspector_add_node.html";
+const PARENT_TREE_LEVEL = 3;
 
 add_task(function* () {
   let {inspector} = yield openInspectorForURL(TEST_URL);
 
   info("Adding in element that has no children and is collapsed");
   let parentNode = yield getNodeFront("#foo", inspector);
   yield selectNode(parentNode, inspector);
   yield testAddNode(parentNode, inspector);
@@ -33,16 +34,20 @@ add_task(function* () {
   parentNode = yield getNodeFront("#bar", inspector);
   yield selectNode(parentNode, inspector);
   yield testAddNode(parentNode, inspector);
 });
 
 function* testAddNode(parentNode, inspector) {
   let btn = inspector.panelDoc.querySelector("#inspector-element-add-button");
   let markupWindow = inspector.markup.win;
+  let parentContainer = inspector.markup.getContainer(parentNode);
+
+  is(parentContainer.tagLine.getAttribute("aria-level"), PARENT_TREE_LEVEL,
+    "Parent level should be up to date.");
 
   info("Clicking 'add node' and expecting a markup mutation and focus event");
   let onMutation = inspector.once("markupmutation");
   btn.click();
   let mutations = yield onMutation;
 
   info("Expecting an inspector-updated event right after the mutation event " +
        "to wait for the new node selection");
@@ -51,26 +56,29 @@ function* testAddNode(parentNode, inspec
   is(mutations.length, 1, "There is one mutation only");
   is(mutations[0].added.length, 1, "There is one new node only");
 
   let newNode = mutations[0].added[0];
 
   is(newNode, inspector.selection.nodeFront,
      "The new node is selected");
 
-  ok(inspector.markup.getContainer(parentNode).expanded,
-     "The parent node is now expanded");
+  ok(parentContainer.expanded, "The parent node is now expanded");
 
   is(inspector.selection.nodeFront.parentNode(), parentNode,
      "The new node is inside the right parent");
 
   let focusedElement = markupWindow.document.activeElement;
-  let focusedContainer = focusedElement.closest(".child").container;
-  is(focusedContainer.node, inspector.selection.nodeFront,
-     "The right container is focused in the markup-view");
-  ok(focusedElement.classList.contains("tag"),
-     "The tagName part of the container is focused");
+  let focusedContainer = focusedElement.container;
+  let selectedContainer = inspector.markup._selectedContainer;
+  is(selectedContainer.tagLine.getAttribute("aria-level"),
+    PARENT_TREE_LEVEL + 1, "Added container level should be up to date.");
+  is(selectedContainer.node, inspector.selection.nodeFront,
+     "The right container is selected in the markup-view");
+  ok(selectedContainer.selected, "Selected container is set to selected");
+  is(focusedContainer.toString(), "[root container]",
+    "Root container is focused");
 }
 
 function collapseNode(node, inspector) {
   let container = inspector.markup.getContainer(node);
   container.setExpanded(false);
 }
--- a/devtools/client/inspector/test/head.js
+++ b/devtools/client/inspector/test/head.js
@@ -135,16 +135,32 @@ var selectNode = Task.async(function* (s
   info("Selecting the node for '" + selector + "'");
   let nodeFront = yield getNodeFront(selector, inspector);
   let updated = inspector.once("inspector-updated");
   inspector.selection.setNodeFront(nodeFront, reason);
   yield updated;
 });
 
 /**
+ * Select node for a given selector, make it focusable and set focus in its
+ * container element.
+ * @param {String|NodeFront} selector
+ * @param {InspectorPanel} inspector The current inspector-panel instance.
+ * @return {MarkupContainer}
+ */
+function* focusNode(selector, inspector) {
+  getContainerForNodeFront(inspector.walker.rootNode, inspector).elt.focus();
+  let nodeFront = yield getNodeFront(selector, inspector);
+  let container = getContainerForNodeFront(nodeFront, inspector);
+  yield selectNode(nodeFront, inspector);
+  EventUtils.sendKey("return", inspector.panelWin);
+  return container;
+}
+
+/**
  * Set the inspector's current selection to null so that no node is selected
  *
  * @param {InspectorPanel} inspector
  *        The instance of InspectorPanel currently loaded in the toolbox
  * @return a promise that resolves when the inspector is updated
  */
 function clearCurrentNodeSelection(inspector) {
   info("Clearing the current selection");
--- a/devtools/client/shared/inplace-editor.js
+++ b/devtools/client/shared/inplace-editor.js
@@ -186,16 +186,19 @@ function editableItem(options, callback)
 
   // Mark the element editable field for tab
   // navigation while editing.
   element._editable = true;
 
   // Save the trigger type so we can dispatch this later
   element._trigger = trigger;
 
+  // Add button semantics to the element, to indicate that it can be activated.
+  element.setAttribute("role", "button");
+
   return function turnOnEditMode() {
     callback(element);
   };
 }
 
 exports.editableItem = editableItem;
 
 /*
--- a/devtools/client/shared/test/browser_inplace-editor-01.js
+++ b/devtools/client/shared/test/browser_inplace-editor-01.js
@@ -146,16 +146,18 @@ function onDone(value, isCommit, def) {
 }
 
 function createInplaceEditorAndClick(options, doc) {
   doc.body.innerHTML = "";
   let span = options.element = createSpan(doc);
 
   info("Creating an inplace-editor field");
   editableField(options);
+  is(span.getAttribute("role"), "button",
+    "Editable element should have button semantics");
 
   info("Clicking on the inplace-editor field to turn to edit mode");
   span.click();
 }
 
 function createSpan(doc) {
   info("Creating a new span element");
   let span = doc.createElement("span");
--- a/devtools/client/themes/markup.css
+++ b/devtools/client/themes/markup.css
@@ -168,17 +168,17 @@ ul.children + .tag-line::before {
   display: inline-block;
   margin-left: -14px;
   vertical-align: middle;
   /* Make sure the expander still appears above the tag-state */
   position: relative;
   z-index: 1;
 }
 
-.child.collapsed .child {
+.child.collapsed .child, .child.collapsed .children {
   display: none;
 }
 
 .child > .tag-line:first-child .close {
   display: none;
 }
 
 .child.collapsed > .tag-line:first-child .close {