diff --git a/devtools/client/inspector/boxmodel/components/BoxModel.js b/devtools/client/inspector/boxmodel/components/BoxModel.js index e25c9fc3a8d4..5c723f595d61 100644 --- a/devtools/client/inspector/boxmodel/components/BoxModel.js +++ b/devtools/client/inspector/boxmodel/components/BoxModel.js @@ -30,6 +30,14 @@ module.exports = createClass({ mixins: [ addons.PureRenderMixin ], + onKeyDown(event) { + let { target } = event; + + if (target == this.boxModelContainer) { + this.boxModelMain.onKeyDown(event); + } + }, + render() { let { boxModel, @@ -45,10 +53,19 @@ module.exports = createClass({ return dom.div( { className: "boxmodel-container", + tabIndex: 0, + ref: div => { + this.boxModelContainer = div; + }, + onKeyDown: this.onKeyDown, }, BoxModelMain({ boxModel, + boxModelContainer: this.boxModelContainer, setSelectedNode, + ref: boxModelMain => { + this.boxModelMain = boxModelMain; + }, onHideBoxModelHighlighter, onShowBoxModelEditor, onShowBoxModelHighlighter, diff --git a/devtools/client/inspector/boxmodel/components/BoxModelEditable.js b/devtools/client/inspector/boxmodel/components/BoxModelEditable.js index 150e3cd3e075..a80c45862623 100644 --- a/devtools/client/inspector/boxmodel/components/BoxModelEditable.js +++ b/devtools/client/inspector/boxmodel/components/BoxModelEditable.js @@ -17,6 +17,8 @@ module.exports = createClass({ propTypes: { box: PropTypes.string.isRequired, direction: PropTypes.string, + focusable: PropTypes.bool.isRequired, + level: PropTypes.string.isRequired, property: PropTypes.string.isRequired, textContent: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, onShowBoxModelEditor: PropTypes.func.isRequired, @@ -28,7 +30,7 @@ module.exports = createClass({ let { property, onShowBoxModelEditor } = this.props; editableItem({ - element: this.refs.span, + element: this.boxModelEditable, }, (element, event) => { onShowBoxModelEditor(element, event, property); }); @@ -38,6 +40,8 @@ module.exports = createClass({ let { box, direction, + focusable, + level, property, textContent, } = this.props; @@ -57,8 +61,11 @@ module.exports = createClass({ { className: "boxmodel-editable", "data-box": box, + tabIndex: box === level && focusable ? 0 : -1, title: property, - ref: "span", + ref: span => { + this.boxModelEditable = span; + }, }, textContent ) diff --git a/devtools/client/inspector/boxmodel/components/BoxModelMain.js b/devtools/client/inspector/boxmodel/components/BoxModelMain.js index 6c64ead1c954..8644cd4b620e 100644 --- a/devtools/client/inspector/boxmodel/components/BoxModelMain.js +++ b/devtools/client/inspector/boxmodel/components/BoxModelMain.js @@ -6,10 +6,13 @@ const { addons, createClass, createFactory, DOM: dom, PropTypes } = require("devtools/client/shared/vendor/react"); +const { findDOMNode } = require("devtools/client/shared/vendor/react-dom"); +const { KeyCodes } = require("devtools/client/shared/keycodes"); const { LocalizationHelper } = require("devtools/shared/l10n"); const BoxModelEditable = createFactory(require("./BoxModelEditable")); + // Reps const { REPS, MODE } = require("devtools/client/shared/components/reps/reps"); const Rep = createFactory(REPS.Rep); @@ -28,6 +31,7 @@ module.exports = createClass({ propTypes: { boxModel: PropTypes.shape(Types.boxModel).isRequired, + boxModelContainer: PropTypes.object.isRequired, setSelectedNode: PropTypes.func.isRequired, onHideBoxModelHighlighter: PropTypes.func.isRequired, onShowBoxModelEditor: PropTypes.func.isRequired, @@ -37,11 +41,92 @@ module.exports = createClass({ mixins: [ addons.PureRenderMixin ], + getInitialState() { + return { + "activedescendant": null, + focusable: false, + }; + }, + + componentDidUpdate() { + let { layout } = this.props.boxModel; + let displayPosition = this.getDisplayPosition(); + let isContentBox = this.getContextBox(); + + this.layouts = { + "position": new Map([ + [KeyCodes.DOM_VK_ESCAPE, this.positionLayout], + [KeyCodes.DOM_VK_DOWN, this.marginLayout], + [KeyCodes.DOM_VK_RETURN, this.positionEditable], + [KeyCodes.DOM_VK_UP, null], + ["click", this.positionLayout] + ]), + "margin": new Map([ + [KeyCodes.DOM_VK_ESCAPE, this.marginLayout], + [KeyCodes.DOM_VK_DOWN, this.borderLayout], + [KeyCodes.DOM_VK_RETURN, this.marginEditable], + [KeyCodes.DOM_VK_UP, displayPosition ? this.positionLayout : null], + ["click", this.marginLayout] + ]), + "border": new Map([ + [KeyCodes.DOM_VK_ESCAPE, this.borderLayout], + [KeyCodes.DOM_VK_DOWN, this.paddingLayout], + [KeyCodes.DOM_VK_RETURN, this.borderEditable], + [KeyCodes.DOM_VK_UP, this.marginLayout], + ["click", this.borderLayout] + ]), + "padding": new Map([ + [KeyCodes.DOM_VK_ESCAPE, this.paddingLayout], + [KeyCodes.DOM_VK_DOWN, isContentBox ? this.contentLayout : null], + [KeyCodes.DOM_VK_RETURN, this.paddingEditable], + [KeyCodes.DOM_VK_UP, this.borderLayout], + ["click", this.paddingLayout] + ]), + "content": new Map([ + [KeyCodes.DOM_VK_ESCAPE, this.contentLayout], + [KeyCodes.DOM_VK_DOWN, null], + [KeyCodes.DOM_VK_RETURN, this.contentEditable], + [KeyCodes.DOM_VK_UP, this.paddingLayout], + ["click", this.contentLayout] + ]) + }; + }, + + getAriaActiveDescendant() { + let activeDescendant = this.state["activedescendant"]; + + if (!activeDescendant) { + let { layout } = this.props.boxModel; + let displayPosition = this.getDisplayPosition(); + let nextLayout = displayPosition ? this.positionLayout : this.marginLayout; + activeDescendant = nextLayout.getAttribute("data-box"); + this.setAriaActive(nextLayout); + } + + return activeDescendant; + }, + getBorderOrPaddingValue(property) { let { layout } = this.props.boxModel; return layout[property] ? parseFloat(layout[property]) : "-"; }, + /** + * Returns true if the layout box sizing is context box and false otherwise. + */ + getContextBox() { + let { layout } = this.props.boxModel; + return layout["box-sizing"] == "content-box"; + }, + + /** + * Returns true if the position is displayed and false otherwise. + */ + getDisplayPosition() { + let { layout } = this.props.boxModel; + return layout.position && layout.position != "static"; + }, + getHeightValue(property) { let { layout } = this.props.boxModel; @@ -117,13 +202,64 @@ module.exports = createClass({ return value; }, + /** + * Move the focus to the next/previous editable element of the current layout. + * + * @param {Element} 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 = [ + ...findDOMNode(this).querySelectorAll(`[data-box="${level}"].boxmodel-editable`) + ]; + 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(); + } + }, + + /** + * Active aria-level set to current layout. + * + * @param {Element} nextLayout + * Element of next layout that user has navigated to + */ + setAriaActive(nextLayout) { + let { boxModelContainer } = this.props; + // We set this attribute for testing purposes. + boxModelContainer.setAttribute("activedescendant", nextLayout.className); + + this.setState({ + ["activedescendant"]: nextLayout.getAttribute("data-box"), + }); + }, + /** * While waiting for a reps fix in https://github.com/devtools-html/reps/issues/92, * translate nodeFront to a grip-like object that can be used with an ElementNode rep. * - * @params {NodeFront} nodeFront - * The NodeFront for which we want to create a grip-like object. - * @returns {Object} a grip-like object that can be used with Reps. + * @param {NodeFront} nodeFront + * The NodeFront for which we want to create a grip-like object. + * @return {Object} a grip-like object that can be used with Reps. */ translateNodeFrontToGrip(nodeFront) { let { @@ -180,6 +316,96 @@ module.exports = createClass({ }); }, + /** + * 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(event) { + let { target, keyCode } = event; + let isEditable = target._editable || target.editor; + + let level = this.getAriaActiveDescendant(); + let editingMode = target.tagName === "input"; + + switch (keyCode) { + case KeyCodes.DOM_VK_RETURN: + if (!isEditable) { + this.setState({ focusable: true }); + let editableBox = this.layouts[level].get(keyCode); + if (editableBox) { + editableBox.boxModelEditable.focus(); + } + } + break; + case KeyCodes.DOM_VK_DOWN: + case KeyCodes.DOM_VK_UP: + if (!editingMode) { + event.preventDefault(); + this.setState({ focusable: false }); + + let nextLayout = this.layouts[level].get(keyCode); + this.setAriaActive(nextLayout); + + if (target && target._editable) { + target.blur(); + } + + this.props.boxModelContainer.focus(); + } + break; + case KeyCodes.DOM_VK_TAB: + if (isEditable) { + event.preventDefault(); + this.moveFocus(event, level); + } + break; + case KeyCodes.DOM_VK_ESCAPE: + if (target._editable) { + event.preventDefault(); + event.stopPropagation(); + this.setState({ focusable: false }); + this.props.boxModelContainer.focus(); + } + break; + default: + break; + } + }, + + /** + * Update aria-active on mouse click. + * + * @param {Event} event + * The event triggered by a mouse click on the box model + */ + onLevelClick(event) { + let { target } = event; + let { layout } = this.props.boxModel; + let displayPosition = this.getDisplayPosition(); + let isContentBox = this.getContextBox(); + + // Avoid switching the aria active descendant to the position or content layout + // if those are not editable. + if ((!displayPosition && target == this.positionLayout) || + (!isContentBox && target == this.contentLayout)) { + return; + } + + let nextLayout = this.layouts[target.getAttribute("data-box")].get("click"); + this.setAriaActive(nextLayout); + + if (target && target._editable) { + target.blur(); + } + }, + render() { let { boxModel, @@ -201,7 +427,7 @@ module.exports = createClass({ let paddingBottom = this.getBorderOrPaddingValue("padding-bottom"); let paddingLeft = this.getBorderOrPaddingValue("padding-left"); - let displayPosition = layout.position && layout.position != "static"; + let displayPosition = this.getDisplayPosition(); let positionTop = this.getPositionValue("top"); let positionRight = this.getPositionValue("right"); let positionBottom = this.getPositionValue("bottom"); @@ -215,6 +441,9 @@ module.exports = createClass({ height = this.getHeightValue(height); width = this.getWidthValue(width); + let { focusable } = this.state; + let level = this.state["activedescendant"]; + let contentBox = layout["box-sizing"] == "content-box" ? dom.p( { @@ -222,7 +451,12 @@ module.exports = createClass({ }, BoxModelEditable({ box: "content", + focusable, + level, property: "width", + ref: editable => { + this.contentEditable = editable; + }, textContent: width, onShowBoxModelEditor }), @@ -232,6 +466,8 @@ module.exports = createClass({ ), BoxModelEditable({ box: "content", + focusable, + level, property: "height", textContent: height, onShowBoxModelEditor @@ -253,6 +489,12 @@ module.exports = createClass({ return dom.div( { className: "boxmodel-main", + "data-box": "position", + ref: div => { + this.positionLayout = div; + }, + onClick: this.onLevelClick, + onKeyDown: this.onKeyDown, onMouseOver: this.onHighlightMouseOver, onMouseOut: this.props.onHideBoxModelHighlighter, }, @@ -301,6 +543,9 @@ module.exports = createClass({ className: "boxmodel-margins", "data-box": "margin", title: BOXMODEL_L10N.getStr("boxmodel.margin"), + ref: div => { + this.marginLayout = div; + }, }, dom.span( { @@ -315,6 +560,9 @@ module.exports = createClass({ className: "boxmodel-borders", "data-box": "border", title: BOXMODEL_L10N.getStr("boxmodel.border"), + ref: div => { + this.borderLayout = div; + }, }, dom.span( { @@ -329,11 +577,17 @@ module.exports = createClass({ className: "boxmodel-paddings", "data-box": "padding", title: BOXMODEL_L10N.getStr("boxmodel.padding"), + ref: div => { + this.paddingLayout = div; + }, }, dom.div({ className: "boxmodel-contents", "data-box": "content", title: BOXMODEL_L10N.getStr("boxmodel.content"), + ref: div => { + this.contentLayout = div; + }, }) ) ) @@ -343,7 +597,12 @@ module.exports = createClass({ BoxModelEditable({ box: "position", direction: "top", + focusable, + level, property: "position-top", + ref: editable => { + this.positionEditable = editable; + }, textContent: positionTop, onShowBoxModelEditor, }) @@ -353,6 +612,8 @@ module.exports = createClass({ BoxModelEditable({ box: "position", direction: "right", + focusable, + level, property: "position-right", textContent: positionRight, onShowBoxModelEditor, @@ -363,6 +624,8 @@ module.exports = createClass({ BoxModelEditable({ box: "position", direction: "bottom", + focusable, + level, property: "position-bottom", textContent: positionBottom, onShowBoxModelEditor, @@ -373,6 +636,8 @@ module.exports = createClass({ BoxModelEditable({ box: "position", direction: "left", + focusable, + level, property: "position-left", textContent: positionLeft, onShowBoxModelEditor, @@ -382,13 +647,20 @@ module.exports = createClass({ BoxModelEditable({ box: "margin", direction: "top", + focusable, + level, property: "margin-top", + ref: editable => { + this.marginEditable = editable; + }, textContent: marginTop, onShowBoxModelEditor, }), BoxModelEditable({ box: "margin", direction: "right", + focusable, + level, property: "margin-right", textContent: marginRight, onShowBoxModelEditor, @@ -396,6 +668,8 @@ module.exports = createClass({ BoxModelEditable({ box: "margin", direction: "bottom", + focusable, + level, property: "margin-bottom", textContent: marginBottom, onShowBoxModelEditor, @@ -403,6 +677,8 @@ module.exports = createClass({ BoxModelEditable({ box: "margin", direction: "left", + focusable, + level, property: "margin-left", textContent: marginLeft, onShowBoxModelEditor, @@ -410,13 +686,20 @@ module.exports = createClass({ BoxModelEditable({ box: "border", direction: "top", + focusable, + level, property: "border-top-width", + ref: editable => { + this.borderEditable = editable; + }, textContent: borderTop, onShowBoxModelEditor, }), BoxModelEditable({ box: "border", direction: "right", + focusable, + level, property: "border-right-width", textContent: borderRight, onShowBoxModelEditor, @@ -424,6 +707,8 @@ module.exports = createClass({ BoxModelEditable({ box: "border", direction: "bottom", + focusable, + level, property: "border-bottom-width", textContent: borderBottom, onShowBoxModelEditor, @@ -431,6 +716,8 @@ module.exports = createClass({ BoxModelEditable({ box: "border", direction: "left", + focusable, + level, property: "border-left-width", textContent: borderLeft, onShowBoxModelEditor, @@ -438,13 +725,20 @@ module.exports = createClass({ BoxModelEditable({ box: "padding", direction: "top", + focusable, + level, property: "padding-top", + ref: editable => { + this.paddingEditable = editable; + }, textContent: paddingTop, onShowBoxModelEditor, }), BoxModelEditable({ box: "padding", direction: "right", + focusable, + level, property: "padding-right", textContent: paddingRight, onShowBoxModelEditor, @@ -452,6 +746,8 @@ module.exports = createClass({ BoxModelEditable({ box: "padding", direction: "bottom", + focusable, + level, property: "padding-bottom", textContent: paddingBottom, onShowBoxModelEditor, @@ -459,6 +755,8 @@ module.exports = createClass({ BoxModelEditable({ box: "padding", direction: "left", + focusable, + level, property: "padding-left", textContent: paddingLeft, onShowBoxModelEditor, diff --git a/devtools/client/inspector/boxmodel/test/browser.ini b/devtools/client/inspector/boxmodel/test/browser.ini index a8639d3d6c4c..4efae6f7df83 100644 --- a/devtools/client/inspector/boxmodel/test/browser.ini +++ b/devtools/client/inspector/boxmodel/test/browser.ini @@ -22,7 +22,6 @@ support-files = [browser_boxmodel_editablemodel_stylerules.js] [browser_boxmodel_guides.js] [browser_boxmodel_navigation.js] -skip-if = true # Bug 1336198 [browser_boxmodel_offsetparent.js] [browser_boxmodel_positions.js] [browser_boxmodel_properties.js] diff --git a/devtools/client/inspector/boxmodel/test/browser_boxmodel_navigation.js b/devtools/client/inspector/boxmodel/test/browser_boxmodel_navigation.js index deee191fd2aa..c2d8ebcce3a1 100644 --- a/devtools/client/inspector/boxmodel/test/browser_boxmodel_navigation.js +++ b/devtools/client/inspector/boxmodel/test/browser_boxmodel_navigation.js @@ -28,49 +28,65 @@ add_task(function* () { function* testInitialFocus(inspector, view) { info("Test that the focus is on margin layout."); - let viewdoc = view.doc; - let boxmodel = viewdoc.getElementById("boxmodel-wrapper"); + let viewdoc = view.document; + let boxmodel = viewdoc.querySelector(".boxmodel-container"); boxmodel.focus(); EventUtils.synthesizeKey("VK_RETURN", {}); - is(boxmodel.getAttribute("aria-activedescendant"), "boxmodel-margins", - "Should be set to the margin layout."); + is(boxmodel.getAttribute("activedescendant"), "boxmodel-main", + "Should be set to the position layout."); } function* testChangingLevels(inspector, view) { info("Test that using arrow keys updates level."); - let viewdoc = view.doc; - let boxmodel = viewdoc.getElementById("boxmodel-wrapper"); + let viewdoc = view.document; + let boxmodel = viewdoc.querySelector(".boxmodel-container"); boxmodel.focus(); EventUtils.synthesizeKey("VK_RETURN", {}); EventUtils.synthesizeKey("VK_ESCAPE", {}); EventUtils.synthesizeKey("VK_DOWN", {}); - is(boxmodel.getAttribute("aria-activedescendant"), "boxmodel-borders", + is(boxmodel.getAttribute("activedescendant"), "boxmodel-margins", + "Should be set to the margin layout."); + + EventUtils.synthesizeKey("VK_DOWN", {}); + is(boxmodel.getAttribute("activedescendant"), "boxmodel-borders", "Should be set to the border layout."); EventUtils.synthesizeKey("VK_DOWN", {}); - is(boxmodel.getAttribute("aria-activedescendant"), "boxmodel-padding", + is(boxmodel.getAttribute("activedescendant"), "boxmodel-paddings", + "Should be set to the padding layout."); + + EventUtils.synthesizeKey("VK_DOWN", {}); + is(boxmodel.getAttribute("activedescendant"), "boxmodel-contents", + "Should be set to the content layout."); + + EventUtils.synthesizeKey("VK_UP", {}); + is(boxmodel.getAttribute("activedescendant"), "boxmodel-paddings", "Should be set to the padding layout."); EventUtils.synthesizeKey("VK_UP", {}); - is(boxmodel.getAttribute("aria-activedescendant"), "boxmodel-borders", + is(boxmodel.getAttribute("activedescendant"), "boxmodel-borders", "Should be set to the border layout."); EventUtils.synthesizeKey("VK_UP", {}); - is(boxmodel.getAttribute("aria-activedescendant"), "boxmodel-margins", + is(boxmodel.getAttribute("activedescendant"), "boxmodel-margins", "Should be set to the margin layout."); + + EventUtils.synthesizeKey("VK_UP", {}); + is(boxmodel.getAttribute("activedescendant"), "boxmodel-main", + "Should be set to the position layout."); } function* testTabbingWrapAround(inspector, view) { info("Test that using arrow keys updates level."); - let viewdoc = view.doc; - let boxmodel = viewdoc.getElementById("boxmodel-wrapper"); + let viewdoc = view.document; + let boxmodel = viewdoc.querySelector(".boxmodel-container"); boxmodel.focus(); EventUtils.synthesizeKey("VK_RETURN", {}); - let editLevel = boxmodel.getAttribute("aria-activedescendant"); - let dataLevel = viewdoc.getElementById(editLevel).getAttribute("data-box"); + let editLevel = boxmodel.getAttribute("activedescendant"); + let dataLevel = viewdoc.querySelector(`.${editLevel}`).getAttribute("data-box"); let editBoxes = [...viewdoc.querySelectorAll( `[data-box="${dataLevel}"].boxmodel-editable`)]; @@ -86,18 +102,19 @@ function* testTabbingWrapAround(inspector, view) { function* testChangingLevelsByClicking(inspector, view) { info("Test that clicking on levels updates level."); - let viewdoc = view.doc; - let boxmodel = viewdoc.getElementById("boxmodel-wrapper"); + let viewdoc = view.document; + let boxmodel = viewdoc.querySelector(".boxmodel-container"); 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]; + let marginLayout = viewdoc.querySelector(".boxmodel-margins"); + let borderLayout = viewdoc.querySelector(".boxmodel-borders"); + let paddingLayout = viewdoc.querySelector(".boxmodel-paddings"); + let contentLayout = viewdoc.querySelector(".boxmodel-contents"); + let layouts = [contentLayout, paddingLayout, borderLayout, marginLayout]; layouts.forEach(layout => { layout.click(); - is(boxmodel.getAttribute("aria-activedescendant"), layout.id, + is(boxmodel.getAttribute("activedescendant"), layout.className, "Should be set to" + layout.getAttribute("data-box") + "layout."); }); } diff --git a/devtools/client/inspector/inspector.xhtml b/devtools/client/inspector/inspector.xhtml index 69e9cf651f60..06f16f0bbe33 100644 --- a/devtools/client/inspector/inspector.xhtml +++ b/devtools/client/inspector/inspector.xhtml @@ -135,7 +135,7 @@
-
+
diff --git a/devtools/client/themes/boxmodel.css b/devtools/client/themes/boxmodel.css index e44ee536cfad..3765c60fb233 100644 --- a/devtools/client/themes/boxmodel.css +++ b/devtools/client/themes/boxmodel.css @@ -7,10 +7,6 @@ */ .boxmodel-container { - /* The view will grow bigger as the window gets resized, until 400px */ - max-width: 400px; - margin: 0px auto; - padding: 0; overflow: auto; } @@ -33,9 +29,11 @@ position: relative; color: var(--theme-selection-color); /* Make sure there is some space between the window's edges and the regions */ - margin: 14px 14px 4px 14px; + margin: 14px auto; width: calc(100% - 2 * 14px); min-width: 240px; + /* The view will grow bigger as the window gets resized, until 400px */ + max-width: 400px; } .boxmodel-box {