Bug 1243045 - Added navigation for padding, border and margin. r=yzen
MozReview-Commit-ID: 75bANHjA9Vg
--- 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.");
+ });
+}