diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /devtools/server/actors/accessibility/audit | |
parent | Initial commit. (diff) | |
download | firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esrupstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/server/actors/accessibility/audit')
-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 |
4 files changed, 1270 insertions, 0 deletions
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; |