summaryrefslogtreecommitdiffstats
path: root/devtools/server/actors/accessibility/audit
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 19:33:14 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 19:33:14 +0000
commit36d22d82aa202bb199967e9512281e9a53db42c9 (patch)
tree105e8c98ddea1c1e4784a60a5a6410fa416be2de /devtools/server/actors/accessibility/audit
parentInitial commit. (diff)
downloadfirefox-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.js306
-rw-r--r--devtools/server/actors/accessibility/audit/keyboard.js514
-rw-r--r--devtools/server/actors/accessibility/audit/moz.build12
-rw-r--r--devtools/server/actors/accessibility/audit/text-label.js438
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;