Skip to content

Commit a4255da

Browse files
jeeyyyWilcoFiers
authored andcommitted
feat(rule): Label and Name from Content mismatch WCAG21 (Issue #1149) (#1335)
* chore(WIP): rewrite accessibleText * chore: More refactoring for accname * chore(WIP): More improvements to accessibleName * feat: Reimplement accessible name computation * chore: All accessible name tests passing * chore(accName): All tests passing * chore: Add tests * chore: Test form-control-value * chore: Refactor and add docs to accessible-text * chore: Add tests for namedFromContents * chore: Refactor subtreeText method * chore: Refactor native accessible text methods * chore: Coverage for text.labelText * fix: update to axe.commons.matches usage * test: fix nativeTextboxValue tests * test: fix failing tests * fix: compute includeHidden as a part of accessibleName fn * fix: do not mutate context in accessibleText * feat: rule for label and name from content mismatch * fix: refactor based on review and add unicode computation * refactor: update based on code review * test: update test * chore: fix linting errors * refactor: updates based on code review * refactor: unicode and punctuation methods * test: update tests
1 parent 70b30fc commit a4255da

18 files changed

+972
-8
lines changed

doc/rule-descriptions.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
| image-alt | Ensures <img> elements have alternate text or a role of none or presentation | Critical | cat.text-alternatives, wcag2a, wcag111, section508, section508.22.a | true |
4242
| image-redundant-alt | Ensure button and link text is not repeated as image alternative | Minor | cat.text-alternatives, best-practice | true |
4343
| input-image-alt | Ensures <input type="image"> elements have alternate text | Critical | cat.text-alternatives, wcag2a, wcag111, section508, section508.22.a | true |
44+
| label-content-name-mismatch | Ensures that elements labelled through their content must have their visible text as part of their accessible name | Serious | wcag21a, wcag253, experimental | true |
4445
| label-title-only | Ensures that every form element is not solely labeled using the title or aria-describedby attributes | Serious | cat.forms, best-practice | true |
4546
| label | Ensures every form element has a label | Minor, Critical | cat.forms, wcag2a, wcag332, wcag131, section508, section508.22.n | true |
4647
| landmark-banner-is-top-level | Ensures the banner landmark is at top level | Moderate | cat.semantics, best-practice | true |
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
const { text } = axe.commons;
2+
3+
const accText = text.accessibleText(node).toLowerCase();
4+
if (text.isHumanInterpretable(accText) < 1) {
5+
return undefined;
6+
}
7+
8+
const visibleText = text
9+
.sanitize(text.visibleVirtual(virtualNode))
10+
.toLowerCase();
11+
if (text.isHumanInterpretable(visibleText) < 1) {
12+
if (isStringContained(visibleText, accText)) {
13+
return true;
14+
}
15+
return undefined;
16+
}
17+
18+
return isStringContained(visibleText, accText);
19+
20+
/**
21+
* Check if a given text exists in another
22+
*
23+
* @param {String} compare given text to check
24+
* @param {String} compareWith text against which to be compared
25+
* @returns {Boolean}
26+
*/
27+
function isStringContained(compare, compareWith) {
28+
const curatedCompareWith = curateString(compareWith);
29+
const curatedCompare = curateString(compare);
30+
if (!curatedCompareWith || !curatedCompare) {
31+
return false;
32+
}
33+
return curatedCompareWith.includes(curatedCompare);
34+
}
35+
36+
/**
37+
* Curate given text, by removing emoji's, punctuations, unicode and trim whitespace.
38+
*
39+
* @param {String} str given text to curate
40+
* @returns {String}
41+
*/
42+
function curateString(str) {
43+
const noUnicodeStr = text.removeUnicode(str, {
44+
emoji: true,
45+
nonBmp: true,
46+
punctuations: true
47+
});
48+
return text.sanitize(noUnicodeStr);
49+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"id": "label-content-name-mismatch",
3+
"evaluate": "label-content-name-mismatch.js",
4+
"metadata": {
5+
"impact": "serious",
6+
"messages": {
7+
"pass": "Element contains visible text as part of it's accessible name",
8+
"fail": "Text inside the element is not included in the accessible name"
9+
}
10+
}
11+
}

lib/commons/aria/index.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2357,3 +2357,45 @@ lookupTable.evaluateRoleForElement = {
23572357
return out;
23582358
}
23592359
};
2360+
2361+
/**
2362+
* Reference -> https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques#Widget_roles
2363+
* The current lookupTable.role['widget'] widget, yeilds
2364+
* ->
2365+
* [
2366+
* "alert", "alertdialog", "button", "checkbox", "dialog", "gridcell", "link", "log", "marquee", "menuitem", "menuitemcheckbox",
2367+
* "menuitemradio", "option", "progressbar", "radio", "scrollbar", "searchbox", "slider", "spinbutton", "status", "switch", "tab", "tabpanel",
2368+
* "textbox", "timer", "tooltip", "treeitem"
2369+
* ]
2370+
* There are some differences against specs, hence the below listing was made
2371+
*/
2372+
lookupTable.rolesOfType = {
2373+
widget: [
2374+
'button',
2375+
'checkbox',
2376+
'dialog',
2377+
'gridcell',
2378+
'heading',
2379+
'link',
2380+
'log',
2381+
'marquee',
2382+
'menuitem',
2383+
'menuitemcheckbox',
2384+
'menuitemradio',
2385+
'option',
2386+
'progressbar',
2387+
'radio',
2388+
'scrollbar',
2389+
'slider',
2390+
'spinbutton',
2391+
'status',
2392+
'switch',
2393+
'tab',
2394+
'tabpanel',
2395+
'textbox',
2396+
'timer',
2397+
'tooltip',
2398+
'tree',
2399+
'treeitem'
2400+
]
2401+
};
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/* global text */
2+
3+
/**
4+
* Determines if a given text is human friendly and interpretable
5+
*
6+
* @method isHumanInterpretable
7+
* @memberof axe.commons.text
8+
* @instance
9+
* @param {String} str text to be validated
10+
* @returns {Number} Between 0 and 1, (0 -> not interpretable, 1 -> interpretable)
11+
*/
12+
text.isHumanInterpretable = function(str) {
13+
/**
14+
* Steps:
15+
* 1) Check for single character edge cases
16+
* a) handle if character is alphanumeric & within the given icon mapping
17+
* eg: x (close), i (info)
18+
*
19+
* 3) handle unicode from astral (non bilingual multi plane) unicode, emoji & punctuations
20+
* eg: Windings font
21+
* eg: '💪'
22+
* eg: I saw a shooting 💫
23+
* eg: ? (help), > (next arrow), < (back arrow), need help ?
24+
*/
25+
26+
if (!str.length) {
27+
return 0;
28+
}
29+
30+
// Step 1
31+
const alphaNumericIconMap = [
32+
'x', // close
33+
'i' // info
34+
];
35+
// Step 1a
36+
if (alphaNumericIconMap.includes(str)) {
37+
return 0;
38+
}
39+
40+
// Step 2
41+
const noUnicodeStr = text.removeUnicode(str, {
42+
emoji: true,
43+
nonBmp: true,
44+
punctuations: true
45+
});
46+
if (!text.sanitize(noUnicodeStr)) {
47+
return 0;
48+
}
49+
50+
return 1;
51+
};

lib/commons/text/unicode.js

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/* global text */
2+
3+
/**
4+
* Determine if a given string contains unicode characters, specified in options
5+
*
6+
* @method hasUnicode
7+
* @memberof axe.commons.text
8+
* @instance
9+
* @param {String} str string to verify
10+
* @param {Object} options config containing which unicode character sets to verify
11+
* @property {Boolean} options.emoji verify emoji unicode
12+
* @property {Boolean} options.nonBmp verify nonBmp unicode
13+
* @property {Boolean} options.punctuations verify punctuations unicode
14+
* @returns {Boolean}
15+
*/
16+
text.hasUnicode = function hasUnicode(str, options) {
17+
const { emoji, nonBmp, punctuations } = options;
18+
if (emoji) {
19+
return axe.imports.emojiRegexText().test(str);
20+
}
21+
if (nonBmp) {
22+
return getUnicodeNonBmpRegExp().test(str);
23+
}
24+
if (punctuations) {
25+
return getPunctuationRegExp().test(str);
26+
}
27+
return false;
28+
};
29+
30+
/**
31+
* Remove specified type(s) unicode characters
32+
*
33+
* @method removeUnicode
34+
* @memberof axe.commons.text
35+
* @instance
36+
* @param {String} str string to operate on
37+
* @param {Object} options config containing which unicode character sets to remove
38+
* @property {Boolean} options.emoji remove emoji unicode
39+
* @property {Boolean} options.nonBmp remove nonBmp unicode
40+
* @property {Boolean} options.punctuations remove punctuations unicode
41+
* @returns {String}
42+
*/
43+
text.removeUnicode = function removeUnicode(str, options) {
44+
const { emoji, nonBmp, punctuations } = options;
45+
46+
if (emoji) {
47+
str = str.replace(axe.imports.emojiRegexText(), '');
48+
}
49+
if (nonBmp) {
50+
str = str.replace(getUnicodeNonBmpRegExp(), '');
51+
}
52+
if (punctuations) {
53+
str = str.replace(getPunctuationRegExp(), '');
54+
}
55+
56+
return str;
57+
};
58+
59+
/**
60+
* Regex for matching unicode values out of Basic Multilingual Plane (BMP)
61+
* Reference:
62+
* - https://github.com/mathiasbynens/regenerate
63+
* - https://unicode-table.com/
64+
* - https://mathiasbynens.be/notes/javascript-unicode
65+
*
66+
* @returns {RegExp}
67+
*/
68+
function getUnicodeNonBmpRegExp() {
69+
/**
70+
* Regex for matching astral plane unicode
71+
* - http://kourge.net/projects/regexp-unicode-block
72+
*/
73+
return new RegExp(
74+
'[' +
75+
'\u1D00-\u1D7F' + // Phonetic Extensions
76+
'\u1D80-\u1DBF' + // Phonetic Extensions Supplement
77+
'\u1DC0-\u1DFF' + // Combining Diacritical Marks Supplement
78+
// '\u2000-\u206F' + // General punctuation - handled in -> getPunctuationRegExp
79+
'\u20A0-\u20CF' + // Currency symbols
80+
'\u20D0-\u20FF' + // Combining Diacritical Marks for Symbols
81+
'\u2100-\u214F' + // Letter like symbols
82+
'\u2150-\u218F' + // Number forms (eg: Roman numbers)
83+
'\u2190-\u21FF' + // Arrows
84+
'\u2200-\u22FF' + // Mathematical operators
85+
'\u2300-\u23FF' + // Misc Technical
86+
'\u2400-\u243F' + // Control pictures
87+
'\u2440-\u245F' + // OCR
88+
'\u2460-\u24FF' + // Enclosed alpha numerics
89+
'\u2500-\u257F' + // Box Drawing
90+
'\u2580-\u259F' + // Block Elements
91+
'\u25A0-\u25FF' + // Geometric Shapes
92+
'\u2600-\u26FF' + // Misc Symbols
93+
'\u2700-\u27BF' + // Dingbats
94+
']'
95+
);
96+
}
97+
98+
/**
99+
* Get regular expression for matching punctuations
100+
*
101+
* @returns {RegExp}
102+
*/
103+
function getPunctuationRegExp() {
104+
/**
105+
* Reference: http://kunststube.net/encoding/
106+
* US-ASCII
107+
* -> !"#$%&'()*+,-./:;<=>?@[\]^_`{|}~
108+
*
109+
* General Punctuation block
110+
* -> \u2000-\u206F
111+
*
112+
* Supplemental Punctuation block
113+
* Reference: https://en.wikipedia.org/wiki/Supplemental_Punctuation
114+
* -> \u2E00-\u2E7F Reference
115+
*/
116+
return /[\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,\-.\/:;<=>?@\[\]^_`{|}~]/g;
117+
}

lib/core/imports/index.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ require('es6-promise').polyfill();
2020
*/
2121
axe.imports = {
2222
axios: require('axios'),
23+
CssSelectorParser: require('css-selector-parser').CssSelectorParser,
2324
doT: require('dot'),
24-
CssSelectorParser: require('css-selector-parser').CssSelectorParser
25+
emojiRegexText: require('emoji-regex')
2526
};
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
* Applicability:
3+
* Rule applies to any element that has
4+
* a) a semantic role that is `widget` that supports name from content
5+
* b) has visible text content
6+
* c) has accessible name (eg: `aria-label`)
7+
*/
8+
const { aria, text } = axe.commons;
9+
10+
const role = aria.getRole(node);
11+
if (!role) {
12+
return false;
13+
}
14+
15+
const isWidgetType = aria.lookupTable.rolesOfType.widget.includes(role);
16+
if (!isWidgetType) {
17+
return false;
18+
}
19+
20+
const rolesWithNameFromContents = aria.getRolesWithNameFromContents();
21+
if (!rolesWithNameFromContents.includes(role)) {
22+
return false;
23+
}
24+
25+
/**
26+
* if no `aria-label` or `aria-labelledby` attribute - ignore `node`
27+
*/
28+
if (
29+
!text.sanitize(aria.arialabelText(node)) &&
30+
!text.sanitize(aria.arialabelledbyText(node))
31+
) {
32+
return false;
33+
}
34+
35+
/**
36+
* if no `contentText` - ignore `node`
37+
*/
38+
if (!text.sanitize(text.visibleVirtual(virtualNode))) {
39+
return false;
40+
}
41+
42+
return true;
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"id": "label-content-name-mismatch",
3+
"matches": "label-content-name-mismatch-matches.js",
4+
"tags": ["wcag21a", "wcag253", "experimental"],
5+
"metadata": {
6+
"description": "Ensures that elements labelled through their content must have their visible text as part of their accessible name",
7+
"help": "Elements must have their visible text as part of their accessible name"
8+
},
9+
"all": [],
10+
"any": ["label-content-name-mismatch"],
11+
"none": []
12+
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
"clone": "~2.1.1",
8787
"css-selector-parser": "^1.3.0",
8888
"dot": "~1.1.2",
89+
"emoji-regex": "7.0.3",
8990
"es6-promise": "^4.2.6",
9091
"eslint": "^5.14.0",
9192
"eslint-config-prettier": "^3.4.0",

0 commit comments

Comments
 (0)