summaryrefslogtreecommitdiffstats
path: root/devtools/client/accessibility/components/Accessible.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/accessibility/components/Accessible.js')
-rw-r--r--devtools/client/accessibility/components/Accessible.js563
1 files changed, 563 insertions, 0 deletions
diff --git a/devtools/client/accessibility/components/Accessible.js b/devtools/client/accessibility/components/Accessible.js
new file mode 100644
index 0000000000..36f0068218
--- /dev/null
+++ b/devtools/client/accessibility/components/Accessible.js
@@ -0,0 +1,563 @@
+/* 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("resource://devtools/client/shared/vendor/react.js");
+const {
+ div,
+ span,
+} = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const {
+ findDOMNode,
+} = require("resource://devtools/client/shared/vendor/react-dom.js");
+const {
+ connect,
+} = require("resource://devtools/client/shared/vendor/react-redux.js");
+
+const {
+ TREE_ROW_HEIGHT,
+ ORDERED_PROPS,
+ ACCESSIBLE_EVENTS,
+ VALUE_FLASHING_DURATION,
+} = require("resource://devtools/client/accessibility/constants.js");
+const {
+ L10N,
+} = require("resource://devtools/client/accessibility/utils/l10n.js");
+const {
+ flashElementOn,
+ flashElementOff,
+} = require("resource://devtools/client/inspector/markup/utils.js");
+const {
+ updateDetails,
+} = require("resource://devtools/client/accessibility/actions/details.js");
+const {
+ select,
+ unhighlight,
+} = require("resource://devtools/client/accessibility/actions/accessibles.js");
+
+const Tree = createFactory(
+ require("resource://devtools/client/shared/components/VirtualizedTree.js")
+);
+// Reps
+const {
+ REPS,
+ MODE,
+} = require("resource://devtools/client/shared/components/reps/index.js");
+const { Rep, ElementNode, Accessible: AccessibleRep, Obj } = REPS;
+
+const {
+ translateNodeFrontToGrip,
+} = require("resource://devtools/client/inspector/shared/utils.js");
+
+loader.lazyRequireGetter(
+ this,
+ "openContentLink",
+ "resource://devtools/client/shared/link.js",
+ 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.inspectIconTitle = L10N.getStr(
+ "accessibility.accessible.selectNodeInInspector.title"
+ );
+ 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.inspectIconTitle = L10N.getStr(
+ "accessibility.accessible.selectElement.title"
+ );
+ 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) {
+ 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);