Files
tubestation/devtools/client/accessibility/components/Accessible.js
2018-10-11 14:51:35 +00:00

489 lines
14 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
/* global EVENTS, gTelemetry, gToolbox */
// React & Redux
const { createFactory, Component } = require("devtools/client/shared/vendor/react");
const { div, span } = require("devtools/client/shared/vendor/react-dom-factories");
const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
const { findDOMNode } = require("devtools/client/shared/vendor/react-dom");
const { connect } = require("devtools/client/shared/vendor/react-redux");
const { TREE_ROW_HEIGHT, ORDERED_PROPS, ACCESSIBLE_EVENTS, VALUE_FLASHING_DURATION } =
require("../constants");
const { L10N } = require("../utils/l10n");
const {flashElementOn, flashElementOff} =
require("devtools/client/inspector/markup/utils");
const { updateDetails } = require("../actions/details");
const { select, unhighlight } = require("../actions/accessibles");
const Tree = createFactory(require("devtools/client/shared/components/VirtualizedTree"));
// Reps
const { REPS, MODE } = require("devtools/client/shared/components/reps/reps");
const { Rep, ElementNode, Accessible: AccessibleRep, Obj } = REPS;
const { translateNodeFrontToGrip } = require("devtools/client/inspector/shared/utils");
loader.lazyRequireGetter(this, "openContentLink", "devtools/client/shared/link", true);
const TELEMETRY_NODE_INSPECTED_COUNT = "devtools.accessibility.node_inspected_count";
const TREE_DEPTH_PADDING_INCREMENT = 15;
class AccessiblePropertyClass extends Component {
static get propTypes() {
return {
accessible: PropTypes.string,
object: PropTypes.any,
focused: PropTypes.bool,
children: PropTypes.func
};
}
componentDidUpdate({ object: prevObject, accessible: prevAccessible }) {
const { accessible, object, focused } = this.props;
// Fast check if row is focused or if the value did not update.
if (focused || accessible !== prevAccessible || prevObject === object ||
(object && prevObject && typeof object === "object")) {
return;
}
this.flashRow();
}
flashRow() {
const row = findDOMNode(this);
flashElementOn(row);
if (this._flashMutationTimer) {
clearTimeout(this._flashMutationTimer);
this._flashMutationTimer = null;
}
this._flashMutationTimer = setTimeout(() => {
flashElementOff(row);
}, VALUE_FLASHING_DURATION);
}
render() {
return this.props.children();
}
}
const AccessibleProperty = createFactory(AccessiblePropertyClass);
class Accessible extends Component {
static get propTypes() {
return {
accessible: PropTypes.object,
dispatch: PropTypes.func.isRequired,
DOMNode: PropTypes.object,
items: PropTypes.array,
labelledby: PropTypes.string.isRequired,
parents: PropTypes.object,
relations: PropTypes.object,
supports: PropTypes.object,
walker: PropTypes.object.isRequired
};
}
constructor(props) {
super(props);
this.state = {
expanded: new Set(),
focused: null
};
this.onAccessibleInspected = this.onAccessibleInspected.bind(this);
this.renderItem = this.renderItem.bind(this);
this.update = this.update.bind(this);
}
componentWillMount() {
window.on(EVENTS.NEW_ACCESSIBLE_FRONT_INSPECTED, this.onAccessibleInspected);
}
componentWillReceiveProps({ accessible }) {
const oldAccessible = this.props.accessible;
if (oldAccessible) {
if (accessible && accessible.actorID === oldAccessible.actorID) {
return;
}
ACCESSIBLE_EVENTS.forEach(event => oldAccessible.off(event, this.update));
}
if (accessible) {
ACCESSIBLE_EVENTS.forEach(event => accessible.on(event, this.update));
}
}
componentWillUnmount() {
window.off(EVENTS.NEW_ACCESSIBLE_FRONT_INSPECTED, this.onAccessibleInspected);
const { accessible } = this.props;
if (accessible) {
ACCESSIBLE_EVENTS.forEach(event => accessible.off(event, this.update));
}
}
onAccessibleInspected() {
const { props } = this.refs;
if (props) {
props.refs.tree.focus();
}
}
update() {
const { dispatch, accessible, supports } = this.props;
if (gToolbox) {
dispatch(updateDetails(gToolbox.walker, accessible, supports));
}
}
setExpanded(item, isExpanded) {
const { expanded } = this.state;
if (isExpanded) {
expanded.add(item.path);
} else {
expanded.delete(item.path);
}
this.setState({ expanded });
}
showHighlighter(nodeFront) {
if (!gToolbox) {
return;
}
gToolbox.highlighterUtils.highlightNodeFront(nodeFront);
}
hideHighlighter() {
if (!gToolbox) {
return;
}
gToolbox.highlighterUtils.unhighlight();
}
showAccessibleHighlighter(accessible) {
const { walker, dispatch } = this.props;
dispatch(unhighlight());
if (!accessible || !walker) {
return;
}
walker.highlightAccessible(accessible).catch(error => {
// Only report an error where there's still a toolbox. Ignore cases where toolbox is
// already destroyed.
if (gToolbox) {
console.error(error);
}
});
}
hideAccessibleHighlighter() {
const { walker, dispatch } = this.props;
dispatch(unhighlight());
if (!walker) {
return;
}
walker.unhighlight().catch(error => {
// Only report an error where there's still a toolbox. Ignore cases where toolbox is
// already destroyed.
if (gToolbox) {
console.error(error);
}
});
}
selectNode(nodeFront, reason = "accessibility") {
if (gTelemetry) {
gTelemetry.scalarAdd(TELEMETRY_NODE_INSPECTED_COUNT, 1);
}
if (!gToolbox) {
return;
}
gToolbox.selectTool("inspector").then(() =>
gToolbox.selection.setNodeFront(nodeFront, reason));
}
async selectAccessible(accessible) {
const { walker, dispatch } = this.props;
if (!walker) {
return;
}
await dispatch(select(walker, accessible));
const { props } = this.refs;
if (props) {
props.refs.tree.blur();
}
await this.setState({ focused: null });
window.emit(EVENTS.NEW_ACCESSIBLE_FRONT_INSPECTED);
}
openLink(link, e) {
openContentLink(link);
}
renderItem(item, depth, focused, arrow, expanded) {
const object = item.contents;
const valueProps = {
object,
mode: MODE.TINY,
title: "Object",
openLink: this.openLink
};
if (isNode(object)) {
valueProps.defaultRep = ElementNode;
valueProps.onDOMNodeMouseOut = () => this.hideHighlighter();
valueProps.onDOMNodeMouseOver = () => this.showHighlighter(this.props.DOMNode);
valueProps.onInspectIconClick = () => this.selectNode(this.props.DOMNode);
} else if (isAccessible(object)) {
const target = findAccessibleTarget(this.props.relations, object.actor);
valueProps.defaultRep = AccessibleRep;
valueProps.onAccessibleMouseOut = () => this.hideAccessibleHighlighter();
valueProps.onAccessibleMouseOver = () => this.showAccessibleHighlighter(target);
valueProps.onInspectIconClick = (obj, e) => {
e.stopPropagation();
this.selectAccessible(target);
};
valueProps.separatorText = "";
} else if (item.name === "relations") {
valueProps.defaultRep = Obj;
} else {
valueProps.noGrip = true;
}
const classList = [ "node", "object-node" ];
if (focused) {
classList.push("focused");
}
const depthPadding = depth * TREE_DEPTH_PADDING_INCREMENT;
return AccessibleProperty(
{ object, focused, accessible: this.props.accessible.actorID },
() => div({
className: classList.join(" "),
style: {
paddingInlineStart: depthPadding,
"inline-size":
`calc(var(--accessibility-properties-item-width) - ${depthPadding}px)`
},
onClick: e => {
if (e.target.classList.contains("theme-twisty")) {
this.setExpanded(item, !expanded);
}
}
},
arrow,
span({ className: "object-label" }, item.name),
span({ className: "object-delimiter" }, ":"),
span({ className: "object-value" }, Rep(valueProps) || "")
)
);
}
render() {
const { expanded, focused } = this.state;
const { items, parents, accessible, labelledby } = this.props;
if (accessible) {
return Tree({
ref: "props",
key: "accessible-properties",
itemHeight: TREE_ROW_HEIGHT,
getRoots: () => items,
getKey: item => item.path,
getParent: item => parents.get(item),
getChildren: item => item.children,
isExpanded: item => expanded.has(item.path),
onExpand: item => this.setExpanded(item, true),
onCollapse: item => this.setExpanded(item, false),
onFocus: item => {
if (this.state.focused !== item.path) {
this.setState({ focused: item.path });
}
},
onActivate: ({ contents }) => {
if (isNode(contents)) {
this.selectNode(this.props.DOMNode, "accessibility-keyboard");
} else if (isAccessible(contents)) {
const target = findAccessibleTarget(this.props.relations, contents.actor);
if (target) {
this.selectAccessible(target);
}
}
},
focused: findFocused(focused, items),
renderItem: this.renderItem,
labelledby
});
}
return div({ className: "info" },
L10N.getStr("accessibility.accessible.notAvailable"));
}
}
/**
* Match accessibility object from relations targets to the grip that's being activated.
* @param {Object} relations Object containing relations grouped by type and targets.
* @param {String} actorID Actor ID to match to the relation target.
* @return {Object} Accessible front that matches the relation target.
*/
const findAccessibleTarget = (relations, actorID) => {
for (const relationType in relations) {
let targets = relations[relationType];
targets = Array.isArray(targets) ? targets : [targets];
for (const target of targets) {
if (target.actorID === actorID) {
return target;
}
}
}
return null;
};
/**
* Find currently focused item.
* @param {String} focused Key of the currently focused item.
* @param {Array} items Accessibility properties array.
* @return {Object?} Possibly found focused item.
*/
const findFocused = (focused, items) => {
for (const item of items) {
if (item.path === focused) {
return item;
}
const found = findFocused(focused, item.children);
if (found) {
return found;
}
}
return null;
};
/**
* Check if a given property is a DOMNode actor.
* @param {Object?} value A property to check for being a DOMNode.
* @return {Boolean} A flag that indicates whether a property is a DOMNode.
*/
const isNode = value => value && value.typeName === "domnode";
/**
* Check if a given property is an Accessible actor.
* @param {Object?} value A property to check for being an Accessible.
* @return {Boolean} A flag that indicates whether a property is an Accessible.
*/
const isAccessible = value => value && value.typeName === "accessible";
/**
* While waiting for a reps fix in https://github.com/devtools-html/reps/issues/92,
* translate accessibleFront to a grip-like object that can be used with an Accessible
* rep.
*
* @params {accessibleFront} accessibleFront
* The AccessibleFront for which we want to create a grip-like object.
* @returns {Object} a grip-like object that can be used with Reps.
*/
const translateAccessibleFrontToGrip = accessibleFront => ({
actor: accessibleFront.actorID,
typeName: accessibleFront.typeName,
preview: {
name: accessibleFront.name,
role: accessibleFront.role,
// All the grid containers are assumed to be in the Accessibility tree.
isConnected: true
}
});
const translateNodeFrontToGripWrapper = nodeFront => ({
...translateNodeFrontToGrip(nodeFront),
typeName: nodeFront.typeName
});
/**
* Build props ingestible by Tree component.
* @param {Object} props Component properties to be processed.
* @param {String} parentPath Unique path that is used to identify a Tree Node.
* @return {Object} Processed properties.
*/
const makeItemsForDetails = (props, parentPath) =>
Object.getOwnPropertyNames(props).map(name => {
let children = [];
const path = `${parentPath}/${name}`;
let contents = props[name];
if (contents) {
if (isNode(contents)) {
contents = translateNodeFrontToGripWrapper(contents);
} else if (isAccessible(contents)) {
contents = translateAccessibleFrontToGrip(contents);
} else if (Array.isArray(contents) || typeof contents === "object") {
children = makeItemsForDetails(contents, path);
}
}
return { name, path, contents, children };
});
const makeParentMap = (items) => {
const map = new WeakMap();
function _traverse(item) {
if (item.children.length > 0) {
for (const child of item.children) {
map.set(child, item);
_traverse(child);
}
}
}
items.forEach(_traverse);
return map;
};
const mapStateToProps = ({ details, ui }) => {
const { accessible, DOMNode, relations } = details;
const { supports } = ui;
if (!accessible || !DOMNode) {
return {};
}
const items = makeItemsForDetails(ORDERED_PROPS.reduce((props, key) => {
if (key === "DOMNode") {
props.DOMNode = DOMNode;
} else if (key === "relations") {
if (supports.relations) {
props.relations = relations;
}
} else {
props[key] = accessible[key];
}
return props;
}, {}), "");
const parents = makeParentMap(items);
return { accessible, DOMNode, items, parents, relations, supports };
};
module.exports = connect(mapStateToProps)(Accessible);