gecko-dev/devtools/client/accessibility/components/Accessible.js

548 строки
15 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 */
// 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("devtools/client/accessibility/constants");
const { L10N } = require("devtools/client/accessibility/utils/l10n");
const {
flashElementOn,
flashElementOff,
} = require("devtools/client/inspector/markup/utils");
const {
updateDetails,
} = require("devtools/client/accessibility/actions/details");
const {
select,
unhighlight,
} = require("devtools/client/accessibility/actions/accessibles");
const Tree = createFactory(
require("devtools/client/shared/components/VirtualizedTree")
);
// Reps
const { REPS, MODE } = require("devtools/client/shared/components/reps/index");
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 = 20;
class AccessiblePropertyClass extends Component {
static get propTypes() {
return {
accessibleFrontActorID: PropTypes.string,
object: PropTypes.any,
focused: PropTypes.bool,
children: PropTypes.func,
};
}
componentDidUpdate({
object: prevObject,
accessibleFrontActorID: prevAccessibleFrontActorID,
}) {
const { accessibleFrontActorID, object, focused } = this.props;
// Fast check if row is focused or if the value did not update.
if (
focused ||
accessibleFrontActorID !== prevAccessibleFrontActorID ||
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 {
accessibleFront: PropTypes.object,
dispatch: PropTypes.func.isRequired,
nodeFront: PropTypes.object,
items: PropTypes.array,
labelledby: PropTypes.string.isRequired,
parents: PropTypes.object,
relations: PropTypes.object,
toolbox: PropTypes.object.isRequired,
toolboxHighlighter: PropTypes.object.isRequired,
highlightAccessible: PropTypes.func.isRequired,
unhighlightAccessible: PropTypes.func.isRequired,
};
}
constructor(props) {
super(props);
this.state = {
expanded: new Set(),
active: null,
focused: null,
};
this.onAccessibleInspected = this.onAccessibleInspected.bind(this);
this.renderItem = this.renderItem.bind(this);
this.update = this.update.bind(this);
}
// FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
UNSAFE_componentWillMount() {
window.on(
EVENTS.NEW_ACCESSIBLE_FRONT_INSPECTED,
this.onAccessibleInspected
);
}
// FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
UNSAFE_componentWillReceiveProps({ accessibleFront }) {
const oldAccessibleFront = this.props.accessibleFront;
if (oldAccessibleFront) {
if (
accessibleFront &&
accessibleFront.actorID === oldAccessibleFront.actorID
) {
return;
}
ACCESSIBLE_EVENTS.forEach(event =>
oldAccessibleFront.off(event, this.update)
);
}
if (accessibleFront) {
ACCESSIBLE_EVENTS.forEach(event =>
accessibleFront.on(event, this.update)
);
}
}
componentDidUpdate(prevProps) {
if (
this.props.accessibleFront &&
!this.props.accessibleFront.isDestroyed() &&
this.props.accessibleFront !== prevProps.accessibleFront
) {
window.emit(EVENTS.PROPERTIES_UPDATED);
}
}
componentWillUnmount() {
window.off(
EVENTS.NEW_ACCESSIBLE_FRONT_INSPECTED,
this.onAccessibleInspected
);
const { accessibleFront } = this.props;
if (accessibleFront) {
ACCESSIBLE_EVENTS.forEach(event =>
accessibleFront.off(event, this.update)
);
}
}
onAccessibleInspected() {
const { props } = this.refs;
if (props) {
props.refs.tree.focus();
}
}
update() {
const { dispatch, accessibleFront } = this.props;
if (accessibleFront.isDestroyed()) {
return;
}
dispatch(updateDetails(accessibleFront));
}
setExpanded(item, isExpanded) {
const { expanded } = this.state;
if (isExpanded) {
expanded.add(item.path);
} else {
expanded.delete(item.path);
}
this.setState({ expanded });
}
async showHighlighter(nodeFront) {
if (!this.props.toolboxHighlighter) {
return;
}
await this.props.toolboxHighlighter.highlight(nodeFront);
}
async hideHighlighter() {
if (!this.props.toolboxHighlighter) {
return;
}
await this.props.toolboxHighlighter.unhighlight();
}
showAccessibleHighlighter(accessibleFront) {
this.props.dispatch(unhighlight());
this.props.highlightAccessible(accessibleFront);
}
hideAccessibleHighlighter(accessibleFront) {
this.props.dispatch(unhighlight());
this.props.unhighlightAccessible(accessibleFront);
}
async selectNode(nodeFront, reason = "accessibility") {
if (gTelemetry) {
gTelemetry.scalarAdd(TELEMETRY_NODE_INSPECTED_COUNT, 1);
}
if (!this.props.toolbox) {
return;
}
const inspector = await this.props.toolbox.selectTool("inspector");
inspector.selection.setNodeFront(nodeFront, reason);
}
async selectAccessible(accessibleFront) {
if (!accessibleFront) {
return;
}
await this.props.dispatch(select(accessibleFront));
const { props } = this.refs;
if (props) {
props.refs.tree.blur();
}
await this.setState({ active: null, 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 (isNodeFront(object)) {
valueProps.defaultRep = ElementNode;
valueProps.onDOMNodeMouseOut = () => this.hideHighlighter();
valueProps.onDOMNodeMouseOver = () =>
this.showHighlighter(this.props.nodeFront);
valueProps.onInspectIconClick = () =>
this.selectNode(this.props.nodeFront);
} else if (isAccessibleFront(object)) {
const target = findAccessibleTarget(this.props.relations, object.actor);
valueProps.defaultRep = AccessibleRep;
valueProps.onAccessibleMouseOut = () =>
this.hideAccessibleHighlighter(target);
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,
accessibleFrontActorID: this.props.accessibleFront.actorID,
},
() =>
div(
{
className: classList.join(" "),
style: {
paddingInlineStart: depthPadding,
inlineSize: `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, active, focused } = this.state;
const { items, parents, accessibleFront, labelledby } = this.props;
if (accessibleFront) {
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: item => {
if (item == null) {
this.setState({ active: null });
} else if (this.state.active !== item.path) {
this.setState({ active: item.path });
}
},
focused: findByPath(focused, items),
active: findByPath(active, 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 an item based on a given path.
* @param {String} path
* Key of the item to be looked up.
* @param {Array} items
* Accessibility properties array.
* @return {Object?}
* Possibly found item.
*/
const findByPath = (path, items) => {
for (const item of items) {
if (item.path === path) {
return item;
}
const found = findByPath(path, item.children);
if (found) {
return found;
}
}
return null;
};
/**
* Check if a given property is a DOMNode front.
* @param {Object?} value A property to check for being a DOMNode.
* @return {Boolean} A flag that indicates whether a property is a DOMNode.
*/
const isNodeFront = value => value && value.typeName === "domnode";
/**
* Check if a given property is an Accessible front.
* @param {Object?} value A property to check for being an Accessible.
* @return {Boolean} A flag that indicates whether a property is an Accessible.
*/
const isAccessibleFront = value => value && value.typeName === "accessible";
/**
* While waiting for a reps fix in https://github.com/firefox-devtools/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 (isNodeFront(contents)) {
contents = translateNodeFrontToGripWrapper(contents);
name = "DOMNode";
} else if (isAccessibleFront(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 }) => {
const {
accessible: accessibleFront,
DOMNode: nodeFront,
relations,
} = details;
if (!accessibleFront || !nodeFront) {
return {};
}
const items = makeItemsForDetails(
ORDERED_PROPS.reduce((props, key) => {
if (key === "DOMNode") {
props.nodeFront = nodeFront;
} else if (key === "relations") {
props.relations = relations;
} else {
props[key] = accessibleFront[key];
}
return props;
}, {}),
""
);
const parents = makeParentMap(items);
return { accessibleFront, nodeFront, items, parents, relations };
};
module.exports = connect(mapStateToProps)(Accessible);