Accessible.js (15126B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 "use strict"; 5 6 /* global EVENTS */ 7 8 // React & Redux 9 const { 10 createFactory, 11 Component, 12 } = require("resource://devtools/client/shared/vendor/react.mjs"); 13 const { 14 div, 15 span, 16 } = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); 17 const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.mjs"); 18 const { 19 findDOMNode, 20 } = require("resource://devtools/client/shared/vendor/react-dom.mjs"); 21 const { 22 connect, 23 } = require("resource://devtools/client/shared/vendor/react-redux.js"); 24 25 const { 26 TREE_ROW_HEIGHT, 27 ORDERED_PROPS, 28 ACCESSIBLE_EVENTS, 29 VALUE_FLASHING_DURATION, 30 } = require("resource://devtools/client/accessibility/constants.js"); 31 const { 32 L10N, 33 } = require("resource://devtools/client/accessibility/utils/l10n.js"); 34 const { 35 flashElementOn, 36 flashElementOff, 37 } = require("resource://devtools/client/inspector/markup/utils.js"); 38 const { 39 updateDetails, 40 } = require("resource://devtools/client/accessibility/actions/details.js"); 41 const { 42 select, 43 unhighlight, 44 } = require("resource://devtools/client/accessibility/actions/accessibles.js"); 45 46 const VirtualizedTree = createFactory( 47 require("resource://devtools/client/shared/components/VirtualizedTree.js") 48 ); 49 // Reps 50 const { REPS, MODE } = ChromeUtils.importESModule( 51 "resource://devtools/client/shared/components/reps/index.mjs" 52 ); 53 const { Rep, ElementNode, Accessible: AccessibleRep, Obj } = REPS; 54 55 const { 56 translateNodeFrontToGrip, 57 } = require("resource://devtools/client/inspector/shared/utils.js"); 58 59 loader.lazyRequireGetter( 60 this, 61 "openContentLink", 62 "resource://devtools/client/shared/link.js", 63 true 64 ); 65 66 const TREE_DEPTH_PADDING_INCREMENT = 20; 67 68 class AccessiblePropertyClass extends Component { 69 static get propTypes() { 70 return { 71 accessibleFrontActorID: PropTypes.string, 72 object: PropTypes.any, 73 focused: PropTypes.bool, 74 children: PropTypes.func, 75 }; 76 } 77 78 componentDidUpdate({ 79 object: prevObject, 80 accessibleFrontActorID: prevAccessibleFrontActorID, 81 }) { 82 const { accessibleFrontActorID, object, focused } = this.props; 83 // Fast check if row is focused or if the value did not update. 84 if ( 85 focused || 86 accessibleFrontActorID !== prevAccessibleFrontActorID || 87 prevObject === object || 88 (object && prevObject && typeof object === "object") 89 ) { 90 return; 91 } 92 93 this.flashRow(); 94 } 95 96 flashRow() { 97 const row = findDOMNode(this); 98 flashElementOn(row); 99 if (this._flashMutationTimer) { 100 clearTimeout(this._flashMutationTimer); 101 this._flashMutationTimer = null; 102 } 103 this._flashMutationTimer = setTimeout(() => { 104 flashElementOff(row); 105 }, VALUE_FLASHING_DURATION); 106 } 107 108 render() { 109 return this.props.children(); 110 } 111 } 112 113 const AccessibleProperty = createFactory(AccessiblePropertyClass); 114 115 class Accessible extends Component { 116 static get propTypes() { 117 return { 118 accessibleFront: PropTypes.object, 119 dispatch: PropTypes.func.isRequired, 120 nodeFront: PropTypes.object, 121 items: PropTypes.array, 122 labelledby: PropTypes.string.isRequired, 123 parents: PropTypes.object, 124 relations: PropTypes.object, 125 toolbox: PropTypes.object.isRequired, 126 toolboxHighlighter: PropTypes.object.isRequired, 127 highlightAccessible: PropTypes.func.isRequired, 128 unhighlightAccessible: PropTypes.func.isRequired, 129 }; 130 } 131 132 constructor(props) { 133 super(props); 134 135 this.state = { 136 expanded: new Set(), 137 active: null, 138 focused: null, 139 }; 140 141 this.onAccessibleInspected = this.onAccessibleInspected.bind(this); 142 this.renderItem = this.renderItem.bind(this); 143 this.update = this.update.bind(this); 144 } 145 146 componentDidMount() { 147 window.on( 148 EVENTS.NEW_ACCESSIBLE_FRONT_INSPECTED, 149 this.onAccessibleInspected 150 ); 151 } 152 153 componentDidUpdate(prevProps) { 154 const oldAccessibleFront = prevProps.accessibleFront; 155 const { accessibleFront } = this.props; 156 157 if ( 158 accessibleFront && 159 !accessibleFront.isDestroyed() && 160 accessibleFront !== oldAccessibleFront 161 ) { 162 window.emit(EVENTS.PROPERTIES_UPDATED); 163 } 164 165 if (oldAccessibleFront) { 166 if ( 167 accessibleFront && 168 accessibleFront.actorID === oldAccessibleFront.actorID 169 ) { 170 return; 171 } 172 ACCESSIBLE_EVENTS.forEach(event => 173 oldAccessibleFront.off(event, this.update) 174 ); 175 } 176 177 if (accessibleFront) { 178 ACCESSIBLE_EVENTS.forEach(event => 179 accessibleFront.on(event, this.update) 180 ); 181 } 182 } 183 184 componentWillUnmount() { 185 window.off( 186 EVENTS.NEW_ACCESSIBLE_FRONT_INSPECTED, 187 this.onAccessibleInspected 188 ); 189 190 const { accessibleFront } = this.props; 191 if (accessibleFront) { 192 ACCESSIBLE_EVENTS.forEach(event => 193 accessibleFront.off(event, this.update) 194 ); 195 } 196 } 197 198 onAccessibleInspected() { 199 const { props } = this.refs; 200 if (props) { 201 props.refs.tree.focus(); 202 } 203 } 204 205 update() { 206 const { dispatch, accessibleFront } = this.props; 207 if (accessibleFront.isDestroyed()) { 208 return; 209 } 210 211 dispatch(updateDetails(accessibleFront)); 212 } 213 214 setExpanded(item, isExpanded) { 215 const { expanded } = this.state; 216 217 if (isExpanded) { 218 expanded.add(item.path); 219 } else { 220 expanded.delete(item.path); 221 } 222 223 this.setState({ expanded }); 224 } 225 226 async showHighlighter(nodeFront) { 227 if (!this.props.toolboxHighlighter) { 228 return; 229 } 230 231 await this.props.toolboxHighlighter.highlight(nodeFront); 232 } 233 234 async hideHighlighter() { 235 if (!this.props.toolboxHighlighter) { 236 return; 237 } 238 239 await this.props.toolboxHighlighter.unhighlight(); 240 } 241 242 showAccessibleHighlighter(accessibleFront) { 243 this.props.dispatch(unhighlight()); 244 this.props.highlightAccessible(accessibleFront); 245 } 246 247 hideAccessibleHighlighter(accessibleFront) { 248 this.props.dispatch(unhighlight()); 249 this.props.unhighlightAccessible(accessibleFront); 250 } 251 252 async selectNode(nodeFront, reason = "accessibility") { 253 Glean.devtoolsAccessibility.nodeInspectedCount.add(1); 254 255 if (!this.props.toolbox) { 256 return; 257 } 258 259 const inspector = await this.props.toolbox.selectTool("inspector"); 260 inspector.selection.setNodeFront(nodeFront, reason); 261 } 262 263 async selectAccessible(accessibleFront) { 264 if (!accessibleFront) { 265 return; 266 } 267 268 await this.props.dispatch(select(accessibleFront)); 269 270 const { props } = this.refs; 271 if (props) { 272 props.refs.tree.blur(); 273 } 274 await this.setState({ active: null, focused: null }); 275 276 window.emit(EVENTS.NEW_ACCESSIBLE_FRONT_INSPECTED); 277 } 278 279 openLink(link) { 280 openContentLink(link); 281 } 282 283 renderItem(item, depth, focused, arrow, expanded) { 284 const object = item.contents; 285 const valueProps = { 286 object, 287 mode: MODE.TINY, 288 title: "Object", 289 openLink: this.openLink, 290 }; 291 292 if (isNodeFront(object)) { 293 valueProps.defaultRep = ElementNode; 294 valueProps.onDOMNodeMouseOut = () => this.hideHighlighter(); 295 valueProps.onDOMNodeMouseOver = () => 296 this.showHighlighter(this.props.nodeFront); 297 298 valueProps.inspectIconTitle = L10N.getStr( 299 "accessibility.accessible.selectNodeInInspector.title" 300 ); 301 valueProps.onInspectIconClick = () => 302 this.selectNode(this.props.nodeFront); 303 } else if (isAccessibleFront(object)) { 304 const target = findAccessibleTarget(this.props.relations, object.actor); 305 valueProps.defaultRep = AccessibleRep; 306 valueProps.onAccessibleMouseOut = () => 307 this.hideAccessibleHighlighter(target); 308 valueProps.onAccessibleMouseOver = () => 309 this.showAccessibleHighlighter(target); 310 valueProps.inspectIconTitle = L10N.getStr( 311 "accessibility.accessible.selectElement.title" 312 ); 313 valueProps.onInspectIconClick = (obj, e) => { 314 e.stopPropagation(); 315 this.selectAccessible(target); 316 }; 317 valueProps.separatorText = ""; 318 } else if (item.name === "relations") { 319 valueProps.defaultRep = Obj; 320 } else { 321 valueProps.noGrip = true; 322 } 323 324 const classList = ["node", "object-node"]; 325 if (focused) { 326 classList.push("focused"); 327 } 328 329 const depthPadding = depth * TREE_DEPTH_PADDING_INCREMENT; 330 331 return AccessibleProperty( 332 { 333 object, 334 focused, 335 accessibleFrontActorID: this.props.accessibleFront.actorID, 336 }, 337 () => 338 div( 339 { 340 className: classList.join(" "), 341 style: { 342 paddingInlineStart: depthPadding, 343 inlineSize: `calc(var(--accessibility-properties-item-width) - ${depthPadding}px)`, 344 }, 345 onClick: e => { 346 if (e.target.classList.contains("theme-twisty")) { 347 this.setExpanded(item, !expanded); 348 } 349 }, 350 }, 351 arrow, 352 span({ className: "object-label" }, item.name), 353 span({ className: "object-delimiter" }, ":"), 354 span({ className: "object-value" }, Rep(valueProps) || "") 355 ) 356 ); 357 } 358 359 render() { 360 const { expanded, active, focused } = this.state; 361 const { items, parents, accessibleFront, labelledby } = this.props; 362 363 if (accessibleFront) { 364 return VirtualizedTree({ 365 ref: "props", 366 key: "accessible-properties", 367 itemHeight: TREE_ROW_HEIGHT, 368 getRoots: () => items, 369 getKey: item => item.path, 370 getParent: item => parents.get(item), 371 getChildren: item => item.children, 372 isExpanded: item => expanded.has(item.path), 373 onExpand: item => this.setExpanded(item, true), 374 onCollapse: item => this.setExpanded(item, false), 375 onFocus: item => { 376 if (this.state.focused !== item.path) { 377 this.setState({ focused: item.path }); 378 } 379 }, 380 onActivate: item => { 381 if (item == null) { 382 this.setState({ active: null }); 383 } else if (this.state.active !== item.path) { 384 this.setState({ active: item.path }); 385 } 386 }, 387 focused: findByPath(focused, items), 388 active: findByPath(active, items), 389 renderItem: this.renderItem, 390 labelledby, 391 }); 392 } 393 394 return div( 395 { className: "info" }, 396 L10N.getStr("accessibility.accessible.notAvailable") 397 ); 398 } 399 } 400 401 /** 402 * Match accessibility object from relations targets to the grip that's being activated. 403 * 404 * @param {object} relations Object containing relations grouped by type and targets. 405 * @param {string} actorID Actor ID to match to the relation target. 406 * @return {object} Accessible front that matches the relation target. 407 */ 408 const findAccessibleTarget = (relations, actorID) => { 409 for (const relationType in relations) { 410 let targets = relations[relationType]; 411 targets = Array.isArray(targets) ? targets : [targets]; 412 for (const target of targets) { 413 if (target.actorID === actorID) { 414 return target; 415 } 416 } 417 } 418 419 return null; 420 }; 421 422 /** 423 * Find an item based on a given path. 424 * 425 * @param {string} path 426 * Key of the item to be looked up. 427 * @param {Array} items 428 * Accessibility properties array. 429 * @return {object?} 430 * Possibly found item. 431 */ 432 const findByPath = (path, items) => { 433 for (const item of items) { 434 if (item.path === path) { 435 return item; 436 } 437 438 const found = findByPath(path, item.children); 439 if (found) { 440 return found; 441 } 442 } 443 return null; 444 }; 445 446 /** 447 * Check if a given property is a DOMNode front. 448 * 449 * @param {object?} value A property to check for being a DOMNode. 450 * @return {boolean} A flag that indicates whether a property is a DOMNode. 451 */ 452 const isNodeFront = value => value && value.typeName === "domnode"; 453 454 /** 455 * Check if a given property is an Accessible front. 456 * 457 * @param {object?} value A property to check for being an Accessible. 458 * @return {boolean} A flag that indicates whether a property is an Accessible. 459 */ 460 const isAccessibleFront = value => value && value.typeName === "accessible"; 461 462 /** 463 * While waiting for a reps fix in https://github.com/firefox-devtools/reps/issues/92, 464 * translate accessibleFront to a grip-like object that can be used with an Accessible 465 * rep. 466 * 467 * @param {AccessibleFront} accessibleFront 468 * The AccessibleFront for which we want to create a grip-like object. 469 * @returns {object} a grip-like object that can be used with Reps. 470 */ 471 const translateAccessibleFrontToGrip = accessibleFront => ({ 472 actor: accessibleFront.actorID, 473 typeName: accessibleFront.typeName, 474 preview: { 475 name: accessibleFront.name, 476 role: accessibleFront.role, 477 // All the grid containers are assumed to be in the Accessibility tree. 478 isConnected: true, 479 }, 480 }); 481 482 const translateNodeFrontToGripWrapper = nodeFront => ({ 483 ...translateNodeFrontToGrip(nodeFront), 484 typeName: nodeFront.typeName, 485 }); 486 487 /** 488 * Build props ingestible by VirtualizedTree component. 489 * 490 * @param {object} props Component properties to be processed. 491 * @param {string} parentPath Unique path that is used to identify a Tree Node. 492 * @return {object} Processed properties. 493 */ 494 const makeItemsForDetails = (props, parentPath) => 495 Object.getOwnPropertyNames(props).map(name => { 496 let children = []; 497 const path = `${parentPath}/${name}`; 498 let contents = props[name]; 499 500 if (contents) { 501 if (isNodeFront(contents)) { 502 contents = translateNodeFrontToGripWrapper(contents); 503 name = "DOMNode"; 504 } else if (isAccessibleFront(contents)) { 505 contents = translateAccessibleFrontToGrip(contents); 506 } else if (Array.isArray(contents) || typeof contents === "object") { 507 children = makeItemsForDetails(contents, path); 508 } 509 } 510 511 return { name, path, contents, children }; 512 }); 513 514 const makeParentMap = items => { 515 const map = new WeakMap(); 516 517 function _traverse(item) { 518 if (item.children.length) { 519 for (const child of item.children) { 520 map.set(child, item); 521 _traverse(child); 522 } 523 } 524 } 525 526 items.forEach(_traverse); 527 return map; 528 }; 529 530 const mapStateToProps = ({ details }) => { 531 const { 532 accessible: accessibleFront, 533 DOMNode: nodeFront, 534 relations, 535 } = details; 536 if (!accessibleFront || !nodeFront) { 537 return {}; 538 } 539 540 const items = makeItemsForDetails( 541 ORDERED_PROPS.reduce((props, key) => { 542 if (key === "DOMNode") { 543 props.nodeFront = nodeFront; 544 } else if (key === "relations") { 545 props.relations = relations; 546 } else { 547 props[key] = accessibleFront[key]; 548 } 549 550 return props; 551 }, {}), 552 "" 553 ); 554 const parents = makeParentMap(items); 555 556 return { accessibleFront, nodeFront, items, parents, relations }; 557 }; 558 559 module.exports = connect(mapStateToProps)(Accessible);