Bug 1243045 - Added navigation for padding, border and margin. r=yzen draft
authorNancy Pang <npang@mozilla.com>
Wed, 18 Jan 2017 10:03:49 -0500
changeset 463130 ea242851f709f49740deff5b400daadfc136f085
parent 463121 b3885db8150b4e0450717776f3a652ec2425503c
child 542583 98d412d15d1121ac19ab89056acfc6e85f23ea33
push id41964
push userbmo:npang@mozilla.com
push dateWed, 18 Jan 2017 15:04:10 +0000
reviewersyzen
bugs1243045
milestone53.0a1
Bug 1243045 - Added navigation for padding, border and margin. r=yzen MozReview-Commit-ID: 75bANHjA9Vg
devtools/client/inspector/components/box-model.js
devtools/client/inspector/components/test/browser.ini
devtools/client/inspector/components/test/browser_boxmodel_navigation.js
--- a/devtools/client/inspector/components/box-model.js
+++ b/devtools/client/inspector/components/box-model.js
@@ -7,16 +7,17 @@
 "use strict";
 
 const {Task} = require("devtools/shared/task");
 const {InplaceEditor, editableItem} =
       require("devtools/client/shared/inplace-editor");
 const {ReflowFront} = require("devtools/shared/fronts/reflow");
 const {LocalizationHelper} = require("devtools/shared/l10n");
 const {getCssProperties} = require("devtools/shared/fronts/css-properties");
+const {KeyCodes} = require("devtools/client/shared/keycodes");
 
 const STRINGS_URI = "devtools/client/locales/shared.properties";
 const STRINGS_INSPECTOR = "devtools/shared/locales/styleinspector.properties";
 const SHARED_L10N = new LocalizationHelper(STRINGS_URI);
 const INSPECTOR_L10N = new LocalizationHelper(STRINGS_INSPECTOR);
 const NUMERIC = /^-?[\d\.]+$/;
 const LONG_TEXT_ROTATE_LIMIT = 3;
 
@@ -224,16 +225,54 @@ BoxModelView.prototype = {
     this.onFilterComputedView = this.onFilterComputedView.bind(this);
     this.inspector.on("computed-view-filtered",
       this.onFilterComputedView);
 
     this.onPickerStarted = this.onPickerStarted.bind(this);
     this.onMarkupViewLeave = this.onMarkupViewLeave.bind(this);
     this.onMarkupViewNodeHover = this.onMarkupViewNodeHover.bind(this);
     this.onWillNavigate = this.onWillNavigate.bind(this);
+    this.onKeyDown = this.onKeyDown.bind(this);
+    this.onLevelClick = this.onLevelClick.bind(this);
+    this.setAriaActive = this.setAriaActive.bind(this);
+    this.getEditBoxes = this.getEditBoxes.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.borderLayout = this.doc.getElementById("boxmodel-borders");
+    this.boxModel = this.doc.getElementById("boxmodel-wrapper");
+    this.marginLayout = this.doc.getElementById("boxmodel-margins");
+    this.paddingLayout = this.doc.getElementById("boxmodel-padding");
+
+    this.layouts = {
+      "margin": new Map([
+        [KeyCodes.DOM_VK_ESCAPE, this.marginLayout],
+        [KeyCodes.DOM_VK_DOWN, this.borderLayout],
+        [KeyCodes.DOM_VK_UP, null],
+        ["click", this.marginLayout]
+      ]),
+      "border": new Map([
+        [KeyCodes.DOM_VK_ESCAPE, this.borderLayout],
+        [KeyCodes.DOM_VK_DOWN, this.paddingLayout],
+        [KeyCodes.DOM_VK_UP, this.marginLayout],
+        ["click", this.borderLayout]
+      ]),
+      "padding": new Map([
+        [KeyCodes.DOM_VK_ESCAPE, this.paddingLayout],
+        [KeyCodes.DOM_VK_DOWN, null],
+        [KeyCodes.DOM_VK_UP, this.borderLayout],
+        ["click", this.paddingLayout]
+      ])
+    };
+
+    this.boxModel.addEventListener("click", this.onLevelClick, true);
+    this.boxModel.addEventListener("focus", this.onFocus, true);
+    this.boxModel.addEventListener("keydown", this.onKeyDown, true);
 
     this.initBoxModelHighlighter();
 
     // Store for the different dimensions of the node.
     // 'selector' refers to the element that holds the value;
     // 'property' is what we are measuring;
     // 'value' is the computed dimension, computed in update().
     this.map = {
@@ -449,16 +488,20 @@ BoxModelView.prototype = {
 
     this.expander.removeEventListener("click", this.onToggleExpander);
     let header = this.doc.getElementById("boxmodel-header");
     header.removeEventListener("dblclick", this.onToggleExpander);
 
     let nodeGeometry = this.doc.getElementById("layout-geometry-editor");
     nodeGeometry.removeEventListener("click", this.onGeometryButtonClick);
 
+    this.boxModel.removeEventListener("click", this.onLevelClick, true);
+    this.boxModel.removeEventListener("focus", this.onFocus, true);
+    this.boxModel.removeEventListener("keydown", this.onKeyDown, true);
+
     this.inspector.off("picker-started", this.onPickerStarted);
 
     // Inspector Panel will destroy `markup` object on "will-navigate" event,
     // therefore we have to check if it's still available in case BoxModelView
     // is destroyed immediately after.
     if (this.inspector.markup) {
       this.inspector.markup.off("leave", this.onMarkupViewLeave);
       this.inspector.markup.off("node-hover", this.onMarkupViewNodeHover);
@@ -473,23 +516,204 @@ BoxModelView.prototype = {
     this.inspector = null;
     this.doc = null;
     this.wrapper = null;
     this.container = null;
     this.expander = null;
     this.sizeLabel = null;
     this.sizeHeadingLabel = null;
 
+    this.marginLayout = null;
+    this.borderLayout = null;
+    this.paddingLayout = null;
+    this.boxModel = null;
+    this.layouts = null;
+
     if (this.reflowFront) {
       this.untrackReflows();
       this.reflowFront.destroy();
       this.reflowFront = null;
     }
   },
 
+  /**
+   * Set initial box model focus to the margin layout.
+   */
+  onFocus: function () {
+    let activeDescendant = this.boxModel.getAttribute("aria-activedescendant");
+
+    if (!activeDescendant) {
+      let nextLayout = this.marginLayout;
+      this.setAriaActive(nextLayout);
+    }
+  },
+
+  /**
+   * Active aria-level set to current layout.
+   *
+   * @param {Element} nextLayout
+   *        Element of next layout that user has navigated to
+   * @param {Node} target
+   *        Node to be observed
+   */
+  setAriaActive: function (nextLayout, target) {
+    this.boxModel.setAttribute("aria-activedescendant", nextLayout.id);
+    if (target && target._editable) {
+      target.blur();
+    }
+
+    // Clear all
+    this.marginLayout.classList.remove("layout-active-elm");
+    this.borderLayout.classList.remove("layout-active-elm");
+    this.paddingLayout.classList.remove("layout-active-elm");
+
+    // Set the next level's border outline
+    nextLayout.classList.add("layout-active-elm");
+  },
+
+  /**
+   * Update aria-active on mouse click.
+   *
+   * @param {Event} event
+   *         The event triggered by a mouse click on the box model
+   */
+  onLevelClick: function (event) {
+    let {target} = event;
+    let nextLayout = this.layouts[target.getAttribute("data-box")].get("click");
+
+    this.setAriaActive(nextLayout, target);
+  },
+
+  /**
+   * Handle keyboard navigation and focus for box model layouts.
+   *
+   * Updates active layout on arrow key navigation
+   * Focuses next layout's editboxes on enter key
+   * Unfocuses current layout's editboxes when active layout changes
+   * Controls tabbing between editBoxes
+   *
+   * @param {Event} event
+   *         The event triggered by a keypress on the box model
+   */
+  onKeyDown: function (event) {
+    let {target, keyCode} = event;
+    // If focused on editable value or in editing mode
+    let isEditable = target._editable || target.editor;
+    let level = this.boxModel.getAttribute("aria-activedescendant");
+    let editingMode = target.tagName === "input";
+    let nextLayout;
+
+    switch (keyCode) {
+      case KeyCodes.DOM_VK_RETURN:
+        if (!isEditable) {
+          this.makeFocusable(level);
+        }
+        break;
+      case KeyCodes.DOM_VK_DOWN:
+      case KeyCodes.DOM_VK_UP:
+        if (!editingMode) {
+          event.preventDefault();
+          this.makeUnfocasable(level);
+          let datalevel = this.doc.getElementById(level).getAttribute("data-box");
+          nextLayout = this.layouts[datalevel].get(keyCode);
+          this.boxModel.focus();
+        }
+        break;
+      case KeyCodes.DOM_VK_TAB:
+        if (isEditable) {
+          event.preventDefault();
+          this.moveFocus(event, level);
+        }
+        break;
+      case KeyCodes.DOM_VK_ESCAPE:
+        if (isEditable && target._editable) {
+          event.preventDefault();
+          event.stopPropagation();
+          this.makeUnfocasable(level);
+          this.boxModel.focus();
+        }
+        break;
+      default:
+        break;
+    }
+
+    if (nextLayout) {
+      this.setAriaActive(nextLayout, target);
+    }
+  },
+
+  /**
+   * Make previous layout's elements unfocusable.
+   *
+   * @param {String} editLevel
+   *        The previous layout
+   */
+  makeUnfocasable: function (editLevel) {
+    let editBoxes = this.getEditBoxes(editLevel);
+    editBoxes.forEach(editBox => editBox.setAttribute("tabindex", "-1"));
+  },
+
+  /**
+   * Make current layout's elements focusable.
+   *
+   * @param {String} editLevel
+   *        The current layout
+   */
+  makeFocusable: function (editLevel) {
+    let editBoxes = this.getEditBoxes(editLevel);
+    editBoxes.forEach(editBox => editBox.setAttribute("tabindex", "0"));
+    editBoxes[0].focus();
+  },
+
+  /**
+   * Keyboard navigation of edit boxes wraps around on edge
+   * elements ([layout]-top, [layout]-left).
+   *
+   * @param {Node} target
+   *        Node to be observed
+   * @param {Boolean} shiftKey
+   *        Determines if shiftKey was pressed
+   * @param {String} level
+   *        Current active layout
+   */
+  moveFocus: function ({target, shiftKey}, level) {
+    let editBoxes = this.getEditBoxes(level);
+    let editingMode = target.tagName === "input";
+    // target.nextSibling is input field
+    let position = editingMode ? editBoxes.indexOf(target.nextSibling)
+                               : editBoxes.indexOf(target);
+
+    if (position === editBoxes.length - 1 && !shiftKey) {
+      position = 0;
+    } else if (position === 0 && shiftKey) {
+      position = editBoxes.length - 1;
+    } else {
+      shiftKey ? position-- : position++;
+    }
+
+    let editBox = editBoxes[position];
+    editBox.focus();
+
+    if (editingMode) {
+      editBox.click();
+    }
+  },
+
+  /**
+   * Retrieve edit boxes for current layout.
+   *
+   * @param {String} editLevel
+   *        Current active layout
+   * @return Layout's edit boxes
+   */
+  getEditBoxes: function (editLevel) {
+    let dataLevel = this.doc.getElementById(editLevel).getAttribute("data-box");
+    return [...this.doc.querySelectorAll(`[data-box="${dataLevel}"].boxmodel-editable`)];
+  },
+
   onSidebarSelect: function (e, sidebar) {
     this.setActive(sidebar === "computedview");
   },
 
   /**
    * Selection 'new-node-front' event handler.
    */
   onNewSelection: function () {
--- a/devtools/client/inspector/components/test/browser.ini
+++ b/devtools/client/inspector/components/test/browser.ini
@@ -15,15 +15,16 @@ support-files =
 [browser_boxmodel.js]
 [browser_boxmodel_editablemodel.js]
 # [browser_boxmodel_editablemodel_allproperties.js]
 # Disabled for too many intermittent failures (bug 1009322)
 [browser_boxmodel_editablemodel_bluronclick.js]
 [browser_boxmodel_editablemodel_border.js]
 [browser_boxmodel_editablemodel_stylerules.js]
 [browser_boxmodel_guides.js]
+[browser_boxmodel_navigation.js]
 [browser_boxmodel_rotate-labels-on-sides.js]
 [browser_boxmodel_sync.js]
 [browser_boxmodel_tooltips.js]
 [browser_boxmodel_update-after-navigation.js]
 [browser_boxmodel_update-after-reload.js]
 # [browser_boxmodel_update-in-iframes.js]
 # Bug 1020038 boxmodel-view updates for iframe elements changes
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/components/test/browser_boxmodel_navigation.js
@@ -0,0 +1,103 @@
+/* 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";
+
+// Tests that keyboard and mouse navigation updates aria-active and focus
+// of elements.
+
+const TEST_URI = `
+  <style>
+  div { position: absolute; top: 42px; left: 42px;
+  height: 100.111px; width: 100px; border: 10px solid black;
+  padding: 20px; margin: 30px auto;}
+  </style><div></div>
+`;
+
+add_task(function* () {
+  yield addTab("data:text/html," + encodeURIComponent(TEST_URI));
+  let {inspector, view} = yield openBoxModelView();
+  yield selectNode("div", inspector);
+
+  yield testInitialFocus(inspector, view);
+  yield testChangingLevels(inspector, view);
+  yield testTabbingWrapAround(inspector, view);
+  yield testChangingLevelsByClicking(inspector, view);
+});
+
+function* testInitialFocus(inspector, view) {
+  info("Test that the focus is on margin layout.");
+  let viewdoc = view.doc;
+  let boxmodel = viewdoc.getElementById("boxmodel-wrapper");
+  boxmodel.focus();
+  EventUtils.synthesizeKey("VK_RETURN", {});
+
+  is(boxmodel.getAttribute("aria-activedescendant"), "boxmodel-margins",
+    "Should be set to the margin layout.");
+}
+
+function* testChangingLevels(inspector, view) {
+  info("Test that using arrow keys updates level.");
+  let viewdoc = view.doc;
+  let boxmodel = viewdoc.getElementById("boxmodel-wrapper");
+  boxmodel.focus();
+  EventUtils.synthesizeKey("VK_RETURN", {});
+  EventUtils.synthesizeKey("VK_ESCAPE", {});
+
+  EventUtils.synthesizeKey("VK_DOWN", {});
+  is(boxmodel.getAttribute("aria-activedescendant"), "boxmodel-borders",
+    "Should be set to the border layout.");
+
+  EventUtils.synthesizeKey("VK_DOWN", {});
+  is(boxmodel.getAttribute("aria-activedescendant"), "boxmodel-padding",
+    "Should be set to the padding layout.");
+
+  EventUtils.synthesizeKey("VK_UP", {});
+  is(boxmodel.getAttribute("aria-activedescendant"), "boxmodel-borders",
+    "Should be set to the border layout.");
+
+  EventUtils.synthesizeKey("VK_UP", {});
+  is(boxmodel.getAttribute("aria-activedescendant"), "boxmodel-margins",
+    "Should be set to the margin layout.");
+}
+
+function* testTabbingWrapAround(inspector, view) {
+  info("Test that using arrow keys updates level.");
+  let viewdoc = view.doc;
+  let boxmodel = viewdoc.getElementById("boxmodel-wrapper");
+  boxmodel.focus();
+  EventUtils.synthesizeKey("VK_RETURN", {});
+
+  let editLevel = boxmodel.getAttribute("aria-activedescendant");
+  let dataLevel = viewdoc.getElementById(editLevel).getAttribute("data-box");
+  let editBoxes = [...viewdoc.querySelectorAll(
+    `[data-box="${dataLevel}"].boxmodel-editable`)];
+
+  EventUtils.synthesizeKey("VK_ESCAPE", {});
+  editBoxes[3].focus();
+  EventUtils.synthesizeKey("VK_TAB", {});
+  is(editBoxes[0], viewdoc.activeElement, "Top edit box should have focus.");
+
+  editBoxes[0].focus();
+  EventUtils.synthesizeKey("VK_TAB", { shiftKey: true });
+  is(editBoxes[3], viewdoc.activeElement, "Left edit box should have focus.");
+}
+
+function* testChangingLevelsByClicking(inspector, view) {
+  info("Test that clicking on levels updates level.");
+  let viewdoc = view.doc;
+  let boxmodel = viewdoc.getElementById("boxmodel-wrapper");
+  boxmodel.focus();
+
+  let marginLayout = viewdoc.getElementById("boxmodel-margins");
+  let borderLayout = viewdoc.getElementById("boxmodel-borders");
+  let paddingLayout = viewdoc.getElementById("boxmodel-padding");
+  let layouts = [paddingLayout, borderLayout, marginLayout];
+
+  layouts.forEach(layout => {
+    layout.click();
+    is(boxmodel.getAttribute("aria-activedescendant"), layout.id,
+      "Should be set to" + layout.getAttribute("data-box") + "layout.");
+  });
+}