/* 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;