/* 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"; const DevToolsUtils = require("devtools/shared/DevToolsUtils"); const { getCurrentZoom } = require("devtools/shared/layout/utils"); const { moveInfobar, } = require("devtools/server/actors/highlighters/utils/markup"); const { truncateString } = require("devtools/shared/inspector/utils"); const STRINGS_URI = "devtools/shared/locales/accessibility.properties"; loader.lazyRequireGetter( this, "LocalizationHelper", "devtools/shared/l10n", true ); DevToolsUtils.defineLazyGetter( this, "L10N", () => new LocalizationHelper(STRINGS_URI) ); const { accessibility: { AUDIT_TYPE, ISSUE_TYPE: { [AUDIT_TYPE.KEYBOARD]: { FOCUSABLE_NO_SEMANTICS, FOCUSABLE_POSITIVE_TABINDEX, INTERACTIVE_NO_ACTION, INTERACTIVE_NOT_FOCUSABLE, MOUSE_INTERACTIVE_ONLY, NO_FOCUS_VISIBLE, }, [AUDIT_TYPE.TEXT_LABEL]: { AREA_NO_NAME_FROM_ALT, DIALOG_NO_NAME, DOCUMENT_NO_TITLE, EMBED_NO_NAME, FIGURE_NO_NAME, FORM_FIELDSET_NO_NAME, FORM_FIELDSET_NO_NAME_FROM_LEGEND, FORM_NO_NAME, FORM_NO_VISIBLE_NAME, FORM_OPTGROUP_NO_NAME_FROM_LABEL, FRAME_NO_NAME, HEADING_NO_CONTENT, HEADING_NO_NAME, IFRAME_NO_NAME_FROM_TITLE, IMAGE_NO_NAME, INTERACTIVE_NO_NAME, MATHML_GLYPH_NO_NAME, TOOLBAR_NO_NAME, }, }, SCORES, }, } = require("devtools/shared/constants"); // Max string length for truncating accessible name values. const MAX_STRING_LENGTH = 50; /** * The AccessibleInfobar is a class responsible for creating the markup for the * accessible highlighter. It is also reponsible for updating content within the * infobar such as role and name values. */ class Infobar { constructor(highlighter) { this.highlighter = highlighter; this.audit = new Audit(this); } get markup() { return this.highlighter.markup; } get document() { return this.highlighter.win.document; } get bounds() { return this.highlighter._bounds; } get options() { return this.highlighter.options; } get prefix() { return this.highlighter.ID_CLASS_PREFIX; } get win() { return this.highlighter.win; } /** * Move the Infobar to the right place in the highlighter. * * @param {Element} container * Container of infobar. */ _moveInfobar(container) { // Position the infobar using accessible's bounds const { left: x, top: y, bottom, width } = this.bounds; const infobarBounds = { x, y, bottom, width }; moveInfobar(container, infobarBounds, this.win); } /** * Build markup for infobar. * * @param {Element} root * Root element to build infobar with. */ buildMarkup(root) { const container = this.markup.createNode({ parent: root, attributes: { class: "infobar-container", id: "infobar-container", "aria-hidden": "true", hidden: "true", }, prefix: this.prefix, }); const infobar = this.markup.createNode({ parent: container, attributes: { class: "infobar", id: "infobar", }, prefix: this.prefix, }); const infobarText = this.markup.createNode({ parent: infobar, attributes: { class: "infobar-text", id: "infobar-text", }, prefix: this.prefix, }); this.markup.createNode({ nodeType: "span", parent: infobarText, attributes: { class: "infobar-role", id: "infobar-role", }, prefix: this.prefix, }); this.markup.createNode({ nodeType: "span", parent: infobarText, attributes: { class: "infobar-name", id: "infobar-name", }, prefix: this.prefix, }); this.audit.buildMarkup(infobarText); } /** * Destroy the Infobar's highlighter. */ destroy() { this.highlighter = null; this.audit.destroy(); this.audit = null; } /** * Gets the element with the specified ID. * * @param {String} id * Element ID. * @return {Element} The element with specified ID. */ getElement(id) { return this.highlighter.getElement(id); } /** * Gets the text content of element. * * @param {String} id * Element ID to retrieve text content from. * @return {String} The text content of the element. */ getTextContent(id) { const anonymousContent = this.markup.content; return anonymousContent.getTextContentForElement(`${this.prefix}${id}`); } /** * Hide the accessible infobar. */ hide() { const container = this.getElement("infobar-container"); container.setAttribute("hidden", "true"); } /** * Show the accessible infobar highlighter. */ show() { const container = this.getElement("infobar-container"); // Remove accessible's infobar "hidden" attribute. We do this first to get the // computed styles of the infobar container. container.removeAttribute("hidden"); // Update the infobar's position and content. this.update(container); } /** * Update content of the infobar. */ update(container) { const { audit, name, role } = this.options; this.updateRole(role, this.getElement("infobar-role")); this.updateName(name, this.getElement("infobar-name")); this.audit.update(audit); // Position the infobar. this._moveInfobar(container); } /** * Sets the text content of the specified element. * * @param {Element} el * Element to set text content on. * @param {String} text * Text for content. */ setTextContent(el, text) { el.setTextContent(text); } /** * Show the accessible's name message. * * @param {String} name * Accessible's name value. * @param {Element} el * Element to set text content on. */ updateName(name, el) { const nameText = name ? `"${truncateString(name, MAX_STRING_LENGTH)}"` : ""; this.setTextContent(el, nameText); } /** * Show the accessible's role. * * @param {String} role * Accessible's role value. * @param {Element} el * Element to set text content on. */ updateRole(role, el) { this.setTextContent(el, role); } } /** * Audit component used within the accessible highlighter infobar. This component is * responsible for rendering and updating its containing AuditReport components that * display various audit information such as contrast ratio score. */ class Audit { constructor(infobar) { this.infobar = infobar; // A list of audit reports to be shown on the fly when highlighting an accessible // object. this.reports = { [AUDIT_TYPE.CONTRAST]: new ContrastRatio(this), [AUDIT_TYPE.KEYBOARD]: new Keyboard(this), [AUDIT_TYPE.TEXT_LABEL]: new TextLabel(this), }; } get prefix() { return this.infobar.prefix; } get markup() { return this.infobar.markup; } buildMarkup(root) { const audit = this.markup.createNode({ nodeType: "span", parent: root, attributes: { class: "infobar-audit", id: "infobar-audit", }, prefix: this.prefix, }); Object.values(this.reports).forEach(report => report.buildMarkup(audit)); } update(audit = {}) { const el = this.getElement("infobar-audit"); el.setAttribute("hidden", true); let updated = false; Object.values(this.reports).forEach(report => { if (report.update(audit)) { updated = true; } }); if (updated) { el.removeAttribute("hidden"); } } getElement(id) { return this.infobar.getElement(id); } setTextContent(el, text) { return this.infobar.setTextContent(el, text); } destroy() { this.infobar = null; Object.values(this.reports).forEach(report => report.destroy()); this.reports = null; } } /** * A common interface between audit report components used to render accessibility audit * information for the currently highlighted accessible object. */ class AuditReport { constructor(audit) { this.audit = audit; } get prefix() { return this.audit.prefix; } get markup() { return this.audit.markup; } getElement(id) { return this.audit.getElement(id); } setTextContent(el, text) { return this.audit.setTextContent(el, text); } destroy() { this.audit = null; } } /** * Contrast ratio audit report that is used to display contrast ratio score as part of the * inforbar, */ class ContrastRatio extends AuditReport { buildMarkup(root) { this.markup.createNode({ nodeType: "span", parent: root, attributes: { class: "contrast-ratio-label", id: "contrast-ratio-label", }, prefix: this.prefix, }); this.markup.createNode({ nodeType: "span", parent: root, attributes: { class: "contrast-ratio-error", id: "contrast-ratio-error", }, prefix: this.prefix, text: L10N.getStr("accessibility.contrast.ratio.error"), }); this.markup.createNode({ nodeType: "span", parent: root, attributes: { class: "contrast-ratio", id: "contrast-ratio-min", }, prefix: this.prefix, }); this.markup.createNode({ nodeType: "span", parent: root, attributes: { class: "contrast-ratio-separator", id: "contrast-ratio-separator", }, prefix: this.prefix, }); this.markup.createNode({ nodeType: "span", parent: root, attributes: { class: "contrast-ratio", id: "contrast-ratio-max", }, prefix: this.prefix, }); } _fillAndStyleContrastValue(el, { value, className, color, backgroundColor }) { value = value.toFixed(2); this.setTextContent(el, value); el.classList.add(className); el.setAttribute( "style", `--accessibility-highlighter-contrast-ratio-color: rgba(${color});` + `--accessibility-highlighter-contrast-ratio-bg: rgba(${backgroundColor});` ); el.removeAttribute("hidden"); } /** * Update contrast ratio score infobar markup. * @param {Object} * Audit report for a given highlighted accessible. * @return {Boolean} * True if the contrast ratio markup was updated correctly and infobar audit * block should be visible. */ update(audit) { const els = {}; for (const key of ["label", "min", "max", "error", "separator"]) { const el = (els[key] = this.getElement(`contrast-ratio-${key}`)); if (["min", "max"].includes(key)) { Object.values(SCORES).forEach(className => el.classList.remove(className) ); this.setTextContent(el, ""); } el.setAttribute("hidden", true); el.removeAttribute("style"); } if (!audit) { return false; } const contrastRatio = audit[AUDIT_TYPE.CONTRAST]; if (!contrastRatio) { return false; } const { isLargeText, error } = contrastRatio; this.setTextContent( els.label, L10N.getStr( `accessibility.contrast.ratio.label${isLargeText ? ".large" : ""}` ) ); els.label.removeAttribute("hidden"); if (error) { els.error.removeAttribute("hidden"); return true; } if (contrastRatio.value) { const { value, color, score, backgroundColor } = contrastRatio; this._fillAndStyleContrastValue(els.min, { value, className: score, color, backgroundColor, }); return true; } const { min, max, color, backgroundColorMin, backgroundColorMax, scoreMin, scoreMax, } = contrastRatio; this._fillAndStyleContrastValue(els.min, { value: min, className: scoreMin, color, backgroundColor: backgroundColorMin, }); els.separator.removeAttribute("hidden"); this._fillAndStyleContrastValue(els.max, { value: max, className: scoreMax, color, backgroundColor: backgroundColorMax, }); return true; } } /** * Keyboard audit report that is used to display a problem with keyboard * accessibility as part of the inforbar. */ class Keyboard extends AuditReport { /** * A map from keyboard issues to annotation component properties. */ static get ISSUE_TO_INFOBAR_LABEL_MAP() { return { [FOCUSABLE_NO_SEMANTICS]: "accessibility.keyboard.issue.semantics", [FOCUSABLE_POSITIVE_TABINDEX]: "accessibility.keyboard.issue.tabindex", [INTERACTIVE_NO_ACTION]: "accessibility.keyboard.issue.action", [INTERACTIVE_NOT_FOCUSABLE]: "accessibility.keyboard.issue.focusable", [MOUSE_INTERACTIVE_ONLY]: "accessibility.keyboard.issue.mouse.only", [NO_FOCUS_VISIBLE]: "accessibility.keyboard.issue.focus.visible", }; } buildMarkup(root) { this.markup.createNode({ nodeType: "span", parent: root, attributes: { class: "audit", id: "keyboard", }, prefix: this.prefix, }); } /** * Update keyboard audit infobar markup. * @param {Object} * Audit report for a given highlighted accessible. * @return {Boolean} * True if the keyboard markup was updated correctly and infobar audit * block should be visible. */ update(audit) { const el = this.getElement("keyboard"); el.setAttribute("hidden", true); Object.values(SCORES).forEach(className => el.classList.remove(className)); if (!audit) { return false; } const keyboardAudit = audit[AUDIT_TYPE.KEYBOARD]; if (!keyboardAudit) { return false; } const { issue, score } = keyboardAudit; this.setTextContent( el, L10N.getStr(Keyboard.ISSUE_TO_INFOBAR_LABEL_MAP[issue]) ); el.classList.add(score); el.removeAttribute("hidden"); return true; } } /** * Text label audit report that is used to display a problem with text alternatives * as part of the inforbar. */ class TextLabel extends AuditReport { /** * A map from text label issues to annotation component properties. */ static get ISSUE_TO_INFOBAR_LABEL_MAP() { return { [AREA_NO_NAME_FROM_ALT]: "accessibility.text.label.issue.area", [DIALOG_NO_NAME]: "accessibility.text.label.issue.dialog", [DOCUMENT_NO_TITLE]: "accessibility.text.label.issue.document.title", [EMBED_NO_NAME]: "accessibility.text.label.issue.embed", [FIGURE_NO_NAME]: "accessibility.text.label.issue.figure", [FORM_FIELDSET_NO_NAME]: "accessibility.text.label.issue.fieldset", [FORM_FIELDSET_NO_NAME_FROM_LEGEND]: "accessibility.text.label.issue.fieldset.legend2", [FORM_NO_NAME]: "accessibility.text.label.issue.form", [FORM_NO_VISIBLE_NAME]: "accessibility.text.label.issue.form.visible", [FORM_OPTGROUP_NO_NAME_FROM_LABEL]: "accessibility.text.label.issue.optgroup.label2", [FRAME_NO_NAME]: "accessibility.text.label.issue.frame", [HEADING_NO_CONTENT]: "accessibility.text.label.issue.heading.content", [HEADING_NO_NAME]: "accessibility.text.label.issue.heading", [IFRAME_NO_NAME_FROM_TITLE]: "accessibility.text.label.issue.iframe", [IMAGE_NO_NAME]: "accessibility.text.label.issue.image", [INTERACTIVE_NO_NAME]: "accessibility.text.label.issue.interactive", [MATHML_GLYPH_NO_NAME]: "accessibility.text.label.issue.glyph", [TOOLBAR_NO_NAME]: "accessibility.text.label.issue.toolbar", }; } buildMarkup(root) { this.markup.createNode({ nodeType: "span", parent: root, attributes: { class: "audit", id: "text-label", }, prefix: this.prefix, }); } /** * Update text label audit infobar markup. * @param {Object} * Audit report for a given highlighted accessible. * @return {Boolean} * True if the text label markup was updated correctly and infobar * audit block should be visible. */ update(audit) { const el = this.getElement("text-label"); el.setAttribute("hidden", true); Object.values(SCORES).forEach(className => el.classList.remove(className)); if (!audit) { return false; } const textLabelAudit = audit[AUDIT_TYPE.TEXT_LABEL]; if (!textLabelAudit) { return false; } const { issue, score } = textLabelAudit; this.setTextContent( el, L10N.getStr(TextLabel.ISSUE_TO_INFOBAR_LABEL_MAP[issue]) ); el.classList.add(score); el.removeAttribute("hidden"); return true; } } /** * A helper function that calculate accessible object bounds and positioning to * be used for highlighting. * * @param {Object} win * window that contains accessible object. * @param {Object} options * Object used for passing options: * - {Number} x * x coordinate of the top left corner of the accessible object * - {Number} y * y coordinate of the top left corner of the accessible object * - {Number} w * width of the the accessible object * - {Number} h * height of the the accessible object * @return {Object|null} Returns, if available, positioning and bounds information for * the accessible object. */ function getBounds(win, { x, y, w, h }) { const { mozInnerScreenX, mozInnerScreenY, scrollX, scrollY } = win; const zoom = getCurrentZoom(win); let left = x; let right = x + w; let top = y; let bottom = y + h; left -= mozInnerScreenX - scrollX; right -= mozInnerScreenX - scrollX; top -= mozInnerScreenY - scrollY; bottom -= mozInnerScreenY - scrollY; left *= zoom; right *= zoom; top *= zoom; bottom *= zoom; const width = right - left; const height = bottom - top; return { left, right, top, bottom, width, height }; } /** * A helper function that calculate accessible object bounds and positioning to * be used for highlighting in browser toolbox. * * @param {Object} win * window that contains accessible object. * @param {Object} options * Object used for passing options: * - {Number} x * x coordinate of the top left corner of the accessible object * - {Number} y * y coordinate of the top left corner of the accessible object * - {Number} w * width of the the accessible object * - {Number} h * height of the the accessible object * - {Number} zoom * zoom level of the accessible object's parent window * @return {Object|null} Returns, if available, positioning and bounds information for * the accessible object. */ function getBoundsXUL(win, { x, y, w, h, zoom }) { const { mozInnerScreenX, mozInnerScreenY } = win; let left = x; let right = x + w; let top = y; let bottom = y + h; left *= zoom; right *= zoom; top *= zoom; bottom *= zoom; left -= mozInnerScreenX; right -= mozInnerScreenX; top -= mozInnerScreenY; bottom -= mozInnerScreenY; const width = right - left; const height = bottom - top; return { left, right, top, bottom, width, height }; } exports.MAX_STRING_LENGTH = MAX_STRING_LENGTH; exports.getBounds = getBounds; exports.getBoundsXUL = getBoundsXUL; exports.Infobar = Infobar;