diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /devtools/server/actors/accessibility | |
parent | Initial commit. (diff) | |
download | thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/server/actors/accessibility')
-rw-r--r-- | devtools/server/actors/accessibility/accessibility.js | 130 | ||||
-rw-r--r-- | devtools/server/actors/accessibility/accessible.js | 675 | ||||
-rw-r--r-- | devtools/server/actors/accessibility/audit/contrast.js | 306 | ||||
-rw-r--r-- | devtools/server/actors/accessibility/audit/keyboard.js | 514 | ||||
-rw-r--r-- | devtools/server/actors/accessibility/audit/moz.build | 12 | ||||
-rw-r--r-- | devtools/server/actors/accessibility/audit/text-label.js | 438 | ||||
-rw-r--r-- | devtools/server/actors/accessibility/constants.js | 59 | ||||
-rw-r--r-- | devtools/server/actors/accessibility/moz.build | 20 | ||||
-rw-r--r-- | devtools/server/actors/accessibility/parent-accessibility.js | 154 | ||||
-rw-r--r-- | devtools/server/actors/accessibility/simulator.js | 81 | ||||
-rw-r--r-- | devtools/server/actors/accessibility/walker.js | 1324 | ||||
-rw-r--r-- | devtools/server/actors/accessibility/worker.js | 105 |
12 files changed, 3818 insertions, 0 deletions
diff --git a/devtools/server/actors/accessibility/accessibility.js b/devtools/server/actors/accessibility/accessibility.js new file mode 100644 index 0000000000..6bdf0e9f32 --- /dev/null +++ b/devtools/server/actors/accessibility/accessibility.js @@ -0,0 +1,130 @@ +/* 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 { Actor } = require("resource://devtools/shared/protocol.js"); +const { + accessibilitySpec, +} = require("resource://devtools/shared/specs/accessibility.js"); + +loader.lazyRequireGetter( + this, + "AccessibleWalkerActor", + "resource://devtools/server/actors/accessibility/walker.js", + true +); +loader.lazyRequireGetter( + this, + "SimulatorActor", + "resource://devtools/server/actors/accessibility/simulator.js", + true +); + +/** + * The AccessibilityActor is a top level container actor that initializes + * accessible walker and is the top-most point of interaction for accessibility + * tools UI for a top level content process. + */ +class AccessibilityActor extends Actor { + constructor(conn, targetActor) { + super(conn, accessibilitySpec); + // This event is fired when accessibility service is initialized or shut + // down. "init" and "shutdown" events are only relayed when the enabled + // state matches the event (e.g. the event came from the same process as + // the actor). + Services.obs.addObserver(this, "a11y-init-or-shutdown"); + this.targetActor = targetActor; + } + + getTraits() { + // The traits are used to know if accessibility actors support particular + // API on the server side. + return { + // @backward-compat { version 84 } Fixed on the server by Bug 1654956. + tabbingOrder: true, + }; + } + + bootstrap() { + return { + enabled: this.enabled, + }; + } + + get enabled() { + return Services.appinfo.accessibilityEnabled; + } + + /** + * Observe Accessibility service init and shutdown events. It relays these + * events to AccessibilityFront if the event is fired for the a11y service + * that lives in the same process. + * + * @param {null} subject + * Not used. + * @param {String} topic + * Name of the a11y service event: "a11y-init-or-shutdown". + * @param {String} data + * "0" corresponds to shutdown and "1" to init. + */ + observe(subject, topic, data) { + const enabled = data === "1"; + if (enabled && this.enabled) { + this.emit("init"); + } else if (!enabled && !this.enabled) { + if (this.walker) { + this.walker.reset(); + } + + this.emit("shutdown"); + } + } + + /** + * Get or create AccessibilityWalker actor, similar to WalkerActor. + * + * @return {Object} + * AccessibleWalkerActor for the current tab. + */ + getWalker() { + if (!this.walker) { + this.walker = new AccessibleWalkerActor(this.conn, this.targetActor); + this.manage(this.walker); + } + return this.walker; + } + + /** + * Get or create Simulator actor, managed by AccessibilityActor, + * only if webrender is enabled. Simulator applies color filters on an entire + * viewport. This needs to be done using webrender and not an SVG + * <feColorMatrix> since it is accelerated and scrolling with filter applied + * needs to be smooth (Bug1431466). + * + * @return {Object|null} + * SimulatorActor for the current tab. + */ + getSimulator() { + if (!this.simulator) { + this.simulator = new SimulatorActor(this.conn, this.targetActor); + this.manage(this.simulator); + } + + return this.simulator; + } + + /** + * Destroy accessibility actor. This method also shutsdown accessibility + * service if possible. + */ + async destroy() { + super.destroy(); + Services.obs.removeObserver(this, "a11y-init-or-shutdown"); + this.walker = null; + this.targetActor = null; + } +} + +exports.AccessibilityActor = AccessibilityActor; diff --git a/devtools/server/actors/accessibility/accessible.js b/devtools/server/actors/accessibility/accessible.js new file mode 100644 index 0000000000..1866d0a91b --- /dev/null +++ b/devtools/server/actors/accessibility/accessible.js @@ -0,0 +1,675 @@ +/* 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 { Actor } = require("resource://devtools/shared/protocol.js"); +const { + accessibleSpec, +} = require("resource://devtools/shared/specs/accessibility.js"); + +const { + accessibility: { AUDIT_TYPE }, +} = require("resource://devtools/shared/constants.js"); + +loader.lazyRequireGetter( + this, + "getContrastRatioFor", + "resource://devtools/server/actors/accessibility/audit/contrast.js", + true +); +loader.lazyRequireGetter( + this, + "auditKeyboard", + "resource://devtools/server/actors/accessibility/audit/keyboard.js", + true +); +loader.lazyRequireGetter( + this, + "auditTextLabel", + "resource://devtools/server/actors/accessibility/audit/text-label.js", + true +); +loader.lazyRequireGetter( + this, + "isDefunct", + "resource://devtools/server/actors/utils/accessibility.js", + true +); +loader.lazyRequireGetter( + this, + "findCssSelector", + "resource://devtools/shared/inspector/css-logic.js", + true +); +loader.lazyRequireGetter( + this, + "events", + "resource://devtools/shared/event-emitter.js" +); +loader.lazyRequireGetter( + this, + "getBounds", + "resource://devtools/server/actors/highlighters/utils/accessibility.js", + true +); +loader.lazyRequireGetter( + this, + "isFrameWithChildTarget", + "resource://devtools/shared/layout/utils.js", + true +); +const lazy = {}; +loader.lazyGetter( + lazy, + "ContentDOMReference", + () => + ChromeUtils.importESModule( + "resource://gre/modules/ContentDOMReference.sys.mjs", + { + // ContentDOMReference needs to be retrieved from the shared global + // since it is a shared singleton. + loadInDevToolsLoader: false, + } + ).ContentDOMReference +); + +const RELATIONS_TO_IGNORE = new Set([ + Ci.nsIAccessibleRelation.RELATION_CONTAINING_APPLICATION, + Ci.nsIAccessibleRelation.RELATION_CONTAINING_TAB_PANE, + Ci.nsIAccessibleRelation.RELATION_CONTAINING_WINDOW, + Ci.nsIAccessibleRelation.RELATION_PARENT_WINDOW_OF, + Ci.nsIAccessibleRelation.RELATION_SUBWINDOW_OF, +]); + +const nsIAccessibleRole = Ci.nsIAccessibleRole; +const TEXT_ROLES = new Set([ + nsIAccessibleRole.ROLE_TEXT_LEAF, + nsIAccessibleRole.ROLE_STATICTEXT, +]); + +const STATE_DEFUNCT = Ci.nsIAccessibleStates.EXT_STATE_DEFUNCT; +const CSS_TEXT_SELECTOR = "#text"; + +/** + * Get node inforamtion such as nodeType and the unique CSS selector for the node. + * @param {DOMNode} node + * Node for which to get the information. + * @return {Object} + * Information about the type of the node and how to locate it. + */ +function getNodeDescription(node) { + if (!node || Cu.isDeadWrapper(node)) { + return { nodeType: undefined, nodeCssSelector: "" }; + } + + const { nodeType } = node; + return { + nodeType, + // If node is a text node, we find a unique CSS selector for its parent and add a + // CSS_TEXT_SELECTOR postfix to indicate that it's a text node. + nodeCssSelector: + nodeType === Node.TEXT_NODE + ? `${findCssSelector(node.parentNode)}${CSS_TEXT_SELECTOR}` + : findCssSelector(node), + }; +} + +/** + * Get a snapshot of the nsIAccessible object including its subtree. None of the subtree + * queried here is cached via accessible walker's refMap. + * @param {nsIAccessible} acc + * Accessible object to take a snapshot of. + * @param {nsIAccessibilityService} a11yService + * Accessibility service instance in the current process, used to get localized + * string representation of various accessible properties. + * @param {WindowGlobalTargetActor} targetActor + * @return {JSON} + * JSON snapshot of the accessibility tree with root at current accessible. + */ +function getSnapshot(acc, a11yService, targetActor) { + if (isDefunct(acc)) { + return { + states: [a11yService.getStringStates(0, STATE_DEFUNCT)], + }; + } + + const actions = []; + for (let i = 0; i < acc.actionCount; i++) { + actions.push(acc.getActionDescription(i)); + } + + const attributes = {}; + if (acc.attributes) { + for (const { key, value } of acc.attributes.enumerate()) { + attributes[key] = value; + } + } + + const state = {}; + const extState = {}; + acc.getState(state, extState); + const states = [...a11yService.getStringStates(state.value, extState.value)]; + + const children = []; + for (let child = acc.firstChild; child; child = child.nextSibling) { + // Ignore children from different documents when we have targets for every documents. + if ( + targetActor.ignoreSubFrames && + child.DOMNode.ownerDocument !== targetActor.contentDocument + ) { + continue; + } + children.push(getSnapshot(child, a11yService, targetActor)); + } + + const { nodeType, nodeCssSelector } = getNodeDescription(acc.DOMNode); + const snapshot = { + name: acc.name, + role: getStringRole(acc, a11yService), + actions, + value: acc.value, + nodeCssSelector, + nodeType, + description: acc.description, + keyboardShortcut: acc.accessKey || acc.keyboardShortcut, + childCount: acc.childCount, + indexInParent: acc.indexInParent, + states, + children, + attributes, + }; + const useChildTargetToFetchChildren = + acc.role === Ci.nsIAccessibleRole.ROLE_INTERNAL_FRAME && + isFrameWithChildTarget(targetActor, acc.DOMNode); + if (useChildTargetToFetchChildren) { + snapshot.useChildTargetToFetchChildren = useChildTargetToFetchChildren; + snapshot.childCount = 1; + snapshot.contentDOMReference = lazy.ContentDOMReference.get(acc.DOMNode); + } + + return snapshot; +} + +/** + * Get a string indicating the role of the nsIAccessible object. + * An ARIA role token will be returned unless the role can't be mapped to an + * ARIA role (e.g. <iframe>), in which case a Gecko role string will be + * returned. + * @param {nsIAccessible} acc + * Accessible object to take a snapshot of. + * @param {nsIAccessibilityService} a11yService + * Accessibility service instance in the current process, used to get localized + * string representation of various accessible properties. + * @return String + */ +function getStringRole(acc, a11yService) { + let role = acc.computedARIARole; + if (!role) { + // We couldn't map to an ARIA role, so use a Gecko role string. + role = a11yService.getStringRole(acc.role); + } + return role; +} + +/** + * The AccessibleActor provides information about a given accessible object: its + * role, name, states, etc. + */ +class AccessibleActor extends Actor { + constructor(walker, rawAccessible) { + super(walker.conn, accessibleSpec); + this.walker = walker; + this.rawAccessible = rawAccessible; + + /** + * Indicates if the raw accessible is no longer alive. + * + * @return Boolean + */ + Object.defineProperty(this, "isDefunct", { + get() { + const defunct = isDefunct(this.rawAccessible); + if (defunct) { + delete this.isDefunct; + this.isDefunct = true; + return this.isDefunct; + } + + return defunct; + }, + configurable: true, + }); + } + + destroy() { + super.destroy(); + this.walker = null; + this.rawAccessible = null; + } + + get role() { + if (this.isDefunct) { + return null; + } + return getStringRole(this.rawAccessible, this.walker.a11yService); + } + + get name() { + if (this.isDefunct) { + return null; + } + return this.rawAccessible.name; + } + + get value() { + if (this.isDefunct) { + return null; + } + return this.rawAccessible.value; + } + + get description() { + if (this.isDefunct) { + return null; + } + return this.rawAccessible.description; + } + + get keyboardShortcut() { + if (this.isDefunct) { + return null; + } + // Gecko accessibility exposes two key bindings: Accessible::AccessKey and + // Accessible::KeyboardShortcut. The former is used for accesskey, where the latter + // is used for global shortcuts defined by XUL menu items, etc. Here - do what the + // Windows implementation does: try AccessKey first, and if that's empty, use + // KeyboardShortcut. + return this.rawAccessible.accessKey || this.rawAccessible.keyboardShortcut; + } + + get childCount() { + if (this.isDefunct) { + return 0; + } + // In case of a remote frame declare at least one child (the #document + // element) so that they can be expanded. + if (this.useChildTargetToFetchChildren) { + return 1; + } + + return this.rawAccessible.childCount; + } + + get domNodeType() { + if (this.isDefunct) { + return 0; + } + return this.rawAccessible.DOMNode ? this.rawAccessible.DOMNode.nodeType : 0; + } + + get parentAcc() { + if (this.isDefunct) { + return null; + } + return this.walker.addRef(this.rawAccessible.parent); + } + + children() { + const children = []; + if (this.isDefunct) { + return children; + } + + for ( + let child = this.rawAccessible.firstChild; + child; + child = child.nextSibling + ) { + children.push(this.walker.addRef(child)); + } + return children; + } + + get indexInParent() { + if (this.isDefunct) { + return -1; + } + + try { + return this.rawAccessible.indexInParent; + } catch (e) { + // Accessible is dead. + return -1; + } + } + + get actions() { + const actions = []; + if (this.isDefunct) { + return actions; + } + + for (let i = 0; i < this.rawAccessible.actionCount; i++) { + actions.push(this.rawAccessible.getActionDescription(i)); + } + return actions; + } + + get states() { + if (this.isDefunct) { + return []; + } + + const state = {}; + const extState = {}; + this.rawAccessible.getState(state, extState); + return [ + ...this.walker.a11yService.getStringStates(state.value, extState.value), + ]; + } + + get attributes() { + if (this.isDefunct || !this.rawAccessible.attributes) { + return {}; + } + + const attributes = {}; + for (const { key, value } of this.rawAccessible.attributes.enumerate()) { + attributes[key] = value; + } + + return attributes; + } + + get bounds() { + if (this.isDefunct) { + return null; + } + + let x = {}, + y = {}, + w = {}, + h = {}; + try { + this.rawAccessible.getBoundsInCSSPixels(x, y, w, h); + x = x.value; + y = y.value; + w = w.value; + h = h.value; + } catch (e) { + return null; + } + + // Check if accessible bounds are invalid. + const left = x, + right = x + w, + top = y, + bottom = y + h; + if (left === right || top === bottom) { + return null; + } + + return { x, y, w, h }; + } + + async getRelations() { + const relationObjects = []; + if (this.isDefunct) { + return relationObjects; + } + + const relations = [ + ...this.rawAccessible.getRelations().enumerate(Ci.nsIAccessibleRelation), + ]; + if (relations.length === 0) { + return relationObjects; + } + + const doc = await this.walker.getDocument(); + if (this.isDestroyed()) { + // This accessible actor is destroyed. + return relationObjects; + } + relations.forEach(relation => { + if (RELATIONS_TO_IGNORE.has(relation.relationType)) { + return; + } + + const type = this.walker.a11yService.getStringRelationType( + relation.relationType + ); + const targets = [...relation.getTargets().enumerate(Ci.nsIAccessible)]; + let relationObject; + for (const target of targets) { + let targetAcc; + try { + targetAcc = this.walker.attachAccessible(target, doc.rawAccessible); + } catch (e) { + // Target is not available. + } + + if (targetAcc) { + if (!relationObject) { + relationObject = { type, targets: [] }; + } + + relationObject.targets.push(targetAcc); + } + } + + if (relationObject) { + relationObjects.push(relationObject); + } + }); + + return relationObjects; + } + + get useChildTargetToFetchChildren() { + if (this.isDefunct) { + return false; + } + + return ( + this.rawAccessible.role === Ci.nsIAccessibleRole.ROLE_INTERNAL_FRAME && + isFrameWithChildTarget( + this.walker.targetActor, + this.rawAccessible.DOMNode + ) + ); + } + + form() { + return { + actor: this.actorID, + role: this.role, + name: this.name, + useChildTargetToFetchChildren: this.useChildTargetToFetchChildren, + childCount: this.childCount, + checks: this._lastAudit, + }; + } + + /** + * Provide additional (full) information about the accessible object that is + * otherwise missing from the form. + * + * @return {Object} + * Object that contains accessible object information such as states, + * actions, attributes, etc. + */ + hydrate() { + return { + value: this.value, + description: this.description, + keyboardShortcut: this.keyboardShortcut, + domNodeType: this.domNodeType, + indexInParent: this.indexInParent, + states: this.states, + actions: this.actions, + attributes: this.attributes, + }; + } + + _isValidTextLeaf(rawAccessible) { + return ( + !isDefunct(rawAccessible) && + TEXT_ROLES.has(rawAccessible.role) && + rawAccessible.name && + !!rawAccessible.name.trim().length + ); + } + + /** + * Calculate the contrast ratio of the given accessible. + */ + async _getContrastRatio() { + if (!this._isValidTextLeaf(this.rawAccessible)) { + return null; + } + + const { bounds } = this; + if (!bounds) { + return null; + } + + const { DOMNode: rawNode } = this.rawAccessible; + const win = rawNode.ownerGlobal; + + // Keep the reference to the walker actor in case the actor gets destroyed + // during the colour contrast ratio calculation. + const { walker } = this; + await walker.clearStyles(win); + const contrastRatio = await getContrastRatioFor(rawNode.parentNode, { + bounds: getBounds(win, bounds), + win, + appliedColorMatrix: this.walker.colorMatrix, + }); + + if (this.isDestroyed()) { + // This accessible actor is destroyed. + return null; + } + await walker.restoreStyles(win); + + return contrastRatio; + } + + /** + * Run an accessibility audit for a given audit type. + * @param {String} type + * Type of an audit (Check AUDIT_TYPE in devtools/shared/constants + * to see available audit types). + * + * @return {null|Object} + * Object that contains accessible audit data for a given type or null + * if there's nothing to report for this accessible. + */ + _getAuditByType(type) { + switch (type) { + case AUDIT_TYPE.CONTRAST: + return this._getContrastRatio(); + case AUDIT_TYPE.KEYBOARD: + // Determine if keyboard accessibility is lacking where it is necessary. + return auditKeyboard(this.rawAccessible); + case AUDIT_TYPE.TEXT_LABEL: + // Determine if text alternative is missing for an accessible where it + // is necessary. + return auditTextLabel(this.rawAccessible); + default: + return null; + } + } + + /** + * Audit the state of the accessible object. + * + * @param {Object} options + * Options for running audit, may include: + * - types: Array of audit types to be performed during audit. + * + * @return {Object|null} + * Audit results for the accessible object. + */ + audit(options = {}) { + if (this._auditing) { + return this._auditing; + } + + const { types } = options; + let auditTypes = Object.values(AUDIT_TYPE); + if (types && types.length) { + auditTypes = auditTypes.filter(auditType => types.includes(auditType)); + } + + // For some reason keyboard checks for focus styling affect values (that are + // used by other types of checks (text names and values)) returned by + // accessible objects. This happens only when multiple checks are run at the + // same time (asynchronously) and the audit might return unexpected + // failures. We thus split the execution of the checks into two parts, first + // performing keyboard checks and only after the rest of the checks. See bug + // 1594743 for more detail. + let keyboardAuditResult; + const keyboardAuditIndex = auditTypes.indexOf(AUDIT_TYPE.KEYBOARD); + if (keyboardAuditIndex > -1) { + // If we are performing a keyboard audit, remove its value from the + // complete list and run it. + auditTypes.splice(keyboardAuditIndex, 1); + keyboardAuditResult = this._getAuditByType(AUDIT_TYPE.KEYBOARD); + } + + this._auditing = Promise.resolve(keyboardAuditResult) + .then(keyboardResult => { + const audits = auditTypes.map(auditType => + this._getAuditByType(auditType) + ); + + // If we are also performing a keyboard audit, add its type and its + // result back to the complete list of audits. + if (keyboardAuditIndex > -1) { + auditTypes.splice(keyboardAuditIndex, 0, AUDIT_TYPE.KEYBOARD); + audits.splice(keyboardAuditIndex, 0, keyboardResult); + } + + return Promise.all(audits); + }) + .then(results => { + if (this.isDefunct || this.isDestroyed()) { + return null; + } + + const audit = results.reduce((auditResults, result, index) => { + auditResults[auditTypes[index]] = result; + return auditResults; + }, {}); + this._lastAudit = this._lastAudit || {}; + Object.assign(this._lastAudit, audit); + events.emit(this, "audited", audit); + + return audit; + }) + .catch(error => { + if (!this.isDefunct && !this.isDestroyed()) { + throw error; + } + return null; + }) + .finally(() => { + this._auditing = null; + }); + + return this._auditing; + } + + snapshot() { + return getSnapshot( + this.rawAccessible, + this.walker.a11yService, + this.walker.targetActor + ); + } +} + +exports.AccessibleActor = AccessibleActor; diff --git a/devtools/server/actors/accessibility/audit/contrast.js b/devtools/server/actors/accessibility/audit/contrast.js new file mode 100644 index 0000000000..68e7b497f8 --- /dev/null +++ b/devtools/server/actors/accessibility/audit/contrast.js @@ -0,0 +1,306 @@ +/* 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"; + +loader.lazyRequireGetter( + this, + "CssLogic", + "resource://devtools/server/actors/inspector/css-logic.js", + true +); +loader.lazyRequireGetter( + this, + "getCurrentZoom", + "resource://devtools/shared/layout/utils.js", + true +); +loader.lazyRequireGetter( + this, + "addPseudoClassLock", + "resource://devtools/server/actors/highlighters/utils/markup.js", + true +); +loader.lazyRequireGetter( + this, + "removePseudoClassLock", + "resource://devtools/server/actors/highlighters/utils/markup.js", + true +); +loader.lazyRequireGetter( + this, + "getContrastRatioAgainstBackground", + "resource://devtools/shared/accessibility.js", + true +); +loader.lazyRequireGetter( + this, + "getTextProperties", + "resource://devtools/shared/accessibility.js", + true +); +loader.lazyRequireGetter( + this, + "DevToolsWorker", + "resource://devtools/shared/worker/worker.js", + true +); +loader.lazyRequireGetter( + this, + "InspectorActorUtils", + "resource://devtools/server/actors/inspector/utils.js" +); + +const WORKER_URL = "resource://devtools/server/actors/accessibility/worker.js"; +const HIGHLIGHTED_PSEUDO_CLASS = ":-moz-devtools-highlighted"; +const { + LARGE_TEXT: { BOLD_LARGE_TEXT_MIN_PIXELS, LARGE_TEXT_MIN_PIXELS }, +} = require("resource://devtools/shared/accessibility.js"); + +loader.lazyGetter(this, "worker", () => new DevToolsWorker(WORKER_URL)); + +/** + * Get canvas rendering context for the current target window bound by the bounds of the + * accessible objects. + * @param {Object} win + * Current target window. + * @param {Object} bounds + * Bounds for the accessible object. + * @param {Object} zoom + * Current zoom level for the window. + * @param {Object} scale + * Scale value to scale down the drawn image. + * @param {null|DOMNode} node + * If not null, a node that corresponds to the accessible object to be used to + * make its text color transparent. + * @return {CanvasRenderingContext2D} + * Canvas rendering context for the current window. + */ +function getImageCtx(win, bounds, zoom, scale, node) { + const doc = win.document; + const canvas = doc.createElementNS("http://www.w3.org/1999/xhtml", "canvas"); + + const { left, top, width, height } = bounds; + canvas.width = width * zoom * scale; + canvas.height = height * zoom * scale; + const ctx = canvas.getContext("2d", { alpha: false }); + ctx.imageSmoothingEnabled = false; + ctx.scale(scale, scale); + + // If node is passed, make its color related text properties invisible. + if (node) { + addPseudoClassLock(node, HIGHLIGHTED_PSEUDO_CLASS); + } + + ctx.drawWindow( + win, + left * zoom, + top * zoom, + width * zoom, + height * zoom, + "#fff", + ctx.DRAWWINDOW_USE_WIDGET_LAYERS + ); + + // Restore all inline styling. + if (node) { + removePseudoClassLock(node, HIGHLIGHTED_PSEUDO_CLASS); + } + + return ctx; +} + +/** + * Calculate the transformed RGBA when a color matrix is set in docShell by + * multiplying the color matrix with the RGBA vector. + * + * @param {Array} rgba + * Original RGBA array which we want to transform. + * @param {Array} colorMatrix + * Flattened 4x5 color matrix that is set in docShell. + * A 4x5 matrix of the form: + * 1 2 3 4 5 + * 6 7 8 9 10 + * 11 12 13 14 15 + * 16 17 18 19 20 + * will be set in docShell as: + * [1, 6, 11, 16, 2, 7, 12, 17, 3, 8, 13, 18, 4, 9, 14, 19, 5, 10, 15, 20] + * @return {Array} + * Transformed RGBA after the color matrix is multiplied with the original RGBA. + */ +function getTransformedRGBA(rgba, colorMatrix) { + const transformedRGBA = [0, 0, 0, 0]; + + // Only use the first four columns of the color matrix corresponding to R, G, B and A + // color channels respectively. The fifth column is a fixed offset that does not need + // to be considered for the matrix multiplication. We end up multiplying a 4x4 color + // matrix with a 4x1 RGBA vector. + for (let i = 0; i < 16; i++) { + const row = i % 4; + const col = Math.floor(i / 4); + transformedRGBA[row] += colorMatrix[i] * rgba[col]; + } + + return transformedRGBA; +} + +/** + * Find RGBA or a range of RGBAs for the background pixels under the text. + * + * @param {DOMNode} node + * Node for which we want to get the background color data. + * @param {Object} options + * - bounds {Object} + * Bounds for the accessible object. + * - win {Object} + * Target window. + * - size {Number} + * Font size of the selected text node + * - isBoldText {Boolean} + * True if selected text node is bold + * @return {Object} + * Object with one or more of the following RGBA fields: value, min, max + */ +function getBackgroundFor(node, { win, bounds, size, isBoldText }) { + const zoom = 1 / getCurrentZoom(win); + // When calculating colour contrast, we traverse image data for text nodes that are + // drawn both with and without transparent text. Image data arrays are typically really + // big. In cases when the font size is fairly large or when the page is zoomed in image + // data is especially large (retrieving it and/or traversing it takes significant amount + // of time). Here we optimize the size of the image data by scaling down the drawn nodes + // to a size where their text size equals either BOLD_LARGE_TEXT_MIN_PIXELS or + // LARGE_TEXT_MIN_PIXELS (lower threshold for large text size) depending on the font + // weight. + // + // IMPORTANT: this optimization, in some cases where background colour is non-uniform + // (gradient or image), can result in small (not noticeable) blending of the background + // colours. In turn this might affect the reported values of the contrast ratio. The + // delta is fairly small (<0.1) to noticeably skew the results. + // + // NOTE: this optimization does not help in cases where contrast is being calculated for + // nodes with a lot of text. + let scale = + ((isBoldText ? BOLD_LARGE_TEXT_MIN_PIXELS : LARGE_TEXT_MIN_PIXELS) / size) * + zoom; + // We do not need to scale the images if the font is smaller than large or if the page + // is zoomed out (scaling in this case would've been scaling up). + scale = scale > 1 ? 1 : scale; + + const textContext = getImageCtx(win, bounds, zoom, scale); + const backgroundContext = getImageCtx(win, bounds, zoom, scale, node); + + const { data: dataText } = textContext.getImageData( + 0, + 0, + bounds.width * scale, + bounds.height * scale + ); + const { data: dataBackground } = backgroundContext.getImageData( + 0, + 0, + bounds.width * scale, + bounds.height * scale + ); + + return worker.performTask( + "getBgRGBA", + { + dataTextBuf: dataText.buffer, + dataBackgroundBuf: dataBackground.buffer, + }, + [dataText.buffer, dataBackground.buffer] + ); +} + +/** + * Calculates the contrast ratio of the referenced DOM node. + * + * @param {DOMNode} node + * The node for which we want to calculate the contrast ratio. + * @param {Object} options + * - bounds {Object} + * Bounds for the accessible object. + * - win {Object} + * Target window. + * - appliedColorMatrix {Array|null} + * Simulation color matrix applied to + * to the viewport, if it exists. + * @return {Object} + * An object that may contain one or more of the following fields: error, + * isLargeText, value, min, max values for contrast. + */ +async function getContrastRatioFor(node, options = {}) { + const computedStyle = CssLogic.getComputedStyle(node); + const props = computedStyle ? getTextProperties(computedStyle) : null; + + if (!props) { + return { + error: true, + }; + } + + const { isLargeText, isBoldText, size, opacity } = props; + const { appliedColorMatrix } = options; + const color = appliedColorMatrix + ? getTransformedRGBA(props.color, appliedColorMatrix) + : props.color; + let rgba = await getBackgroundFor(node, { + ...options, + isBoldText, + size, + }); + + if (!rgba) { + // Fallback (original) contrast calculation algorithm. It tries to get the + // closest background colour for the node and use it to calculate contrast. + const backgroundColor = InspectorActorUtils.getClosestBackgroundColor(node); + const backgroundImage = InspectorActorUtils.getClosestBackgroundImage(node); + + if (backgroundImage !== "none") { + // Both approaches failed, at this point we don't have a better one yet. + return { + error: true, + }; + } + + let { r, g, b, a } = InspectorUtils.colorToRGBA(backgroundColor); + // If the element has opacity in addition to background alpha value, take it + // into account. TODO: this does not handle opacity set on ancestor + // elements (see bug https://bugzilla.mozilla.org/show_bug.cgi?id=1544721). + if (opacity < 1) { + a = opacity * a; + } + + return getContrastRatioAgainstBackground( + { + value: appliedColorMatrix + ? getTransformedRGBA([r, g, b, a], appliedColorMatrix) + : [r, g, b, a], + }, + { + color, + isLargeText, + } + ); + } + + if (appliedColorMatrix) { + rgba = rgba.value + ? { + value: getTransformedRGBA(rgba.value, appliedColorMatrix), + } + : { + min: getTransformedRGBA(rgba.min, appliedColorMatrix), + max: getTransformedRGBA(rgba.max, appliedColorMatrix), + }; + } + + return getContrastRatioAgainstBackground(rgba, { + color, + isLargeText, + }); +} + +exports.getContrastRatioFor = getContrastRatioFor; +exports.getBackgroundFor = getBackgroundFor; diff --git a/devtools/server/actors/accessibility/audit/keyboard.js b/devtools/server/actors/accessibility/audit/keyboard.js new file mode 100644 index 0000000000..d1b13dbbf6 --- /dev/null +++ b/devtools/server/actors/accessibility/audit/keyboard.js @@ -0,0 +1,514 @@ +/* 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"; + +loader.lazyRequireGetter( + this, + "CssLogic", + "resource://devtools/server/actors/inspector/css-logic.js", + true +); +loader.lazyRequireGetter( + this, + "getCSSStyleRules", + "resource://devtools/shared/inspector/css-logic.js", + true +); +loader.lazyRequireGetter( + this, + "nodeConstants", + "resource://devtools/shared/dom-node-constants.js" +); +loader.lazyRequireGetter( + this, + ["isDefunct", "getAriaRoles"], + "resource://devtools/server/actors/utils/accessibility.js", + true +); + +const { + accessibility: { + AUDIT_TYPE: { KEYBOARD }, + ISSUE_TYPE: { + [KEYBOARD]: { + FOCUSABLE_NO_SEMANTICS, + FOCUSABLE_POSITIVE_TABINDEX, + INTERACTIVE_NO_ACTION, + INTERACTIVE_NOT_FOCUSABLE, + MOUSE_INTERACTIVE_ONLY, + NO_FOCUS_VISIBLE, + }, + }, + SCORES: { FAIL, WARNING }, + }, +} = require("resource://devtools/shared/constants.js"); + +// Specified by the author CSS rule type. +const STYLE_RULE = 1; + +// Accessible action for showing long description. +const CLICK_ACTION = "click"; + +/** + * Focus specific pseudo classes that the keyboard audit simulates to determine + * focus styling. + */ +const FOCUS_PSEUDO_CLASS = ":focus"; +const MOZ_FOCUSRING_PSEUDO_CLASS = ":-moz-focusring"; + +const KEYBOARD_FOCUSABLE_ROLES = new Set([ + Ci.nsIAccessibleRole.ROLE_BUTTONMENU, + Ci.nsIAccessibleRole.ROLE_CHECKBUTTON, + Ci.nsIAccessibleRole.ROLE_COMBOBOX, + Ci.nsIAccessibleRole.ROLE_EDITCOMBOBOX, + Ci.nsIAccessibleRole.ROLE_ENTRY, + Ci.nsIAccessibleRole.ROLE_LINK, + Ci.nsIAccessibleRole.ROLE_LISTBOX, + Ci.nsIAccessibleRole.ROLE_PASSWORD_TEXT, + Ci.nsIAccessibleRole.ROLE_PUSHBUTTON, + Ci.nsIAccessibleRole.ROLE_RADIOBUTTON, + Ci.nsIAccessibleRole.ROLE_SLIDER, + Ci.nsIAccessibleRole.ROLE_SPINBUTTON, + Ci.nsIAccessibleRole.ROLE_SUMMARY, + Ci.nsIAccessibleRole.ROLE_SWITCH, + Ci.nsIAccessibleRole.ROLE_TOGGLE_BUTTON, +]); + +const INTERACTIVE_ROLES = new Set([ + ...KEYBOARD_FOCUSABLE_ROLES, + Ci.nsIAccessibleRole.ROLE_CHECK_MENU_ITEM, + Ci.nsIAccessibleRole.ROLE_CHECK_RICH_OPTION, + Ci.nsIAccessibleRole.ROLE_COMBOBOX_OPTION, + Ci.nsIAccessibleRole.ROLE_MENUITEM, + Ci.nsIAccessibleRole.ROLE_OPTION, + Ci.nsIAccessibleRole.ROLE_OUTLINE, + Ci.nsIAccessibleRole.ROLE_OUTLINEITEM, + Ci.nsIAccessibleRole.ROLE_PAGETAB, + Ci.nsIAccessibleRole.ROLE_PARENT_MENUITEM, + Ci.nsIAccessibleRole.ROLE_RADIO_MENU_ITEM, + Ci.nsIAccessibleRole.ROLE_RICH_OPTION, +]); + +const INTERACTIVE_IF_FOCUSABLE_ROLES = new Set([ + // If article is focusable, we can assume it is inside a feed. + Ci.nsIAccessibleRole.ROLE_ARTICLE, + // Column header can be focusable. + Ci.nsIAccessibleRole.ROLE_COLUMNHEADER, + Ci.nsIAccessibleRole.ROLE_GRID_CELL, + Ci.nsIAccessibleRole.ROLE_MENUBAR, + Ci.nsIAccessibleRole.ROLE_MENUPOPUP, + Ci.nsIAccessibleRole.ROLE_PAGETABLIST, + // Row header can be focusable. + Ci.nsIAccessibleRole.ROLE_ROWHEADER, + Ci.nsIAccessibleRole.ROLE_SCROLLBAR, + Ci.nsIAccessibleRole.ROLE_SEPARATOR, + Ci.nsIAccessibleRole.ROLE_TOOLBAR, +]); + +/** + * Determine if a node is dead or is not an element node. + * + * @param {DOMNode} node + * Node to be tested for validity. + * + * @returns {Boolean} + * True if the node is either dead or is not an element node. + */ +function isInvalidNode(node) { + return ( + !node || + Cu.isDeadWrapper(node) || + node.nodeType !== nodeConstants.ELEMENT_NODE || + !node.ownerGlobal + ); +} + +/** + * Determine if accessible is focusable with the keyboard. + * + * @param {nsIAccessible} accessible + * Accessible for which to determine if it is keyboard focusable. + * + * @returns {Boolean} + * True if focusable with the keyboard. + */ +function isKeyboardFocusable(accessible) { + const state = {}; + accessible.getState(state, {}); + // State will be focusable even if the tabindex is negative. + return ( + state.value & Ci.nsIAccessibleStates.STATE_FOCUSABLE && + // Platform accessibility will still report STATE_FOCUSABLE even with the + // tabindex="-1" so we need to check that it is >= 0 to be considered + // keyboard focusable. + accessible.DOMNode.tabIndex > -1 + ); +} + +/** + * Determine if a current node has focus specific styling by applying a + * focus-related pseudo class (such as :focus or :-moz-focusring) to a focusable + * node. + * + * @param {DOMNode} focusableNode + * Node to apply focus-related pseudo class to. + * @param {DOMNode} currentNode + * Node to be checked for having focus specific styling. + * @param {String} pseudoClass + * A focus related pseudo-class to be simulated for style comparison. + * + * @returns {Boolean} + * True if the currentNode has focus specific styling. + */ +function hasStylesForFocusRelatedPseudoClass( + focusableNode, + currentNode, + pseudoClass +) { + const defaultRules = getCSSStyleRules(currentNode); + + InspectorUtils.addPseudoClassLock(focusableNode, pseudoClass); + + // Determine a set of properties that are specific to CSS rules that are only + // present when a focus related pseudo-class is locked in. + const tempRules = getCSSStyleRules(currentNode); + const properties = new Set(); + for (const rule of tempRules) { + if (rule.type !== STYLE_RULE) { + continue; + } + + if (!defaultRules.includes(rule)) { + for (let index = 0; index < rule.style.length; index++) { + properties.add(rule.style.item(index)); + } + } + } + + // If there are no focus specific CSS rules or properties, currentNode does + // node have any focus specific styling, we are done. + if (properties.size === 0) { + InspectorUtils.removePseudoClassLock(focusableNode, pseudoClass); + return false; + } + + // Determine values for properties that are focus specific. + const tempStyle = CssLogic.getComputedStyle(currentNode); + const focusStyle = {}; + for (const name of properties.values()) { + focusStyle[name] = tempStyle.getPropertyValue(name); + } + + InspectorUtils.removePseudoClassLock(focusableNode, pseudoClass); + + // If values for focus specific properties are different from default style + // values, assume we have focus spefic styles for the currentNode. + const defaultStyle = CssLogic.getComputedStyle(currentNode); + for (const name of properties.values()) { + if (defaultStyle.getPropertyValue(name) !== focusStyle[name]) { + return true; + } + } + + return false; +} + +/** + * Check if an element node (currentNode) has distinct focus styling. This + * function also takes into account a case when focus styling is applied to a + * descendant too. + * + * @param {DOMNode} focusableNode + * Node to apply focus-related pseudo class to. + * @param {DOMNode} currentNode + * Node to be checked for having focus specific styling. + * + * @returns {Boolean} + * True if the node or its descendant has distinct focus styling. + */ +function hasFocusStyling(focusableNode, currentNode) { + if (isInvalidNode(currentNode)) { + return false; + } + + // Check if an element node has distinct :-moz-focusring styling. + const hasStylesForMozFocusring = hasStylesForFocusRelatedPseudoClass( + focusableNode, + currentNode, + MOZ_FOCUSRING_PSEUDO_CLASS + ); + if (hasStylesForMozFocusring) { + return true; + } + + // Check if an element node has distinct :focus styling. + const hasStylesForFocus = hasStylesForFocusRelatedPseudoClass( + focusableNode, + currentNode, + FOCUS_PSEUDO_CLASS + ); + if (hasStylesForFocus) { + return true; + } + + // If no element specific focus styles where found, check if its element + // children have them. + for ( + let child = currentNode.firstElementChild; + child; + child = currentNode.nextnextElementSibling + ) { + if (hasFocusStyling(focusableNode, child)) { + return true; + } + } + + return false; +} + +/** + * A rule that determines if a focusable accessible object has appropriate focus + * styling. + * + * @param {nsIAccessible} accessible + * Accessible to be checked for being focusable and having focus + * styling. + * + * @return {null|Object} + * Null if accessible has keyboard focus styling, audit report object + * otherwise. + */ +function focusStyleRule(accessible) { + const { DOMNode } = accessible; + if (isInvalidNode(DOMNode)) { + return null; + } + + // Ignore non-focusable elements. + if (!isKeyboardFocusable(accessible)) { + return null; + } + + if (hasFocusStyling(DOMNode, DOMNode)) { + return null; + } + + // If no browser or author focus styling was found, check if the node is a + // widget that is themed by platform native theme. + if (InspectorUtils.isElementThemed(DOMNode)) { + return null; + } + + return { score: WARNING, issue: NO_FOCUS_VISIBLE }; +} + +/** + * A rule that determines if an interactive accessible has any associated + * accessible actions with it. If the element is interactive but and has no + * actions, assistive technology users will not be able to interact with it. + * + * @param {nsIAccessible} accessible + * Accessible to be checked for being interactive and having accessible + * actions. + * + * @return {null|Object} + * Null if accessible is not interactive or if it is and it has + * accessible action associated with it, audit report object otherwise. + */ +function interactiveRule(accessible) { + if (!INTERACTIVE_ROLES.has(accessible.role)) { + return null; + } + + if (accessible.actionCount > 0) { + return null; + } + + return { score: FAIL, issue: INTERACTIVE_NO_ACTION }; +} + +/** + * A rule that determines if an interactive accessible is also focusable when + * not disabled. + * + * @param {nsIAccessible} accessible + * Accessible to be checked for being interactive and being focusable + * when enabled. + * + * @return {null|Object} + * Null if accessible is not interactive or if it is and it is focusable + * when enabled, audit report object otherwise. + */ +function focusableRule(accessible) { + if (!KEYBOARD_FOCUSABLE_ROLES.has(accessible.role)) { + return null; + } + + const state = {}; + accessible.getState(state, {}); + // We only expect in interactive accessible object to be focusable if it is + // not disabled. + if (state.value & Ci.nsIAccessibleStates.STATE_UNAVAILABLE) { + return null; + } + + if (isKeyboardFocusable(accessible)) { + return null; + } + + const ariaRoles = getAriaRoles(accessible); + if ( + ariaRoles && + (ariaRoles.includes("combobox") || ariaRoles.includes("listbox")) + ) { + // Do not force ARIA combobox or listbox to be focusable. + return null; + } + + return { score: FAIL, issue: INTERACTIVE_NOT_FOCUSABLE }; +} + +/** + * A rule that determines if a focusable accessible has an associated + * interactive role. + * + * @param {nsIAccessible} accessible + * Accessible to be checked for having an interactive role if it is + * focusable. + * + * @return {null|Object} + * Null if accessible is not interactive or if it is and it has an + * interactive role, audit report object otherwise. + */ +function semanticsRule(accessible) { + if ( + INTERACTIVE_ROLES.has(accessible.role) || + // Visible listboxes will have focusable state when inside comboboxes. + accessible.role === Ci.nsIAccessibleRole.ROLE_COMBOBOX_LIST + ) { + return null; + } + + if (isKeyboardFocusable(accessible)) { + if (INTERACTIVE_IF_FOCUSABLE_ROLES.has(accessible.role)) { + return null; + } + + // ROLE_TABLE is used for grids too which are considered interactive. + if (accessible.role === Ci.nsIAccessibleRole.ROLE_TABLE) { + const ariaRoles = getAriaRoles(accessible); + if (ariaRoles && ariaRoles.includes("grid")) { + return null; + } + } + + return { score: WARNING, issue: FOCUSABLE_NO_SEMANTICS }; + } + + const state = {}; + accessible.getState(state, {}); + if ( + // Ignore text leafs. + accessible.role === Ci.nsIAccessibleRole.ROLE_TEXT_LEAF || + // Ignore accessibles with no accessible actions. + accessible.actionCount === 0 || + // Ignore labels that have a label for relation with their target because + // they are clickable. + (accessible.role === Ci.nsIAccessibleRole.ROLE_LABEL && + accessible.getRelationByType(Ci.nsIAccessibleRelation.RELATION_LABEL_FOR) + .targetsCount > 0) || + // Ignore images that are inside an anchor (have linked state). + (accessible.role === Ci.nsIAccessibleRole.ROLE_GRAPHIC && + state.value & Ci.nsIAccessibleStates.STATE_LINKED) + ) { + return null; + } + + // Ignore anything but a click action in the list of actions. + for (let i = 0; i < accessible.actionCount; i++) { + if (accessible.getActionName(i) === CLICK_ACTION) { + return { score: FAIL, issue: MOUSE_INTERACTIVE_ONLY }; + } + } + + return null; +} + +/** + * A rule that determines if an element associated with a focusable accessible + * has a positive tabindex. + * + * @param {nsIAccessible} accessible + * Accessible to be checked for having an element with positive tabindex + * attribute. + * + * @return {null|Object} + * Null if accessible is not focusable or if it is and its element's + * tabindex attribute is less than 1, audit report object otherwise. + */ +function tabIndexRule(accessible) { + const { DOMNode } = accessible; + if (isInvalidNode(DOMNode)) { + return null; + } + + if (!isKeyboardFocusable(accessible)) { + return null; + } + + if (DOMNode.tabIndex > 0) { + return { score: WARNING, issue: FOCUSABLE_POSITIVE_TABINDEX }; + } + + return null; +} + +function auditKeyboard(accessible) { + if (isDefunct(accessible)) { + return null; + } + // Do not test anything on accessible objects for documents or frames. + if ( + accessible.role === Ci.nsIAccessibleRole.ROLE_DOCUMENT || + accessible.role === Ci.nsIAccessibleRole.ROLE_INTERNAL_FRAME + ) { + return null; + } + + // Check if interactive accessible can be used by the assistive + // technology. + let issue = interactiveRule(accessible); + if (issue) { + return issue; + } + + // Check if interactive accessible is also focusable when enabled. + issue = focusableRule(accessible); + if (issue) { + return issue; + } + + // Check if accessible object has an element with a positive tabindex. + issue = tabIndexRule(accessible); + if (issue) { + return issue; + } + + // Check if a focusable accessible has interactive semantics. + issue = semanticsRule(accessible); + if (issue) { + return issue; + } + + // Check if focusable accessible has associated focus styling. + issue = focusStyleRule(accessible); + if (issue) { + return issue; + } + + return issue; +} + +module.exports.auditKeyboard = auditKeyboard; diff --git a/devtools/server/actors/accessibility/audit/moz.build b/devtools/server/actors/accessibility/audit/moz.build new file mode 100644 index 0000000000..01bd0af849 --- /dev/null +++ b/devtools/server/actors/accessibility/audit/moz.build @@ -0,0 +1,12 @@ +# 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/. + +DevToolsModules( + "contrast.js", + "keyboard.js", + "text-label.js", +) + +with Files("**"): + BUG_COMPONENT = ("DevTools", "Accessibility Tools") diff --git a/devtools/server/actors/accessibility/audit/text-label.js b/devtools/server/actors/accessibility/audit/text-label.js new file mode 100644 index 0000000000..8570c5cce8 --- /dev/null +++ b/devtools/server/actors/accessibility/audit/text-label.js @@ -0,0 +1,438 @@ +/* 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 { + accessibility: { + AUDIT_TYPE: { TEXT_LABEL }, + ISSUE_TYPE, + SCORES: { BEST_PRACTICES, FAIL, WARNING }, + }, +} = require("resource://devtools/shared/constants.js"); + +const { + 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, +} = ISSUE_TYPE[TEXT_LABEL]; + +/** + * Check if the accessible is visible to the assistive technology. + * @param {nsIAccessible} accessible + * Accessible object to be tested for visibility. + * + * @returns {Boolean} + * True if accessible object is visible to assistive technology. + */ +function isVisible(accessible) { + const state = {}; + accessible.getState(state, {}); + return !(state.value & Ci.nsIAccessibleStates.STATE_INVISIBLE); +} + +/** + * Get related accessible objects that are targets of labelled by relation e.g. + * labels. + * @param {nsIAccessible} accessible + * Accessible objects to get labels for. + * + * @returns {Array} + * A list of accessible objects that are labels for a given accessible. + */ +function getLabels(accessible) { + const relation = accessible.getRelationByType( + Ci.nsIAccessibleRelation.RELATION_LABELLED_BY + ); + return [...relation.getTargets().enumerate(Ci.nsIAccessible)]; +} + +/** + * Get a trimmed name of the accessible object. + * + * @param {nsIAccessible} accessible + * Accessible objects to get a name for. + * + * @returns {null|String} + * Trimmed name of the accessible object if available. + */ +function getAccessibleName(accessible) { + return accessible.name && accessible.name.trim(); +} + +/** + * A text label rule for accessible objects that must have a non empty + * accessible name. + * + * @returns {null|Object} + * Failure audit report if accessible object has no or empty name, null + * otherwise. + */ +const mustHaveNonEmptyNameRule = function (issue, accessible) { + const name = getAccessibleName(accessible); + return name ? null : { score: FAIL, issue }; +}; + +/** + * A text label rule for accessible objects that should have a non empty + * accessible name as a best practice. + * + * @returns {null|Object} + * Best practices audit report if accessible object has no or empty + * name, null otherwise. + */ +const shouldHaveNonEmptyNameRule = function (issue, accessible) { + const name = getAccessibleName(accessible); + return name ? null : { score: BEST_PRACTICES, issue }; +}; + +/** + * A text label rule for accessible objects that can be activated via user + * action and must have a non-empty name. + * + * @returns {null|Object} + * Failure audit report if interactive accessible object has no or + * empty name, null otherwise. + */ +const interactiveRule = mustHaveNonEmptyNameRule.bind( + null, + INTERACTIVE_NO_NAME +); + +/** + * A text label rule for accessible objects that correspond to dialogs and thus + * should have a non-empty name. + * + * @returns {null|Object} + * Best practices audit report if dialog accessible object has no or + * empty name, null otherwise. + */ +const dialogRule = shouldHaveNonEmptyNameRule.bind(null, DIALOG_NO_NAME); + +/** + * A text label rule for accessible objects that provide visual information + * (images, canvas, etc.) and must have a defined name (that can be empty, e.g. + * ""). + * + * @returns {null|Object} + * Failure audit report if interactive accessible object has no name, + * null otherwise. + */ +const imageRule = function (accessible) { + const name = getAccessibleName(accessible); + return name != null ? null : { score: FAIL, issue: IMAGE_NO_NAME }; +}; + +/** + * A text label rule for accessible objects that correspond to form elements. + * These objects must have a non-empty name and must have a visible label. + * + * @returns {null|Object} + * Failure audit report if form element accessible object has no name, + * warning if the name does not come from a visible label, null + * otherwise. + */ +const formRule = function (accessible) { + const name = getAccessibleName(accessible); + if (!name) { + return { score: FAIL, issue: FORM_NO_NAME }; + } + + const labels = getLabels(accessible); + const hasNameFromVisibleLabel = labels.some(label => isVisible(label)); + + return hasNameFromVisibleLabel + ? null + : { score: WARNING, issue: FORM_NO_VISIBLE_NAME }; +}; + +/** + * A text label rule for elements that map to ROLE_GROUPING: + * * <OPTGROUP> must have a non-empty name and must be provided via the + * "label" attribute. + * * <FIELDSET> must have a non-empty name and must be provided via the + * corresponding <LEGEND> element. + * + * @returns {null|Object} + * Failure audit report if form grouping accessible object has no name, + * or has a name that is not derived from a required location, null + * otherwise. + */ +const formGroupingRule = function (accessible) { + const name = getAccessibleName(accessible); + const { DOMNode } = accessible; + + switch (DOMNode.nodeName) { + case "OPTGROUP": + return name && DOMNode.label && DOMNode.label.trim() === name + ? null + : { + score: FAIL, + issue: FORM_OPTGROUP_NO_NAME_FROM_LABEL, + }; + case "FIELDSET": + if (!name) { + return { score: FAIL, issue: FORM_FIELDSET_NO_NAME }; + } + + const labels = getLabels(accessible); + const hasNameFromLegend = labels.some( + label => + label.DOMNode.nodeName === "LEGEND" && + label.name && + label.name.trim() === name && + isVisible(label) + ); + + return hasNameFromLegend + ? null + : { + score: WARNING, + issue: FORM_FIELDSET_NO_NAME_FROM_LEGEND, + }; + default: + return null; + } +}; + +/** + * A text label rule for elements that map to ROLE_TEXT_CONTAINER: + * * <METER> mapps to ROLE_TEXT_CONTAINER and must have a name provided via + * the visible label. Note: Will only work when bug 559770 is resolved (right + * now, unlabelled meters are not mapped to an accessible object). + * + * @returns {null|Object} + * Failure audit report depending on requirements for dialogs or form + * meter element, null otherwise. + */ +const textContainerRule = function (accessible) { + const { DOMNode } = accessible; + + switch (DOMNode.nodeName) { + case "DIALOG": + return dialogRule(accessible); + case "METER": + return formRule(accessible); + default: + return null; + } +}; + +/** + * A text label rule for elements that map to ROLE_INTERNAL_FRAME: + * * <OBJECT> maps to ROLE_INTERNAL_FRAME. Check the type attribute and whether + * it includes "image/" (e.g. image/jpeg, image/png, image/gif). If so, audit + * it the same way other image roles are audited. + * * <EMBED> maps to ROLE_INTERNAL_FRAME and must have a non-empty name. + * * <FRAME> and <IFRAME> map to ROLE_INTERNAL_FRAME and must have a non-empty + * title attribute. + * + * @returns {null|Object} + * Failure audit report if the internal frame accessible object name is + * not provided or if it is not derived from a required location, null + * otherwise. + */ +const internalFrameRule = function (accessible) { + const { DOMNode } = accessible; + switch (DOMNode.nodeName) { + case "FRAME": + return mustHaveNonEmptyNameRule(FRAME_NO_NAME, accessible); + case "IFRAME": + const name = getAccessibleName(accessible); + const title = DOMNode.title && DOMNode.title.trim(); + + return title && title === name + ? null + : { score: FAIL, issue: IFRAME_NO_NAME_FROM_TITLE }; + case "OBJECT": { + const type = DOMNode.getAttribute("type"); + if (!type || !type.startsWith("image/")) { + return null; + } + + return imageRule(accessible); + } + case "EMBED": { + const type = DOMNode.getAttribute("type"); + if (!type || !type.startsWith("image/")) { + return mustHaveNonEmptyNameRule(EMBED_NO_NAME, accessible); + } + return imageRule(accessible); + } + default: + return null; + } +}; + +/** + * A text label rule for accessible objects that represent documents and should + * have title element provided. + * + * @returns {null|Object} + * Failure audit report if document accessible object has no or empty + * title, null otherwise. + */ +const documentRule = function (accessible) { + const title = accessible.DOMNode.title && accessible.DOMNode.title.trim(); + return title ? null : { score: FAIL, issue: DOCUMENT_NO_TITLE }; +}; + +/** + * A text label rule for accessible objects that correspond to headings and thus + * must be non-empty. + * + * @returns {null|Object} + * Failure audit report if heading accessible object has no or + * empty name or if its text content is empty, null otherwise. + */ +const headingRule = function (accessible) { + const name = getAccessibleName(accessible); + if (!name) { + return { score: FAIL, issue: HEADING_NO_NAME }; + } + + const content = + accessible.DOMNode.textContent && accessible.DOMNode.textContent.trim(); + return content ? null : { score: WARNING, issue: HEADING_NO_CONTENT }; +}; + +/** + * A text label rule for accessible objects that represent toolbars and must + * have a non-empty name if there is more than one toolbar present. + * + * @returns {null|Object} + * Failure audit report if toolbar accessible object is not the only + * toolbar in the document and has no or empty title, null otherwise. + */ +const toolbarRule = function (accessible) { + const toolbars = + accessible.DOMNode.ownerDocument.querySelectorAll(`[role="toolbar"]`); + + return toolbars.length > 1 + ? mustHaveNonEmptyNameRule(TOOLBAR_NO_NAME, accessible) + : null; +}; + +/** + * A text label rule for accessible objects that represent link (anchors, areas) + * and must have a non-empty name. + * + * @returns {null|Object} + * Failure audit report if link accessible object has no or empty name, + * or in case when it's an <area> element with href attribute the name + * is not specified by an alt attribute, null otherwise. + */ +const linkRule = function (accessible) { + const { DOMNode } = accessible; + if (DOMNode.nodeName === "AREA" && DOMNode.hasAttribute("href")) { + const alt = DOMNode.getAttribute("alt"); + const name = getAccessibleName(accessible); + return alt && alt.trim() === name + ? null + : { score: FAIL, issue: AREA_NO_NAME_FROM_ALT }; + } + + return interactiveRule(accessible); +}; + +/** + * A text label rule for accessible objects that are used to display + * non-standard symbols where existing Unicode characters are not available and + * must have a non-empty name. + * + * @returns {null|Object} + * Failure audit report if mglyph accessible object has no or empty + * name, and no or empty alt attribute, null otherwise. + */ +const mathmlGlyphRule = function (accessible) { + const name = getAccessibleName(accessible); + if (name) { + return null; + } + + const { DOMNode } = accessible; + const alt = DOMNode.getAttribute("alt"); + return alt && alt.trim() + ? null + : { score: FAIL, issue: MATHML_GLYPH_NO_NAME }; +}; + +const RULES = { + [Ci.nsIAccessibleRole.ROLE_BUTTONMENU]: interactiveRule, + [Ci.nsIAccessibleRole.ROLE_CANVAS]: imageRule, + [Ci.nsIAccessibleRole.ROLE_CHECKBUTTON]: formRule, + [Ci.nsIAccessibleRole.ROLE_CHECK_MENU_ITEM]: interactiveRule, + [Ci.nsIAccessibleRole.ROLE_CHECK_RICH_OPTION]: formRule, + [Ci.nsIAccessibleRole.ROLE_COLUMNHEADER]: interactiveRule, + [Ci.nsIAccessibleRole.ROLE_COMBOBOX]: formRule, + [Ci.nsIAccessibleRole.ROLE_COMBOBOX_OPTION]: interactiveRule, + [Ci.nsIAccessibleRole.ROLE_DIAGRAM]: imageRule, + [Ci.nsIAccessibleRole.ROLE_DIALOG]: dialogRule, + [Ci.nsIAccessibleRole.ROLE_DOCUMENT]: documentRule, + [Ci.nsIAccessibleRole.ROLE_EDITCOMBOBOX]: formRule, + [Ci.nsIAccessibleRole.ROLE_ENTRY]: formRule, + [Ci.nsIAccessibleRole.ROLE_FIGURE]: shouldHaveNonEmptyNameRule.bind( + null, + FIGURE_NO_NAME + ), + [Ci.nsIAccessibleRole.ROLE_GRAPHIC]: imageRule, + [Ci.nsIAccessibleRole.ROLE_GROUPING]: formGroupingRule, + [Ci.nsIAccessibleRole.ROLE_HEADING]: headingRule, + [Ci.nsIAccessibleRole.ROLE_IMAGE_MAP]: imageRule, + [Ci.nsIAccessibleRole.ROLE_INTERNAL_FRAME]: internalFrameRule, + [Ci.nsIAccessibleRole.ROLE_LINK]: linkRule, + [Ci.nsIAccessibleRole.ROLE_LISTBOX]: formRule, + [Ci.nsIAccessibleRole.ROLE_MATHML_GLYPH]: mathmlGlyphRule, + [Ci.nsIAccessibleRole.ROLE_MENUITEM]: interactiveRule, + [Ci.nsIAccessibleRole.ROLE_OPTION]: interactiveRule, + [Ci.nsIAccessibleRole.ROLE_OUTLINEITEM]: interactiveRule, + [Ci.nsIAccessibleRole.ROLE_PAGETAB]: interactiveRule, + [Ci.nsIAccessibleRole.ROLE_PASSWORD_TEXT]: formRule, + [Ci.nsIAccessibleRole.ROLE_PROGRESSBAR]: formRule, + [Ci.nsIAccessibleRole.ROLE_PUSHBUTTON]: interactiveRule, + [Ci.nsIAccessibleRole.ROLE_RADIOBUTTON]: formRule, + [Ci.nsIAccessibleRole.ROLE_RADIO_MENU_ITEM]: interactiveRule, + [Ci.nsIAccessibleRole.ROLE_ROWHEADER]: interactiveRule, + [Ci.nsIAccessibleRole.ROLE_SLIDER]: formRule, + [Ci.nsIAccessibleRole.ROLE_SPINBUTTON]: formRule, + [Ci.nsIAccessibleRole.ROLE_SWITCH]: formRule, + [Ci.nsIAccessibleRole.ROLE_TEXT_CONTAINER]: textContainerRule, + [Ci.nsIAccessibleRole.ROLE_TOGGLE_BUTTON]: interactiveRule, + [Ci.nsIAccessibleRole.ROLE_TOOLBAR]: toolbarRule, +}; + +/** + * Perform audit for WCAG 1.1 criteria related to providing alternative text + * depending on the type of content. + * @param {nsIAccessible} accessible + * Accessible object to be tested to determine if it requires and has + * an appropriate text alternative. + * + * @return {null|Object} + * Null if accessible does not need or has the right text alternative, + * audit data otherwise. This data is used in the accessibility panel + * for its audit filters, audit badges, sidebar checks section and + * highlighter. + */ +function auditTextLabel(accessible) { + const rule = RULES[accessible.role]; + return rule ? rule(accessible) : null; +} + +module.exports.auditTextLabel = auditTextLabel; diff --git a/devtools/server/actors/accessibility/constants.js b/devtools/server/actors/accessibility/constants.js new file mode 100644 index 0000000000..6035b5c844 --- /dev/null +++ b/devtools/server/actors/accessibility/constants.js @@ -0,0 +1,59 @@ +/* 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 { + accessibility: { + SIMULATION_TYPE: { + ACHROMATOPSIA, + DEUTERANOPIA, + PROTANOPIA, + TRITANOPIA, + CONTRAST_LOSS, + }, + }, +} = require("resource://devtools/shared/constants.js"); + +/** + * Constants used in accessibility actors. + */ + +// Color blindness matrix values taken from Machado et al. (2009), https://doi.org/10.1109/TVCG.2009.113: +// https://www.inf.ufrgs.br/~oliveira/pubs_files/CVD_Simulation/CVD_Simulation.html +// Contrast loss matrix values are for 50% contrast (see https://docs.rainmeter.net/tips/colormatrix-guide/, +// and https://stackoverflow.com/questions/23865511/contrast-with-color-matrix). The matrices are flattened +// 4x5 matrices, needed for docShell setColorMatrix method. i.e. A 4x5 matrix of the form: +// 1 2 3 4 5 +// 6 7 8 9 10 +// 11 12 13 14 15 +// 16 17 18 19 20 +// will be need to be set in docShell as: +// [1, 6, 11, 16, 2, 7, 12, 17, 3, 8, 13, 18, 4, 9, 14, 19, 5, 10, 15, 20] +const COLOR_TRANSFORMATION_MATRICES = { + NONE: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0], + [ACHROMATOPSIA]: [ + 0.299, 0.299, 0.299, 0, 0.587, 0.587, 0.587, 0, 0.114, 0.114, 0.114, 0, 0, + 0, 0, 1, 0, 0, 0, 0, + ], + [PROTANOPIA]: [ + 0.152286, 0.114503, -0.003882, 0, 1.052583, 0.786281, -0.048116, 0, + -0.204868, 0.099216, 1.051998, 0, 0, 0, 0, 1, 0, 0, 0, 0, + ], + [DEUTERANOPIA]: [ + 0.367322, 0.280085, -0.01182, 0, 0.860646, 0.672501, 0.04294, 0, -0.227968, + 0.047413, 0.968881, 0, 0, 0, 0, 1, 0, 0, 0, 0, + ], + [TRITANOPIA]: [ + 1.255528, -0.078411, 0.004733, 0, -0.076749, 0.930809, 0.691367, 0, + -0.178779, 0.147602, 0.3039, 0, 0, 0, 0, 1, 0, 0, 0, 0, + ], + [CONTRAST_LOSS]: [ + 0.5, 0, 0, 0, 0, 0.5, 0, 0, 0, 0, 0.5, 0, 0, 0, 0, 0.5, 0.25, 0.25, 0.25, 0, + ], +}; + +exports.simulation = { + COLOR_TRANSFORMATION_MATRICES, +}; diff --git a/devtools/server/actors/accessibility/moz.build b/devtools/server/actors/accessibility/moz.build new file mode 100644 index 0000000000..4da1cd0b24 --- /dev/null +++ b/devtools/server/actors/accessibility/moz.build @@ -0,0 +1,20 @@ +# 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/. + +DIRS += [ + "audit", +] + +DevToolsModules( + "accessibility.js", + "accessible.js", + "constants.js", + "parent-accessibility.js", + "simulator.js", + "walker.js", + "worker.js", +) + +with Files("**"): + BUG_COMPONENT = ("DevTools", "Accessibility Tools") diff --git a/devtools/server/actors/accessibility/parent-accessibility.js b/devtools/server/actors/accessibility/parent-accessibility.js new file mode 100644 index 0000000000..fd2c945ea7 --- /dev/null +++ b/devtools/server/actors/accessibility/parent-accessibility.js @@ -0,0 +1,154 @@ +/* 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 { Actor } = require("resource://devtools/shared/protocol.js"); +const { + parentAccessibilitySpec, +} = require("resource://devtools/shared/specs/accessibility.js"); + +const PREF_ACCESSIBILITY_FORCE_DISABLED = "accessibility.force_disabled"; + +class ParentAccessibilityActor extends Actor { + constructor(conn) { + super(conn, parentAccessibilitySpec); + + this.userPref = Services.prefs.getIntPref( + PREF_ACCESSIBILITY_FORCE_DISABLED + ); + + if (this.enabled && !this.accService) { + // Set a local reference to an accessibility service if accessibility was + // started elsewhere to ensure that parent process a11y service does not + // get GC'ed away. + this.accService = Cc["@mozilla.org/accessibilityService;1"].getService( + Ci.nsIAccessibilityService + ); + } + + Services.obs.addObserver(this, "a11y-consumers-changed"); + Services.prefs.addObserver(PREF_ACCESSIBILITY_FORCE_DISABLED, this); + } + + bootstrap() { + return { + canBeDisabled: this.canBeDisabled, + canBeEnabled: this.canBeEnabled, + }; + } + + observe(subject, topic, data) { + if (topic === "a11y-consumers-changed") { + // This event is fired when accessibility service consumers change. Since + // this observer lives in parent process there are 2 possible consumers of + // a11y service: XPCOM and PlatformAPI (e.g. screen readers). We only care + // about PlatformAPI consumer changes because when set, we can no longer + // disable accessibility service. + const { PlatformAPI } = JSON.parse(data); + this.emit("can-be-disabled-change", !PlatformAPI); + } else if ( + !this.disabling && + topic === "nsPref:changed" && + data === PREF_ACCESSIBILITY_FORCE_DISABLED + ) { + // PREF_ACCESSIBILITY_FORCE_DISABLED preference change event. When set to + // >=1, it means that the user wants to disable accessibility service and + // prevent it from starting in the future. Note: we also check + // this.disabling state when handling this pref change because this is how + // we disable the accessibility inspector itself. + this.emit("can-be-enabled-change", this.canBeEnabled); + } + } + + /** + * A getter that indicates if accessibility service is enabled. + * + * @return {Boolean} + * True if accessibility service is on. + */ + get enabled() { + return Services.appinfo.accessibilityEnabled; + } + + /** + * A getter that indicates if the accessibility service can be disabled. + * + * @return {Boolean} + * True if accessibility service can be disabled. + */ + get canBeDisabled() { + if (this.enabled) { + const a11yService = Cc["@mozilla.org/accessibilityService;1"].getService( + Ci.nsIAccessibilityService + ); + const { PlatformAPI } = JSON.parse(a11yService.getConsumers()); + return !PlatformAPI; + } + + return true; + } + + /** + * A getter that indicates if the accessibility service can be enabled. + * + * @return {Boolean} + * True if accessibility service can be enabled. + */ + get canBeEnabled() { + return Services.prefs.getIntPref(PREF_ACCESSIBILITY_FORCE_DISABLED) < 1; + } + + /** + * Enable accessibility service (via XPCOM service). + */ + enable() { + if (this.enabled || !this.canBeEnabled) { + return; + } + + this.accService = Cc["@mozilla.org/accessibilityService;1"].getService( + Ci.nsIAccessibilityService + ); + } + + /** + * Force disable accessibility service. This method removes the reference to + * the XPCOM a11y service object and flips the + * PREF_ACCESSIBILITY_FORCE_DISABLED preference on and off to shutdown a11y + * service. + */ + disable() { + if (!this.enabled || !this.canBeDisabled) { + return; + } + + this.disabling = true; + this.accService = null; + // Set PREF_ACCESSIBILITY_FORCE_DISABLED to 1 to force disable + // accessibility service. This is the only way to guarantee an immediate + // accessibility service shutdown in all processes. This also prevents + // accessibility service from starting up in the future. + Services.prefs.setIntPref(PREF_ACCESSIBILITY_FORCE_DISABLED, 1); + // Set PREF_ACCESSIBILITY_FORCE_DISABLED back to previous default or user + // set value. This will not start accessibility service until the user + // activates it again. It simply ensures that accessibility service can + // start again (when value is below 1). + Services.prefs.setIntPref(PREF_ACCESSIBILITY_FORCE_DISABLED, this.userPref); + delete this.disabling; + } + + /** + * Destroy the helper class, remove all listeners and if possible disable + * accessibility service in the parent process. + */ + destroy() { + this.disable(); + super.destroy(); + Services.obs.removeObserver(this, "a11y-consumers-changed"); + Services.prefs.removeObserver(PREF_ACCESSIBILITY_FORCE_DISABLED, this); + } +} + +exports.ParentAccessibilityActor = ParentAccessibilityActor; diff --git a/devtools/server/actors/accessibility/simulator.js b/devtools/server/actors/accessibility/simulator.js new file mode 100644 index 0000000000..4f7e059d8c --- /dev/null +++ b/devtools/server/actors/accessibility/simulator.js @@ -0,0 +1,81 @@ +/* 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 { Actor } = require("resource://devtools/shared/protocol.js"); +const { + simulatorSpec, +} = require("resource://devtools/shared/specs/accessibility.js"); + +const { + simulation: { COLOR_TRANSFORMATION_MATRICES }, +} = require("resource://devtools/server/actors/accessibility/constants.js"); + +/** + * The SimulatorActor is responsible for setting color matrices + * based on the simulation type specified. + */ +class SimulatorActor extends Actor { + constructor(conn, targetActor) { + super(conn, simulatorSpec); + this.targetActor = targetActor; + } + + /** + * Simulates a type of visual impairment (i.e. color blindness or contrast loss). + * + * @param {Object} options + * Properties: {Array} types + * Contains the types of visual impairment(s) to be simulated. + * Set default color matrix if array is empty. + * @return {Boolean} + * True if matrix was successfully applied, false otherwise. + */ + simulate(options) { + if (options.types.length > 1) { + return false; + } + + return this.setColorMatrix( + COLOR_TRANSFORMATION_MATRICES[ + options.types.length === 1 ? options.types[0] : "NONE" + ] + ); + } + + setColorMatrix(colorMatrix) { + if (!this.docShell) { + return false; + } + + try { + this.docShell.setColorMatrix(colorMatrix); + } catch (error) { + return false; + } + + return true; + } + + /** + * Disables all simulations by setting the default color matrix. + */ + disable() { + this.simulate({ types: [] }); + } + + destroy() { + super.destroy(); + + this.disable(); + this.targetActor = null; + } + + get docShell() { + return this.targetActor && this.targetActor.docShell; + } +} + +exports.SimulatorActor = SimulatorActor; diff --git a/devtools/server/actors/accessibility/walker.js b/devtools/server/actors/accessibility/walker.js new file mode 100644 index 0000000000..a742914109 --- /dev/null +++ b/devtools/server/actors/accessibility/walker.js @@ -0,0 +1,1324 @@ +/* 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 { Actor } = require("resource://devtools/shared/protocol.js"); +const { + accessibleWalkerSpec, +} = require("resource://devtools/shared/specs/accessibility.js"); + +const { + simulation: { COLOR_TRANSFORMATION_MATRICES }, +} = require("resource://devtools/server/actors/accessibility/constants.js"); + +loader.lazyRequireGetter( + this, + "AccessibleActor", + "resource://devtools/server/actors/accessibility/accessible.js", + true +); +loader.lazyRequireGetter( + this, + ["CustomHighlighterActor"], + "resource://devtools/server/actors/highlighters.js", + true +); +loader.lazyRequireGetter( + this, + "DevToolsUtils", + "resource://devtools/shared/DevToolsUtils.js" +); +loader.lazyRequireGetter( + this, + "events", + "resource://devtools/shared/event-emitter.js" +); +loader.lazyRequireGetter( + this, + ["getCurrentZoom", "isWindowIncluded", "isFrameWithChildTarget"], + "resource://devtools/shared/layout/utils.js", + true +); +loader.lazyRequireGetter( + this, + "isXUL", + "resource://devtools/server/actors/highlighters/utils/markup.js", + true +); +loader.lazyRequireGetter( + this, + [ + "isDefunct", + "loadSheetForBackgroundCalculation", + "removeSheetForBackgroundCalculation", + ], + "resource://devtools/server/actors/utils/accessibility.js", + true +); +loader.lazyRequireGetter( + this, + "accessibility", + "resource://devtools/shared/constants.js", + true +); + +const kStateHover = 0x00000004; // ElementState::HOVER + +const { + EVENT_TEXT_CHANGED, + EVENT_TEXT_INSERTED, + EVENT_TEXT_REMOVED, + EVENT_ACCELERATOR_CHANGE, + EVENT_ACTION_CHANGE, + EVENT_DEFACTION_CHANGE, + EVENT_DESCRIPTION_CHANGE, + EVENT_DOCUMENT_ATTRIBUTES_CHANGED, + EVENT_HIDE, + EVENT_NAME_CHANGE, + EVENT_OBJECT_ATTRIBUTE_CHANGED, + EVENT_REORDER, + EVENT_STATE_CHANGE, + EVENT_TEXT_ATTRIBUTE_CHANGED, + EVENT_VALUE_CHANGE, +} = Ci.nsIAccessibleEvent; + +// TODO: We do not need this once bug 1422913 is fixed. We also would not need +// to fire a name change event for an accessible that has an updated subtree and +// that has its name calculated from the said subtree. +const NAME_FROM_SUBTREE_RULE_ROLES = new Set([ + Ci.nsIAccessibleRole.ROLE_BUTTONDROPDOWN, + Ci.nsIAccessibleRole.ROLE_BUTTONDROPDOWNGRID, + Ci.nsIAccessibleRole.ROLE_BUTTONMENU, + Ci.nsIAccessibleRole.ROLE_CELL, + Ci.nsIAccessibleRole.ROLE_CHECKBUTTON, + Ci.nsIAccessibleRole.ROLE_CHECK_MENU_ITEM, + Ci.nsIAccessibleRole.ROLE_CHECK_RICH_OPTION, + Ci.nsIAccessibleRole.ROLE_COLUMN, + Ci.nsIAccessibleRole.ROLE_COLUMNHEADER, + Ci.nsIAccessibleRole.ROLE_COMBOBOX_OPTION, + Ci.nsIAccessibleRole.ROLE_DEFINITION, + Ci.nsIAccessibleRole.ROLE_GRID_CELL, + Ci.nsIAccessibleRole.ROLE_HEADING, + Ci.nsIAccessibleRole.ROLE_HELPBALLOON, + Ci.nsIAccessibleRole.ROLE_HTML_CONTAINER, + Ci.nsIAccessibleRole.ROLE_KEY, + Ci.nsIAccessibleRole.ROLE_LABEL, + Ci.nsIAccessibleRole.ROLE_LINK, + Ci.nsIAccessibleRole.ROLE_LISTITEM, + Ci.nsIAccessibleRole.ROLE_MATHML_IDENTIFIER, + Ci.nsIAccessibleRole.ROLE_MATHML_NUMBER, + Ci.nsIAccessibleRole.ROLE_MATHML_OPERATOR, + Ci.nsIAccessibleRole.ROLE_MATHML_TEXT, + Ci.nsIAccessibleRole.ROLE_MATHML_STRING_LITERAL, + Ci.nsIAccessibleRole.ROLE_MATHML_GLYPH, + Ci.nsIAccessibleRole.ROLE_MENUITEM, + Ci.nsIAccessibleRole.ROLE_OPTION, + Ci.nsIAccessibleRole.ROLE_OUTLINEITEM, + Ci.nsIAccessibleRole.ROLE_PAGETAB, + Ci.nsIAccessibleRole.ROLE_PARENT_MENUITEM, + Ci.nsIAccessibleRole.ROLE_PUSHBUTTON, + Ci.nsIAccessibleRole.ROLE_RADIOBUTTON, + Ci.nsIAccessibleRole.ROLE_RADIO_MENU_ITEM, + Ci.nsIAccessibleRole.ROLE_RICH_OPTION, + Ci.nsIAccessibleRole.ROLE_ROW, + Ci.nsIAccessibleRole.ROLE_ROWHEADER, + Ci.nsIAccessibleRole.ROLE_SUMMARY, + Ci.nsIAccessibleRole.ROLE_SWITCH, + Ci.nsIAccessibleRole.ROLE_TABLE_COLUMN_HEADER, + Ci.nsIAccessibleRole.ROLE_TABLE_ROW_HEADER, + Ci.nsIAccessibleRole.ROLE_TEAR_OFF_MENU_ITEM, + Ci.nsIAccessibleRole.ROLE_TERM, + Ci.nsIAccessibleRole.ROLE_TOGGLE_BUTTON, + Ci.nsIAccessibleRole.ROLE_TOOLTIP, +]); + +const IS_OSX = Services.appinfo.OS === "Darwin"; + +const { + SCORES: { BEST_PRACTICES, FAIL, WARNING }, +} = accessibility; + +/** + * Helper function that determines if nsIAccessible object is in stale state. When an + * object is stale it means its subtree is not up to date. + * + * @param {nsIAccessible} accessible + * object to be tested. + * @return {Boolean} + * True if accessible object is stale, false otherwise. + */ +function isStale(accessible) { + const extraState = {}; + accessible.getState({}, extraState); + // extraState.value is a bitmask. We are applying bitwise AND to mask out + // irrelevant states. + return !!(extraState.value & Ci.nsIAccessibleStates.EXT_STATE_STALE); +} + +/** + * Get accessibility audit starting with the passed accessible object as a root. + * + * @param {Object} acc + * AccessibileActor to be used as the root for the audit. + * @param {Object} options + * Options for running audit, may include: + * - types: Array of audit types to be performed during audit. + * @param {Map} report + * An accumulator map to be used to store audit information. + * @param {Object} progress + * An audit project object that is used to track the progress of the + * audit and send progress "audit-event" events to the client. + */ +function getAudit(acc, options, report, progress) { + if (acc.isDefunct) { + return; + } + + // Audit returns a promise, save the actual value in the report. + report.set( + acc, + acc.audit(options).then(result => { + report.set(acc, result); + progress.increment(); + }) + ); + + for (const child of acc.children()) { + getAudit(child, options, report, progress); + } +} + +/** + * A helper class that is used to track audit progress and send progress events + * to the client. + */ +class AuditProgress { + constructor(walker) { + this.completed = 0; + this.percentage = 0; + this.walker = walker; + } + + setTotal(size) { + this.size = size; + } + + notify() { + this.walker.emit("audit-event", { + type: "progress", + progress: { + total: this.size, + percentage: this.percentage, + completed: this.completed, + }, + }); + } + + increment() { + this.completed++; + const { completed, size } = this; + if (!size) { + return; + } + + const percentage = Math.round((completed / size) * 100); + if (percentage > this.percentage) { + this.percentage = percentage; + this.notify(); + } + } + + destroy() { + this.walker = null; + } +} + +/** + * The AccessibleWalkerActor stores a cache of AccessibleActors that represent + * accessible objects in a given document. + * + * It is also responsible for implicitely initializing and shutting down + * accessibility engine by storing a reference to the XPCOM accessibility + * service. + */ +class AccessibleWalkerActor extends Actor { + constructor(conn, targetActor) { + super(conn, accessibleWalkerSpec); + this.targetActor = targetActor; + this.refMap = new Map(); + this._loadedSheets = new WeakMap(); + this.setA11yServiceGetter(); + this.onPick = this.onPick.bind(this); + this.onHovered = this.onHovered.bind(this); + this._preventContentEvent = this._preventContentEvent.bind(this); + this.onKey = this.onKey.bind(this); + this.onFocusIn = this.onFocusIn.bind(this); + this.onFocusOut = this.onFocusOut.bind(this); + this.onHighlighterEvent = this.onHighlighterEvent.bind(this); + } + + get highlighter() { + if (!this._highlighter) { + this._highlighter = new CustomHighlighterActor( + this, + "AccessibleHighlighter" + ); + + this.manage(this._highlighter); + this._highlighter.on("highlighter-event", this.onHighlighterEvent); + } + + return this._highlighter; + } + + get tabbingOrderHighlighter() { + if (!this._tabbingOrderHighlighter) { + this._tabbingOrderHighlighter = new CustomHighlighterActor( + this, + "TabbingOrderHighlighter" + ); + + this.manage(this._tabbingOrderHighlighter); + } + + return this._tabbingOrderHighlighter; + } + + setA11yServiceGetter() { + DevToolsUtils.defineLazyGetter(this, "a11yService", () => { + Services.obs.addObserver(this, "accessible-event"); + return Cc["@mozilla.org/accessibilityService;1"].getService( + Ci.nsIAccessibilityService + ); + }); + } + + get rootWin() { + return this.targetActor && this.targetActor.window; + } + + get rootDoc() { + return this.targetActor && this.targetActor.window.document; + } + + get isXUL() { + return isXUL(this.rootWin); + } + + get colorMatrix() { + if (!this.targetActor.docShell) { + return null; + } + + const colorMatrix = this.targetActor.docShell.getColorMatrix(); + if ( + colorMatrix.length === 0 || + colorMatrix === COLOR_TRANSFORMATION_MATRICES.NONE + ) { + return null; + } + + return colorMatrix; + } + + reset() { + try { + Services.obs.removeObserver(this, "accessible-event"); + } catch (e) { + // Accessible event observer might not have been initialized if a11y + // service was never used. + } + + this.cancelPick(); + + // Clean up accessible actors cache. + this.clearRefs(); + + this._childrenPromise = null; + delete this.a11yService; + this.setA11yServiceGetter(); + } + + /** + * Remove existing cache (of accessible actors) from tree. + */ + clearRefs() { + for (const actor of this.refMap.values()) { + actor.destroy(); + } + } + + destroy() { + super.destroy(); + + this.reset(); + + if (this._highlighter) { + this._highlighter.off("highlighter-event", this.onHighlighterEvent); + this._highlighter = null; + } + + if (this._tabbingOrderHighlighter) { + this._tabbingOrderHighlighter = null; + } + + this.targetActor = null; + this.refMap = null; + } + + getRef(rawAccessible) { + return this.refMap.get(rawAccessible); + } + + addRef(rawAccessible) { + let actor = this.refMap.get(rawAccessible); + if (actor) { + return actor; + } + + actor = new AccessibleActor(this, rawAccessible); + // Add the accessible actor as a child of this accessible walker actor, + // assigning it an actorID. + this.manage(actor); + this.refMap.set(rawAccessible, actor); + + return actor; + } + + /** + * Clean up accessible actors cache for a given accessible's subtree. + * + * @param {null|nsIAccessible} rawAccessible + */ + purgeSubtree(rawAccessible) { + if (!rawAccessible) { + return; + } + + try { + for ( + let child = rawAccessible.firstChild; + child; + child = child.nextSibling + ) { + this.purgeSubtree(child); + } + } catch (e) { + // rawAccessible or its descendants are defunct. + } + + const actor = this.getRef(rawAccessible); + if (actor) { + actor.destroy(); + } + } + + unmanage(actor) { + if (actor instanceof AccessibleActor) { + this.refMap.delete(actor.rawAccessible); + } + Actor.prototype.unmanage.call(this, actor); + } + + /** + * A helper method. Accessibility walker is assumed to have only 1 child which + * is the top level document. + */ + async children() { + if (this._childrenPromise) { + return this._childrenPromise; + } + + this._childrenPromise = Promise.all([this.getDocument()]); + const children = await this._childrenPromise; + this._childrenPromise = null; + return children; + } + + /** + * A promise for a root document accessible actor that only resolves when its + * corresponding document accessible object is fully loaded. + * + * @return {Promise} + */ + getDocument() { + if (!this.rootDoc || !this.rootDoc.documentElement) { + return this.once("document-ready").then(docAcc => this.addRef(docAcc)); + } + + if (this.isXUL) { + const doc = this.addRef(this.getRawAccessibleFor(this.rootDoc)); + return Promise.resolve(doc); + } + + const doc = this.getRawAccessibleFor(this.rootDoc); + + // For non-visible same-process iframes we don't get a document and + // won't get a "document-ready" event. + if (!doc && !this.rootWin.windowGlobalChild.isProcessRoot) { + // We can ignore such document as there won't be anything to audit in them. + return null; + } + + if (!doc || isStale(doc)) { + return this.once("document-ready").then(docAcc => this.addRef(docAcc)); + } + + return Promise.resolve(this.addRef(doc)); + } + + /** + * Get an accessible actor for a domnode actor. + * @param {Object} domNode + * domnode actor for which accessible actor is being created. + * @return {Promse} + * A promise that resolves when accessible actor is created for a + * domnode actor. + */ + getAccessibleFor(domNode) { + // We need to make sure that the document is loaded processed by a11y first. + return this.getDocument().then(() => { + const rawAccessible = this.getRawAccessibleFor(domNode.rawNode); + // Not all DOM nodes have corresponding accessible objects. It's usually + // the case where there is no semantics or relevance to the accessibility + // client. + if (!rawAccessible) { + return null; + } + + return this.addRef(rawAccessible); + }); + } + + /** + * Get a raw accessible object for a raw node. + * @param {DOMNode} rawNode + * Raw node for which accessible object is being retrieved. + * @return {nsIAccessible} + * Accessible object for a given DOMNode. + */ + getRawAccessibleFor(rawNode) { + // Accessible can only be retrieved iff accessibility service is enabled. + if (!Services.appinfo.accessibilityEnabled) { + return null; + } + + return this.a11yService.getAccessibleFor(rawNode); + } + + async getAncestry(accessible) { + if (!accessible || accessible.indexInParent === -1) { + return []; + } + const doc = await this.getDocument(); + if (!doc) { + return []; + } + + const ancestry = []; + if (accessible === doc) { + return ancestry; + } + + try { + let parent = accessible; + while (parent && (parent = parent.parentAcc) && parent != doc) { + ancestry.push(parent); + } + ancestry.push(doc); + } catch (error) { + throw new Error(`Failed to get ancestor for ${accessible}: ${error}`); + } + + return ancestry.map(parent => ({ + accessible: parent, + children: parent.children(), + })); + } + + /** + * Run accessibility audit and return relevant ancestries for AccessibleActors + * that have non-empty audit checks. + * + * @param {Object} options + * Options for running audit, may include: + * - types: Array of audit types to be performed during audit. + * + * @return {Promise} + * A promise that resolves when the audit is complete and all relevant + * ancestries are calculated. + */ + async audit(options) { + const doc = await this.getDocument(); + if (!doc) { + return []; + } + + const report = new Map(); + this._auditProgress = new AuditProgress(this); + getAudit(doc, options, report, this._auditProgress); + this._auditProgress.setTotal(report.size); + await Promise.all(report.values()); + + const ancestries = []; + for (const [acc, audit] of report.entries()) { + // Filter out audits that have no failing checks. + if ( + audit && + Object.values(audit).some( + check => + check != null && + !check.error && + [BEST_PRACTICES, FAIL, WARNING].includes(check.score) + ) + ) { + ancestries.push(this.getAncestry(acc)); + } + } + + return Promise.all(ancestries); + } + + /** + * Start accessibility audit. The result of this function will not be an audit + * report. Instead, an "audit-event" event will be fired when the audit is + * completed or fails. + * + * @param {Object} options + * Options for running audit, may include: + * - types: Array of audit types to be performed during audit. + */ + startAudit(options) { + // Audit is already running, wait for the "audit-event" event. + if (this._auditing) { + return; + } + + this._auditing = this.audit(options) + // We do not want to block on audit request, instead fire "audit-event" + // event when internal audit is finished or failed. + .then(ancestries => + this.emit("audit-event", { + type: "completed", + ancestries, + }) + ) + .catch(() => this.emit("audit-event", { type: "error" })) + .finally(() => { + this._auditing = null; + if (this._auditProgress) { + this._auditProgress.destroy(); + this._auditProgress = null; + } + }); + } + + onHighlighterEvent(data) { + this.emit("highlighter-event", data); + } + + /** + * Accessible event observer function. + * + * @param {Ci.nsIAccessibleEvent} subject + * accessible event object. + */ + // eslint-disable-next-line complexity + observe(subject) { + const event = subject.QueryInterface(Ci.nsIAccessibleEvent); + const rawAccessible = event.accessible; + const accessible = this.getRef(rawAccessible); + + if (rawAccessible instanceof Ci.nsIAccessibleDocument && !accessible) { + const rootDocAcc = this.getRawAccessibleFor(this.rootDoc); + if (rawAccessible === rootDocAcc && !isStale(rawAccessible)) { + this.clearRefs(); + // If it's a top level document notify listeners about the document + // being ready. + events.emit(this, "document-ready", rawAccessible); + } + } + + switch (event.eventType) { + case EVENT_STATE_CHANGE: + const { state, isEnabled } = event.QueryInterface( + Ci.nsIAccessibleStateChangeEvent + ); + const isBusy = state & Ci.nsIAccessibleStates.STATE_BUSY; + if (accessible) { + // Only propagate state change events for active accessibles. + if (isBusy && isEnabled) { + if (rawAccessible instanceof Ci.nsIAccessibleDocument) { + // Remove existing cache from tree. + this.clearRefs(); + } + return; + } + events.emit(accessible, "states-change", accessible.states); + } + + break; + case EVENT_NAME_CHANGE: + if (accessible) { + events.emit( + accessible, + "name-change", + rawAccessible.name, + event.DOMNode == this.rootDoc + ? undefined + : this.getRef(rawAccessible.parent) + ); + } + break; + case EVENT_VALUE_CHANGE: + if (accessible) { + events.emit(accessible, "value-change", rawAccessible.value); + } + break; + case EVENT_DESCRIPTION_CHANGE: + if (accessible) { + events.emit( + accessible, + "description-change", + rawAccessible.description + ); + } + break; + case EVENT_REORDER: + if (accessible) { + accessible + .children() + .forEach(child => + events.emit(child, "index-in-parent-change", child.indexInParent) + ); + events.emit(accessible, "reorder", rawAccessible.childCount); + } + break; + case EVENT_HIDE: + if (event.DOMNode == this.rootDoc) { + this.clearRefs(); + } else { + this.purgeSubtree(rawAccessible); + } + break; + case EVENT_DEFACTION_CHANGE: + case EVENT_ACTION_CHANGE: + if (accessible) { + events.emit(accessible, "actions-change", accessible.actions); + } + break; + case EVENT_TEXT_CHANGED: + case EVENT_TEXT_INSERTED: + case EVENT_TEXT_REMOVED: + if (accessible) { + events.emit(accessible, "text-change"); + if (NAME_FROM_SUBTREE_RULE_ROLES.has(rawAccessible.role)) { + events.emit( + accessible, + "name-change", + rawAccessible.name, + event.DOMNode == this.rootDoc + ? undefined + : this.getRef(rawAccessible.parent) + ); + } + } + break; + case EVENT_DOCUMENT_ATTRIBUTES_CHANGED: + case EVENT_OBJECT_ATTRIBUTE_CHANGED: + case EVENT_TEXT_ATTRIBUTE_CHANGED: + if (accessible) { + events.emit(accessible, "attributes-change", accessible.attributes); + } + break; + // EVENT_ACCELERATOR_CHANGE is currently not fired by gecko accessibility. + case EVENT_ACCELERATOR_CHANGE: + if (accessible) { + events.emit( + accessible, + "shortcut-change", + accessible.keyboardShortcut + ); + } + break; + default: + break; + } + } + + /** + * Ensure that nothing interferes with the audit for an accessible object + * (CSS, overlays) by load accessibility highlighter style sheet used for + * preventing transitions and applying transparency when calculating colour + * contrast as well as temporarily hiding accessible highlighter overlay. + * @param {Object} win + * Window where highlighting happens. + */ + async clearStyles(win) { + const requests = this._loadedSheets.get(win); + if (requests != null) { + this._loadedSheets.set(win, requests + 1); + return; + } + + // Disable potential mouse driven transitions (This is important because accessibility + // highlighter temporarily modifies text color related CSS properties. In case where + // there are transitions that affect them, there might be unexpected side effects when + // taking a snapshot for contrast measurement). + loadSheetForBackgroundCalculation(win); + this._loadedSheets.set(win, 1); + await this.hideHighlighter(); + } + + /** + * Restore CSS and overlays that could've interfered with the audit for an + * accessible object by unloading accessibility highlighter style sheet used + * for preventing transitions and applying transparency when calculating + * colour contrast and potentially restoring accessible highlighter overlay. + * @param {Object} win + * Window where highlighting was happenning. + */ + async restoreStyles(win) { + const requests = this._loadedSheets.get(win); + if (!requests) { + return; + } + + if (requests > 1) { + this._loadedSheets.set(win, requests - 1); + return; + } + + await this.showHighlighter(); + removeSheetForBackgroundCalculation(win); + this._loadedSheets.delete(win); + } + + async hideHighlighter() { + // TODO: Fix this workaround that temporarily removes higlighter bounds + // overlay that can interfere with the contrast ratio calculation. + if (this._highlighter) { + const highlighter = this._highlighter.instance; + await highlighter.isReady; + highlighter.hideAccessibleBounds(); + } + } + + async showHighlighter() { + // TODO: Fix this workaround that temporarily removes higlighter bounds + // overlay that can interfere with the contrast ratio calculation. + if (this._highlighter) { + const highlighter = this._highlighter.instance; + await highlighter.isReady; + highlighter.showAccessibleBounds(); + } + } + + /** + * Public method used to show an accessible object highlighter on the client + * side. + * + * @param {Object} accessible + * AccessibleActor to be highlighted. + * @param {Object} options + * Object used for passing options. Available options: + * - duration {Number} + * Duration of time that the highlighter should be shown. + * @return {Boolean} + * True if highlighter shows the accessible object. + */ + async highlightAccessible(accessible, options = {}) { + this.unhighlight(); + // Do not highlight if accessible is dead. + if (!accessible || accessible.isDefunct || accessible.indexInParent < 0) { + return false; + } + + this._highlightingAccessible = accessible; + const { bounds } = accessible; + if (!bounds) { + return false; + } + + const { DOMNode: rawNode } = accessible.rawAccessible; + const audit = await accessible.audit(); + if (this._highlightingAccessible !== accessible) { + return false; + } + + const { name, role } = accessible; + const { highlighter } = this; + await highlighter.instance.isReady; + if (this._highlightingAccessible !== accessible) { + return false; + } + + const shown = highlighter.show( + { rawNode }, + { ...options, ...bounds, name, role, audit, isXUL: this.isXUL } + ); + this._highlightingAccessible = null; + + return shown; + } + + /** + * Public method used to hide an accessible object highlighter on the client + * side. + */ + unhighlight() { + if (!this._highlighter) { + return; + } + + this.highlighter.hide(); + this._highlightingAccessible = null; + } + + /** + * Picking state that indicates if picking is currently enabled and, if so, + * what the current and hovered accessible objects are. + */ + _isPicking = false; + _currentAccessible = null; + + /** + * Check is event handling is allowed. + */ + _isEventAllowed({ view }) { + return ( + this.rootWin instanceof Ci.nsIDOMChromeWindow || + isWindowIncluded(this.rootWin, view) + ); + } + + /** + * Check if the DOM event received when picking shold be ignored. + * @param {Event} event + */ + _ignoreEventWhenPicking(event) { + return ( + !this._isPicking || + // If the DOM event is about a remote frame, only the WalkerActor for that + // remote frame target should emit RDP events (hovered/picked/...). And + // all other WalkerActor for intermediate iframe and top level document + // targets should stay silent. + isFrameWithChildTarget( + this.targetActor, + event.originalTarget || event.target + ) + ); + } + + _preventContentEvent(event) { + if (this._ignoreEventWhenPicking(event)) { + return; + } + + event.stopPropagation(); + event.preventDefault(); + + const target = event.originalTarget || event.target; + if (target !== this._currentTarget) { + this._resetStateAndReleaseTarget(); + this._currentTarget = target; + // We use InspectorUtils to save the original hover content state of the target + // element (that includes its hover state). In order to not trigger any visual + // changes to the element that depend on its hover state we remove the state while + // the element is the most current target of the highlighter. + // + // TODO: This logic can be removed if/when we can use elementsAtPoint API for + // determining topmost DOMNode that corresponds to specific coordinates. We would + // then be able to use a highlighter overlay that would prevent all pointer events + // to content but still render highlighter for the node/element correctly. + this._currentTargetHoverState = + InspectorUtils.getContentState(target) & kStateHover; + InspectorUtils.removeContentState(target, kStateHover); + } + } + + /** + * Click event handler for when picking is enabled. + * + * @param {Object} event + * Current click event. + */ + onPick(event) { + if (this._ignoreEventWhenPicking(event)) { + return; + } + + this._preventContentEvent(event); + if (!this._isEventAllowed(event)) { + return; + } + + // If shift is pressed, this is only a preview click, send the event to + // the client, but don't stop picking. + if (event.shiftKey) { + if (!this._currentAccessible) { + this._currentAccessible = this._findAndAttachAccessible(event); + } + events.emit(this, "picker-accessible-previewed", this._currentAccessible); + return; + } + + this._unsetPickerEnvironment(); + this._isPicking = false; + if (!this._currentAccessible) { + this._currentAccessible = this._findAndAttachAccessible(event); + } + events.emit(this, "picker-accessible-picked", this._currentAccessible); + } + + /** + * Hover event handler for when picking is enabled. + * + * @param {Object} event + * Current hover event. + */ + async onHovered(event) { + if (this._ignoreEventWhenPicking(event)) { + return; + } + + this._preventContentEvent(event); + if (!this._isEventAllowed(event)) { + return; + } + + const accessible = this._findAndAttachAccessible(event); + if (!accessible || this._currentAccessible === accessible) { + return; + } + + this._currentAccessible = accessible; + // Highlight current accessible and by the time we are done, if accessible that was + // highlighted is not current any more (user moved the mouse to a new node) highlight + // the most current accessible again. + const shown = await this.highlightAccessible(accessible); + if (this._isPicking && shown && accessible === this._currentAccessible) { + events.emit(this, "picker-accessible-hovered", accessible); + } + } + + /** + * Keyboard event handler for when picking is enabled. + * + * @param {Object} event + * Current keyboard event. + */ + onKey(event) { + if (!this._currentAccessible || this._ignoreEventWhenPicking(event)) { + return; + } + + this._preventContentEvent(event); + if (!this._isEventAllowed(event)) { + return; + } + + /** + * KEY: Action/scope + * ENTER/CARRIAGE_RETURN: Picks current accessible + * ESC/CTRL+SHIFT+C: Cancels picker + */ + switch (event.keyCode) { + // Select the element. + case event.DOM_VK_RETURN: + this.onPick(event); + break; + // Cancel pick mode. + case event.DOM_VK_ESCAPE: + this.cancelPick(); + events.emit(this, "picker-accessible-canceled"); + break; + case event.DOM_VK_C: + if ( + (IS_OSX && event.metaKey && event.altKey) || + (!IS_OSX && event.ctrlKey && event.shiftKey) + ) { + this.cancelPick(); + events.emit(this, "picker-accessible-canceled"); + } + break; + default: + break; + } + } + + /** + * Picker method that starts picker content listeners. + */ + pick() { + if (!this._isPicking) { + this._isPicking = true; + this._setPickerEnvironment(); + } + } + + /** + * This pick method also focuses the highlighter's target window. + */ + pickAndFocus() { + this.pick(); + this.rootWin.focus(); + } + + attachAccessible(rawAccessible, accessibleDocument) { + // If raw accessible object is defunct or detached, no need to cache it and + // its ancestry. + if ( + !rawAccessible || + isDefunct(rawAccessible) || + rawAccessible.indexInParent < 0 + ) { + return null; + } + + const accessible = this.addRef(rawAccessible); + // There is a chance that ancestry lookup can fail if the accessible is in + // the detached subtree. At that point the root accessible object would be + // defunct and accessing it via parent property will throw. + try { + let parent = accessible; + while (parent && parent.rawAccessible != accessibleDocument) { + parent = parent.parentAcc; + } + } catch (error) { + throw new Error(`Failed to get ancestor for ${accessible}: ${error}`); + } + + return accessible; + } + + get pixelRatio() { + return this.rootWin.devicePixelRatio; + } + + /** + * Find deepest accessible object that corresponds to the screen coordinates of the + * mouse pointer and attach it to the AccessibilityWalker tree. + * + * @param {Object} event + * Correspoinding content event. + * @return {null|Object} + * Accessible object, if available, that corresponds to a DOM node. + */ + _findAndAttachAccessible(event) { + const target = event.originalTarget || event.target; + const docAcc = this.getRawAccessibleFor(this.rootDoc); + const win = target.ownerGlobal; + const zoom = this.isXUL ? 1 : getCurrentZoom(win); + const scale = this.pixelRatio / zoom; + const rawAccessible = docAcc.getDeepestChildAtPointInProcess( + event.screenX * scale, + event.screenY * scale + ); + return this.attachAccessible(rawAccessible, docAcc); + } + + /** + * Start picker content listeners. + */ + _setPickerEnvironment() { + const target = this.targetActor.chromeEventHandler; + target.addEventListener("mousemove", this.onHovered, true); + target.addEventListener("click", this.onPick, true); + target.addEventListener("mousedown", this._preventContentEvent, true); + target.addEventListener("mouseup", this._preventContentEvent, true); + target.addEventListener("mouseover", this._preventContentEvent, true); + target.addEventListener("mouseout", this._preventContentEvent, true); + target.addEventListener("mouseleave", this._preventContentEvent, true); + target.addEventListener("mouseenter", this._preventContentEvent, true); + target.addEventListener("dblclick", this._preventContentEvent, true); + target.addEventListener("keydown", this.onKey, true); + target.addEventListener("keyup", this._preventContentEvent, true); + } + + /** + * If content is still alive, stop picker content listeners, reset the hover state for + * last target element. + */ + _unsetPickerEnvironment() { + const target = this.targetActor.chromeEventHandler; + + if (!target) { + return; + } + + target.removeEventListener("mousemove", this.onHovered, true); + target.removeEventListener("click", this.onPick, true); + target.removeEventListener("mousedown", this._preventContentEvent, true); + target.removeEventListener("mouseup", this._preventContentEvent, true); + target.removeEventListener("mouseover", this._preventContentEvent, true); + target.removeEventListener("mouseout", this._preventContentEvent, true); + target.removeEventListener("mouseleave", this._preventContentEvent, true); + target.removeEventListener("mouseenter", this._preventContentEvent, true); + target.removeEventListener("dblclick", this._preventContentEvent, true); + target.removeEventListener("keydown", this.onKey, true); + target.removeEventListener("keyup", this._preventContentEvent, true); + + this._resetStateAndReleaseTarget(); + } + + /** + * When using accessibility highlighter, we keep track of the most current event pointer + * event target. In order to update or release the target, we need to make sure we set + * the content state (using InspectorUtils) to its original value. + * + * TODO: This logic can be removed if/when we can use elementsAtPoint API for + * determining topmost DOMNode that corresponds to specific coordinates. We would then + * be able to use a highlighter overlay that would prevent all pointer events to content + * but still render highlighter for the node/element correctly. + */ + _resetStateAndReleaseTarget() { + if (!this._currentTarget) { + return; + } + + try { + if (this._currentTargetHoverState) { + InspectorUtils.setContentState(this._currentTarget, kStateHover); + } + } catch (e) { + // DOMNode is already dead. + } + + this._currentTarget = null; + this._currentTargetState = null; + } + + /** + * Cacncel picker pick. Remvoe all content listeners and hide the highlighter. + */ + cancelPick() { + this.unhighlight(); + + if (this._isPicking) { + this._unsetPickerEnvironment(); + this._isPicking = false; + this._currentAccessible = null; + } + } + + /** + * Indicates that the tabbing order current active element (focused) is being + * tracked. + */ + _isTrackingTabbingOrderFocus = false; + + /** + * Current focused element in the tabbing order. + */ + _currentFocusedTabbingOrder = null; + + /** + * Focusin event handler for when interacting with tabbing order overlay. + * + * @param {Object} event + * Most recent focusin event. + */ + async onFocusIn(event) { + if (!this._isTrackingTabbingOrderFocus) { + return; + } + + const target = event.originalTarget || event.target; + if (target === this._currentFocusedTabbingOrder) { + return; + } + + this._currentFocusedTabbingOrder = target; + this.tabbingOrderHighlighter._highlighter.updateFocus({ + node: target, + focused: true, + }); + } + + /** + * Focusout event handler for when interacting with tabbing order overlay. + * + * @param {Object} event + * Most recent focusout event. + */ + async onFocusOut(event) { + if ( + !this._isTrackingTabbingOrderFocus || + !this._currentFocusedTabbingOrder + ) { + return; + } + + const target = event.originalTarget || event.target; + // Sanity check. + if (target !== this._currentFocusedTabbingOrder) { + console.warn( + `focusout target: ${target} does not match current focused element in tabbing order: ${this._currentFocusedTabbingOrder}` + ); + } + + this.tabbingOrderHighlighter._highlighter.updateFocus({ + node: this._currentFocusedTabbingOrder, + focused: false, + }); + this._currentFocusedTabbingOrder = null; + } + + /** + * Show tabbing order overlay for a given target. + * + * @param {Object} elm + * domnode actor to be used as the starting point for generating the + * tabbing order. + * @param {Number} index + * Starting index for the tabbing order. + * + * @return {JSON} + * Tabbing order information for the last element in the tabbing + * order. It includes a ContentDOMReference for the node and a tabbing + * index. If we are at the end of the tabbing order for the top level + * content document, the ContentDOMReference will be null. If focus + * manager discovered a remote IFRAME, then the ContentDOMReference + * references the IFRAME itself. + */ + showTabbingOrder(elm, index) { + // Start track focus related events (only once). `showTabbingOrder` will be + // called multiple times for a given target if it contains other remote + // targets. + if (!this._isTrackingTabbingOrderFocus) { + this._isTrackingTabbingOrderFocus = true; + const target = this.targetActor.chromeEventHandler; + target.addEventListener("focusin", this.onFocusIn, true); + target.addEventListener("focusout", this.onFocusOut, true); + } + + return this.tabbingOrderHighlighter.show(elm, { index }); + } + + /** + * Hide tabbing order overlay for a given target. + */ + hideTabbingOrder() { + if (!this._tabbingOrderHighlighter) { + return; + } + + this.tabbingOrderHighlighter.hide(); + if (!this._isTrackingTabbingOrderFocus) { + return; + } + + this._isTrackingTabbingOrderFocus = false; + this._currentFocusedTabbingOrder = null; + const target = this.targetActor.chromeEventHandler; + if (target) { + target.removeEventListener("focusin", this.onFocusIn, true); + target.removeEventListener("focusout", this.onFocusOut, true); + } + } +} + +exports.AccessibleWalkerActor = AccessibleWalkerActor; diff --git a/devtools/server/actors/accessibility/worker.js b/devtools/server/actors/accessibility/worker.js new file mode 100644 index 0000000000..67fed4d22b --- /dev/null +++ b/devtools/server/actors/accessibility/worker.js @@ -0,0 +1,105 @@ +/* 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"; + +/* eslint-env worker */ + +/** + * Import `createTask` to communicate with `devtools/shared/worker`. + */ +importScripts("resource://gre/modules/workers/require.js"); +const { createTask } = require("resource://devtools/shared/worker/helper.js"); + +/** + * @see LineGraphWidget.prototype.setDataFromTimestamps in Graphs.js + * @param number id + * @param array timestamps + * @param number interval + * @param number duration + */ +createTask(self, "getBgRGBA", ({ dataTextBuf, dataBackgroundBuf }) => + getBgRGBA(dataTextBuf, dataBackgroundBuf) +); + +/** + * Calculates the luminance of a rgba tuple based on the formula given in + * https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef + * + * @param {Array} rgba An array with [r,g,b,a] values. + * @return {Number} The calculated luminance. + */ +function calculateLuminance(rgba) { + for (let i = 0; i < 3; i++) { + rgba[i] /= 255; + rgba[i] = + rgba[i] < 0.03928 + ? rgba[i] / 12.92 + : Math.pow((rgba[i] + 0.055) / 1.055, 2.4); + } + return 0.2126 * rgba[0] + 0.7152 * rgba[1] + 0.0722 * rgba[2]; +} + +/** + * Get RGBA or a range of RGBAs for the background pixels under the text. If luminance is + * uniform, only return one value of RGBA, otherwise return values that correspond to the + * min and max luminances. + * @param {ImageData} dataTextBuf + * pixel data for the accessible object with text visible. + * @param {ImageData} dataBackgroundBuf + * pixel data for the accessible object with transparent text. + * @return {Object} + * RGBA or a range of RGBAs with min and max values. + */ +function getBgRGBA(dataTextBuf, dataBackgroundBuf) { + let min = [0, 0, 0, 1]; + let max = [255, 255, 255, 1]; + let minLuminance = 1; + let maxLuminance = 0; + const luminances = {}; + const dataText = new Uint8ClampedArray(dataTextBuf); + const dataBackground = new Uint8ClampedArray(dataBackgroundBuf); + + let foundDistinctColor = false; + for (let i = 0; i < dataText.length; i = i + 4) { + const tR = dataText[i]; + const bgR = dataBackground[i]; + const tG = dataText[i + 1]; + const bgG = dataBackground[i + 1]; + const tB = dataText[i + 2]; + const bgB = dataBackground[i + 2]; + + // Ignore pixels that are the same where pixels that are different between the two + // images are assumed to belong to the text within the node. + if (tR === bgR && tG === bgG && tB === bgB) { + continue; + } + + foundDistinctColor = true; + + const bgColor = `rgb(${bgR}, ${bgG}, ${bgB})`; + let luminance = luminances[bgColor]; + + if (!luminance) { + // Calculate luminance for the RGB value and store it to only measure once. + luminance = calculateLuminance([bgR, bgG, bgB]); + luminances[bgColor] = luminance; + } + + if (minLuminance >= luminance) { + minLuminance = luminance; + min = [bgR, bgG, bgB, 1]; + } + + if (maxLuminance <= luminance) { + maxLuminance = luminance; + max = [bgR, bgG, bgB, 1]; + } + } + + if (!foundDistinctColor) { + return null; + } + + return minLuminance === maxLuminance ? { value: max } : { min, max }; +} |