keyboard.js (14689B)
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 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 loader.lazyRequireGetter( 8 this, 9 "CssLogic", 10 "resource://devtools/server/actors/inspector/css-logic.js", 11 true 12 ); 13 loader.lazyRequireGetter( 14 this, 15 "getMatchingCSSRules", 16 "resource://devtools/shared/inspector/css-logic.js", 17 true 18 ); 19 loader.lazyRequireGetter( 20 this, 21 "nodeConstants", 22 "resource://devtools/shared/dom-node-constants.js" 23 ); 24 loader.lazyRequireGetter( 25 this, 26 ["isDefunct", "getAriaRoles"], 27 "resource://devtools/server/actors/utils/accessibility.js", 28 true 29 ); 30 31 const { 32 accessibility: { 33 AUDIT_TYPE: { KEYBOARD }, 34 ISSUE_TYPE: { 35 [KEYBOARD]: { 36 FOCUSABLE_NO_SEMANTICS, 37 FOCUSABLE_POSITIVE_TABINDEX, 38 INTERACTIVE_NO_ACTION, 39 INTERACTIVE_NOT_FOCUSABLE, 40 MOUSE_INTERACTIVE_ONLY, 41 NO_FOCUS_VISIBLE, 42 }, 43 }, 44 SCORES: { FAIL, WARNING }, 45 }, 46 } = require("resource://devtools/shared/constants.js"); 47 48 // Accessible action for showing long description. 49 const CLICK_ACTION = "click"; 50 51 /** 52 * Focus specific pseudo classes that the keyboard audit simulates to determine 53 * focus styling. 54 */ 55 const FOCUS_PSEUDO_CLASS = ":focus"; 56 const MOZ_FOCUSRING_PSEUDO_CLASS = ":-moz-focusring"; 57 58 const KEYBOARD_FOCUSABLE_ROLES = new Set([ 59 Ci.nsIAccessibleRole.ROLE_BUTTONMENU, 60 Ci.nsIAccessibleRole.ROLE_CHECKBUTTON, 61 Ci.nsIAccessibleRole.ROLE_COMBOBOX, 62 Ci.nsIAccessibleRole.ROLE_EDITCOMBOBOX, 63 Ci.nsIAccessibleRole.ROLE_ENTRY, 64 Ci.nsIAccessibleRole.ROLE_LINK, 65 Ci.nsIAccessibleRole.ROLE_LISTBOX, 66 Ci.nsIAccessibleRole.ROLE_PASSWORD_TEXT, 67 Ci.nsIAccessibleRole.ROLE_PUSHBUTTON, 68 Ci.nsIAccessibleRole.ROLE_RADIOBUTTON, 69 Ci.nsIAccessibleRole.ROLE_SLIDER, 70 Ci.nsIAccessibleRole.ROLE_SEARCHBOX, 71 Ci.nsIAccessibleRole.ROLE_SPINBUTTON, 72 Ci.nsIAccessibleRole.ROLE_SUMMARY, 73 Ci.nsIAccessibleRole.ROLE_SWITCH, 74 Ci.nsIAccessibleRole.ROLE_TOGGLE_BUTTON, 75 ]); 76 77 const INTERACTIVE_ROLES = new Set([ 78 ...KEYBOARD_FOCUSABLE_ROLES, 79 Ci.nsIAccessibleRole.ROLE_CHECK_MENU_ITEM, 80 Ci.nsIAccessibleRole.ROLE_CHECK_RICH_OPTION, 81 Ci.nsIAccessibleRole.ROLE_COMBOBOX_OPTION, 82 Ci.nsIAccessibleRole.ROLE_MENUITEM, 83 Ci.nsIAccessibleRole.ROLE_OPTION, 84 Ci.nsIAccessibleRole.ROLE_OUTLINE, 85 Ci.nsIAccessibleRole.ROLE_OUTLINEITEM, 86 Ci.nsIAccessibleRole.ROLE_PAGETAB, 87 Ci.nsIAccessibleRole.ROLE_PARENT_MENUITEM, 88 Ci.nsIAccessibleRole.ROLE_RADIO_MENU_ITEM, 89 Ci.nsIAccessibleRole.ROLE_RICH_OPTION, 90 ]); 91 92 const INTERACTIVE_IF_FOCUSABLE_ROLES = new Set([ 93 // If article is focusable, we can assume it is inside a feed. 94 Ci.nsIAccessibleRole.ROLE_ARTICLE, 95 // Column header can be focusable. 96 Ci.nsIAccessibleRole.ROLE_COLUMNHEADER, 97 Ci.nsIAccessibleRole.ROLE_GRID_CELL, 98 Ci.nsIAccessibleRole.ROLE_MENUBAR, 99 Ci.nsIAccessibleRole.ROLE_MENUPOPUP, 100 Ci.nsIAccessibleRole.ROLE_PAGETABLIST, 101 // Row header can be focusable. 102 Ci.nsIAccessibleRole.ROLE_ROWHEADER, 103 Ci.nsIAccessibleRole.ROLE_SCROLLBAR, 104 Ci.nsIAccessibleRole.ROLE_SEPARATOR, 105 Ci.nsIAccessibleRole.ROLE_TOOLBAR, 106 ]); 107 108 /** 109 * Determine if a node is dead or is not an element node. 110 * 111 * @param {DOMNode} node 112 * Node to be tested for validity. 113 * 114 * @returns {boolean} 115 * True if the node is either dead or is not an element node. 116 */ 117 function isInvalidNode(node) { 118 return ( 119 !node || 120 Cu.isDeadWrapper(node) || 121 node.nodeType !== nodeConstants.ELEMENT_NODE || 122 !node.ownerGlobal 123 ); 124 } 125 126 /** 127 * Determine if accessible is focusable with the keyboard. 128 * 129 * @param {nsIAccessible} accessible 130 * Accessible for which to determine if it is keyboard focusable. 131 * 132 * @returns {boolean} 133 * True if focusable with the keyboard. 134 */ 135 function isKeyboardFocusable(accessible) { 136 const state = {}; 137 accessible.getState(state, {}); 138 // State will be focusable even if the tabindex is negative. 139 return ( 140 state.value & Ci.nsIAccessibleStates.STATE_FOCUSABLE && 141 // Platform accessibility will still report STATE_FOCUSABLE even with the 142 // tabindex="-1" so we need to check that it is >= 0 to be considered 143 // keyboard focusable. 144 accessible.DOMNode.tabIndex > -1 145 ); 146 } 147 148 /** 149 * Determine if a current node has focus specific styling by applying a 150 * focus-related pseudo class (such as :focus or :-moz-focusring) to a focusable 151 * node. 152 * 153 * @param {DOMNode} focusableNode 154 * Node to apply focus-related pseudo class to. 155 * @param {DOMNode} currentNode 156 * Node to be checked for having focus specific styling. 157 * @param {string} pseudoClass 158 * A focus related pseudo-class to be simulated for style comparison. 159 * 160 * @returns {boolean} 161 * True if the currentNode has focus specific styling. 162 */ 163 function hasStylesForFocusRelatedPseudoClass( 164 focusableNode, 165 currentNode, 166 pseudoClass 167 ) { 168 const defaultRules = getMatchingCSSRules(currentNode); 169 170 InspectorUtils.addPseudoClassLock(focusableNode, pseudoClass); 171 172 // Determine a set of properties that are specific to CSS rules that are only 173 // present when a focus related pseudo-class is locked in. 174 const tempRules = getMatchingCSSRules(currentNode); 175 const properties = new Set(); 176 for (const rule of tempRules) { 177 if (!defaultRules.includes(rule)) { 178 for (let index = 0; index < rule.style.length; index++) { 179 properties.add(rule.style.item(index)); 180 } 181 } 182 } 183 184 // If there are no focus specific CSS rules or properties, currentNode does 185 // node have any focus specific styling, we are done. 186 if (properties.size === 0) { 187 InspectorUtils.removePseudoClassLock(focusableNode, pseudoClass); 188 return false; 189 } 190 191 // Determine values for properties that are focus specific. 192 const tempStyle = CssLogic.getComputedStyle(currentNode); 193 const focusStyle = {}; 194 for (const name of properties.values()) { 195 focusStyle[name] = tempStyle.getPropertyValue(name); 196 } 197 198 InspectorUtils.removePseudoClassLock(focusableNode, pseudoClass); 199 200 // If values for focus specific properties are different from default style 201 // values, assume we have focus spefic styles for the currentNode. 202 const defaultStyle = CssLogic.getComputedStyle(currentNode); 203 for (const name of properties.values()) { 204 if (defaultStyle.getPropertyValue(name) !== focusStyle[name]) { 205 return true; 206 } 207 } 208 209 return false; 210 } 211 212 /** 213 * Check if an element node (currentNode) has distinct focus styling. This 214 * function also takes into account a case when focus styling is applied to a 215 * descendant too. 216 * 217 * @param {DOMNode} focusableNode 218 * Node to apply focus-related pseudo class to. 219 * @param {DOMNode} currentNode 220 * Node to be checked for having focus specific styling. 221 * 222 * @returns {boolean} 223 * True if the node or its descendant has distinct focus styling. 224 */ 225 function hasFocusStyling(focusableNode, currentNode) { 226 if (isInvalidNode(currentNode)) { 227 return false; 228 } 229 230 // Check if an element node has distinct :-moz-focusring styling. 231 const hasStylesForMozFocusring = hasStylesForFocusRelatedPseudoClass( 232 focusableNode, 233 currentNode, 234 MOZ_FOCUSRING_PSEUDO_CLASS 235 ); 236 if (hasStylesForMozFocusring) { 237 return true; 238 } 239 240 // Check if an element node has distinct :focus styling. 241 const hasStylesForFocus = hasStylesForFocusRelatedPseudoClass( 242 focusableNode, 243 currentNode, 244 FOCUS_PSEUDO_CLASS 245 ); 246 if (hasStylesForFocus) { 247 return true; 248 } 249 250 // If no element specific focus styles where found, check if its element 251 // children have them. 252 for ( 253 let child = currentNode.firstElementChild; 254 child; 255 child = currentNode.nextnextElementSibling 256 ) { 257 if (hasFocusStyling(focusableNode, child)) { 258 return true; 259 } 260 } 261 262 return false; 263 } 264 265 /** 266 * A rule that determines if a focusable accessible object has appropriate focus 267 * styling. 268 * 269 * @param {nsIAccessible} accessible 270 * Accessible to be checked for being focusable and having focus 271 * styling. 272 * 273 * @return {null | object} 274 * Null if accessible has keyboard focus styling, audit report object 275 * otherwise. 276 */ 277 function focusStyleRule(accessible) { 278 const { DOMNode } = accessible; 279 if (isInvalidNode(DOMNode)) { 280 return null; 281 } 282 283 // Ignore non-focusable elements. 284 if (!isKeyboardFocusable(accessible)) { 285 return null; 286 } 287 288 if (hasFocusStyling(DOMNode, DOMNode)) { 289 return null; 290 } 291 292 // If no browser or author focus styling was found, check if the node is a 293 // widget that is themed by platform native theme. 294 if (InspectorUtils.isElementThemed(DOMNode)) { 295 return null; 296 } 297 298 return { score: WARNING, issue: NO_FOCUS_VISIBLE }; 299 } 300 301 /** 302 * A rule that determines if an interactive accessible has any associated 303 * accessible actions with it. If the element is interactive but and has no 304 * actions, assistive technology users will not be able to interact with it. 305 * 306 * @param {nsIAccessible} accessible 307 * Accessible to be checked for being interactive and having accessible 308 * actions. 309 * 310 * @return {null | object} 311 * Null if accessible is not interactive or if it is and it has 312 * accessible action associated with it, audit report object otherwise. 313 */ 314 function interactiveRule(accessible) { 315 if (!INTERACTIVE_ROLES.has(accessible.role)) { 316 return null; 317 } 318 319 if (accessible.actionCount > 0) { 320 return null; 321 } 322 323 return { score: FAIL, issue: INTERACTIVE_NO_ACTION }; 324 } 325 326 /** 327 * A rule that determines if an interactive accessible is also focusable when 328 * not disabled. 329 * 330 * @param {nsIAccessible} accessible 331 * Accessible to be checked for being interactive and being focusable 332 * when enabled. 333 * 334 * @return {null | object} 335 * Null if accessible is not interactive or if it is and it is focusable 336 * when enabled, audit report object otherwise. 337 */ 338 function focusableRule(accessible) { 339 if (!KEYBOARD_FOCUSABLE_ROLES.has(accessible.role)) { 340 return null; 341 } 342 343 const state = {}; 344 accessible.getState(state, {}); 345 // We only expect in interactive accessible object to be focusable if it is 346 // not disabled. 347 if (state.value & Ci.nsIAccessibleStates.STATE_UNAVAILABLE) { 348 return null; 349 } 350 351 if (isKeyboardFocusable(accessible)) { 352 return null; 353 } 354 355 const ariaRoles = getAriaRoles(accessible); 356 if ( 357 ariaRoles && 358 (ariaRoles.includes("combobox") || ariaRoles.includes("listbox")) 359 ) { 360 // Do not force ARIA combobox or listbox to be focusable. 361 return null; 362 } 363 364 return { score: FAIL, issue: INTERACTIVE_NOT_FOCUSABLE }; 365 } 366 367 /** 368 * A rule that determines if a focusable accessible has an associated 369 * interactive role. 370 * 371 * @param {nsIAccessible} accessible 372 * Accessible to be checked for having an interactive role if it is 373 * focusable. 374 * 375 * @return {null | object} 376 * Null if accessible is not interactive or if it is and it has an 377 * interactive role, audit report object otherwise. 378 */ 379 function semanticsRule(accessible) { 380 if ( 381 INTERACTIVE_ROLES.has(accessible.role) || 382 // Visible listboxes will have focusable state when inside comboboxes. 383 accessible.role === Ci.nsIAccessibleRole.ROLE_COMBOBOX_LIST 384 ) { 385 return null; 386 } 387 388 if (isKeyboardFocusable(accessible)) { 389 if (INTERACTIVE_IF_FOCUSABLE_ROLES.has(accessible.role)) { 390 return null; 391 } 392 393 // ROLE_TABLE is used for grids too which are considered interactive. 394 if (accessible.role === Ci.nsIAccessibleRole.ROLE_TABLE) { 395 const ariaRoles = getAriaRoles(accessible); 396 if (ariaRoles && ariaRoles.includes("grid")) { 397 return null; 398 } 399 } 400 401 return { score: WARNING, issue: FOCUSABLE_NO_SEMANTICS }; 402 } 403 404 const state = {}; 405 accessible.getState(state, {}); 406 if ( 407 // Ignore text leafs. 408 accessible.role === Ci.nsIAccessibleRole.ROLE_TEXT_LEAF || 409 // Ignore accessibles with no accessible actions. 410 accessible.actionCount === 0 || 411 // Ignore labels that have a label for relation with their target because 412 // they are clickable. 413 (accessible.role === Ci.nsIAccessibleRole.ROLE_LABEL && 414 accessible.getRelationByType(Ci.nsIAccessibleRelation.RELATION_LABEL_FOR) 415 .targetsCount > 0) || 416 // Ignore images that are inside an anchor (have linked state). 417 (accessible.role === Ci.nsIAccessibleRole.ROLE_GRAPHIC && 418 state.value & Ci.nsIAccessibleStates.STATE_LINKED) 419 ) { 420 return null; 421 } 422 423 // Ignore anything but a click action in the list of actions. 424 for (let i = 0; i < accessible.actionCount; i++) { 425 if (accessible.getActionName(i) === CLICK_ACTION) { 426 return { score: FAIL, issue: MOUSE_INTERACTIVE_ONLY }; 427 } 428 } 429 430 return null; 431 } 432 433 /** 434 * A rule that determines if an element associated with a focusable accessible 435 * has a positive tabindex. 436 * 437 * @param {nsIAccessible} accessible 438 * Accessible to be checked for having an element with positive tabindex 439 * attribute. 440 * 441 * @return {null | object} 442 * Null if accessible is not focusable or if it is and its element's 443 * tabindex attribute is less than 1, audit report object otherwise. 444 */ 445 function tabIndexRule(accessible) { 446 const { DOMNode } = accessible; 447 if (isInvalidNode(DOMNode)) { 448 return null; 449 } 450 451 if (!isKeyboardFocusable(accessible)) { 452 return null; 453 } 454 455 if (DOMNode.tabIndex > 0) { 456 return { score: WARNING, issue: FOCUSABLE_POSITIVE_TABINDEX }; 457 } 458 459 return null; 460 } 461 462 function auditKeyboard(accessible) { 463 if (isDefunct(accessible)) { 464 return null; 465 } 466 // Do not test anything on accessible objects for documents or frames. 467 if ( 468 accessible.role === Ci.nsIAccessibleRole.ROLE_DOCUMENT || 469 accessible.role === Ci.nsIAccessibleRole.ROLE_INTERNAL_FRAME 470 ) { 471 return null; 472 } 473 474 // Check if interactive accessible can be used by the assistive 475 // technology. 476 let issue = interactiveRule(accessible); 477 if (issue) { 478 return issue; 479 } 480 481 // Check if interactive accessible is also focusable when enabled. 482 issue = focusableRule(accessible); 483 if (issue) { 484 return issue; 485 } 486 487 // Check if accessible object has an element with a positive tabindex. 488 issue = tabIndexRule(accessible); 489 if (issue) { 490 return issue; 491 } 492 493 // Check if a focusable accessible has interactive semantics. 494 issue = semanticsRule(accessible); 495 if (issue) { 496 return issue; 497 } 498 499 // Check if focusable accessible has associated focus styling. 500 issue = focusStyleRule(accessible); 501 if (issue) { 502 return issue; 503 } 504 505 return issue; 506 } 507 508 module.exports.auditKeyboard = auditKeyboard;