text-label.js (14594B)
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 const { 8 accessibility: { 9 AUDIT_TYPE: { TEXT_LABEL }, 10 ISSUE_TYPE, 11 SCORES: { BEST_PRACTICES, FAIL, WARNING }, 12 }, 13 } = require("resource://devtools/shared/constants.js"); 14 15 const { 16 AREA_NO_NAME_FROM_ALT, 17 DIALOG_NO_NAME, 18 DOCUMENT_NO_TITLE, 19 EMBED_NO_NAME, 20 FIGURE_NO_NAME, 21 FORM_FIELDSET_NO_NAME, 22 FORM_FIELDSET_NO_NAME_FROM_LEGEND, 23 FORM_NO_NAME, 24 FORM_NO_VISIBLE_NAME, 25 FORM_OPTGROUP_NO_NAME_FROM_LABEL, 26 FRAME_NO_NAME, 27 HEADING_NO_CONTENT, 28 HEADING_NO_NAME, 29 IFRAME_NO_NAME_FROM_TITLE, 30 IMAGE_NO_NAME, 31 INTERACTIVE_NO_NAME, 32 MATHML_GLYPH_NO_NAME, 33 TOOLBAR_NO_NAME, 34 } = ISSUE_TYPE[TEXT_LABEL]; 35 36 /** 37 * Check if the accessible is visible to the assistive technology. 38 * 39 * @param {nsIAccessible} accessible 40 * Accessible object to be tested for visibility. 41 * 42 * @returns {boolean} 43 * True if accessible object is visible to assistive technology. 44 */ 45 function isVisible(accessible) { 46 const state = {}; 47 accessible.getState(state, {}); 48 return !(state.value & Ci.nsIAccessibleStates.STATE_INVISIBLE); 49 } 50 51 /** 52 * Get related accessible objects that are targets of labelled by relation e.g. 53 * labels. 54 * 55 * @param {nsIAccessible} accessible 56 * Accessible objects to get labels for. 57 * 58 * @returns {Array} 59 * A list of accessible objects that are labels for a given accessible. 60 */ 61 function getLabels(accessible) { 62 const relation = accessible.getRelationByType( 63 Ci.nsIAccessibleRelation.RELATION_LABELLED_BY 64 ); 65 return [...relation.getTargets().enumerate(Ci.nsIAccessible)]; 66 } 67 68 /** 69 * Get a trimmed name of the accessible object. 70 * 71 * @param {nsIAccessible} accessible 72 * Accessible objects to get a name for. 73 * 74 * @returns {null | string} 75 * Trimmed name of the accessible object if available. 76 */ 77 function getAccessibleName(accessible) { 78 return accessible.name && accessible.name.trim(); 79 } 80 81 /** 82 * A text label rule for accessible objects that must have a non empty 83 * accessible name. 84 * 85 * @returns {null | object} 86 * Failure audit report if accessible object has no or empty name, null 87 * otherwise. 88 */ 89 const mustHaveNonEmptyNameRule = function (issue, accessible) { 90 const name = getAccessibleName(accessible); 91 return name ? null : { score: FAIL, issue }; 92 }; 93 94 /** 95 * A text label rule for accessible objects that should have a non empty 96 * accessible name as a best practice. 97 * 98 * @returns {null | object} 99 * Best practices audit report if accessible object has no or empty 100 * name, null otherwise. 101 */ 102 const shouldHaveNonEmptyNameRule = function (issue, accessible) { 103 const name = getAccessibleName(accessible); 104 return name ? null : { score: BEST_PRACTICES, issue }; 105 }; 106 107 /** 108 * A text label rule for accessible objects that can be activated via user 109 * action and must have a non-empty name. 110 * 111 * @returns {null | object} 112 * Failure audit report if interactive accessible object has no or 113 * empty name, null otherwise. 114 */ 115 const interactiveRule = mustHaveNonEmptyNameRule.bind( 116 null, 117 INTERACTIVE_NO_NAME 118 ); 119 120 /** 121 * A text label rule for accessible objects that correspond to dialogs and thus 122 * should have a non-empty name. 123 * 124 * @returns {null | object} 125 * Best practices audit report if dialog accessible object has no or 126 * empty name, null otherwise. 127 */ 128 const dialogRule = shouldHaveNonEmptyNameRule.bind(null, DIALOG_NO_NAME); 129 130 /** 131 * A text label rule for accessible objects that provide visual information 132 * (images, canvas, etc.) and must have a defined name (that can be empty, e.g. 133 * ""). 134 * 135 * @returns {null | object} 136 * Failure audit report if interactive accessible object has no name, 137 * null otherwise. 138 */ 139 const imageRule = function (accessible) { 140 const name = getAccessibleName(accessible); 141 return name != null ? null : { score: FAIL, issue: IMAGE_NO_NAME }; 142 }; 143 144 /** 145 * A text label rule for accessible objects that correspond to form elements. 146 * These objects must have a non-empty name and must have a visible label. 147 * 148 * @returns {null | object} 149 * Failure audit report if form element accessible object has no name, 150 * warning if the name does not come from a visible label, null 151 * otherwise. 152 */ 153 const formRule = function (accessible) { 154 const name = getAccessibleName(accessible); 155 if (!name) { 156 return { score: FAIL, issue: FORM_NO_NAME }; 157 } 158 159 const labels = getLabels(accessible); 160 const hasNameFromVisibleLabel = labels.some(label => isVisible(label)); 161 162 return hasNameFromVisibleLabel 163 ? null 164 : { score: WARNING, issue: FORM_NO_VISIBLE_NAME }; 165 }; 166 167 /** 168 * A text label rule for elements that map to ROLE_GROUPING: 169 * * <OPTGROUP> must have a non-empty name and must be provided via the 170 * "label" attribute. 171 * * <FIELDSET> must have a non-empty name and must be provided via the 172 * corresponding <LEGEND> element. 173 * 174 * @returns {null | object} 175 * Failure audit report if form grouping accessible object has no name, 176 * or has a name that is not derived from a required location, null 177 * otherwise. 178 */ 179 const formGroupingRule = function (accessible) { 180 const name = getAccessibleName(accessible); 181 const { DOMNode } = accessible; 182 183 switch (DOMNode.nodeName) { 184 case "OPTGROUP": 185 return name && DOMNode.label && DOMNode.label.trim() === name 186 ? null 187 : { 188 score: FAIL, 189 issue: FORM_OPTGROUP_NO_NAME_FROM_LABEL, 190 }; 191 case "FIELDSET": { 192 if (!name) { 193 return { score: FAIL, issue: FORM_FIELDSET_NO_NAME }; 194 } 195 196 const labels = getLabels(accessible); 197 const hasNameFromLegend = labels.some( 198 label => 199 label.DOMNode.nodeName === "LEGEND" && 200 label.name && 201 label.name.trim() === name && 202 isVisible(label) 203 ); 204 205 return hasNameFromLegend 206 ? null 207 : { 208 score: WARNING, 209 issue: FORM_FIELDSET_NO_NAME_FROM_LEGEND, 210 }; 211 } 212 default: 213 return null; 214 } 215 }; 216 217 /** 218 * A text label rule for elements that map to ROLE_TEXT_CONTAINER: 219 * * <METER> mapps to ROLE_TEXT_CONTAINER and must have a name provided via 220 * the visible label. Note: Will only work when bug 559770 is resolved (right 221 * now, unlabelled meters are not mapped to an accessible object). 222 * 223 * @returns {null | object} 224 * Failure audit report depending on requirements for dialogs or form 225 * meter element, null otherwise. 226 */ 227 const textContainerRule = function (accessible) { 228 const { DOMNode } = accessible; 229 230 switch (DOMNode.nodeName) { 231 case "DIALOG": 232 return dialogRule(accessible); 233 case "METER": 234 return formRule(accessible); 235 default: 236 return null; 237 } 238 }; 239 240 /** 241 * A text label rule for elements that map to ROLE_INTERNAL_FRAME: 242 * * <OBJECT> maps to ROLE_INTERNAL_FRAME. Check the type attribute and whether 243 * it includes "image/" (e.g. image/jpeg, image/png, image/gif). If so, audit 244 * it the same way other image roles are audited. 245 * * <EMBED> maps to ROLE_INTERNAL_FRAME and must have a non-empty name. 246 * * <FRAME> and <IFRAME> map to ROLE_INTERNAL_FRAME and must have a non-empty 247 * title attribute. 248 * 249 * @returns {null | object} 250 * Failure audit report if the internal frame accessible object name is 251 * not provided or if it is not derived from a required location, null 252 * otherwise. 253 */ 254 const internalFrameRule = function (accessible) { 255 const { DOMNode } = accessible; 256 switch (DOMNode.nodeName) { 257 case "FRAME": 258 return mustHaveNonEmptyNameRule(FRAME_NO_NAME, accessible); 259 case "IFRAME": { 260 const name = getAccessibleName(accessible); 261 const title = DOMNode.title && DOMNode.title.trim(); 262 263 return title && title === name 264 ? null 265 : { score: FAIL, issue: IFRAME_NO_NAME_FROM_TITLE }; 266 } 267 case "OBJECT": { 268 const type = DOMNode.getAttribute("type"); 269 if (!type || !type.startsWith("image/")) { 270 return null; 271 } 272 273 return imageRule(accessible); 274 } 275 case "EMBED": { 276 const type = DOMNode.getAttribute("type"); 277 if (!type || !type.startsWith("image/")) { 278 return mustHaveNonEmptyNameRule(EMBED_NO_NAME, accessible); 279 } 280 return imageRule(accessible); 281 } 282 default: 283 return null; 284 } 285 }; 286 287 /** 288 * A text label rule for accessible objects that represent documents and should 289 * have title element provided. 290 * 291 * @returns {null | object} 292 * Failure audit report if document accessible object has no or empty 293 * title, null otherwise. 294 */ 295 const documentRule = function (accessible) { 296 const title = accessible.DOMNode.title && accessible.DOMNode.title.trim(); 297 return title ? null : { score: FAIL, issue: DOCUMENT_NO_TITLE }; 298 }; 299 300 /** 301 * A text label rule for accessible objects that correspond to headings and thus 302 * must be non-empty. 303 * 304 * @returns {null | object} 305 * Failure audit report if heading accessible object has no or 306 * empty name or if its text content is empty, null otherwise. 307 */ 308 const headingRule = function (accessible) { 309 const name = getAccessibleName(accessible); 310 if (!name) { 311 return { score: FAIL, issue: HEADING_NO_NAME }; 312 } 313 314 const content = 315 accessible.DOMNode.textContent && accessible.DOMNode.textContent.trim(); 316 return content ? null : { score: WARNING, issue: HEADING_NO_CONTENT }; 317 }; 318 319 /** 320 * A text label rule for accessible objects that represent toolbars and must 321 * have a non-empty name if there is more than one toolbar present. 322 * 323 * @returns {null | object} 324 * Failure audit report if toolbar accessible object is not the only 325 * toolbar in the document and has no or empty title, null otherwise. 326 */ 327 const toolbarRule = function (accessible) { 328 const toolbars = 329 accessible.DOMNode.ownerDocument.querySelectorAll(`[role="toolbar"]`); 330 331 return toolbars.length > 1 332 ? mustHaveNonEmptyNameRule(TOOLBAR_NO_NAME, accessible) 333 : null; 334 }; 335 336 /** 337 * A text label rule for accessible objects that represent link (anchors, areas) 338 * and must have a non-empty name. 339 * 340 * @returns {null | object} 341 * Failure audit report if link accessible object has no or empty name, 342 * or in case when it's an <area> element with href attribute the name 343 * is not specified by an alt attribute, null otherwise. 344 */ 345 const linkRule = function (accessible) { 346 const { DOMNode } = accessible; 347 if (DOMNode.nodeName === "AREA" && DOMNode.hasAttribute("href")) { 348 const alt = DOMNode.getAttribute("alt"); 349 const name = getAccessibleName(accessible); 350 return alt && alt.trim() === name 351 ? null 352 : { score: FAIL, issue: AREA_NO_NAME_FROM_ALT }; 353 } 354 355 return interactiveRule(accessible); 356 }; 357 358 /** 359 * A text label rule for accessible objects that are used to display 360 * non-standard symbols where existing Unicode characters are not available and 361 * must have a non-empty name. 362 * 363 * @returns {null | object} 364 * Failure audit report if mglyph accessible object has no or empty 365 * name, and no or empty alt attribute, null otherwise. 366 */ 367 const mathmlGlyphRule = function (accessible) { 368 const name = getAccessibleName(accessible); 369 if (name) { 370 return null; 371 } 372 373 const { DOMNode } = accessible; 374 const alt = DOMNode.getAttribute("alt"); 375 return alt && alt.trim() 376 ? null 377 : { score: FAIL, issue: MATHML_GLYPH_NO_NAME }; 378 }; 379 380 const RULES = { 381 [Ci.nsIAccessibleRole.ROLE_BUTTONMENU]: interactiveRule, 382 [Ci.nsIAccessibleRole.ROLE_CANVAS]: imageRule, 383 [Ci.nsIAccessibleRole.ROLE_CHECKBUTTON]: formRule, 384 [Ci.nsIAccessibleRole.ROLE_CHECK_MENU_ITEM]: interactiveRule, 385 [Ci.nsIAccessibleRole.ROLE_CHECK_RICH_OPTION]: formRule, 386 [Ci.nsIAccessibleRole.ROLE_COLUMNHEADER]: interactiveRule, 387 [Ci.nsIAccessibleRole.ROLE_COMBOBOX]: formRule, 388 [Ci.nsIAccessibleRole.ROLE_COMBOBOX_OPTION]: interactiveRule, 389 [Ci.nsIAccessibleRole.ROLE_DIAGRAM]: imageRule, 390 [Ci.nsIAccessibleRole.ROLE_DIALOG]: dialogRule, 391 [Ci.nsIAccessibleRole.ROLE_DOCUMENT]: documentRule, 392 [Ci.nsIAccessibleRole.ROLE_EDITCOMBOBOX]: formRule, 393 [Ci.nsIAccessibleRole.ROLE_ENTRY]: formRule, 394 [Ci.nsIAccessibleRole.ROLE_FIGURE]: shouldHaveNonEmptyNameRule.bind( 395 null, 396 FIGURE_NO_NAME 397 ), 398 [Ci.nsIAccessibleRole.ROLE_GRAPHIC]: imageRule, 399 [Ci.nsIAccessibleRole.ROLE_GROUPING]: formGroupingRule, 400 [Ci.nsIAccessibleRole.ROLE_HEADING]: headingRule, 401 [Ci.nsIAccessibleRole.ROLE_IMAGE_MAP]: imageRule, 402 [Ci.nsIAccessibleRole.ROLE_INTERNAL_FRAME]: internalFrameRule, 403 [Ci.nsIAccessibleRole.ROLE_LINK]: linkRule, 404 [Ci.nsIAccessibleRole.ROLE_LISTBOX]: formRule, 405 [Ci.nsIAccessibleRole.ROLE_MATHML_GLYPH]: mathmlGlyphRule, 406 [Ci.nsIAccessibleRole.ROLE_MENUITEM]: interactiveRule, 407 [Ci.nsIAccessibleRole.ROLE_OPTION]: interactiveRule, 408 [Ci.nsIAccessibleRole.ROLE_OUTLINEITEM]: interactiveRule, 409 [Ci.nsIAccessibleRole.ROLE_PAGETAB]: interactiveRule, 410 [Ci.nsIAccessibleRole.ROLE_PASSWORD_TEXT]: formRule, 411 [Ci.nsIAccessibleRole.ROLE_PROGRESSBAR]: formRule, 412 [Ci.nsIAccessibleRole.ROLE_PUSHBUTTON]: interactiveRule, 413 [Ci.nsIAccessibleRole.ROLE_RADIOBUTTON]: formRule, 414 [Ci.nsIAccessibleRole.ROLE_RADIO_MENU_ITEM]: interactiveRule, 415 [Ci.nsIAccessibleRole.ROLE_ROWHEADER]: interactiveRule, 416 [Ci.nsIAccessibleRole.ROLE_SLIDER]: formRule, 417 [Ci.nsIAccessibleRole.ROLE_SPINBUTTON]: formRule, 418 [Ci.nsIAccessibleRole.ROLE_SWITCH]: formRule, 419 [Ci.nsIAccessibleRole.ROLE_TEXT_CONTAINER]: textContainerRule, 420 [Ci.nsIAccessibleRole.ROLE_TOGGLE_BUTTON]: interactiveRule, 421 [Ci.nsIAccessibleRole.ROLE_TOOLBAR]: toolbarRule, 422 }; 423 424 /** 425 * Perform audit for WCAG 1.1 criteria related to providing alternative text 426 * depending on the type of content. 427 * 428 * @param {nsIAccessible} accessible 429 * Accessible object to be tested to determine if it requires and has 430 * an appropriate text alternative. 431 * 432 * @return {null | object} 433 * Null if accessible does not need or has the right text alternative, 434 * audit data otherwise. This data is used in the accessibility panel 435 * for its audit filters, audit badges, sidebar checks section and 436 * highlighter. 437 */ 438 function auditTextLabel(accessible) { 439 const rule = RULES[accessible.role]; 440 return rule ? rule(accessible) : null; 441 } 442 443 module.exports.auditTextLabel = auditTextLabel;