accessible.js (10304B)
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 5 "use strict"; 6 7 const { 8 AutoRefreshHighlighter, 9 } = require("resource://devtools/server/actors/highlighters/auto-refresh.js"); 10 const { 11 CanvasFrameAnonymousContentHelper, 12 isNodeValid, 13 } = require("resource://devtools/server/actors/highlighters/utils/markup.js"); 14 const { 15 TEXT_NODE, 16 DOCUMENT_NODE, 17 } = require("resource://devtools/shared/dom-node-constants.js"); 18 const { 19 getCurrentZoom, 20 setIgnoreLayoutChanges, 21 } = require("resource://devtools/shared/layout/utils.js"); 22 23 loader.lazyRequireGetter( 24 this, 25 ["getBounds", "getBoundsXUL", "Infobar"], 26 "resource://devtools/server/actors/highlighters/utils/accessibility.js", 27 true 28 ); 29 30 /** 31 * The AccessibleHighlighter draws the bounds of an accessible object. 32 * 33 * Usage example: 34 * 35 * let h = new AccessibleHighlighter(env); 36 * h.show(node, { x, y, w, h, [duration] }); 37 * h.hide(); 38 * h.destroy(); 39 * 40 * @param {number} options.x 41 * X coordinate of the top left corner of the accessible object 42 * @param {number} options.y 43 * Y coordinate of the top left corner of the accessible object 44 * @param {number} options.w 45 * Width of the the accessible object 46 * @param {number} options.h 47 * Height of the the accessible object 48 * @param {number} options.duration 49 * Duration of time that the highlighter should be shown. 50 * @param {string | null} options.name 51 * Name of the the accessible object 52 * @param {string} options.role 53 * Role of the the accessible object 54 * 55 * Structure: 56 * <div class="highlighter-container" aria-hidden="true"> 57 * <div class="accessible-root"> 58 * <svg class="accessible-elements" hidden="true"> 59 * <path class="accessible-bounds" points="..." /> 60 * </svg> 61 * <div class="accessible-infobar-container"> 62 * <div class="accessible-infobar"> 63 * <div class="accessible-infobar-text"> 64 * <span class="accessible-infobar-role">Accessible Role</span> 65 * <span class="accessible-infobar-name">Accessible Name</span> 66 * </div> 67 * </div> 68 * </div> 69 * </div> 70 * </div> 71 */ 72 class AccessibleHighlighter extends AutoRefreshHighlighter { 73 constructor(highlighterEnv) { 74 super(highlighterEnv); 75 this.accessibleInfobar = new Infobar(this); 76 77 this.markup = new CanvasFrameAnonymousContentHelper( 78 this.highlighterEnv, 79 this._buildMarkup.bind(this), 80 { 81 contentRootHostClassName: "devtools-highlighter-accessible", 82 } 83 ); 84 this.isReady = this.markup.initialize(); 85 86 this.onPageHide = this.onPageHide.bind(this); 87 this.onWillNavigate = this.onWillNavigate.bind(this); 88 89 this.highlighterEnv.on("will-navigate", this.onWillNavigate); 90 91 this.pageListenerTarget = highlighterEnv.pageListenerTarget; 92 this.pageListenerTarget.addEventListener("pagehide", this.onPageHide); 93 } 94 95 /** 96 * Static getter that indicates that AccessibleHighlighter supports 97 * highlighting in XUL windows. 98 */ 99 static get XULSupported() { 100 return true; 101 } 102 103 get supportsSimpleHighlighters() { 104 return true; 105 } 106 107 /** 108 * Build highlighter markup. 109 * 110 * @return {object} Container element for the highlighter markup. 111 */ 112 _buildMarkup() { 113 const container = this.markup.createNode({ 114 attributes: { 115 class: "highlighter-container", 116 "aria-hidden": "true", 117 }, 118 }); 119 120 this.rootEl = this.markup.createNode({ 121 parent: container, 122 attributes: { 123 id: "accessible-root", 124 class: 125 "accessible-root" + 126 (this.highlighterEnv.useSimpleHighlightersForReducedMotion 127 ? " use-simple-highlighters" 128 : ""), 129 }, 130 }); 131 132 // Build the SVG element. 133 const svg = this.markup.createSVGNode({ 134 nodeType: "svg", 135 parent: this.rootEl, 136 attributes: { 137 id: "accessible-elements", 138 width: "100%", 139 height: "100%", 140 hidden: "true", 141 }, 142 }); 143 144 this.markup.createSVGNode({ 145 nodeType: "path", 146 parent: svg, 147 attributes: { 148 class: "accessible-bounds", 149 id: "accessible-bounds", 150 }, 151 }); 152 153 // Build the accessible's infobar markup. 154 this.accessibleInfobar.buildMarkup(this.rootEl); 155 156 return container; 157 } 158 159 /** 160 * Destroy the nodes. Remove listeners. 161 */ 162 destroy() { 163 if (this._highlightTimer) { 164 clearTimeout(this._highlightTimer); 165 this._highlightTimer = null; 166 } 167 168 this.highlighterEnv.off("will-navigate", this.onWillNavigate); 169 this.pageListenerTarget.removeEventListener("pagehide", this.onPageHide); 170 this.pageListenerTarget = null; 171 172 AutoRefreshHighlighter.prototype.destroy.call(this); 173 174 this.accessibleInfobar.destroy(); 175 this.accessibleInfobar = null; 176 this.markup.destroy(); 177 this.rootEl = null; 178 } 179 180 /** 181 * Find an element in highlighter markup. 182 * 183 * @param {string} id 184 * Highlighter markup elemet id attribute. 185 * @return {DOMNode} Element in the highlighter markup. 186 */ 187 getElement(id) { 188 return this.markup.getElement(id); 189 } 190 191 /** 192 * Check if node is a valid element, document or text node. 193 * 194 * @override 195 * @param {DOMNode} node 196 * The node to highlight. 197 * @return {boolean} whether or not node is valid. 198 */ 199 _isNodeValid(node) { 200 return ( 201 super._isNodeValid(node) || 202 isNodeValid(node, TEXT_NODE) || 203 isNodeValid(node, DOCUMENT_NODE) 204 ); 205 } 206 207 /** 208 * Show the highlighter on a given accessible. 209 * 210 * @return {boolean} True if accessible is highlighted, false otherwise. 211 */ 212 _show() { 213 if (this._highlightTimer) { 214 clearTimeout(this._highlightTimer); 215 this._highlightTimer = null; 216 } 217 218 const { duration } = this.options; 219 const shown = this._update(); 220 if (shown) { 221 this.emit("highlighter-event", { options: this.options, type: "shown" }); 222 if (duration) { 223 this._highlightTimer = setTimeout(() => { 224 this.hide(); 225 }, duration); 226 } 227 } 228 229 return shown; 230 } 231 232 /** 233 * Update and show accessible bounds for a current accessible. 234 * 235 * @return {boolean} True if accessible is highlighted, false otherwise. 236 */ 237 _update() { 238 let shown = false; 239 setIgnoreLayoutChanges(true); 240 241 if (this._updateAccessibleBounds()) { 242 this._showAccessibleBounds(); 243 244 this.accessibleInfobar.show(); 245 246 shown = true; 247 } else { 248 // Nothing to highlight (0px rectangle like a <script> tag for instance) 249 this.hide(); 250 } 251 252 setIgnoreLayoutChanges( 253 false, 254 this.highlighterEnv.window.document.documentElement 255 ); 256 257 return shown; 258 } 259 260 /** 261 * Hide the highlighter. 262 */ 263 _hide() { 264 setIgnoreLayoutChanges(true); 265 this._hideAccessibleBounds(); 266 this.accessibleInfobar.hide(); 267 setIgnoreLayoutChanges( 268 false, 269 this.highlighterEnv.window.document.documentElement 270 ); 271 } 272 273 /** 274 * Public API method to temporarily hide accessible bounds for things like 275 * color contrast calculation. 276 */ 277 hideAccessibleBounds() { 278 if (this.getElement("accessible-elements").hasAttribute("hidden")) { 279 return; 280 } 281 282 this._hideAccessibleBounds(); 283 this._shouldRestoreBoundsVisibility = true; 284 } 285 286 /** 287 * Public API method to show accessible bounds in case they were temporarily 288 * hidden. 289 */ 290 showAccessibleBounds() { 291 if (this._shouldRestoreBoundsVisibility) { 292 this._showAccessibleBounds(); 293 } 294 } 295 296 /** 297 * Hide the accessible bounds container. 298 */ 299 _hideAccessibleBounds() { 300 this._shouldRestoreBoundsVisibility = null; 301 setIgnoreLayoutChanges(true); 302 this.getElement("accessible-elements").setAttribute("hidden", "true"); 303 setIgnoreLayoutChanges( 304 false, 305 this.highlighterEnv.window.document.documentElement 306 ); 307 } 308 309 /** 310 * Show the accessible bounds container. 311 */ 312 _showAccessibleBounds() { 313 this._shouldRestoreBoundsVisibility = null; 314 if (!this.currentNode || !this.highlighterEnv.window) { 315 return; 316 } 317 318 setIgnoreLayoutChanges(true); 319 this.getElement("accessible-elements").removeAttribute("hidden"); 320 setIgnoreLayoutChanges( 321 false, 322 this.highlighterEnv.window.document.documentElement 323 ); 324 } 325 326 /** 327 * Get current accessible bounds. 328 * 329 * @return {object | null} Returns, if available, positioning and bounds 330 * information for the accessible object. 331 */ 332 get _bounds() { 333 let { win, options } = this; 334 let getBoundsFn = getBounds; 335 if (this.options.isXUL) { 336 // Zoom level for the top level browser window does not change and only 337 // inner frames do. So we need to get the zoom level of the current node's 338 // parent window. 339 let zoom = getCurrentZoom(this.currentNode); 340 zoom *= zoom; 341 options = { ...options, zoom }; 342 getBoundsFn = getBoundsXUL; 343 win = this.win.parent.ownerGlobal; 344 } 345 346 return getBoundsFn(win, options); 347 } 348 349 /** 350 * Update accessible bounds for a current accessible. Re-draw highlighter 351 * markup. 352 * 353 * @return {boolean} True if accessible is highlighted, false otherwise. 354 */ 355 _updateAccessibleBounds() { 356 const bounds = this._bounds; 357 if (!bounds) { 358 this._hide(); 359 return false; 360 } 361 362 const boundsEl = this.getElement("accessible-bounds"); 363 const { left, right, top, bottom } = bounds; 364 const path = `M${left},${top} L${right},${top} L${right},${bottom} L${left},${bottom} L${left},${top}`; 365 boundsEl.setAttribute("d", path); 366 367 // Un-zoom the root wrapper if the page was zoomed. 368 this.markup.scaleRootElement(this.currentNode, "accessible-elements"); 369 370 return true; 371 } 372 373 /** 374 * Hide highlighter on page hide. 375 */ 376 onPageHide({ target }) { 377 // If a pagehide event is triggered for current window's highlighter, hide 378 // the highlighter. 379 if (target.defaultView === this.win) { 380 this.hide(); 381 } 382 } 383 384 /** 385 * Hide highlighter on navigation. 386 */ 387 onWillNavigate({ isTopLevel }) { 388 if (isTopLevel) { 389 this.hide(); 390 } 391 } 392 } 393 394 exports.AccessibleHighlighter = AccessibleHighlighter;