accessibility.js (19745B)
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 DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js"); 8 const { 9 getCurrentZoom, 10 } = require("resource://devtools/shared/layout/utils.js"); 11 const { 12 moveInfobar, 13 } = require("resource://devtools/server/actors/highlighters/utils/markup.js"); 14 const { truncateString } = require("resource://devtools/shared/string.js"); 15 16 const STRINGS_URI = "devtools/shared/locales/accessibility.properties"; 17 loader.lazyRequireGetter( 18 this, 19 "LocalizationHelper", 20 "resource://devtools/shared/l10n.js", 21 true 22 ); 23 DevToolsUtils.defineLazyGetter( 24 this, 25 "L10N", 26 () => new LocalizationHelper(STRINGS_URI) 27 ); 28 29 const { 30 accessibility: { 31 AUDIT_TYPE, 32 ISSUE_TYPE: { 33 [AUDIT_TYPE.KEYBOARD]: { 34 FOCUSABLE_NO_SEMANTICS, 35 FOCUSABLE_POSITIVE_TABINDEX, 36 INTERACTIVE_NO_ACTION, 37 INTERACTIVE_NOT_FOCUSABLE, 38 MOUSE_INTERACTIVE_ONLY, 39 NO_FOCUS_VISIBLE, 40 }, 41 [AUDIT_TYPE.TEXT_LABEL]: { 42 AREA_NO_NAME_FROM_ALT, 43 DIALOG_NO_NAME, 44 DOCUMENT_NO_TITLE, 45 EMBED_NO_NAME, 46 FIGURE_NO_NAME, 47 FORM_FIELDSET_NO_NAME, 48 FORM_FIELDSET_NO_NAME_FROM_LEGEND, 49 FORM_NO_NAME, 50 FORM_NO_VISIBLE_NAME, 51 FORM_OPTGROUP_NO_NAME_FROM_LABEL, 52 FRAME_NO_NAME, 53 HEADING_NO_CONTENT, 54 HEADING_NO_NAME, 55 IFRAME_NO_NAME_FROM_TITLE, 56 IMAGE_NO_NAME, 57 INTERACTIVE_NO_NAME, 58 MATHML_GLYPH_NO_NAME, 59 TOOLBAR_NO_NAME, 60 }, 61 }, 62 SCORES, 63 }, 64 } = require("resource://devtools/shared/constants.js"); 65 66 // Max string length for truncating accessible name values. 67 const MAX_STRING_LENGTH = 50; 68 69 /** 70 * The AccessibleInfobar is a class responsible for creating the markup for the 71 * accessible highlighter. It is also reponsible for updating content within the 72 * infobar such as role and name values. 73 */ 74 class Infobar { 75 constructor(highlighter) { 76 this.highlighter = highlighter; 77 this.audit = new Audit(this); 78 } 79 80 get markup() { 81 return this.highlighter.markup; 82 } 83 84 get document() { 85 return this.highlighter.win.document; 86 } 87 88 get bounds() { 89 return this.highlighter._bounds; 90 } 91 92 get options() { 93 return this.highlighter.options; 94 } 95 96 get win() { 97 return this.highlighter.win; 98 } 99 100 /** 101 * Move the Infobar to the right place in the highlighter. 102 * 103 * @param {Element} container 104 * Container of infobar. 105 */ 106 _moveInfobar(container) { 107 // Position the infobar using accessible's bounds 108 const { left: x, top: y, bottom, width } = this.bounds; 109 const infobarBounds = { x, y, bottom, width }; 110 111 moveInfobar(container, infobarBounds, this.win); 112 } 113 114 /** 115 * Build markup for infobar. 116 * 117 * @param {Element} root 118 * Root element to build infobar with. 119 */ 120 buildMarkup(root) { 121 const container = this.markup.createNode({ 122 parent: root, 123 attributes: { 124 class: "accessible-infobar-container", 125 id: "accessible-infobar-container", 126 "aria-hidden": "true", 127 hidden: "true", 128 }, 129 }); 130 131 const infobar = this.markup.createNode({ 132 parent: container, 133 attributes: { 134 class: "accessible-infobar", 135 id: "accessible-infobar", 136 }, 137 }); 138 139 const infobarText = this.markup.createNode({ 140 parent: infobar, 141 attributes: { 142 class: "accessible-infobar-text", 143 id: "accessible-infobar-text", 144 }, 145 }); 146 147 this.markup.createNode({ 148 nodeType: "span", 149 parent: infobarText, 150 attributes: { 151 class: "accessible-infobar-role", 152 id: "accessible-infobar-role", 153 }, 154 }); 155 156 this.markup.createNode({ 157 nodeType: "span", 158 parent: infobarText, 159 attributes: { 160 class: "accessible-infobar-name", 161 id: "accessible-infobar-name", 162 }, 163 }); 164 165 this.audit.buildMarkup(infobarText); 166 } 167 168 /** 169 * Destroy the Infobar's highlighter. 170 */ 171 destroy() { 172 this.highlighter = null; 173 this.audit.destroy(); 174 this.audit = null; 175 } 176 177 /** 178 * Gets the element with the specified ID. 179 * 180 * @param {string} id 181 * Element ID. 182 * @return {Element} The element with specified ID. 183 */ 184 getElement(id) { 185 return this.highlighter.getElement(id); 186 } 187 188 /** 189 * Gets the text content of element. 190 * 191 * @param {string} id 192 * Element ID to retrieve text content from. 193 * @return {string} The text content of the element. 194 */ 195 getTextContent(id) { 196 const anonymousContent = this.markup.content; 197 return anonymousContent.root.getElementById(id).textContent; 198 } 199 200 /** 201 * Hide the accessible infobar. 202 */ 203 hide() { 204 const container = this.getElement("accessible-infobar-container"); 205 container.setAttribute("hidden", "true"); 206 } 207 208 /** 209 * Show the accessible infobar highlighter. 210 */ 211 show() { 212 const container = this.getElement("accessible-infobar-container"); 213 214 // Remove accessible's infobar "hidden" attribute. We do this first to get the 215 // computed styles of the infobar container. 216 container.removeAttribute("hidden"); 217 218 // Update the infobar's position and content. 219 this.update(container); 220 } 221 222 /** 223 * Update content of the infobar. 224 */ 225 update(container) { 226 const { audit, name, role } = this.options; 227 228 this.updateRole(role, this.getElement("accessible-infobar-role")); 229 this.updateName(name, this.getElement("accessible-infobar-name")); 230 this.audit.update(audit); 231 232 // Position the infobar. 233 this._moveInfobar(container); 234 } 235 236 /** 237 * Sets the text content of the specified element. 238 * 239 * @param {Element} el 240 * Element to set text content on. 241 * @param {string} text 242 * Text for content. 243 */ 244 setTextContent(el, text) { 245 el.setTextContent(text); 246 } 247 248 /** 249 * Show the accessible's name message. 250 * 251 * @param {string} name 252 * Accessible's name value. 253 * @param {Element} el 254 * Element to set text content on. 255 */ 256 updateName(name, el) { 257 const nameText = name ? `"${truncateString(name, MAX_STRING_LENGTH)}"` : ""; 258 this.setTextContent(el, nameText); 259 } 260 261 /** 262 * Show the accessible's role. 263 * 264 * @param {string} role 265 * Accessible's role value. 266 * @param {Element} el 267 * Element to set text content on. 268 */ 269 updateRole(role, el) { 270 this.setTextContent(el, role); 271 } 272 } 273 274 /** 275 * Audit component used within the accessible highlighter infobar. This component is 276 * responsible for rendering and updating its containing AuditReport components that 277 * display various audit information such as contrast ratio score. 278 */ 279 class Audit { 280 constructor(infobar) { 281 this.infobar = infobar; 282 283 // A list of audit reports to be shown on the fly when highlighting an accessible 284 // object. 285 this.reports = { 286 [AUDIT_TYPE.CONTRAST]: new ContrastRatio(this), 287 [AUDIT_TYPE.KEYBOARD]: new Keyboard(this), 288 [AUDIT_TYPE.TEXT_LABEL]: new TextLabel(this), 289 }; 290 } 291 292 get markup() { 293 return this.infobar.markup; 294 } 295 296 buildMarkup(root) { 297 const audit = this.markup.createNode({ 298 nodeType: "span", 299 parent: root, 300 attributes: { 301 class: "accessible-infobar-audit", 302 id: "accessible-infobar-audit", 303 }, 304 }); 305 306 Object.values(this.reports).forEach(report => report.buildMarkup(audit)); 307 } 308 309 update(audit = {}) { 310 const el = this.getElement("accessible-infobar-audit"); 311 el.setAttribute("hidden", true); 312 313 let updated = false; 314 Object.values(this.reports).forEach(report => { 315 if (report.update(audit)) { 316 updated = true; 317 } 318 }); 319 320 if (updated) { 321 el.removeAttribute("hidden"); 322 } 323 } 324 325 getElement(id) { 326 return this.infobar.getElement(id); 327 } 328 329 setTextContent(el, text) { 330 return this.infobar.setTextContent(el, text); 331 } 332 333 destroy() { 334 this.infobar = null; 335 Object.values(this.reports).forEach(report => report.destroy()); 336 this.reports = null; 337 } 338 } 339 340 /** 341 * A common interface between audit report components used to render accessibility audit 342 * information for the currently highlighted accessible object. 343 */ 344 class AuditReport { 345 constructor(audit) { 346 this.audit = audit; 347 } 348 349 get markup() { 350 return this.audit.markup; 351 } 352 353 getElement(id) { 354 return this.audit.getElement(id); 355 } 356 357 setTextContent(el, text) { 358 return this.audit.setTextContent(el, text); 359 } 360 361 destroy() { 362 this.audit = null; 363 } 364 } 365 366 /** 367 * Contrast ratio audit report that is used to display contrast ratio score as part of the 368 * inforbar, 369 */ 370 class ContrastRatio extends AuditReport { 371 buildMarkup(root) { 372 this.markup.createNode({ 373 nodeType: "span", 374 parent: root, 375 attributes: { 376 class: "accessible-contrast-ratio-label", 377 id: "accessible-contrast-ratio-label", 378 }, 379 }); 380 381 this.markup.createNode({ 382 nodeType: "span", 383 parent: root, 384 attributes: { 385 class: "accessible-contrast-ratio-error", 386 id: "accessible-contrast-ratio-error", 387 }, 388 text: L10N.getStr("accessibility.contrast.ratio.error"), 389 }); 390 391 this.markup.createNode({ 392 nodeType: "span", 393 parent: root, 394 attributes: { 395 class: "accessible-contrast-ratio", 396 id: "accessible-contrast-ratio-min", 397 }, 398 }); 399 400 this.markup.createNode({ 401 nodeType: "span", 402 parent: root, 403 attributes: { 404 class: "accessible-contrast-ratio-separator", 405 id: "accessible-contrast-ratio-separator", 406 }, 407 }); 408 409 this.markup.createNode({ 410 nodeType: "span", 411 parent: root, 412 attributes: { 413 class: "accessible-contrast-ratio", 414 id: "accessible-contrast-ratio-max", 415 }, 416 }); 417 } 418 419 _fillAndStyleContrastValue(el, { value, className, color, backgroundColor }) { 420 value = value.toFixed(2); 421 this.setTextContent(el, value); 422 el.classList?.add(className); 423 el.setAttribute( 424 "style", 425 `--accessibility-highlighter-contrast-ratio-color: rgba(${color});` + 426 `--accessibility-highlighter-contrast-ratio-bg: rgba(${backgroundColor});` 427 ); 428 el.removeAttribute("hidden"); 429 } 430 431 /** 432 * Update contrast ratio score infobar markup. 433 * 434 * @param {object} 435 * Audit report for a given highlighted accessible. 436 * @return {boolean} 437 * True if the contrast ratio markup was updated correctly and infobar audit 438 * block should be visible. 439 */ 440 update(audit) { 441 const els = {}; 442 for (const key of ["label", "min", "max", "error", "separator"]) { 443 const el = (els[key] = this.getElement( 444 `accessible-contrast-ratio-${key}` 445 )); 446 if (["min", "max"].includes(key)) { 447 Object.values(SCORES).forEach(className => 448 el.classList?.remove(className) 449 ); 450 this.setTextContent(el, ""); 451 } 452 453 el.setAttribute("hidden", true); 454 el.removeAttribute("style"); 455 } 456 457 if (!audit) { 458 return false; 459 } 460 461 const contrastRatio = audit[AUDIT_TYPE.CONTRAST]; 462 if (!contrastRatio) { 463 return false; 464 } 465 466 const { isLargeText, error } = contrastRatio; 467 this.setTextContent( 468 els.label, 469 L10N.getStr( 470 `accessibility.contrast.ratio.label${isLargeText ? ".large" : ""}` 471 ) 472 ); 473 els.label.removeAttribute("hidden"); 474 if (error) { 475 els.error.removeAttribute("hidden"); 476 return true; 477 } 478 479 if (contrastRatio.value) { 480 const { value, color, score, backgroundColor } = contrastRatio; 481 this._fillAndStyleContrastValue(els.min, { 482 value, 483 className: score, 484 color, 485 backgroundColor, 486 }); 487 return true; 488 } 489 490 const { 491 min, 492 max, 493 color, 494 backgroundColorMin, 495 backgroundColorMax, 496 scoreMin, 497 scoreMax, 498 } = contrastRatio; 499 this._fillAndStyleContrastValue(els.min, { 500 value: min, 501 className: scoreMin, 502 color, 503 backgroundColor: backgroundColorMin, 504 }); 505 els.separator.removeAttribute("hidden"); 506 this._fillAndStyleContrastValue(els.max, { 507 value: max, 508 className: scoreMax, 509 color, 510 backgroundColor: backgroundColorMax, 511 }); 512 513 return true; 514 } 515 } 516 517 /** 518 * Keyboard audit report that is used to display a problem with keyboard 519 * accessibility as part of the inforbar. 520 */ 521 class Keyboard extends AuditReport { 522 /** 523 * A map from keyboard issues to annotation component properties. 524 */ 525 static get ISSUE_TO_INFOBAR_LABEL_MAP() { 526 return { 527 [FOCUSABLE_NO_SEMANTICS]: "accessibility.keyboard.issue.semantics", 528 [FOCUSABLE_POSITIVE_TABINDEX]: "accessibility.keyboard.issue.tabindex", 529 [INTERACTIVE_NO_ACTION]: "accessibility.keyboard.issue.action", 530 [INTERACTIVE_NOT_FOCUSABLE]: "accessibility.keyboard.issue.focusable", 531 [MOUSE_INTERACTIVE_ONLY]: "accessibility.keyboard.issue.mouse.only", 532 [NO_FOCUS_VISIBLE]: "accessibility.keyboard.issue.focus.visible", 533 }; 534 } 535 536 buildMarkup(root) { 537 this.markup.createNode({ 538 nodeType: "span", 539 parent: root, 540 attributes: { 541 class: "accessible-audit", 542 id: "accessible-keyboard", 543 }, 544 }); 545 } 546 547 /** 548 * Update keyboard audit infobar markup. 549 * 550 * @param {object} 551 * Audit report for a given highlighted accessible. 552 * @return {boolean} 553 * True if the keyboard markup was updated correctly and infobar audit 554 * block should be visible. 555 */ 556 update(audit) { 557 const el = this.getElement("accessible-keyboard"); 558 el.setAttribute("hidden", true); 559 Object.values(SCORES).forEach(className => el.classList?.remove(className)); 560 561 if (!audit) { 562 return false; 563 } 564 565 const keyboardAudit = audit[AUDIT_TYPE.KEYBOARD]; 566 if (!keyboardAudit) { 567 return false; 568 } 569 570 const { issue, score } = keyboardAudit; 571 this.setTextContent( 572 el, 573 L10N.getStr(Keyboard.ISSUE_TO_INFOBAR_LABEL_MAP[issue]) 574 ); 575 el.classList?.add(score); 576 el.removeAttribute("hidden"); 577 578 return true; 579 } 580 } 581 582 /** 583 * Text label audit report that is used to display a problem with text alternatives 584 * as part of the inforbar. 585 */ 586 class TextLabel extends AuditReport { 587 /** 588 * A map from text label issues to annotation component properties. 589 */ 590 static get ISSUE_TO_INFOBAR_LABEL_MAP() { 591 return { 592 [AREA_NO_NAME_FROM_ALT]: "accessibility.text.label.issue.area", 593 [DIALOG_NO_NAME]: "accessibility.text.label.issue.dialog", 594 [DOCUMENT_NO_TITLE]: "accessibility.text.label.issue.document.title", 595 [EMBED_NO_NAME]: "accessibility.text.label.issue.embed", 596 [FIGURE_NO_NAME]: "accessibility.text.label.issue.figure", 597 [FORM_FIELDSET_NO_NAME]: "accessibility.text.label.issue.fieldset", 598 [FORM_FIELDSET_NO_NAME_FROM_LEGEND]: 599 "accessibility.text.label.issue.fieldset.legend2", 600 [FORM_NO_NAME]: "accessibility.text.label.issue.form", 601 [FORM_NO_VISIBLE_NAME]: "accessibility.text.label.issue.form.visible", 602 [FORM_OPTGROUP_NO_NAME_FROM_LABEL]: 603 "accessibility.text.label.issue.optgroup.label2", 604 [FRAME_NO_NAME]: "accessibility.text.label.issue.frame", 605 [HEADING_NO_CONTENT]: "accessibility.text.label.issue.heading.content", 606 [HEADING_NO_NAME]: "accessibility.text.label.issue.heading", 607 [IFRAME_NO_NAME_FROM_TITLE]: "accessibility.text.label.issue.iframe", 608 [IMAGE_NO_NAME]: "accessibility.text.label.issue.image", 609 [INTERACTIVE_NO_NAME]: "accessibility.text.label.issue.interactive", 610 [MATHML_GLYPH_NO_NAME]: "accessibility.text.label.issue.glyph", 611 [TOOLBAR_NO_NAME]: "accessibility.text.label.issue.toolbar", 612 }; 613 } 614 615 buildMarkup(root) { 616 this.markup.createNode({ 617 nodeType: "span", 618 parent: root, 619 attributes: { 620 class: "accessible-audit", 621 id: "accessible-text-label", 622 }, 623 }); 624 } 625 626 /** 627 * Update text label audit infobar markup. 628 * 629 * @param {object} 630 * Audit report for a given highlighted accessible. 631 * @return {boolean} 632 * True if the text label markup was updated correctly and infobar 633 * audit block should be visible. 634 */ 635 update(audit) { 636 const el = this.getElement("accessible-text-label"); 637 el.setAttribute("hidden", true); 638 Object.values(SCORES).forEach(className => el.classList?.remove(className)); 639 640 if (!audit) { 641 return false; 642 } 643 644 const textLabelAudit = audit[AUDIT_TYPE.TEXT_LABEL]; 645 if (!textLabelAudit) { 646 return false; 647 } 648 649 const { issue, score } = textLabelAudit; 650 this.setTextContent( 651 el, 652 L10N.getStr(TextLabel.ISSUE_TO_INFOBAR_LABEL_MAP[issue]) 653 ); 654 el.classList?.add(score); 655 el.removeAttribute("hidden"); 656 657 return true; 658 } 659 } 660 661 /** 662 * A helper function that calculate accessible object bounds and positioning to 663 * be used for highlighting. 664 * 665 * @param {object} win 666 * window that contains accessible object. 667 * @param {object} options 668 * Object used for passing options: 669 * - {Number} x 670 * x coordinate of the top left corner of the accessible object 671 * - {Number} y 672 * y coordinate of the top left corner of the accessible object 673 * - {Number} w 674 * width of the the accessible object 675 * - {Number} h 676 * height of the the accessible object 677 * @return {object | null} Returns, if available, positioning and bounds information for 678 * the accessible object. 679 */ 680 function getBounds(win, { x, y, w, h }) { 681 const { mozInnerScreenX, mozInnerScreenY, scrollX, scrollY } = win; 682 const zoom = getCurrentZoom(win); 683 let left = x; 684 let right = x + w; 685 let top = y; 686 let bottom = y + h; 687 688 left -= mozInnerScreenX - scrollX; 689 right -= mozInnerScreenX - scrollX; 690 top -= mozInnerScreenY - scrollY; 691 bottom -= mozInnerScreenY - scrollY; 692 693 left *= zoom; 694 right *= zoom; 695 top *= zoom; 696 bottom *= zoom; 697 698 const width = right - left; 699 const height = bottom - top; 700 701 return { left, right, top, bottom, width, height }; 702 } 703 704 /** 705 * A helper function that calculate accessible object bounds and positioning to 706 * be used for highlighting in browser toolbox. 707 * 708 * @param {object} win 709 * window that contains accessible object. 710 * @param {object} options 711 * Object used for passing options: 712 * - {Number} x 713 * x coordinate of the top left corner of the accessible object 714 * - {Number} y 715 * y coordinate of the top left corner of the accessible object 716 * - {Number} w 717 * width of the the accessible object 718 * - {Number} h 719 * height of the the accessible object 720 * - {Number} zoom 721 * zoom level of the accessible object's parent window 722 * @return {object | null} Returns, if available, positioning and bounds information for 723 * the accessible object. 724 */ 725 function getBoundsXUL(win, { x, y, w, h, zoom }) { 726 const { mozInnerScreenX, mozInnerScreenY } = win; 727 let left = x; 728 let right = x + w; 729 let top = y; 730 let bottom = y + h; 731 732 left *= zoom; 733 right *= zoom; 734 top *= zoom; 735 bottom *= zoom; 736 737 left -= mozInnerScreenX; 738 right -= mozInnerScreenX; 739 top -= mozInnerScreenY; 740 bottom -= mozInnerScreenY; 741 742 const width = right - left; 743 const height = bottom - top; 744 745 return { left, right, top, bottom, width, height }; 746 } 747 748 exports.MAX_STRING_LENGTH = MAX_STRING_LENGTH; 749 exports.getBounds = getBounds; 750 exports.getBoundsXUL = getBoundsXUL; 751 exports.Infobar = Infobar;