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 467559 6769588e8db0fd42ab281b52aa43ccc7bf810ca0
parent 466877 d1ea331c6ec7d2a11520ccdf3aca1539a14b5c9b
child 543726 f3c55acd470f19151e1ae401a0f83f5c7951f65b
push id43221
push userbmo:npang@mozilla.com
push dateSat, 28 Jan 2017 01:22:49 +0000
reviewersyzen
bugs977244
milestone54.0a1
Bug 977244 - [a11y] Style inspector [rule view] needs some a11y love. r?yzen MozReview-Commit-ID: 7NVleGEsCQ5 Refactored MozReview-Commit-ID: 8OO8RaOVJKU
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,287 @@ 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.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, shiftKey} = event;
+    let editor = this.getCurrentGroup()._ruleEditor;
+    let editingMode = target.tagName === "textarea" || target.tagName === "input";
+    let nextSection;
+
+    // Check for checkmark or expander
+    let item = editingMode ? target.nextSibling : target;
+    let position = this.currentItems.indexOf(item);
+
+    switch (keyCode) {
+      case KeyCodes.DOM_VK_RETURN:
+        if (target == this.rulePanel) {
+          this.makeFocusable();
+        }
+        break;
+      case KeyCodes.DOM_VK_TAB:
+        if (target != this.rulePanel) {
+          event.preventDefault();
+
+          // Check if popup is open
+          let popup = editor.ruleView.popup;
+          if (popup.isOpen) {
+            let selection = target.getAttribute("aria-activedescendant");
+            this.document.getElementById(selection).click();
+          }
+
+          // Check if new property
+          if (target.parentNode == editor.newPropItem) {
+            shiftKey
+              ? this.currentItems[this.currentItems.length - 1].focus()
+              : this.currentItems[0].focus();
+            return;
+          }
+
+          let ctx = {position: position,
+                     items: this.currentItems,
+                     shiftKey: shiftKey,
+                     editingMode: editingMode
+          };
+          editor._moveFocus(ctx);
+        }
+        break;
+      case KeyCodes.DOM_VK_DOWN:
+      case KeyCodes.DOM_VK_UP:
+        debugger;
+        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 (!editingMode && (target != this.rulePanel)) {
+          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 section = itemGroup._ruleEditor;
+    treeItems.push(section.selectorText);
+    treeItems.push(section.selectorHighlighter);
+    treeItems.push(section.openBrace);
+    treeItems.push(section.source);
+    treeItems.forEach(element => element.setAttribute("role", "treeitem"));
+    this.currentItems = [];
+    this.currentItems = treeItems;
+    [...section.propertyList.children].forEach(listItem => {
+      listItem.setAttribute("role", "treeitem");
+      this.handlePropertyFocus(listItem);
+    });
+    section.closeBrace.setAttribute("role", "treeitem");
+    treeItems.push(section.closeBrace);
+    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 prop = listitem._textPropertyEditor;
+    propertyItems.push(prop.enable);
+    propertyItems.push(prop.expander);
+    propertyItems.push(prop.nameSpan);
+    propertyItems.push(prop.valueSpan);
+    prop.enable.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"));
+  },
+
+  /**
+   * 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
@@ -112,77 +112,99 @@ RuleEditor.prototype = {
     });
     this.source.addEventListener("click", function () {
       if (this.source.hasAttribute("unselectable")) {
         return;
       }
       let rule = this.rule.domRule;
       this.ruleView.emit("ruleview-linked-clicked", rule);
     }.bind(this));
+    this.source.addEventListener("keydown", function (e) {
+      if (e.key === "Enter") {
+        e.preventDefault();
+        e.stopPropagation();
+        this.source.click();
+      }
+    }.bind(this));
     let sourceLabel = this.doc.createElement("span");
     sourceLabel.classList.add("ruleview-rule-source-label");
     this.source.appendChild(sourceLabel);
 
     this.updateSourceLink();
 
     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();
       });
+      this.selectorText.addEventListener("keydown", function (event) {
+        if (event.key === "Enter") {
+          event.preventDefault();
+          event.stopPropagation();
+          this.selectorText.click();
+        }
+      }.bind(this));
 
       editableField({
         element: this.selectorText,
         done: this._onSelectorDone,
         cssProperties: this.rule.cssProperties,
         contextMenu: this.ruleView.inspector.onTextBoxContextMenu
       });
     }
 
     if (this.rule.domRule.type !== CSSRule.KEYFRAME_RULE) {
       let selector = this.rule.domRule.selectors
                ? this.rule.domRule.selectors.join(", ")
                : this.ruleView.inspector.selectionCssSelector;
 
-      let selectorHighlighter = createChild(header, "span", {
+      this.selectorHighlighter = createChild(header, "span", {
         class: "ruleview-selectorhighlighter" +
                (this.ruleView.highlighters.selectorHighlighterShown === selector ?
                 " highlighted" : ""),
         title: l10n("rule.selectorHighlighter.tooltip")
       });
-      selectorHighlighter.addEventListener("click", () => {
-        this.ruleView.toggleSelectorHighlighter(selectorHighlighter, selector);
+      this.selectorHighlighter.addEventListener("click", () => {
+        this.ruleView.toggleSelectorHighlighter(this.selectorHighlighter, selector);
       });
+      this.selectorHighlighter.addEventListener("keydown", function (e) {
+        if (e.key === "Enter") {
+          e.preventDefault();
+          e.stopPropagation();
+          this.ruleView.toggleSelectorHighlighter(this.selectorHighlighter, selector);
+        }
+      }.bind(this));
     }
 
     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;
@@ -600,21 +622,55 @@ RuleEditor.prototype = {
   /**
    * Handle moving the focus change after a tab or return keypress in the
    * selector inplace editor.
    *
    * @param {Number} direction
    *        The move focus direction number.
    */
   _moveSelectorFocus: function (direction) {
+    debugger;
     if (!direction || direction === Services.focus.MOVEFOCUS_BACKWARD) {
       return;
     }
 
     if (this.rule.textProps.length > 0) {
       this.rule.textProps[0].editor.nameSpan.click();
     } else {
       this.propertyList.click();
     }
+  },
+
+  _moveFocus: function (ctx) {
+    let {position, items, shiftKey, editingMode} = ctx;
+    items.forEach(element => {
+      if (element.className.includes("ruleview-enableproperty")) {
+        element.style.visibility = "visible";
+      }
+    });
+    let increment;
+    if (position === items.length - 1 && !shiftKey) {
+      this.propertyList.click();
+    } else if (position === 0 && shiftKey) {
+      position = items.length - 1;
+    } else {
+      shiftKey ? position-- : position++;
+      increment = shiftKey ? -1 : 1;
+    }
+
+    while (items[position].hasAttribute("hidden")
+      || items[position].style.visibility === "hidden") {
+      position = (position + increment) % items.length;
+    }
+    let item = items[position];
+    item.focus();
+
+    // Don't automatically activate these items
+    let notAuto = [this.selectorText, this.selectorHighlighter,
+                   this.openBrace, this.source];
+
+    if (editingMode && !notAuto.includes(item)) {
+      item.click();
+    }
   }
 };
 
 module.exports = RuleEditor;
--- a/devtools/client/inspector/rules/views/text-property-editor.js
+++ b/devtools/client/inspector/rules/views/text-property-editor.js
@@ -117,43 +117,51 @@ 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", function (e) {
+      if (e.key === "Enter") {
+        e.preventDefault();
+        e.stopPropagation();
+        this.expander.click();
+        this.expander.focus();
+      }
+    }.bind(this), 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
@@ -185,26 +193,42 @@ TextPropertyEditor.prototype = {
       hidden: "",
       title: l10n("rule.filterProperty.title"),
     });
 
     this.filterProperty.addEventListener("click", event => {
       this.ruleEditor.ruleView.setFilterStyles("`" + this.prop.name + "`");
       event.stopPropagation();
     });
+    this.filterProperty.addEventListener("keydown", function (event) {
+      if (event.key === "Enter") {
+        event.preventDefault();
+        event.stopPropagation();
+        this.filterProperty.click();
+        this.filterProperty.focus();
+      }
+    }.bind(this));
 
     // Holds the viewers for the computed properties.
     // 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", function (e) {
+        if (e.key === "Enter") {
+          e.preventDefault();
+          e.stopPropagation();
+          this.enable.click();
+          this.enable.focus();
+        }
+      }.bind(this));
 
       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();
--- 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;
 }