514 lines
14 KiB
JavaScript
514 lines
14 KiB
JavaScript
/* 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;
|