tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

AccessibilityUtils.js (47818B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
      3 * You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 "use strict";
      6 
      7 /**
      8 * Accessible states used to check node's state from the accessiblity API
      9 * perspective.
     10 *
     11 * Note: if gecko is built with --disable-accessibility, the interfaces
     12 * are not defined. This is why we use getters instead to be able to use
     13 * these statically.
     14 */
     15 
     16 this.AccessibilityUtils = (function () {
     17  const FORCE_DISABLE_ACCESSIBILITY_PREF = "accessibility.force_disabled";
     18 
     19  // Accessible states.
     20  const { STATE_FOCUSABLE, STATE_INVISIBLE, STATE_LINKED, STATE_UNAVAILABLE } =
     21    Ci.nsIAccessibleStates;
     22 
     23  // Accessible action for showing long description.
     24  const CLICK_ACTION = "click";
     25 
     26  // Roles that are considered focusable with the keyboard.
     27  const KEYBOARD_FOCUSABLE_ROLES = new Set([
     28    Ci.nsIAccessibleRole.ROLE_BUTTONMENU,
     29    Ci.nsIAccessibleRole.ROLE_CHECKBUTTON,
     30    Ci.nsIAccessibleRole.ROLE_COMBOBOX,
     31    Ci.nsIAccessibleRole.ROLE_EDITCOMBOBOX,
     32    Ci.nsIAccessibleRole.ROLE_ENTRY,
     33    Ci.nsIAccessibleRole.ROLE_LINK,
     34    Ci.nsIAccessibleRole.ROLE_LISTBOX,
     35    Ci.nsIAccessibleRole.ROLE_PASSWORD_TEXT,
     36    Ci.nsIAccessibleRole.ROLE_PUSHBUTTON,
     37    Ci.nsIAccessibleRole.ROLE_RADIOBUTTON,
     38    Ci.nsIAccessibleRole.ROLE_SEARCHBOX,
     39    Ci.nsIAccessibleRole.ROLE_SLIDER,
     40    Ci.nsIAccessibleRole.ROLE_SPINBUTTON,
     41    Ci.nsIAccessibleRole.ROLE_SUMMARY,
     42    Ci.nsIAccessibleRole.ROLE_SWITCH,
     43    Ci.nsIAccessibleRole.ROLE_TOGGLE_BUTTON,
     44  ]);
     45 
     46  // Roles that are user interactive.
     47  const INTERACTIVE_ROLES = new Set([
     48    ...KEYBOARD_FOCUSABLE_ROLES,
     49    Ci.nsIAccessibleRole.ROLE_CHECK_MENU_ITEM,
     50    Ci.nsIAccessibleRole.ROLE_CHECK_RICH_OPTION,
     51    Ci.nsIAccessibleRole.ROLE_COMBOBOX_OPTION,
     52    Ci.nsIAccessibleRole.ROLE_MENUITEM,
     53    Ci.nsIAccessibleRole.ROLE_OPTION,
     54    Ci.nsIAccessibleRole.ROLE_OUTLINE,
     55    Ci.nsIAccessibleRole.ROLE_OUTLINEITEM,
     56    Ci.nsIAccessibleRole.ROLE_PAGETAB,
     57    Ci.nsIAccessibleRole.ROLE_PARENT_MENUITEM,
     58    Ci.nsIAccessibleRole.ROLE_RADIO_MENU_ITEM,
     59    Ci.nsIAccessibleRole.ROLE_RICH_OPTION,
     60  ]);
     61 
     62  // Roles that are considered interactive when they are focusable.
     63  const INTERACTIVE_IF_FOCUSABLE_ROLES = new Set([
     64    // If article is focusable, we can assume it is inside a feed.
     65    Ci.nsIAccessibleRole.ROLE_ARTICLE,
     66    // Column header can be focusable.
     67    Ci.nsIAccessibleRole.ROLE_COLUMNHEADER,
     68    Ci.nsIAccessibleRole.ROLE_GRID_CELL,
     69    Ci.nsIAccessibleRole.ROLE_MENUBAR,
     70    Ci.nsIAccessibleRole.ROLE_MENUPOPUP,
     71    Ci.nsIAccessibleRole.ROLE_PAGETABLIST,
     72    // Row header can be focusable.
     73    Ci.nsIAccessibleRole.ROLE_ROWHEADER,
     74    Ci.nsIAccessibleRole.ROLE_SCROLLBAR,
     75    Ci.nsIAccessibleRole.ROLE_SEPARATOR,
     76    Ci.nsIAccessibleRole.ROLE_TOOLBAR,
     77  ]);
     78 
     79  // Roles that are considered form controls.
     80  const FORM_ROLES = new Set([
     81    Ci.nsIAccessibleRole.ROLE_CHECKBUTTON,
     82    Ci.nsIAccessibleRole.ROLE_CHECK_RICH_OPTION,
     83    Ci.nsIAccessibleRole.ROLE_COMBOBOX,
     84    Ci.nsIAccessibleRole.ROLE_EDITCOMBOBOX,
     85    Ci.nsIAccessibleRole.ROLE_ENTRY,
     86    Ci.nsIAccessibleRole.ROLE_LISTBOX,
     87    Ci.nsIAccessibleRole.ROLE_PASSWORD_TEXT,
     88    Ci.nsIAccessibleRole.ROLE_PROGRESSBAR,
     89    Ci.nsIAccessibleRole.ROLE_RADIOBUTTON,
     90    Ci.nsIAccessibleRole.ROLE_SLIDER,
     91    Ci.nsIAccessibleRole.ROLE_SPINBUTTON,
     92    Ci.nsIAccessibleRole.ROLE_SWITCH,
     93  ]);
     94 
     95  const DEFAULT_ENV = Object.freeze({
     96    // Checks that accessible object has at least one accessible action.
     97    actionCountRule: true,
     98    // Checks that accessible object (and its corresponding node) is focusable
     99    // (has focusable state and its node's tabindex is not set to -1).
    100    focusableRule: true,
    101    // Checks that clickable accessible object (and its corresponding node) has
    102    // appropriate interactive semantics.
    103    ifClickableThenInteractiveRule: true,
    104    // Checks that accessible object has a role that is considered to be
    105    // interactive.
    106    interactiveRule: true,
    107    // Checks that accessible object has a non-empty label.
    108    labelRule: true,
    109    // Checks that a node is enabled and is expected to be enabled via
    110    // the accessibility API.
    111    mustBeEnabled: true,
    112    // Checks that a node has a corresponding accessible object.
    113    mustHaveAccessibleRule: true,
    114    // Checks that accessible object (and its corresponding node) have a non-
    115    // negative tabindex. Platform accessibility API still sets focusable state
    116    // on tabindex=-1 nodes.
    117    nonNegativeTabIndexRule: true,
    118  });
    119 
    120  let gA11YChecks = false;
    121 
    122  let gEnv = {
    123    ...DEFAULT_ENV,
    124  };
    125 
    126  // This is set by AccessibilityUtils.init so that we always have a reference
    127  // to SimpleTest regardless of changes to the global scope.
    128  let SimpleTest = null;
    129 
    130  /**
    131   * Get role attribute for an accessible object if specified for its
    132   * corresponding ``DOMNode``.
    133   *
    134   * @param   {nsIAccessible} accessible
    135   *          Accessible for which to determine its role attribute value.
    136   *
    137   * @returns {string}
    138   *          Role attribute value if specified.
    139   */
    140  function getAriaRoles(accessible) {
    141    try {
    142      return accessible.attributes.getStringProperty("xml-roles");
    143    } catch (e) {
    144      // No xml-roles. nsPersistentProperties throws if the attribute for a key
    145      // is not found.
    146    }
    147 
    148    return "";
    149  }
    150 
    151  /**
    152   * Get related accessible objects that are targets of labelled by relation e.g.
    153   * labels.
    154   *
    155   * @param   {nsIAccessible} accessible
    156   *          Accessible objects to get labels for.
    157   *
    158   * @returns {Array}
    159   *          A list of accessible objects that are labels for a given accessible.
    160   */
    161  function getLabels(accessible) {
    162    const relation = accessible.getRelationByType(
    163      Ci.nsIAccessibleRelation.RELATION_LABELLED_BY
    164    );
    165    return [...relation.getTargets().enumerate(Ci.nsIAccessible)];
    166  }
    167 
    168  /**
    169   * Test if an accessible has a ``hidden`` attribute.
    170   *
    171   * @param  {nsIAccessible} accessible
    172   *         Accessible object.
    173   *
    174   * @return {boolean}
    175   *         True if the accessible object has a ``hidden`` attribute, false
    176   *         otherwise.
    177   */
    178  function hasHiddenAttribute(accessible) {
    179    let hidden = false;
    180    try {
    181      hidden = accessible.attributes.getStringProperty("hidden");
    182    } catch (e) {}
    183    // If the property is missing, error will be thrown
    184    return hidden && hidden === "true";
    185  }
    186 
    187  /**
    188   * Test if an accessible is hidden from the user.
    189   *
    190   * @param  {nsIAccessible} accessible
    191   *         Accessible object.
    192   *
    193   * @return {boolean}
    194   *         True if accessible is hidden from user, false otherwise.
    195   */
    196  function isHidden(accessible) {
    197    if (!accessible) {
    198      return true;
    199    }
    200 
    201    while (accessible) {
    202      if (hasHiddenAttribute(accessible)) {
    203        return true;
    204      }
    205 
    206      accessible = accessible.parent;
    207    }
    208 
    209    return false;
    210  }
    211 
    212  /**
    213   * Check if an accessible has a given state.
    214   *
    215   * @param  {nsIAccessible} accessible
    216   *         Accessible object to test.
    217   * @param  {number} stateToMatch
    218   *         State to match.
    219   *
    220   * @return {boolean}
    221   *         True if |accessible| has |stateToMatch|, false otherwise.
    222   */
    223  function matchState(accessible, stateToMatch) {
    224    const state = {};
    225    accessible.getState(state, {});
    226 
    227    return !!(state.value & stateToMatch);
    228  }
    229 
    230  /**
    231   * Determine if an accessible is a keyboard focusable browser toolbar button.
    232   * Browser toolbar buttons aren't keyboard focusable in the usual way.
    233   * Instead, focus is managed by JS code which sets tabindex on a single
    234   * button at a time. Thus, we need to special case the focusable check for
    235   * these buttons.
    236   */
    237  function isKeyboardFocusableBrowserToolbarButton(accessible) {
    238    const node = accessible.DOMNode;
    239    if (!node || !node.ownerGlobal) {
    240      return false;
    241    }
    242    const toolbar =
    243      node.closest("toolbar") ||
    244      node.flattenedTreeParentNode.closest("toolbar");
    245    if (!toolbar || toolbar.getAttribute("keyNav") != "true") {
    246      return false;
    247    }
    248    // The Go button in the Url Bar is an example of a purposefully
    249    // non-focusable image toolbar button that provides an mouse/touch-only
    250    // control for the search query submission, while a keyboard user could
    251    // press `Enter` to do it. Similarly, two scroll buttons that appear when
    252    // toolbar is overflowing, and keyboard-only users would actually scroll
    253    // tabs in the toolbar while trying to navigate to these controls. When
    254    // toolbarbuttons are redundant for keyboard users, we do not want to
    255    // create an extra tab stop for such controls, thus we are expecting the
    256    // button markup to include `keyNav="false"` attribute to flag it.
    257    if (node.getAttribute("keyNav") == "false") {
    258      const ariaRoles = getAriaRoles(accessible);
    259      return (
    260        ariaRoles.includes("button") ||
    261        accessible.role == Ci.nsIAccessibleRole.ROLE_PUSHBUTTON
    262      );
    263    }
    264    return node.ownerGlobal.ToolbarKeyboardNavigator._isButton(node);
    265  }
    266 
    267  /**
    268   * Determine if an accessible is a keyboard focusable control within a Firefox
    269   * View list. The main landmark of the Firefox View has role="application" for
    270   * users to expect a custom keyboard navigation pattern. Controls within this
    271   * area aren't keyboard focusable in the usual way. Instead, focus is managed
    272   * by JS code which sets tabindex on a single control within each list at a
    273   * time. Thus, we need to special case the focusable check for these controls.
    274   */
    275  function isKeyboardFocusableFxviewControlInApplication(accessible) {
    276    const node = accessible.DOMNode;
    277    if (!node || !node.ownerGlobal) {
    278      return false;
    279    }
    280    // Firefox View application rows currently include only buttons and links:
    281    if (
    282      !node.className.includes("fxview-tab-row-") ||
    283      (accessible.role != Ci.nsIAccessibleRole.ROLE_PUSHBUTTON &&
    284        accessible.role != Ci.nsIAccessibleRole.ROLE_LINK)
    285    ) {
    286      return false; // Not a button or a link in a Firefox View app.
    287    }
    288    // ToDo: We may eventually need to support intervening generics between
    289    // a list and its listitem here and/or aria-owns lists.
    290    const listitemAcc = accessible.parent;
    291    const listAcc = listitemAcc.parent;
    292    if (
    293      (!listAcc || listAcc.role != Ci.nsIAccessibleRole.ROLE_LIST) &&
    294      (!listitemAcc || listitemAcc.role != Ci.nsIAccessibleRole.ROLE_LISTITEM)
    295    ) {
    296      return false; // This button/link isn't inside a listitem within a list.
    297    }
    298    // All listitems should be not focusable while both a button and a link
    299    // within each list item might have tabindex="-1".
    300    if (
    301      node.tabIndex &&
    302      matchState(accessible, STATE_FOCUSABLE) &&
    303      !matchState(listitemAcc, STATE_FOCUSABLE)
    304    ) {
    305      // ToDo: We may eventually need to support lists which use aria-owns here.
    306      // Check that there is only one keyboard reachable control within the list.
    307      const childCount = listAcc.childCount;
    308      let foundFocusable = false;
    309      for (let c = 0; c < childCount; c++) {
    310        const listitem = listAcc.getChildAt(c);
    311        const listitemChildCount = listitem.childCount;
    312        for (let i = 0; i < listitemChildCount; i++) {
    313          const listitemControl = listitem.getChildAt(i);
    314          // Use tabIndex rather than a11y focusable state because all controls
    315          // within the listitem might have tabindex="-1".
    316          if (listitemControl.DOMNode.tabIndex == 0) {
    317            if (foundFocusable) {
    318              // Only one control within a list should be focusable.
    319              // ToDo: Fine-tune the a11y-check error message generated in this case.
    320              // Strictly speaking, it's not ideal that we're performing an action
    321              // from an is function, which normally only queries something without
    322              // any externally observable behaviour. That said, fixing that would
    323              // involve different return values for different cases (not a list,
    324              // too many focusable listitem controls, etc) so we could move the
    325              // a11yFail call to the caller.
    326              a11yFail(
    327                "Only one control should be focusable in a list",
    328                accessible
    329              );
    330              return false;
    331            }
    332            foundFocusable = true;
    333          }
    334        }
    335      }
    336      return foundFocusable;
    337    }
    338    return false;
    339  }
    340 
    341  /**
    342   * Determine if an accessible is a keyboard focusable option within a listbox.
    343   * We use it in the Url bar results - these controls are't keyboard focusable
    344   * in the usual way. Instead, focus is managed by JS code which sets tabindex
    345   * on a single option at a time. Thus, we need to special case the focusable
    346   * check for these option items.
    347   */
    348  function isKeyboardFocusableOption(accessible) {
    349    const node = accessible.DOMNode;
    350    if (!node || !node.ownerGlobal) {
    351      return false;
    352    }
    353    const urlbarListbox = node.closest(".urlbarView-results");
    354    if (!urlbarListbox || urlbarListbox.getAttribute("role") != "listbox") {
    355      return false;
    356    }
    357    return node.getAttribute("role") == "option";
    358  }
    359 
    360  /**
    361   * Determine if an accessible is a keyboard focusable PanelMultiView control.
    362   * These controls aren't keyboard focusable in the usual way. Instead, focus
    363   * is managed by JS code which sets tabindex dynamically. Thus, we need to
    364   * special case the focusable check for these controls.
    365   */
    366  function isKeyboardFocusablePanelMultiViewControl(accessible) {
    367    const node = accessible.DOMNode;
    368    if (!node || !node.ownerGlobal) {
    369      return false;
    370    }
    371    const panelview = node.closest("panelview");
    372    if (!panelview || panelview.hasAttribute("disablekeynav")) {
    373      return false;
    374    }
    375    return (
    376      node.ownerGlobal.PanelView.forNode(panelview)._tabNavigableWalker.filter(
    377        node
    378      ) == NodeFilter.FILTER_ACCEPT
    379    );
    380  }
    381 
    382  /**
    383   * Determine if an accessible is a button that is excluded from a focus
    384   * order, because its adjacent sibling is a focusable spinner. Controls with
    385   * role="spinbutton" are often placed between two buttons that could
    386   * increase ("^") or decrease ("v") the value of this spinner. Those buttons
    387   * are not expected to be focusable, because their functionality for keyboard
    388   * users is redundant to the spinner. But they are exposed to assistive
    389   * technology for touch, mouse, switch, and speech-to-text users. Thus, we
    390   * need to special case the focusable check for these buttons adjacent to
    391   * a spinner.
    392   */
    393  function isKeyboardFocusableSpinbuttonSibling(accessible) {
    394    const node = accessible.DOMNode;
    395    if (!node || !node.ownerGlobal) {
    396      return false;
    397    }
    398 
    399    // The control itself is a button:
    400    if (accessible.role != Ci.nsIAccessibleRole.ROLE_PUSHBUTTON) {
    401      return false;
    402    }
    403 
    404    // At least one sibling is a keyboard-focusable spinbutton:
    405    for (const sibling of [
    406      node.previousElementSibling,
    407      node.nextElementSibling,
    408    ]) {
    409      if (sibling && sibling.tabIndex >= 0 && sibling.role == "spinbutton") {
    410        return true;
    411      }
    412    }
    413    return false;
    414  }
    415 
    416  /**
    417   * Determine if an accessible is a keyboard focusable tab within a tablist.
    418   * Per the ARIA design pattern, these controls aren't keyboard focusable in
    419   * the usual way. Instead, focus is managed by JS code which sets tabindex on
    420   * a single tab at a time. Thus, we need to special case the focusable check
    421   * for these tab controls.
    422   */
    423  function isKeyboardFocusableTabInTablist(accessible) {
    424    const node = accessible.DOMNode;
    425    if (!node || !node.ownerGlobal) {
    426      return false;
    427    }
    428    if (accessible.role != Ci.nsIAccessibleRole.ROLE_PAGETAB) {
    429      return false; // Not a tab.
    430    }
    431    const tablist = findNonGenericParentAccessible(accessible);
    432    if (!tablist || tablist.role != Ci.nsIAccessibleRole.ROLE_PAGETABLIST) {
    433      return false; // The tab isn't inside a tablist.
    434    }
    435    // ToDo: We may eventually need to support tablists which use
    436    // aria-activedescendant here.
    437    // Check that there is only one keyboard reachable tab.
    438    let foundFocusable = false;
    439    for (const tab of findNonGenericChildrenAccessible(tablist)) {
    440      // Allow whitespaces to be included in the tablist for styling purposes
    441      const isWhitespace =
    442        tab.role == Ci.nsIAccessibleRole.ROLE_TEXT_LEAF &&
    443        tab.DOMNode.textContent.trim().length === 0;
    444      if (tab.role != Ci.nsIAccessibleRole.ROLE_PAGETAB && !isWhitespace) {
    445        // The tablist includes children other than tabs or whitespaces
    446        a11yFail("Only tabs should be included in a tablist", accessible);
    447      }
    448      // Use tabIndex rather than a11y focusable state because all tabs might
    449      // have tabindex="-1".
    450      if (tab.DOMNode.tabIndex == 0) {
    451        if (foundFocusable) {
    452          // Only one tab within a tablist should be focusable.
    453          // ToDo: Fine-tune the a11y-check error message generated in this case.
    454          // Strictly speaking, it's not ideal that we're performing an action
    455          // from an is function, which normally only queries something without
    456          // any externally observable behaviour. That said, fixing that would
    457          // involve different return values for different cases (not a tab,
    458          // too many focusable tabs, etc) so we could move the a11yFail call
    459          // to the caller.
    460          a11yFail("Only one tab should be focusable in a tablist", accessible);
    461          return false;
    462        }
    463        foundFocusable = true;
    464      }
    465    }
    466    return foundFocusable;
    467  }
    468 
    469  /**
    470   * Determine if an accessible is a keyboard focusable button in the url bar.
    471   * Url bar buttons aren't keyboard focusable in the usual way. Instead,
    472   * focus is managed by JS code which sets tabindex on a single button at a
    473   * time. Thus, we need to special case the focusable check for these buttons.
    474   * This also applies to the search bar buttons that reuse the same pattern.
    475   */
    476  function isKeyboardFocusableUrlbarButton(accessible) {
    477    const node = accessible.DOMNode;
    478    if (!node || !node.ownerGlobal) {
    479      return false;
    480    }
    481    const isUrlBar =
    482      node
    483        .closest(".urlbarView > .search-one-offs")
    484        ?.getAttribute("disabletab") == "true";
    485    const isSearchBar =
    486      node
    487        .closest("#PopupSearchAutoComplete > .search-one-offs")
    488        ?.getAttribute("is_searchbar") == "true";
    489    return (
    490      (isUrlBar || isSearchBar) &&
    491      node.getAttribute("tabindex") == "-1" &&
    492      node.tagName == "button" &&
    493      node.classList.contains("searchbar-engine-one-off-item")
    494    );
    495  }
    496 
    497  /**
    498   * Determine if an accessible is a keyboard focusable XUL tab.
    499   * Only one tab is focusable at a time, but after focusing it, you can use
    500   * the keyboard to focus other tabs.
    501   */
    502  function isKeyboardFocusableXULTab(accessible) {
    503    const node = accessible.DOMNode;
    504    return node && XULElement.isInstance(node) && node.tagName == "tab";
    505  }
    506 
    507  /**
    508   * The gridcells are not expected to be interactive and focusable
    509   * individually, but it is allowed to manually manage focus within the grid
    510   * per ARIA Grid pattern (https://www.w3.org/WAI/ARIA/apg/patterns/grid/).
    511   * Example of such grid would be a datepicker where one gridcell can be
    512   * selected and the focus is moved with arrow keys once the user tabbed into
    513   * the grid. In grids like a calendar, only one element would be included in
    514   * the focus order and the rest of grid cells may not have an interactive
    515   * accessible created. We need to special case the check for these gridcells.
    516   */
    517  function isAccessibleGridcell(node) {
    518    if (!node || !node.ownerGlobal) {
    519      return false;
    520    }
    521    const accessible = getAccessible(node);
    522 
    523    if (!accessible || accessible.role != Ci.nsIAccessibleRole.ROLE_GRID_CELL) {
    524      return false; // Not a grid cell.
    525    }
    526    // ToDo: We may eventually need to support intervening generics between
    527    // a grid cell and its grid container here.
    528    const gridRow = accessible.parent;
    529    if (!gridRow || gridRow.role != Ci.nsIAccessibleRole.ROLE_ROW) {
    530      return false; // The grid cell isn't inside a row.
    531    }
    532    let grid = gridRow.parent;
    533    if (!grid) {
    534      return false; // The grid cell isn't inside a grid.
    535    }
    536    if (grid.role == Ci.nsIAccessibleRole.ROLE_GROUPING) {
    537      // Grid built on the HTML table may include <tbody> wrapper:
    538      grid = grid.parent;
    539      if (!grid || grid.role != Ci.nsIAccessibleRole.ROLE_GRID) {
    540        return false; // The grid cell isn't inside a grid.
    541      }
    542    }
    543    // Check that there is only one keyboard reachable grid cell.
    544    let foundFocusable = false;
    545    for (const gridCell of grid.DOMNode.querySelectorAll(
    546      "td, [role=gridcell]"
    547    )) {
    548      // Grid cells are not expected to have a "tabindex" attribute and to be
    549      // included in the focus order, with the exception of the only one cell
    550      // that is included in the page tab sequence to provide access to the grid.
    551      if (gridCell.tabIndex == 0) {
    552        if (foundFocusable) {
    553          // Only one grid cell within a grid should be focusable.
    554          // ToDo: Fine-tune the a11y-check error message generated in this case.
    555          // Strictly speaking, it's not ideal that we're performing an action
    556          // from an is function, which normally only queries something without
    557          // any externally observable behaviour. That said, fixing that would
    558          // involve different return values for different cases (not a grid
    559          // cell, too many focusable grid cells, etc) so we could move the
    560          // a11yFail call to the caller.
    561          a11yFail(
    562            "Only one grid cell should be focusable in a grid",
    563            accessible
    564          );
    565          return false;
    566        }
    567        foundFocusable = true;
    568      }
    569    }
    570    return foundFocusable;
    571  }
    572 
    573  /**
    574   * XUL treecol elements currently aren't focusable, making them inaccessible.
    575   * For now, we don't flag these as a failure to avoid breaking multiple tests.
    576   * ToDo: We should remove this exception after this is fixed in bug 1848397.
    577   */
    578  function isInaccessibleXulTreecol(node) {
    579    if (!node || !node.ownerGlobal) {
    580      return false;
    581    }
    582    const listheader = node.flattenedTreeParentNode;
    583    if (listheader.tagName !== "listheader" || node.tagName !== "treecol") {
    584      return false;
    585    }
    586    return true;
    587  }
    588 
    589  /**
    590   * Determine if a DOM node is a combobox container of the url bar. We
    591   * intentionally leave this element unlabeled, because its child is a search
    592   * input that is the target and main control of this component. In general, we
    593   * want to avoid duplication in the label announcement when a user focuses the
    594   * input. Both NVDA and VO ignore the label on at least one of these controls
    595   * if both have a label. But the bigger concern here is that it's very
    596   * difficult to keep the accessible name synchronized between the combobox and
    597   * the input. Thus, we need to special case the label check for this control.
    598   */
    599  function isUnlabeledUrlBarCombobox(node) {
    600    if (!node || !node.ownerGlobal) {
    601      return false;
    602    }
    603    let ariaRole = node.getAttribute("role");
    604    // There are only two cases of this pattern: <moz-input-box> and <searchbar>
    605    const isMozInputBox =
    606      node.tagName == "moz-input-box" &&
    607      node.classList.contains("urlbar-input-box");
    608    const isSearchbar = node.tagName == "searchbar" && node.id == "searchbar";
    609    return (isMozInputBox || isSearchbar) && ariaRole == "combobox";
    610  }
    611 
    612  /**
    613   * Determine if a DOM node is an option within the url bar. We know each
    614   * url bar option is accessible, but it disappears as soon as it is clicked
    615   * during tests and the a11y-checks do not have time to test the label,
    616   * because the Fluent localization is not yet completed by then. Thus, we
    617   * need to special case the label check for these controls.
    618   */
    619  function isUnlabeledUrlBarOption(node) {
    620    if (!node || !node.ownerGlobal) {
    621      return false;
    622    }
    623    const role = getAccessible(node)?.role;
    624    const isOption =
    625      node.tagName == "span" &&
    626      node.getAttribute("role") == "option" &&
    627      node.classList.contains("urlbarView-row-inner");
    628    const isMenuItem =
    629      node.tagName == "menuitem" &&
    630      role == Ci.nsIAccessibleRole.ROLE_MENUITEM &&
    631      node.classList.contains("urlbarView-result-menuitem");
    632    // Not all options have "data-l10n-id" attributes in the URL Bar, because
    633    // some of options are autocomplete options based on the user input and
    634    // they are not expected to be localized.
    635    return isOption || isMenuItem;
    636  }
    637 
    638  /**
    639   * Determine if a DOM node is a menuitem within the XUL menu. We know each
    640   * menuitem is accessible, but it disappears as soon as it is clicked during
    641   * tests and the a11y-checks do not have time to test the label, because the
    642   * Fluent localization is not yet completed by then. Thus, we need to special
    643   * case the label check for these controls.
    644   */
    645  function isUnlabeledMenuitem(node) {
    646    if (!node || !node.ownerGlobal) {
    647      return false;
    648    }
    649    const hasLabel = node.querySelector("label, description");
    650    const isMenuItem =
    651      node.getAttribute("role") == "menuitem" ||
    652      (node.tagName == "richlistitem" &&
    653        node.classList.contains("autocomplete-richlistitem")) ||
    654      (node.tagName == "menuitem" &&
    655        node.classList.contains("urlbarView-result-menuitem"));
    656 
    657    let parentNode = node.getRootNode().host ?? node.parentNode;
    658    const isParentMenu =
    659      parentNode.getAttribute("role") == "menu" ||
    660      (parentNode.tagName == "richlistbox" &&
    661        parentNode.classList.contains("autocomplete-richlistbox")) ||
    662      (parentNode.tagName == "menupopup" &&
    663        parentNode.classList.contains("urlbarView-result-menu"));
    664    return (
    665      isMenuItem &&
    666      isParentMenu &&
    667      hasLabel &&
    668      (node.hasAttribute("data-l10n-id") || node.tagName == "richlistitem")
    669    );
    670  }
    671 
    672  /**
    673   * Determine if the node is a "Show All" or one of image buttons on the
    674   * about:config page, or a "X" close button on moz-message-bar. We know these
    675   * buttons are accessible, but they disappear/are replaced as soon as they
    676   * are clicked during tests and the a11y-checks do not have time to test the
    677   * label, because the Fluent localization is not yet completed by then.
    678   * Thus, we need to special case the label check for these controls.
    679   */
    680  function isUnlabeledImageButton(node) {
    681    if (!node || !node.ownerGlobal) {
    682      return false;
    683    }
    684    const isShowAllButton = node.id == "show-all";
    685    const isReplacedImageButton =
    686      node.classList.contains("button-add") ||
    687      node.classList.contains("button-delete") ||
    688      node.classList.contains("button-reset");
    689    const isCloseMozMessageBarButton =
    690      node.classList.contains("close") &&
    691      node.getAttribute("data-l10n-id") == "moz-message-bar-close-button";
    692    return (
    693      node.tagName.toLowerCase() == "button" &&
    694      node.hasAttribute("data-l10n-id") &&
    695      (isShowAllButton || isReplacedImageButton || isCloseMozMessageBarButton)
    696    );
    697  }
    698 
    699  /**
    700   * Determine if a node is a XUL:button on a prompt popup. We know this button
    701   * is accessible, but it disappears as soon as it is clicked during tests and
    702   * the a11y-checks do not have time to test the label, because the Fluent
    703   * localization is not yet completed by then. Thus, we need to special case
    704   * the label check for these controls.
    705   */
    706  function isUnlabeledXulButton(node) {
    707    if (!node || !node.ownerGlobal) {
    708      return false;
    709    }
    710    const hasLabel = node.querySelector("label, xul\\:label");
    711    const isButton =
    712      node.getAttribute("role") == "button" ||
    713      node.tagName == "button" ||
    714      node.tagName == "xul:button";
    715    return isButton && hasLabel && node.hasAttribute("data-l10n-id");
    716  }
    717 
    718  /**
    719   * Determine if a node is a XUL element for which tabIndex should be ignored.
    720   * Some XUL elements report -1 for the .tabIndex property, even though they
    721   * are in fact keyboard focusable.
    722   */
    723  function shouldIgnoreTabIndex(node) {
    724    if (!XULElement.isInstance(node)) {
    725      return false;
    726    }
    727    return node.tagName == "label" && node.getAttribute("is") == "text-link";
    728  }
    729 
    730  /**
    731   * Determine if accessible is focusable with the keyboard.
    732   *
    733   * @param   {nsIAccessible} accessible
    734   *          Accessible for which to determine if it is keyboard focusable.
    735   *
    736   * @returns {boolean}
    737   *          True if focusable with the keyboard.
    738   */
    739  function isKeyboardFocusable(accessible) {
    740    if (
    741      isKeyboardFocusableBrowserToolbarButton(accessible) ||
    742      isKeyboardFocusableOption(accessible) ||
    743      isKeyboardFocusablePanelMultiViewControl(accessible) ||
    744      isKeyboardFocusableUrlbarButton(accessible) ||
    745      isKeyboardFocusableXULTab(accessible) ||
    746      isKeyboardFocusableTabInTablist(accessible) ||
    747      isKeyboardFocusableFxviewControlInApplication(accessible) ||
    748      isKeyboardFocusableSpinbuttonSibling(accessible)
    749    ) {
    750      return true;
    751    }
    752    // State will be focusable even if the tabindex is negative.
    753    const node = accessible.DOMNode;
    754    const role = accessible.role;
    755    return (
    756      matchState(accessible, STATE_FOCUSABLE) &&
    757      // Platform accessibility will still report STATE_FOCUSABLE even with the
    758      // tabindex="-1" so we need to check that it is >= 0 to be considered
    759      // keyboard focusable.
    760      (!gEnv.nonNegativeTabIndexRule ||
    761        node.tabIndex > -1 ||
    762        node.closest('[aria-activedescendant][tabindex="0"]') ||
    763        // If an ARIA toolbar uses a roving tabindex, some controls on the
    764        // toolbar might not currently be focusable even though they can be
    765        // reached with arrow keys and become focusable at that point.
    766        ((role == Ci.nsIAccessibleRole.ROLE_PUSHBUTTON ||
    767          role == Ci.nsIAccessibleRole.ROLE_TOGGLE_BUTTON) &&
    768          node.closest('[role="toolbar"]')) ||
    769        // <moz-radio-group> and <moz-visual-picker> also use a roving tabindex.
    770        (role === Ci.nsIAccessibleRole.ROLE_RADIOBUTTON &&
    771          node.getRootNode().host?.localName === "moz-radio") ||
    772        (role === Ci.nsIAccessibleRole.ROLE_RADIOBUTTON &&
    773          node.getRootNode().host?.localName === "moz-visual-picker-item") ||
    774        shouldIgnoreTabIndex(node))
    775    );
    776  }
    777 
    778  function buildMessage(message, DOMNode) {
    779    if (DOMNode) {
    780      const { id, tagName, className } = DOMNode;
    781      message += `: id: ${id}, tagName: ${tagName}, className: ${className}`;
    782    }
    783 
    784    return message;
    785  }
    786 
    787  /**
    788   * Fail a test with a given message because of an issue with a given
    789   * accessible object. This is used for cases where there's an actual
    790   * accessibility failure that prevents UI from being accessible to keyboard/AT
    791   * users.
    792   *
    793   * @param {string} message
    794   * @param {nsIAccessible} accessible
    795   *        Accessible to log along with the failure message.
    796   */
    797  function a11yFail(message, { DOMNode }) {
    798    SimpleTest.ok(false, buildMessage(message, DOMNode));
    799  }
    800 
    801  /**
    802   * Log a todo statement with a given message because of an issue with a given
    803   * accessible object. This is used for cases where accessibility best
    804   * practices are not followed or for something that is not as severe to be
    805   * considered a failure.
    806   *
    807   * @param {string} message
    808   * @param {nsIAccessible} accessible
    809   *        Accessible to log along with the todo message.
    810   */
    811  function a11yWarn(message, { DOMNode }) {
    812    SimpleTest.todo(false, buildMessage(message, DOMNode));
    813  }
    814 
    815  /**
    816   * Test if the node's unavailable via the accessibility API.
    817   *
    818   * @param {nsIAccessible} accessible
    819   *        Accessible object.
    820   */
    821  function assertEnabled(accessible) {
    822    if (gEnv.mustBeEnabled && matchState(accessible, STATE_UNAVAILABLE)) {
    823      a11yFail(
    824        "Node expected to be enabled but is disabled via the accessibility API",
    825        accessible
    826      );
    827    }
    828  }
    829 
    830  /**
    831   * Test if it is possible to focus on a node with the keyboard. This method
    832   * also checks for additional keyboard focus issues that might arise.
    833   *
    834   * @param {nsIAccessible} accessible
    835   *        Accessible object for a node.
    836   */
    837  function assertFocusable(accessible) {
    838    if (
    839      gEnv.mustBeEnabled &&
    840      gEnv.focusableRule &&
    841      !isKeyboardFocusable(accessible)
    842    ) {
    843      const ariaRoles = getAriaRoles(accessible);
    844      // Do not force ARIA combobox or listbox to be focusable.
    845      if (!ariaRoles.includes("combobox") && !ariaRoles.includes("listbox")) {
    846        a11yFail("Node is not focusable via the accessibility API", accessible);
    847      }
    848 
    849      return;
    850    }
    851 
    852    if (!INTERACTIVE_IF_FOCUSABLE_ROLES.has(accessible.role)) {
    853      // ROLE_TABLE is used for grids too which are considered interactive.
    854      if (
    855        accessible.role === Ci.nsIAccessibleRole.ROLE_TABLE &&
    856        !getAriaRoles(accessible).includes("grid")
    857      ) {
    858        a11yWarn(
    859          "Focusable nodes should have interactive semantics",
    860          accessible
    861        );
    862 
    863        return;
    864      }
    865    }
    866 
    867    if (accessible.DOMNode.tabIndex > 0) {
    868      a11yWarn("Avoid using tabindex attribute greater than zero", accessible);
    869    }
    870  }
    871 
    872  /**
    873   * Test if it is possible to interact with a node via the accessibility API.
    874   *
    875   * @param {nsIAccessible} accessible
    876   *        Accessible object for a node.
    877   */
    878  function assertInteractive(accessible) {
    879    if (
    880      gEnv.mustBeEnabled &&
    881      gEnv.actionCountRule &&
    882      accessible.actionCount === 0
    883    ) {
    884      a11yFail("Node does not support any accessible actions", accessible);
    885 
    886      return;
    887    }
    888 
    889    if (
    890      gEnv.mustBeEnabled &&
    891      gEnv.interactiveRule &&
    892      !INTERACTIVE_ROLES.has(accessible.role)
    893    ) {
    894      if (
    895        // Labels that have a label for relation with their target are clickable.
    896        (accessible.role !== Ci.nsIAccessibleRole.ROLE_LABEL ||
    897          accessible.getRelationByType(
    898            Ci.nsIAccessibleRelation.RELATION_LABEL_FOR
    899          ).targetsCount === 0) &&
    900        // Images that are inside an anchor (have linked state).
    901        (accessible.role !== Ci.nsIAccessibleRole.ROLE_GRAPHIC ||
    902          !matchState(accessible, STATE_LINKED))
    903      ) {
    904        // Look for click action in the list of actions.
    905        for (let i = 0; i < accessible.actionCount; i++) {
    906          if (
    907            gEnv.ifClickableThenInteractiveRule &&
    908            accessible.getActionName(i) === CLICK_ACTION
    909          ) {
    910            a11yFail(
    911              "Clickable nodes must have interactive semantics",
    912              accessible
    913            );
    914          }
    915        }
    916      }
    917 
    918      a11yFail(
    919        "Node does not have a correct interactive role and may not be " +
    920          "manipulated via the accessibility API",
    921        accessible
    922      );
    923    }
    924  }
    925 
    926  /**
    927   * Test if the node is labelled appropriately for accessibility API.
    928   *
    929   * @param {nsIAccessible} accessible
    930   *        Accessible object for a node.
    931   */
    932  function assertLabelled(accessible, allowRecurse = true) {
    933    const { DOMNode } = accessible;
    934    let name = accessible.name;
    935    if (!name) {
    936      // If text has just been inserted into the tree, the a11y engine might not
    937      // have picked it up yet.
    938      forceRefreshDriverTick(DOMNode);
    939      try {
    940        name = accessible.name;
    941      } catch (e) {
    942        // The Accessible died because the DOM node was removed or hidden.
    943        if (gEnv.labelRule) {
    944          // Some elements disappear as soon as they are clicked during tests,
    945          // their accessible dies before the Fluent localization is completed.
    946          // We want to exclude these groups of nodes from the label check.
    947          // Note: In other cases, this first block isn't necessarily hit
    948          // because Fluent isn't finished yet. This might happen if a text
    949          // node was inserted (whether by Fluent or something else) but a11y
    950          // hasn't picked it up yet, but the node gets hidden before a11y
    951          // can pick it up.
    952          if (
    953            isUnlabeledUrlBarOption(DOMNode) ||
    954            isUnlabeledMenuitem(DOMNode) ||
    955            isUnlabeledImageButton(DOMNode)
    956          ) {
    957            return;
    958          }
    959          a11yWarn("Unlabeled element removed before l10n finished", {
    960            DOMNode,
    961          });
    962        }
    963        return;
    964      }
    965      const doc = DOMNode.ownerDocument;
    966      if (
    967        !name &&
    968        allowRecurse &&
    969        gEnv.labelRule &&
    970        doc.hasPendingL10nMutations
    971      ) {
    972        // There are pending async l10n mutations which might result in a valid
    973        // accessible name. Try this check again once l10n is finished.
    974        doc.addEventListener(
    975          "L10nMutationsFinished",
    976          () => {
    977            try {
    978              accessible.name;
    979            } catch (e) {
    980              // The Accessible died because the DOM node was removed or hidden.
    981              if (
    982                isUnlabeledUrlBarOption(DOMNode) ||
    983                isUnlabeledImageButton(DOMNode) ||
    984                isUnlabeledXulButton(DOMNode)
    985              ) {
    986                return;
    987              }
    988              a11yWarn("Unlabeled element removed before l10n finished", {
    989                DOMNode,
    990              });
    991              return;
    992            }
    993            assertLabelled(accessible, false);
    994          },
    995          { once: true }
    996        );
    997        return;
    998      }
    999    }
   1000    if (name) {
   1001      name = name.trim();
   1002    }
   1003    if (gEnv.labelRule && !name) {
   1004      // The URL and Search Bar comboboxes are purposefully unlabeled,
   1005      // since they include labeled inputs that are receiving focus.
   1006      // Or the Accessible died because the DOM node was removed or hidden.
   1007      if (
   1008        isUnlabeledUrlBarCombobox(DOMNode) ||
   1009        isUnlabeledUrlBarOption(DOMNode)
   1010      ) {
   1011        return;
   1012      }
   1013      a11yFail("Interactive elements must be labeled", accessible);
   1014 
   1015      return;
   1016    }
   1017 
   1018    if (FORM_ROLES.has(accessible.role)) {
   1019      const labels = getLabels(accessible);
   1020      const hasNameFromVisibleLabel = labels.some(
   1021        label => !matchState(label, STATE_INVISIBLE)
   1022      );
   1023 
   1024      if (!hasNameFromVisibleLabel) {
   1025        a11yWarn("Form elements should have a visible text label", accessible);
   1026      }
   1027    } else if (
   1028      accessible.role === Ci.nsIAccessibleRole.ROLE_LINK &&
   1029      DOMNode.nodeName === "AREA" &&
   1030      DOMNode.hasAttribute("href")
   1031    ) {
   1032      const alt = DOMNode.getAttribute("alt");
   1033      if (alt && alt.trim() !== name) {
   1034        a11yFail(
   1035          "Use alt attribute to label area elements that have the href attribute",
   1036          accessible
   1037        );
   1038      }
   1039    }
   1040  }
   1041 
   1042  /**
   1043   * Test if the node's visible via accessibility API.
   1044   *
   1045   * @param {nsIAccessible} accessible
   1046   *        Accessible object for a node.
   1047   */
   1048  function assertVisible(accessible) {
   1049    if (isHidden(accessible)) {
   1050      a11yFail(
   1051        "Node is not currently visible via the accessibility API and may not " +
   1052          "be manipulated by it",
   1053        accessible
   1054      );
   1055    }
   1056  }
   1057 
   1058  /**
   1059   * Walk node ancestry and force refresh driver tick in every document.
   1060   *
   1061   * @param {DOMNode} node
   1062   *        Node for traversing the ancestry.
   1063   */
   1064  function forceRefreshDriverTick(node) {
   1065    const wins = [];
   1066    let bc = BrowsingContext.getFromWindow(node.ownerDocument.defaultView); // eslint-disable-line
   1067    while (bc) {
   1068      wins.push(bc.associatedWindow);
   1069      bc = bc.embedderWindowGlobal?.browsingContext;
   1070    }
   1071 
   1072    let win = wins.pop();
   1073    while (win) {
   1074      // Stop the refresh driver from doing its regular ticks and force two
   1075      // refresh driver ticks: first to let layout update and notify a11y,  and
   1076      // the second to let a11y process updates.
   1077      win.windowUtils.advanceTimeAndRefresh(100);
   1078      win.windowUtils.advanceTimeAndRefresh(100);
   1079      // Go back to normal refresh driver ticks.
   1080      win.windowUtils.restoreNormalRefresh();
   1081      win = wins.pop();
   1082    }
   1083  }
   1084 
   1085  /**
   1086   * Get an accessible object for a node.
   1087   * Note: this method will not resolve if accessible object does not become
   1088   * available for a given node.
   1089   *
   1090   * @param  {DOMNode} node
   1091   *         Node to get the accessible object for.
   1092   *
   1093   * @return {nsIAccessible}
   1094   *         Accessibility object for a given node.
   1095   */
   1096  function getAccessible(node) {
   1097    const accessibilityService = Cc[
   1098      "@mozilla.org/accessibilityService;1"
   1099    ].getService(Ci.nsIAccessibilityService);
   1100    if (!accessibilityService) {
   1101      // This is likely a build with --disable-accessibility
   1102      return null;
   1103    }
   1104 
   1105    let acc = accessibilityService.getAccessibleFor(node);
   1106    if (acc) {
   1107      return acc;
   1108    }
   1109 
   1110    // Force refresh tick throughout document hierarchy
   1111    forceRefreshDriverTick(node);
   1112    return accessibilityService.getAccessibleFor(node);
   1113  }
   1114 
   1115  /**
   1116   * Find the nearest interactive accessible ancestor for a node.
   1117   */
   1118  function findInteractiveAccessible(node) {
   1119    let acc;
   1120    // Walk DOM ancestors until we find one with an accessible.
   1121    for (; node && !acc; node = node.flattenedTreeParentNode) {
   1122      acc = getAccessible(node);
   1123    }
   1124    if (!acc) {
   1125      // No accessible ancestor.
   1126      return acc;
   1127    }
   1128    // Walk a11y ancestors until we find one which is interactive.
   1129    for (; acc; acc = acc.parent) {
   1130      const relation = acc.getRelationByType(
   1131        Ci.nsIAccessibleRelation.RELATION_LABEL_FOR
   1132      );
   1133      if (
   1134        acc.role === Ci.nsIAccessibleRole.ROLE_LABEL &&
   1135        relation.targetsCount > 0
   1136      ) {
   1137        // If a <label> was clicked to activate a radiobutton or a checkbox,
   1138        // return the accessible of the related input.
   1139        // Note: aria-labelledby doesn't give the node a role of label, so this
   1140        // won't work for aria-labelledby cases. That said, aria-labelledby also
   1141        // doesn't have implicit click behaviour either and there's not really
   1142        // any way we can check for that.
   1143        const targetAcc = relation.getTarget(0);
   1144        return targetAcc;
   1145      }
   1146      if (INTERACTIVE_ROLES.has(acc.role)) {
   1147        return acc;
   1148      }
   1149    }
   1150    // No interactive ancestor.
   1151    return null;
   1152  }
   1153 
   1154  /**
   1155   * Find the nearest non-generic ancestor for a node to account for generic
   1156   * containers to intervene between the ancestor and it child.
   1157   */
   1158  function findNonGenericParentAccessible(childAcc) {
   1159    for (let acc = childAcc.parent; acc; acc = acc.parent) {
   1160      if (acc.computedARIARole != "generic") {
   1161        return acc;
   1162      }
   1163    }
   1164    return null;
   1165  }
   1166 
   1167  /**
   1168   * Find the nearest non-generic children for a node to account for generic
   1169   * containers to intervene between the ancestor and its children.
   1170   */
   1171  function* findNonGenericChildrenAccessible(parentAcc) {
   1172    const count = parentAcc.childCount;
   1173    for (let c = 0; c < count; ++c) {
   1174      const child = parentAcc.getChildAt(c);
   1175      // When Gecko will consider only one role as generic, we'd use child.role
   1176      if (child.computedARIARole == "generic") {
   1177        yield* findNonGenericChildrenAccessible(child);
   1178      } else {
   1179        yield child;
   1180      }
   1181    }
   1182  }
   1183 
   1184  function runIfA11YChecks(task) {
   1185    return (...args) => (gA11YChecks ? task(...args) : null);
   1186  }
   1187 
   1188  /**
   1189   * AccessibilityUtils provides utility methods for retrieving accessible objects
   1190   * and performing accessibility related checks.
   1191   * Current methods:
   1192   *   assertCanBeClicked
   1193   *   setEnv
   1194   *   resetEnv
   1195   *
   1196   */
   1197  const AccessibilityUtils = {
   1198    assertCanBeClicked(node) {
   1199      // Click events might fire on an inaccessible or non-interactive
   1200      // descendant, even if the test author targeted them at an interactive
   1201      // element. For example, if there's a button with an image inside it,
   1202      // node might be the image.
   1203      const acc = findInteractiveAccessible(node);
   1204      if (!acc) {
   1205        if (isAccessibleGridcell(node) || isInaccessibleXulTreecol(node)) {
   1206          return;
   1207        }
   1208        if (gEnv.mustHaveAccessibleRule) {
   1209          a11yFail("Node is not accessible via accessibility API", {
   1210            DOMNode: node,
   1211          });
   1212        }
   1213 
   1214        return;
   1215      }
   1216 
   1217      assertInteractive(acc);
   1218      assertFocusable(acc);
   1219      assertVisible(acc);
   1220      assertEnabled(acc);
   1221      assertLabelled(acc);
   1222    },
   1223 
   1224    setEnv(env = DEFAULT_ENV) {
   1225      gEnv = {
   1226        ...DEFAULT_ENV,
   1227        ...env,
   1228      };
   1229    },
   1230 
   1231    resetEnv() {
   1232      gEnv = { ...DEFAULT_ENV };
   1233    },
   1234 
   1235    reset(a11yChecks = false, testPath = "") {
   1236      gA11YChecks = a11yChecks;
   1237 
   1238      const { Services } = SpecialPowers;
   1239      // Disable accessibility service if it is running and if a11y checks are
   1240      // disabled. However, don't do this for accessibility engine tests.
   1241      if (
   1242        !gA11YChecks &&
   1243        Services.appinfo.accessibilityEnabled &&
   1244        !testPath.startsWith("chrome://mochitests/content/browser/accessible/")
   1245      ) {
   1246        Services.prefs.setIntPref(FORCE_DISABLE_ACCESSIBILITY_PREF, 1);
   1247        Services.prefs.clearUserPref(FORCE_DISABLE_ACCESSIBILITY_PREF);
   1248      }
   1249 
   1250      // Reset accessibility environment flags that might've been set within the
   1251      // test.
   1252      this.resetEnv();
   1253    },
   1254 
   1255    init(simpleTest) {
   1256      this._shouldHandleClicks = true;
   1257      // A top level xul window's DocShell doesn't have a chromeEventHandler
   1258      // attribute. In that case, the chrome event handler is just the global
   1259      // window object.
   1260      this._handler ??=
   1261        window.docShell.chromeEventHandler ?? window.docShell.domWindow;
   1262      this._handler.addEventListener("click", this, true, true);
   1263      SimpleTest = simpleTest;
   1264    },
   1265 
   1266    uninit() {
   1267      this._handler?.removeEventListener("click", this, true);
   1268      this._handler = null;
   1269      SimpleTest = null;
   1270    },
   1271 
   1272    /**
   1273     * Suppress (or disable suppression of) handling of captured click events.
   1274     * This should only be called by EventUtils, etc. when a click event will
   1275     * be generated but we know it is not actually a click intended to activate
   1276     * a control; e.g. drag/drop. Tests that wish to disable specific checks
   1277     * should use setEnv instead.
   1278     */
   1279    suppressClickHandling(shouldSuppress) {
   1280      this._shouldHandleClicks = !shouldSuppress;
   1281    },
   1282 
   1283    handleEvent({ composedTarget }) {
   1284      if (!this._shouldHandleClicks) {
   1285        return;
   1286      }
   1287      if (composedTarget.tagName.toLowerCase() == "slot") {
   1288        // The click occurred on a text node inside a slot. Since events don't
   1289        // target text nodes, the event was retargeted to the slot. However, a
   1290        // slot isn't itself rendered. To deal with this, use the slot's parent
   1291        // instead.
   1292        composedTarget = composedTarget.flattenedTreeParentNode;
   1293      }
   1294      const bounds =
   1295        composedTarget.ownerGlobal?.windowUtils?.getBoundsWithoutFlushing(
   1296          composedTarget
   1297        );
   1298      if (bounds && (bounds.width == 0 || bounds.height == 0)) {
   1299        // Some tests click hidden nodes. These clearly aren't testing the UI
   1300        // for the node itself (and presumably there is a test somewhere else
   1301        // that does). Therefore, we can't (and shouldn't) do a11y checks.
   1302        return;
   1303      }
   1304      this.assertCanBeClicked(composedTarget);
   1305    },
   1306  };
   1307 
   1308  AccessibilityUtils.assertCanBeClicked = runIfA11YChecks(
   1309    AccessibilityUtils.assertCanBeClicked.bind(AccessibilityUtils)
   1310  );
   1311 
   1312  AccessibilityUtils.setEnv = runIfA11YChecks(
   1313    AccessibilityUtils.setEnv.bind(AccessibilityUtils)
   1314  );
   1315 
   1316  AccessibilityUtils.resetEnv = runIfA11YChecks(
   1317    AccessibilityUtils.resetEnv.bind(AccessibilityUtils)
   1318  );
   1319 
   1320  return AccessibilityUtils;
   1321 })();