Bug 977244 - [a11y] Style inspector [rule view] needs some a11y love. r?yzen draft
authorNancy Pang <npang@mozilla.com>
Thu, 26 Jan 2017 13:13:51 -0500
changeset 466878 0702519df23444294ac2de22758e31b01017bd90
parent 466877 d1ea331c6ec7d2a11520ccdf3aca1539a14b5c9b
child 543544 f1e661cad2b0a695ab3d2e211de22ec38dff9d97
push id43026
push userbmo:npang@mozilla.com
push dateThu, 26 Jan 2017 19:17:39 +0000
reviewersyzen
bugs977244
milestone54.0a1
Bug 977244 - [a11y] Style inspector [rule view] needs some a11y love. r?yzen MozReview-Commit-ID: 7NVleGEsCQ5
devtools/client/inspector/inspector.xhtml
devtools/client/inspector/rules/rules.js
devtools/client/inspector/rules/views/rule-editor.js
devtools/client/inspector/rules/views/text-property-editor.js
devtools/client/themes/rules.css
--- a/devtools/client/inspector/inspector.xhtml
+++ b/devtools/client/inspector/inspector.xhtml
@@ -102,17 +102,17 @@
           <div id="pseudo-class-panel" hidden="true">
             <label><input id="pseudo-hover-toggle" type="checkbox" value=":hover" tabindex="-1" />:hover</label>
             <label><input id="pseudo-active-toggle" type="checkbox" value=":active" tabindex="-1" />:active</label>
             <label><input id="pseudo-focus-toggle" type="checkbox" value=":focus" tabindex="-1" />:focus</label>
         </div>
         </div>
 
         <div id="ruleview-container" class="ruleview">
-          <div id="ruleview-container-focusable" tabindex="-1">
+          <div id="ruleview-container-focusable" tabindex="0">
           </div>
         </div>
       </div>
 
       <div id="sidebar-panel-computedview" class="devtools-monospace theme-sidebar inspector-tabpanel"
                 data-localization-bundle="devtools/client/locales/inspector.properties">
         <div id="computedview-toolbar" class="devtools-toolbar">
           <div class="devtools-searchbox has-clear-btn">
--- a/devtools/client/inspector/rules/rules.js
+++ b/devtools/client/inspector/rules/rules.js
@@ -28,16 +28,17 @@ const {
 } = require("devtools/client/inspector/shared/node-types");
 const StyleInspectorMenu = require("devtools/client/inspector/shared/style-inspector-menu");
 const TooltipsOverlay = require("devtools/client/inspector/shared/tooltips-overlay");
 const {createChild, promiseWarn, throttle} = require("devtools/client/inspector/shared/utils");
 const EventEmitter = require("devtools/shared/event-emitter");
 const KeyShortcuts = require("devtools/client/shared/key-shortcuts");
 const clipboardHelper = require("devtools/shared/platform/clipboard");
 const AutocompletePopup = require("devtools/client/shared/autocomplete-popup");
+const {KeyCodes} = require("devtools/client/shared/keycodes");
 
 const HTML_NS = "http://www.w3.org/1999/xhtml";
 const PREF_UA_STYLES = "devtools.inspector.showUserAgentStyles";
 const PREF_DEFAULT_COLOR_UNIT = "devtools.defaultColorUnit";
 const PREF_ENABLE_MDN_DOCS_TOOLTIP =
       "devtools.inspector.mdnDocsTooltip.enabled";
 const FILTER_CHANGED_TIMEOUT = 150;
 const PREF_ORIG_SOURCES = "devtools.styleeditor.source-maps-enabled";
@@ -1517,20 +1518,304 @@ function RuleViewTool(inspector, window)
   this.inspector.selection.on("new-node-front", this.onSelected);
   this.inspector.selection.on("pseudoclass", this.refresh);
   this.inspector.target.on("navigate", this.clearUserProperties);
   this.inspector.sidebar.on("ruleview-selected", this.onPanelSelected);
   this.inspector.pageStyle.on("stylesheet-updated", this.refresh);
   this.inspector.walker.on("mutations", this.onMutations);
   this.inspector.walker.on("resize", this.onResized);
 
+  // Accessibility stuff
+  this.onKeyDown = this.onKeyDown.bind(this);
+  this.onClick = this.onClick.bind(this);
+  this.setAriaActive = this.setAriaActive.bind(this);
+  this.getGroupItems = this.getGroupItems.bind(this);
+  this.getGroupSelectors = this.getGroupSelectors.bind(this);
+  this.getCurrentGroup = this.getCurrentGroup.bind(this);
+  this.makeFocusable = this.makeFocusable.bind(this);
+  this.makeUnfocasable = this.makeUnfocasable.bind(this);
+  this.moveFocus = this.moveFocus.bind(this);
+  this.onFocus = this.onFocus.bind(this);
+
+  this.rulePanel = this.document.getElementById("ruleview-container-focusable");
+  this.rulePanel.setAttribute("role", "tree");
+  this.rulePanel.addEventListener("focus", this.onFocus, true);
+  this.rulePanel.addEventListener("keydown", this.onKeyDown, true);
+  this.rulePanel.addEventListener("click", this.onClick, true);
+  this.sections = [];
+  this.currentGroup = null;
+  this.currentItems = [];
+  // Accessibility stuff
+
   this.onSelected();
 }
 
 RuleViewTool.prototype = {
+
+  /**
+   * Update aria-active section on mouse click.
+   *
+   * @param {Event} event
+   *         The event triggered by a mouse click on the rule panel
+   */
+  onClick: function (event) {
+    let {target} = event;
+    let nextSection = target.offsetParent;
+    this.setAriaActive(nextSection);
+    this.makeUnfocasable();
+    this.makeFocusable(nextSection);
+  },
+
+  /**
+   * Handle keyboard navigation and focus for rule panel sections.
+   *
+   * Updates active section on arrow key navigation
+   * Focuses next section's edit boxes on ENTER key
+   * Unfocuses current section's edit boxes on ESC key
+   * Unfocuses current section's edit boxes when active layout changes
+   * Controls tabbing between editBoxes
+   *
+   * @param {Event} event
+   *         The event triggered by a keypress on the rule panel
+   */
+  onKeyDown: function (event) {
+    let {keyCode, target} = event;
+    let isEditable = target._editable || target.editor;
+    let editingMode = (target.tagName === "input") || (target.type === "textarea");
+    let popUp = editingMode ? target.getAttribute("aria-activedescendant") : false;
+    let nextSection;
+
+    switch (keyCode) {
+      case KeyCodes.DOM_VK_RETURN:
+        if (!isEditable && !this.currentItems.includes(target)) {
+          this.makeFocusable();
+        }
+        break;
+      case KeyCodes.DOM_VK_TAB:
+        if (popUp) {
+          this.document.getElementById(popUp).click();
+        }
+        if (isEditable || editingMode) {
+          let item = editingMode ? target.nextSibling : target;
+          event.preventDefault();
+          this.moveFocus(event, item);
+        }
+        break;
+      case KeyCodes.DOM_VK_DOWN:
+      case KeyCodes.DOM_VK_UP:
+        if (!editingMode) {
+          event.preventDefault();
+          this.makeUnfocasable();
+          nextSection = (keyCode === KeyCodes.DOM_VK_UP)
+            ? this.getNextSection(true)
+            : this.getNextSection(false);
+          this.rulePanel.focus();
+        }
+        break;
+      case KeyCodes.DOM_VK_ESCAPE:
+        if (this.currentItems.includes(target)) {
+          event.preventDefault();
+          event.stopPropagation();
+          this.makeUnfocasable();
+          this.rulePanel.focus();
+        }
+        break;
+      default:
+        break;
+    }
+
+    if (nextSection) {
+      this.setAriaActive(nextSection);
+    }
+  },
+
+  /**
+   * Helper function to determine current active group
+   *
+   * @return {DOMNode}
+   *         Node of current group section
+   */
+  getCurrentGroup: function () {
+    let itemIndex = this.rulePanel.getAttribute("aria-activedescendant")
+      .replace(/^\D+/g, "");
+    return this.sections[itemIndex];
+  },
+
+  /**
+   * Helper function to determine next section relative to current section.
+   *
+   * @param  Boolean back
+   *         Whether user pressed SHIFT + TAB
+   * @return {DOMNode}
+   *         Node of next section
+   */
+  getNextSection: function (back) {
+    let itemGroup = this.getCurrentGroup();
+    let prevIndex = this.sections.indexOf(itemGroup);
+    let index = prevIndex;
+    back ? index-- : index++;
+    if (index >= 0 || index < this.sections.length) {
+      return this.sections[index];
+    }
+    return this.sections[prevIndex];
+  },
+
+  /**
+   * Active aria-level set to current section.
+   *
+   * @param {DOMNode} nextItem
+   *        Element of next section that user has navigated to
+   */
+  setAriaActive: function (nextItem) {
+    // Remove current active descendant
+    let currentActive = this.document.querySelector(".layout-active-elm");
+    if (currentActive) {
+      currentActive.classList.remove("layout-active-elm");
+    }
+
+    // Set new active descendant
+    this.rulePanel.setAttribute("aria-activedescendant", nextItem.getAttribute("role"));
+    nextItem.classList.add("layout-active-elm");
+  },
+
+  /**
+   * Helper function for onFocus to get intial rule sections.
+   *
+   * @param  {DOMNode} element
+   *         Node of current element
+   * @return {Array}
+   *         Rule panel sections
+   */
+  getGroupItems: function (element) {
+    return [...element.querySelectorAll(".ruleview-rule")];
+  },
+
+  /**
+   * Helper function for makeFocusable to get focusable items for section
+   * filtering out containers and hidden elements.
+   *
+   * @return {Array}
+   *         Focusable items for section
+   */
+  getGroupSelectors: function () {
+    let itemGroup = this.getCurrentGroup();
+    let treeItems = [];
+    let selectors = [".ruleview-selectorcontainer", ".ruleview-selectorhighlighter",
+                     ".ruleview-ruleopen", ".theme-link"];
+    selectors.forEach(selector => treeItems.push(itemGroup.querySelector(selector)));
+    treeItems.forEach(element => element.setAttribute("role", "treeitem"));
+    this.currentItems = [];
+    this.currentItems = treeItems;
+    [...itemGroup.querySelectorAll("li")].forEach(listItem => {
+      listItem.setAttribute("role", "treeitem");
+      this.handlePropertyFocus(listItem);
+    });
+    return this.currentItems;
+  },
+
+  /**
+   * Helper function for getGroupSelectors to get focusable items for property
+   * filtering out containers and hidden elements.
+   *
+   * @param  {DOMNode} listitem
+   *         Node of current section
+   * @return {Array}
+   *         Focusable items for property
+   */
+  handlePropertyFocus: function (listitem) {
+    let propertyItems = [];
+    let selectors = [".ruleview-enableproperty", ".ruleview-expander",
+                     ".ruleview-propertyname", ".ruleview-propertyvalue"];
+    selectors.forEach(selector => propertyItems.push(listitem.querySelector(selector)));
+    propertyItems[0].style.visibility = "visible";
+    propertyItems.forEach(item => this.currentItems.push(item));
+  },
+
+  /**
+   * Make current section's elements focusable.
+   */
+  makeFocusable: function () {
+    let items = this.getGroupSelectors();
+    items.forEach(item => {
+      item.setAttribute("tabindex", "0");
+    });
+    items[0].focus();
+  },
+
+  /**
+   * Make section's elements unfocusable.
+   */
+  makeUnfocasable: function () {
+    let items = this.currentItems;
+    items.forEach(item => item.setAttribute("tabindex", "-1"));
+  },
+
+  /**
+   * Keyboard navigation of edit boxes wraps around on edge elements.
+   *
+   * @param {Node} target
+   *        Node to be observed
+   * @param {Boolean} shiftKey
+   *        Determines if shiftKey was pressed
+   * @param {String} curItem
+   *        Next focusable edit box within section
+   */
+  moveFocus: function ({target, shiftKey}, curItem) {
+    let items = this.currentItems;
+    items.forEach(element => {
+      if (element.className.includes("ruleview-enableproperty")) {
+        element.style.visibility = "visible";
+      }
+    });
+    let position = items.indexOf(curItem);
+    let editingMode = (target.tagName === "input") || (target.type === "textarea");
+
+    let direction;
+    if (position === items.length - 1 && !shiftKey) {
+      position = 0;
+    } else if (position === 0 && shiftKey) {
+      position = items.length - 1;
+    } else {
+      shiftKey ? position-- : position++;
+      direction = shiftKey ? -1 : 1;
+    }
+
+    while (items[position].hasAttribute("hidden")
+      || items[position].style.visibility === "hidden") {
+      position = (position + direction) % items.length;
+    }
+    let item = items[position];
+    item.focus();
+
+    let nonClick = ["ruleview-enableproperty theme-checkbox", "ruleview-rule-source-label"];
+    if (editingMode && !nonClick.includes(item.className)) {
+      item.click();
+    }
+  },
+
+  /**
+   * Set initial rule panel focus to the first focusable section.
+   */
+  onFocus: function () {
+    let activeDescendant = this.rulePanel.getAttribute("aria-activedescendant");
+    let section = this.rulePanel;
+
+    if (!activeDescendant) {
+      let items = this.getGroupItems(section);
+      let nextItem = items[0];
+       // Create id's, set tab indexes and
+      items.forEach((item, i) => {
+        let name = "group" + i;
+        item.setAttribute("role", name);
+        this.sections.push(item);
+      });
+      this.setAriaActive(nextItem);
+    }
+  },
+
   isSidebarActive: function () {
     if (!this.view) {
       return false;
     }
     return this.inspector.sidebar.getCurrentTabID() == "ruleview";
   },
 
   onSelected: function (event) {
--- a/devtools/client/inspector/rules/views/rule-editor.js
+++ b/devtools/client/inspector/rules/views/rule-editor.js
@@ -126,17 +126,17 @@ RuleEditor.prototype = {
     let code = createChild(this.element, "div", {
       class: "ruleview-code"
     });
 
     let header = createChild(code, "div", {});
 
     this.selectorText = createChild(header, "span", {
       class: "ruleview-selectorcontainer theme-fg-color3",
-      tabindex: this.isSelectorEditable ? "0" : "-1",
+      tabindex: "-1",
     });
 
     if (this.isSelectorEditable) {
       this.selectorText.addEventListener("click", event => {
         // Clicks within the selector shouldn't propagate any further.
         event.stopPropagation();
       });
 
@@ -161,28 +161,29 @@ RuleEditor.prototype = {
       });
       selectorHighlighter.addEventListener("click", () => {
         this.ruleView.toggleSelectorHighlighter(selectorHighlighter, selector);
       });
     }
 
     this.openBrace = createChild(header, "span", {
       class: "ruleview-ruleopen",
+      title: "open brace",
       textContent: " {"
     });
 
     this.propertyList = createChild(code, "ul", {
       class: "ruleview-propertylist"
     });
 
     this.populate();
 
     this.closeBrace = createChild(code, "div", {
       class: "ruleview-ruleclose",
-      tabindex: this.isEditable ? "0" : "-1",
+      tabindex: "-1",
       textContent: "}"
     });
 
     if (this.isEditable) {
       // A newProperty editor should only be created when no editor was
       // previously displayed. Since the editors are cleared on blur,
       // check this.ruleview.isEditing on mousedown
       this._ruleViewIsEditing = false;
--- a/devtools/client/inspector/rules/views/text-property-editor.js
+++ b/devtools/client/inspector/rules/views/text-property-editor.js
@@ -117,43 +117,44 @@ TextPropertyEditor.prototype = {
       tabindex: "-1"
     });
 
     // Click to expand the computed properties of the text property.
     this.expander = createChild(this.container, "span", {
       class: "ruleview-expander theme-twisty"
     });
     this.expander.addEventListener("click", this._onExpandClicked, true);
+    this.expander.addEventListener("keydown", e => this._onKeyDown(e), true);
 
     this.nameContainer = createChild(this.container, "span", {
       class: "ruleview-namecontainer"
     });
 
     // Property name, editable when focused.  Property name
     // is committed when the editor is unfocused.
     this.nameSpan = createChild(this.nameContainer, "span", {
       class: "ruleview-propertyname theme-fg-color5",
-      tabindex: this.ruleEditor.isEditable ? "0" : "-1",
+      tabindex: "-1",
     });
 
     appendText(this.nameContainer, ": ");
 
     // Create a span that will hold the property and semicolon.
     // Use this span to create a slightly larger click target
     // for the value.
     this.valueContainer = createChild(this.container, "span", {
       class: "ruleview-propertyvaluecontainer"
     });
 
     // Property value, editable when focused.  Changes to the
     // property value are applied as they are typed, and reverted
     // if the user presses escape.
     this.valueSpan = createChild(this.valueContainer, "span", {
       class: "ruleview-propertyvalue theme-fg-color1",
-      tabindex: this.ruleEditor.isEditable ? "0" : "-1",
+      tabindex: "-1",
     });
 
     // Storing the TextProperty on the elements for easy access
     // (for instance by the tooltip)
     this.valueSpan.textProperty = this.prop;
     this.nameSpan.textProperty = this.prop;
 
     // If the value is a color property we need to put it through the parser
@@ -195,16 +196,21 @@ TextPropertyEditor.prototype = {
     // will be populated in |_updateComputed|.
     this.computed = createChild(this.element, "ul", {
       class: "ruleview-computedlist",
     });
 
     // Only bind event handlers if the rule is editable.
     if (this.ruleEditor.isEditable) {
       this.enable.addEventListener("click", this._onEnableClicked, true);
+      this.enable.addEventListener("keydown", (e) => {
+        if (e.key === "Enter") {
+          this._onEnableClicked(e);
+        }
+      });
 
       this.nameContainer.addEventListener("click", (event) => {
         // Clicks within the name shouldn't propagate any further.
         event.stopPropagation();
 
         // Forward clicks on nameContainer to the editable nameSpan
         if (event.target === this.nameContainer) {
           this.nameSpan.click();
@@ -575,16 +581,23 @@ TextPropertyEditor.prototype = {
       this.enable.removeAttribute("checked");
     } else {
       this.enable.setAttribute("checked", "");
     }
     this.prop.setEnabled(!checked);
     event.stopPropagation();
   },
 
+  _onKeyDown: function (e) {
+    let {key} = e;
+    if (key === "Enter") {
+      this._onExpandClicked(e);
+    }
+  },
+
   /**
    * Handles clicks on the computed property expander. If the computed list is
    * open due to user expanding or style filtering, collapse the computed list
    * and close the expander. Otherwise, add user-open attribute which is used to
    * expand the computed list and tracks whether or not the computed list is
    * expanded by manually by the user.
    */
   _onExpandClicked: function (event) {
--- a/devtools/client/themes/rules.css
+++ b/devtools/client/themes/rules.css
@@ -79,17 +79,16 @@
 
 /* This extra wrapper only serves as a way to get the content of the view focusable.
    So that when the user reaches it either via keyboard or mouse, we know that the view
    is focused and therefore can handle shortcuts.
    However, for accessibility reasons, tabindex is set to -1 to avoid having to tab
    through it, and the outline is hidden. */
 #ruleview-container-focusable {
   height: 100%;
-  outline: none;
 }
 
 #ruleview-container.non-interactive {
   pointer-events: none;
   visibility: collapse;
   transition: visibility 0.25s;
 }