Source code
Revision control
Copy as Markdown
Other Tools
/* 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,
"use strict";
/**
* Accessible states used to check node's state from the accessiblity API
* perspective.
*
* Note: if gecko is built with --disable-accessibility, the interfaces
* are not defined. This is why we use getters instead to be able to use
* these statically.
*/
this.AccessibilityUtils = (function () {
const FORCE_DISABLE_ACCESSIBILITY_PREF = "accessibility.force_disabled";
// Accessible states.
const { STATE_FOCUSABLE, STATE_INVISIBLE, STATE_LINKED, STATE_UNAVAILABLE } =
Ci.nsIAccessibleStates;
// Accessible action for showing long description.
const CLICK_ACTION = "click";
// Roles that are considered focusable with the keyboard.
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,
]);
// Roles that are user interactive.
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,
]);
// Roles that are considered interactive when they are focusable.
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,
]);
// Roles that are considered form controls.
const FORM_ROLES = new Set([
Ci.nsIAccessibleRole.ROLE_CHECKBUTTON,
Ci.nsIAccessibleRole.ROLE_CHECK_RICH_OPTION,
Ci.nsIAccessibleRole.ROLE_COMBOBOX,
Ci.nsIAccessibleRole.ROLE_EDITCOMBOBOX,
Ci.nsIAccessibleRole.ROLE_ENTRY,
Ci.nsIAccessibleRole.ROLE_LISTBOX,
Ci.nsIAccessibleRole.ROLE_PASSWORD_TEXT,
Ci.nsIAccessibleRole.ROLE_PROGRESSBAR,
Ci.nsIAccessibleRole.ROLE_RADIOBUTTON,
Ci.nsIAccessibleRole.ROLE_SLIDER,
Ci.nsIAccessibleRole.ROLE_SPINBUTTON,
Ci.nsIAccessibleRole.ROLE_SWITCH,
]);
const DEFAULT_ENV = Object.freeze({
// Checks that accessible object has at least one accessible action.
actionCountRule: true,
// Checks that accessible object (and its corresponding node) is focusable
// (has focusable state and its node's tabindex is not set to -1).
focusableRule: true,
// Checks that clickable accessible object (and its corresponding node) has
// appropriate interactive semantics.
ifClickableThenInteractiveRule: true,
// Checks that accessible object has a role that is considered to be
// interactive.
interactiveRule: true,
// Checks that accessible object has a non-empty label.
labelRule: true,
// Checks that a node is enabled and is expected to be enabled via
// the accessibility API.
mustBeEnabled: true,
// Checks that a node has a corresponding accessible object.
mustHaveAccessibleRule: true,
// Checks that accessible object (and its corresponding node) have a non-
// negative tabindex. Platform accessibility API still sets focusable state
// on tabindex=-1 nodes.
nonNegativeTabIndexRule: true,
});
let gA11YChecks = false;
let gEnv = {
...DEFAULT_ENV,
};
/**
* Get role attribute for an accessible object if specified for its
* corresponding {@code DOMNode}.
*
* @param {nsIAccessible} accessible
* Accessible for which to determine its role attribute value.
*
* @returns {String}
* Role attribute value if specified.
*/
function getAriaRoles(accessible) {
try {
return accessible.attributes.getStringProperty("xml-roles");
} catch (e) {
// No xml-roles. nsPersistentProperties throws if the attribute for a key
// is not found.
}
return "";
}
/**
* 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)];
}
/**
* Test if an accessible has a {@code hidden} attribute.
*
* @param {nsIAccessible} accessible
* Accessible object.
*
* @return {boolean}
* True if the accessible object has a {@code hidden} attribute, false
* otherwise.
*/
function hasHiddenAttribute(accessible) {
let hidden = false;
try {
hidden = accessible.attributes.getStringProperty("hidden");
} catch (e) {}
// If the property is missing, error will be thrown
return hidden && hidden === "true";
}
/**
* Test if an accessible is hidden from the user.
*
* @param {nsIAccessible} accessible
* Accessible object.
*
* @return {boolean}
* True if accessible is hidden from user, false otherwise.
*/
function isHidden(accessible) {
if (!accessible) {
return true;
}
while (accessible) {
if (hasHiddenAttribute(accessible)) {
return true;
}
accessible = accessible.parent;
}
return false;
}
/**
* Check if an accessible has a given state.
*
* @param {nsIAccessible} accessible
* Accessible object to test.
* @param {number} stateToMatch
* State to match.
*
* @return {boolean}
* True if |accessible| has |stateToMatch|, false otherwise.
*/
function matchState(accessible, stateToMatch) {
const state = {};
accessible.getState(state, {});
return !!(state.value & stateToMatch);
}
/**
* Determine if an accessible is a keyboard focusable browser toolbar button.
* Browser toolbar buttons aren't keyboard focusable in the usual way.
* Instead, focus is managed by JS code which sets tabindex on a single
* button at a time. Thus, we need to special case the focusable check for
* these buttons.
*/
function isKeyboardFocusableBrowserToolbarButton(accessible) {
const node = accessible.DOMNode;
if (!node || !node.ownerGlobal) {
return false;
}
const toolbar =
node.closest("toolbar") ||
node.flattenedTreeParentNode.closest("toolbar");
if (!toolbar || toolbar.getAttribute("keyNav") != "true") {
return false;
}
// The Go button in the Url Bar is an example of a purposefully
// non-focusable image toolbar button that provides an mouse/touch-only
// control for the search query submission, while a keyboard user could
// press `Enter` to do it. Similarly, two scroll buttons that appear when
// toolbar is overflowing, and keyboard-only users would actually scroll
// tabs in the toolbar while trying to navigate to these controls. When
// toolbarbuttons are redundant for keyboard users, we do not want to
// create an extra tab stop for such controls, thus we are expecting the
// button markup to include `keyNav="false"` attribute to flag it.
if (node.getAttribute("keyNav") == "false") {
const ariaRoles = getAriaRoles(accessible);
return (
ariaRoles.includes("button") ||
accessible.role == Ci.nsIAccessibleRole.ROLE_PUSHBUTTON
);
}
return node.ownerGlobal.ToolbarKeyboardNavigator._isButton(node);
}
/**
* Determine if an accessible is a keyboard focusable control within a Firefox
* View list. The main landmark of the Firefox View has role="application" for
* users to expect a custom keyboard navigation pattern. Controls within this
* area aren't keyboard focusable in the usual way. Instead, focus is managed
* by JS code which sets tabindex on a single control within each list at a
* time. Thus, we need to special case the focusable check for these controls.
*/
function isKeyboardFocusableFxviewControlInApplication(accessible) {
const node = accessible.DOMNode;
if (!node || !node.ownerGlobal) {
return false;
}
// Firefox View application rows currently include only buttons and links:
if (
!node.className.includes("fxview-tab-row-") ||
(accessible.role != Ci.nsIAccessibleRole.ROLE_PUSHBUTTON &&
accessible.role != Ci.nsIAccessibleRole.ROLE_LINK)
) {
return false; // Not a button or a link in a Firefox View app.
}
// ToDo: We may eventually need to support intervening generics between
// a list and its listitem here and/or aria-owns lists.
const listitemAcc = accessible.parent;
const listAcc = listitemAcc.parent;
if (
(!listAcc || listAcc.role != Ci.nsIAccessibleRole.ROLE_LIST) &&
(!listitemAcc || listitemAcc.role != Ci.nsIAccessibleRole.ROLE_LISTITEM)
) {
return false; // This button/link isn't inside a listitem within a list.
}
// All listitems should be not focusable while both a button and a link
// within each list item might have tabindex="-1".
if (
node.tabIndex &&
matchState(accessible, STATE_FOCUSABLE) &&
!matchState(listitemAcc, STATE_FOCUSABLE)
) {
// ToDo: We may eventually need to support lists which use aria-owns here.
// Check that there is only one keyboard reachable control within the list.
const childCount = listAcc.childCount;
let foundFocusable = false;
for (let c = 0; c < childCount; c++) {
const listitem = listAcc.getChildAt(c);
const listitemChildCount = listitem.childCount;
for (let i = 0; i < listitemChildCount; i++) {
const listitemControl = listitem.getChildAt(i);
// Use tabIndex rather than a11y focusable state because all controls
// within the listitem might have tabindex="-1".
if (listitemControl.DOMNode.tabIndex == 0) {
if (foundFocusable) {
// Only one control within a list should be focusable.
// ToDo: Fine-tune the a11y-check error message generated in this case.
// Strictly speaking, it's not ideal that we're performing an action
// from an is function, which normally only queries something without
// any externally observable behaviour. That said, fixing that would
// involve different return values for different cases (not a list,
// too many focusable listitem controls, etc) so we could move the
// a11yFail call to the caller.
a11yFail(
"Only one control should be focusable in a list",
accessible
);
return false;
}
foundFocusable = true;
}
}
}
return foundFocusable;
}
return false;
}
/**
* Determine if an accessible is a keyboard focusable option within a listbox.
* We use it in the Url bar results - these controls are't keyboard focusable
* in the usual way. Instead, focus is managed by JS code which sets tabindex
* on a single option at a time. Thus, we need to special case the focusable
* check for these option items.
*/
function isKeyboardFocusableOption(accessible) {
const node = accessible.DOMNode;
if (!node || !node.ownerGlobal) {
return false;
}
const urlbarListbox = node.closest(".urlbarView-results");
if (!urlbarListbox || urlbarListbox.getAttribute("role") != "listbox") {
return false;
}
return node.getAttribute("role") == "option";
}
/**
* Determine if an accessible is a keyboard focusable PanelMultiView control.
* These controls aren't keyboard focusable in the usual way. Instead, focus
* is managed by JS code which sets tabindex dynamically. Thus, we need to
* special case the focusable check for these controls.
*/
function isKeyboardFocusablePanelMultiViewControl(accessible) {
const node = accessible.DOMNode;
if (!node || !node.ownerGlobal) {
return false;
}
const panelview = node.closest("panelview");
if (!panelview || panelview.hasAttribute("disablekeynav")) {
return false;
}
return (
node.ownerGlobal.PanelView.forNode(panelview)._tabNavigableWalker.filter(
node
) == NodeFilter.FILTER_ACCEPT
);
}
/**
* Determine if an accessible is a keyboard focusable tab within a tablist.
* Per the ARIA design pattern, these controls aren't keyboard focusable in
* the usual way. Instead, focus is managed by JS code which sets tabindex on
* a single tab at a time. Thus, we need to special case the focusable check
* for these tab controls.
*/
function isKeyboardFocusableTabInTablist(accessible) {
const node = accessible.DOMNode;
if (!node || !node.ownerGlobal) {
return false;
}
if (accessible.role != Ci.nsIAccessibleRole.ROLE_PAGETAB) {
return false; // Not a tab.
}
const tablist = findNonGenericParentAccessible(accessible);
if (!tablist || tablist.role != Ci.nsIAccessibleRole.ROLE_PAGETABLIST) {
return false; // The tab isn't inside a tablist.
}
// ToDo: We may eventually need to support tablists which use
// aria-activedescendant here.
// Check that there is only one keyboard reachable tab.
let foundFocusable = false;
for (const tab of findNonGenericChildrenAccessible(tablist)) {
// Allow whitespaces to be included in the tablist for styling purposes
const isWhitespace =
tab.role == Ci.nsIAccessibleRole.ROLE_TEXT_LEAF &&
tab.DOMNode.textContent.trim().length === 0;
if (tab.role != Ci.nsIAccessibleRole.ROLE_PAGETAB && !isWhitespace) {
// The tablist includes children other than tabs or whitespaces
a11yFail("Only tabs should be included in a tablist", accessible);
}
// Use tabIndex rather than a11y focusable state because all tabs might
// have tabindex="-1".
if (tab.DOMNode.tabIndex == 0) {
if (foundFocusable) {
// Only one tab within a tablist should be focusable.
// ToDo: Fine-tune the a11y-check error message generated in this case.
// Strictly speaking, it's not ideal that we're performing an action
// from an is function, which normally only queries something without
// any externally observable behaviour. That said, fixing that would
// involve different return values for different cases (not a tab,
// too many focusable tabs, etc) so we could move the a11yFail call
// to the caller.
a11yFail("Only one tab should be focusable in a tablist", accessible);
return false;
}
foundFocusable = true;
}
}
return foundFocusable;
}
/**
* Determine if an accessible is a keyboard focusable button in the url bar.
* Url bar buttons aren't keyboard focusable in the usual way. Instead,
* focus is managed by JS code which sets tabindex on a single button at a
* time. Thus, we need to special case the focusable check for these buttons.
* This also applies to the search bar buttons that reuse the same pattern.
*/
function isKeyboardFocusableUrlbarButton(accessible) {
const node = accessible.DOMNode;
if (!node || !node.ownerGlobal) {
return false;
}
const isUrlBar =
node
.closest(".urlbarView > .search-one-offs")
?.getAttribute("disabletab") == "true";
const isSearchBar =
node
.closest("#PopupSearchAutoComplete > .search-one-offs")
?.getAttribute("is_searchbar") == "true";
return (
(isUrlBar || isSearchBar) &&
node.getAttribute("tabindex") == "-1" &&
node.tagName == "button" &&
node.classList.contains("searchbar-engine-one-off-item")
);
}
/**
* Determine if an accessible is a keyboard focusable XUL tab.
* Only one tab is focusable at a time, but after focusing it, you can use
* the keyboard to focus other tabs.
*/
function isKeyboardFocusableXULTab(accessible) {
const node = accessible.DOMNode;
return node && XULElement.isInstance(node) && node.tagName == "tab";
}
/**
* The gridcells are not expected to be interactive and focusable
* individually, but it is allowed to manually manage focus within the grid
* Example of such grid would be a datepicker where one gridcell can be
* selected and the focus is moved with arrow keys once the user tabbed into
* the grid. In grids like a calendar, only one element would be included in
* the focus order and the rest of grid cells may not have an interactive
* accessible created. We need to special case the check for these gridcells.
*/
function isAccessibleGridcell(node) {
if (!node || !node.ownerGlobal) {
return false;
}
const accessible = getAccessible(node);
if (!accessible || accessible.role != Ci.nsIAccessibleRole.ROLE_GRID_CELL) {
return false; // Not a grid cell.
}
// ToDo: We may eventually need to support intervening generics between
// a grid cell and its grid container here.
const gridRow = accessible.parent;
if (!gridRow || gridRow.role != Ci.nsIAccessibleRole.ROLE_ROW) {
return false; // The grid cell isn't inside a row.
}
let grid = gridRow.parent;
if (!grid) {
return false; // The grid cell isn't inside a grid.
}
if (grid.role == Ci.nsIAccessibleRole.ROLE_GROUPING) {
// Grid built on the HTML table may include <tbody> wrapper:
grid = grid.parent;
if (!grid || grid.role != Ci.nsIAccessibleRole.ROLE_GRID) {
return false; // The grid cell isn't inside a grid.
}
}
// Check that there is only one keyboard reachable grid cell.
let foundFocusable = false;
for (const gridCell of grid.DOMNode.querySelectorAll(
"td, [role=gridcell]"
)) {
// Grid cells are not expected to have a "tabindex" attribute and to be
// included in the focus order, with the exception of the only one cell
// that is included in the page tab sequence to provide access to the grid.
if (gridCell.tabIndex == 0) {
if (foundFocusable) {
// Only one grid cell within a grid should be focusable.
// ToDo: Fine-tune the a11y-check error message generated in this case.
// Strictly speaking, it's not ideal that we're performing an action
// from an is function, which normally only queries something without
// any externally observable behaviour. That said, fixing that would
// involve different return values for different cases (not a grid
// cell, too many focusable grid cells, etc) so we could move the
// a11yFail call to the caller.
a11yFail(
"Only one grid cell should be focusable in a grid",
accessible
);
return false;
}
foundFocusable = true;
}
}
return foundFocusable;
}
/**
* XUL treecol elements currently aren't focusable, making them inaccessible.
* For now, we don't flag these as a failure to avoid breaking multiple tests.
*/
function isInaccessibleXulTreecol(node) {
if (!node || !node.ownerGlobal) {
return false;
}
const listheader = node.flattenedTreeParentNode;
if (listheader.tagName !== "listheader" || node.tagName !== "treecol") {
return false;
}
return true;
}
/**
* Determine if a DOM node is a combobox container of the url bar. We
* intentionally leave this element unlabeled, because its child is a search
* input that is the target and main control of this component. In general, we
* want to avoid duplication in the label announcement when a user focuses the
* input. Both NVDA and VO ignore the label on at least one of these controls
* if both have a label. But the bigger concern here is that it's very
* difficult to keep the accessible name synchronized between the combobox and
* the input. Thus, we need to special case the label check for this control.
*/
function isUnlabeledUrlBarCombobox(node) {
if (!node || !node.ownerGlobal) {
return false;
}
let ariaRole = node.getAttribute("role");
// There are only two cases of this pattern: <moz-input-box> and <searchbar>
const isMozInputBox =
node.tagName == "moz-input-box" &&
node.classList.contains("urlbar-input-box");
const isSearchbar = node.tagName == "searchbar" && node.id == "searchbar";
return (isMozInputBox || isSearchbar) && ariaRole == "combobox";
}
/**
* Determine if a DOM node is an option within the url bar. We know each
* url bar option is accessible, but it disappears as soon as it is clicked
* during tests and the a11y-checks do not have time to test the label,
* because the Fluent localization is not yet completed by then. Thus, we
* need to special case the label check for these controls.
*/
function isUnlabeledUrlBarOption(node) {
if (!node || !node.ownerGlobal) {
return false;
}
const role = getAccessible(node)?.role;
const isOption =
node.tagName == "span" &&
node.getAttribute("role") == "option" &&
node.classList.contains("urlbarView-row-inner");
const isMenuItem =
node.tagName == "menuitem" &&
role == Ci.nsIAccessibleRole.ROLE_MENUITEM &&
node.classList.contains("urlbarView-result-menuitem");
// Not all options have "data-l10n-id" attributes in the URL Bar, because
// some of options are autocomplete options based on the user input and
// they are not expected to be localized.
return isOption || isMenuItem;
}
/**
* Determine if a DOM node is a menuitem within the XUL menu. We know each
* menuitem is accessible, but it disappears as soon as it is clicked during
* tests and the a11y-checks do not have time to test the label, because the
* Fluent localization is not yet completed by then. Thus, we need to special
* case the label check for these controls.
*/
function isUnlabeledMenuitem(node) {
if (!node || !node.ownerGlobal) {
return false;
}
const hasLabel = node.querySelector("label, description");
const isMenuItem =
node.getAttribute("role") == "menuitem" ||
(node.tagName == "richlistitem" &&
node.classList.contains("autocomplete-richlistitem")) ||
(node.tagName == "menuitem" &&
node.classList.contains("urlbarView-result-menuitem"));
let parentNode = node.getRootNode().host ?? node.parentNode;
const isParentMenu =
parentNode.getAttribute("role") == "menu" ||
(parentNode.tagName == "richlistbox" &&
parentNode.classList.contains("autocomplete-richlistbox")) ||
(parentNode.tagName == "menupopup" &&
parentNode.classList.contains("urlbarView-result-menu"));
return (
isMenuItem &&
isParentMenu &&
hasLabel &&
(node.hasAttribute("data-l10n-id") || node.tagName == "richlistitem")
);
}
/**
* Determine if the node is a "Show All" or one of image buttons on the
* about:config page, or a "X" close button on moz-message-bar. We know these
* buttons are accessible, but they disappear/are replaced as soon as they
* are clicked during tests and the a11y-checks do not have time to test the
* label, because the Fluent localization is not yet completed by then.
* Thus, we need to special case the label check for these controls.
*/
function isUnlabeledImageButton(node) {
if (!node || !node.ownerGlobal) {
return false;
}
const isShowAllButton = node.id == "show-all";
const isReplacedImageButton =
node.classList.contains("button-add") ||
node.classList.contains("button-delete") ||
node.classList.contains("button-reset");
const isCloseMozMessageBarButton =
node.classList.contains("close") &&
node.getAttribute("data-l10n-id") == "moz-message-bar-close-button";
return (
node.tagName.toLowerCase() == "button" &&
node.hasAttribute("data-l10n-id") &&
(isShowAllButton || isReplacedImageButton || isCloseMozMessageBarButton)
);
}
/**
* Determine if a node is a XUL:button on a prompt popup. We know this button
* is accessible, but it disappears as soon as it is clicked during tests and
* the a11y-checks do not have time to test the label, because the Fluent
* localization is not yet completed by then. Thus, we need to special case
* the label check for these controls.
*/
function isUnlabeledXulButton(node) {
if (!node || !node.ownerGlobal) {
return false;
}
const hasLabel = node.querySelector("label, xul\\:label");
const isButton =
node.getAttribute("role") == "button" ||
node.tagName == "button" ||
node.tagName == "xul:button";
return isButton && hasLabel && node.hasAttribute("data-l10n-id");
}
/**
* Determine if a node is a XUL element for which tabIndex should be ignored.
* Some XUL elements report -1 for the .tabIndex property, even though they
* are in fact keyboard focusable.
*/
function shouldIgnoreTabIndex(node) {
if (!XULElement.isInstance(node)) {
return false;
}
return node.tagName == "label" && node.getAttribute("is") == "text-link";
}
/**
* 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) {
if (
isKeyboardFocusableBrowserToolbarButton(accessible) ||
isKeyboardFocusableOption(accessible) ||
isKeyboardFocusablePanelMultiViewControl(accessible) ||
isKeyboardFocusableUrlbarButton(accessible) ||
isKeyboardFocusableXULTab(accessible) ||
isKeyboardFocusableTabInTablist(accessible) ||
isKeyboardFocusableFxviewControlInApplication(accessible)
) {
return true;
}
// State will be focusable even if the tabindex is negative.
const node = accessible.DOMNode;
const role = accessible.role;
return (
matchState(accessible, 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.
(!gEnv.nonNegativeTabIndexRule ||
node.tabIndex > -1 ||
node.closest('[aria-activedescendant][tabindex="0"]') ||
// If an ARIA toolbar uses a roving tabindex, some controls on the
// toolbar might not currently be focusable even though they can be
// reached with arrow keys and become focusable at that point.
((role == Ci.nsIAccessibleRole.ROLE_PUSHBUTTON ||
role == Ci.nsIAccessibleRole.ROLE_TOGGLE_BUTTON) &&
node.closest('[role="toolbar"]')) ||
// <moz-radio-group> also uses a roving tabindex.
(role === Ci.nsIAccessibleRole.ROLE_RADIOBUTTON &&
node.getRootNode().host?.tagName?.toLowerCase() === "moz-radio") ||
shouldIgnoreTabIndex(node))
);
}
function buildMessage(message, DOMNode) {
if (DOMNode) {
const { id, tagName, className } = DOMNode;
message += `: id: ${id}, tagName: ${tagName}, className: ${className}`;
}
return message;
}
/**
* Fail a test with a given message because of an issue with a given
* accessible object. This is used for cases where there's an actual
* accessibility failure that prevents UI from being accessible to keyboard/AT
* users.
*
* @param {String} message
* @param {nsIAccessible} accessible
* Accessible to log along with the failure message.
*/
function a11yFail(message, { DOMNode }) {
SpecialPowers.SimpleTest.ok(false, buildMessage(message, DOMNode));
}
/**
* Log a todo statement with a given message because of an issue with a given
* accessible object. This is used for cases where accessibility best
* practices are not followed or for something that is not as severe to be
* considered a failure.
* @param {String} message
* @param {nsIAccessible} accessible
* Accessible to log along with the todo message.
*/
function a11yWarn(message, { DOMNode }) {
SpecialPowers.SimpleTest.todo(false, buildMessage(message, DOMNode));
}
/**
* Test if the node's unavailable via the accessibility API.
*
* @param {nsIAccessible} accessible
* Accessible object.
*/
function assertEnabled(accessible) {
if (gEnv.mustBeEnabled && matchState(accessible, STATE_UNAVAILABLE)) {
a11yFail(
"Node expected to be enabled but is disabled via the accessibility API",
accessible
);
}
}
/**
* Test if it is possible to focus on a node with the keyboard. This method
* also checks for additional keyboard focus issues that might arise.
*
* @param {nsIAccessible} accessible
* Accessible object for a node.
*/
function assertFocusable(accessible) {
if (
gEnv.mustBeEnabled &&
gEnv.focusableRule &&
!isKeyboardFocusable(accessible)
) {
const ariaRoles = getAriaRoles(accessible);
// Do not force ARIA combobox or listbox to be focusable.
if (!ariaRoles.includes("combobox") && !ariaRoles.includes("listbox")) {
a11yFail("Node is not focusable via the accessibility API", accessible);
}
return;
}
if (!INTERACTIVE_IF_FOCUSABLE_ROLES.has(accessible.role)) {
// ROLE_TABLE is used for grids too which are considered interactive.
if (
accessible.role === Ci.nsIAccessibleRole.ROLE_TABLE &&
!getAriaRoles(accessible).includes("grid")
) {
a11yWarn(
"Focusable nodes should have interactive semantics",
accessible
);
return;
}
}
if (accessible.DOMNode.tabIndex > 0) {
a11yWarn("Avoid using tabindex attribute greater than zero", accessible);
}
}
/**
* Test if it is possible to interact with a node via the accessibility API.
*
* @param {nsIAccessible} accessible
* Accessible object for a node.
*/
function assertInteractive(accessible) {
if (
gEnv.mustBeEnabled &&
gEnv.actionCountRule &&
accessible.actionCount === 0
) {
a11yFail("Node does not support any accessible actions", accessible);
return;
}
if (
gEnv.mustBeEnabled &&
gEnv.interactiveRule &&
!INTERACTIVE_ROLES.has(accessible.role)
) {
if (
// Labels that have a label for relation with their target are clickable.
(accessible.role !== Ci.nsIAccessibleRole.ROLE_LABEL ||
accessible.getRelationByType(
Ci.nsIAccessibleRelation.RELATION_LABEL_FOR
).targetsCount === 0) &&
// Images that are inside an anchor (have linked state).
(accessible.role !== Ci.nsIAccessibleRole.ROLE_GRAPHIC ||
!matchState(accessible, STATE_LINKED))
) {
// Look for click action in the list of actions.
for (let i = 0; i < accessible.actionCount; i++) {
if (
gEnv.ifClickableThenInteractiveRule &&
accessible.getActionName(i) === CLICK_ACTION
) {
a11yFail(
"Clickable nodes must have interactive semantics",
accessible
);
}
}
}
a11yFail(
"Node does not have a correct interactive role and may not be " +
"manipulated via the accessibility API",
accessible
);
}
}
/**
* Test if the node is labelled appropriately for accessibility API.
*
* @param {nsIAccessible} accessible
* Accessible object for a node.
*/
function assertLabelled(accessible, allowRecurse = true) {
const { DOMNode } = accessible;
let name = accessible.name;
if (!name) {
// If text has just been inserted into the tree, the a11y engine might not
// have picked it up yet.
forceRefreshDriverTick(DOMNode);
try {
name = accessible.name;
} catch (e) {
// The Accessible died because the DOM node was removed or hidden.
if (gEnv.labelRule) {
// Some elements disappear as soon as they are clicked during tests,
// their accessible dies before the Fluent localization is completed.
// We want to exclude these groups of nodes from the label check.
// Note: In other cases, this first block isn't necessarily hit
// because Fluent isn't finished yet. This might happen if a text
// node was inserted (whether by Fluent or something else) but a11y
// hasn't picked it up yet, but the node gets hidden before a11y
// can pick it up.
if (
isUnlabeledUrlBarOption(DOMNode) ||
isUnlabeledMenuitem(DOMNode) ||
isUnlabeledImageButton(DOMNode)
) {
return;
}
a11yWarn("Unlabeled element removed before l10n finished", {
DOMNode,
});
}
return;
}
const doc = DOMNode.ownerDocument;
if (
!name &&
allowRecurse &&
gEnv.labelRule &&
doc.hasPendingL10nMutations
) {
// There are pending async l10n mutations which might result in a valid
// accessible name. Try this check again once l10n is finished.
doc.addEventListener(
"L10nMutationsFinished",
() => {
try {
accessible.name;
} catch (e) {
// The Accessible died because the DOM node was removed or hidden.
if (
isUnlabeledUrlBarOption(DOMNode) ||
isUnlabeledImageButton(DOMNode) ||
isUnlabeledXulButton(DOMNode)
) {
return;
}
a11yWarn("Unlabeled element removed before l10n finished", {
DOMNode,
});
return;
}
assertLabelled(accessible, false);
},
{ once: true }
);
return;
}
}
if (name) {
name = name.trim();
}
if (gEnv.labelRule && !name) {
// The URL and Search Bar comboboxes are purposefully unlabeled,
// since they include labeled inputs that are receiving focus.
// Or the Accessible died because the DOM node was removed or hidden.
if (
isUnlabeledUrlBarCombobox(DOMNode) ||
isUnlabeledUrlBarOption(DOMNode)
) {
return;
}
a11yFail("Interactive elements must be labeled", accessible);
return;
}
if (FORM_ROLES.has(accessible.role)) {
const labels = getLabels(accessible);
const hasNameFromVisibleLabel = labels.some(
label => !matchState(label, STATE_INVISIBLE)
);
if (!hasNameFromVisibleLabel) {
a11yWarn("Form elements should have a visible text label", accessible);
}
} else if (
accessible.role === Ci.nsIAccessibleRole.ROLE_LINK &&
DOMNode.nodeName === "AREA" &&
DOMNode.hasAttribute("href")
) {
const alt = DOMNode.getAttribute("alt");
if (alt && alt.trim() !== name) {
a11yFail(
"Use alt attribute to label area elements that have the href attribute",
accessible
);
}
}
}
/**
* Test if the node's visible via accessibility API.
*
* @param {nsIAccessible} accessible
* Accessible object for a node.
*/
function assertVisible(accessible) {
if (isHidden(accessible)) {
a11yFail(
"Node is not currently visible via the accessibility API and may not " +
"be manipulated by it",
accessible
);
}
}
/**
* Walk node ancestry and force refresh driver tick in every document.
* @param {DOMNode} node
* Node for traversing the ancestry.
*/
function forceRefreshDriverTick(node) {
const wins = [];
let bc = BrowsingContext.getFromWindow(node.ownerDocument.defaultView); // eslint-disable-line
while (bc) {
wins.push(bc.associatedWindow);
bc = bc.embedderWindowGlobal?.browsingContext;
}
let win = wins.pop();
while (win) {
// Stop the refresh driver from doing its regular ticks and force two
// refresh driver ticks: first to let layout update and notify a11y, and
// the second to let a11y process updates.
win.windowUtils.advanceTimeAndRefresh(100);
win.windowUtils.advanceTimeAndRefresh(100);
// Go back to normal refresh driver ticks.
win.windowUtils.restoreNormalRefresh();
win = wins.pop();
}
}
/**
* Get an accessible object for a node.
* Note: this method will not resolve if accessible object does not become
* available for a given node.
*
* @param {DOMNode} node
* Node to get the accessible object for.
*
* @return {nsIAccessible}
* Accessibility object for a given node.
*/
function getAccessible(node) {
const accessibilityService = Cc[
"@mozilla.org/accessibilityService;1"
].getService(Ci.nsIAccessibilityService);
if (!accessibilityService) {
// This is likely a build with --disable-accessibility
return null;
}
let acc = accessibilityService.getAccessibleFor(node);
if (acc) {
return acc;
}
// Force refresh tick throughout document hierarchy
forceRefreshDriverTick(node);
return accessibilityService.getAccessibleFor(node);
}
/**
* Find the nearest interactive accessible ancestor for a node.
*/
function findInteractiveAccessible(node) {
let acc;
// Walk DOM ancestors until we find one with an accessible.
for (; node && !acc; node = node.flattenedTreeParentNode) {
acc = getAccessible(node);
}
if (!acc) {
// No accessible ancestor.
return acc;
}
// Walk a11y ancestors until we find one which is interactive.
for (; acc; acc = acc.parent) {
const relation = acc.getRelationByType(
Ci.nsIAccessibleRelation.RELATION_LABEL_FOR
);
if (
acc.role === Ci.nsIAccessibleRole.ROLE_LABEL &&
relation.targetsCount > 0
) {
// If a <label> was clicked to activate a radiobutton or a checkbox,
// return the accessible of the related input.
// Note: aria-labelledby doesn't give the node a role of label, so this
// won't work for aria-labelledby cases. That said, aria-labelledby also
// doesn't have implicit click behaviour either and there's not really
// any way we can check for that.
const targetAcc = relation.getTarget(0);
return targetAcc;
}
if (INTERACTIVE_ROLES.has(acc.role)) {
return acc;
}
}
// No interactive ancestor.
return null;
}
/**
* Find the nearest non-generic ancestor for a node to account for generic
* containers to intervene between the ancestor and it child.
*/
function findNonGenericParentAccessible(childAcc) {
for (let acc = childAcc.parent; acc; acc = acc.parent) {
if (acc.computedARIARole != "generic") {
return acc;
}
}
return null;
}
/**
* Find the nearest non-generic children for a node to account for generic
* containers to intervene between the ancestor and its children.
*/
function* findNonGenericChildrenAccessible(parentAcc) {
const count = parentAcc.childCount;
for (let c = 0; c < count; ++c) {
const child = parentAcc.getChildAt(c);
// When Gecko will consider only one role as generic, we'd use child.role
if (child.computedARIARole == "generic") {
yield* findNonGenericChildrenAccessible(child);
} else {
yield child;
}
}
}
function runIfA11YChecks(task) {
return (...args) => (gA11YChecks ? task(...args) : null);
}
/**
* AccessibilityUtils provides utility methods for retrieving accessible objects
* and performing accessibility related checks.
* Current methods:
* assertCanBeClicked
* setEnv
* resetEnv
*
*/
const AccessibilityUtils = {
assertCanBeClicked(node) {
// Click events might fire on an inaccessible or non-interactive
// descendant, even if the test author targeted them at an interactive
// element. For example, if there's a button with an image inside it,
// node might be the image.
const acc = findInteractiveAccessible(node);
if (!acc) {
if (isAccessibleGridcell(node) || isInaccessibleXulTreecol(node)) {
return;
}
if (gEnv.mustHaveAccessibleRule) {
a11yFail("Node is not accessible via accessibility API", {
DOMNode: node,
});
}
return;
}
assertInteractive(acc);
assertFocusable(acc);
assertVisible(acc);
assertEnabled(acc);
assertLabelled(acc);
},
setEnv(env = DEFAULT_ENV) {
gEnv = {
...DEFAULT_ENV,
...env,
};
},
resetEnv() {
gEnv = { ...DEFAULT_ENV };
},
reset(a11yChecks = false, testPath = "") {
gA11YChecks = a11yChecks;
const { Services } = SpecialPowers;
// Disable accessibility service if it is running and if a11y checks are
// disabled. However, don't do this for accessibility engine tests.
if (
!gA11YChecks &&
Services.appinfo.accessibilityEnabled &&
) {
Services.prefs.setIntPref(FORCE_DISABLE_ACCESSIBILITY_PREF, 1);
Services.prefs.clearUserPref(FORCE_DISABLE_ACCESSIBILITY_PREF);
}
// Reset accessibility environment flags that might've been set within the
// test.
this.resetEnv();
},
init() {
this._shouldHandleClicks = true;
// A top level xul window's DocShell doesn't have a chromeEventHandler
// attribute. In that case, the chrome event handler is just the global
// window object.
this._handler ??=
window.docShell.chromeEventHandler ?? window.docShell.domWindow;
this._handler.addEventListener("click", this, true, true);
},
uninit() {
this._handler?.removeEventListener("click", this, true);
this._handler = null;
},
/**
* Suppress (or disable suppression of) handling of captured click events.
* This should only be called by EventUtils, etc. when a click event will
* be generated but we know it is not actually a click intended to activate
* a control; e.g. drag/drop. Tests that wish to disable specific checks
* should use setEnv instead.
*/
suppressClickHandling(shouldSuppress) {
this._shouldHandleClicks = !shouldSuppress;
},
handleEvent({ composedTarget }) {
if (!this._shouldHandleClicks) {
return;
}
if (composedTarget.tagName.toLowerCase() == "slot") {
// The click occurred on a text node inside a slot. Since events don't
// target text nodes, the event was retargeted to the slot. However, a
// slot isn't itself rendered. To deal with this, use the slot's parent
// instead.
composedTarget = composedTarget.flattenedTreeParentNode;
}
const bounds =
composedTarget.ownerGlobal?.windowUtils?.getBoundsWithoutFlushing(
composedTarget
);
if (bounds && (bounds.width == 0 || bounds.height == 0)) {
// Some tests click hidden nodes. These clearly aren't testing the UI
// for the node itself (and presumably there is a test somewhere else
// that does). Therefore, we can't (and shouldn't) do a11y checks.
return;
}
this.assertCanBeClicked(composedTarget);
},
};
AccessibilityUtils.assertCanBeClicked = runIfA11YChecks(
AccessibilityUtils.assertCanBeClicked.bind(AccessibilityUtils)
);
AccessibilityUtils.setEnv = runIfA11YChecks(
AccessibilityUtils.setEnv.bind(AccessibilityUtils)
);
AccessibilityUtils.resetEnv = runIfA11YChecks(
AccessibilityUtils.resetEnv.bind(AccessibilityUtils)
);
return AccessibilityUtils;
})();