Skip to content

Commit

Permalink
feat(rule): Label and Name from Content mismatch WCAG21 (Issue #1149) (
Browse files Browse the repository at this point in the history
…#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
  • Loading branch information
jeeyyy authored and WilcoFiers committed Feb 21, 2019
1 parent 70b30fc commit a4255da
Show file tree
Hide file tree
Showing 18 changed files with 972 additions and 8 deletions.
1 change: 1 addition & 0 deletions doc/rule-descriptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
| 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 |
| image-redundant-alt | Ensure button and link text is not repeated as image alternative | Minor | cat.text-alternatives, best-practice | true |
| input-image-alt | Ensures <input type="image"> elements have alternate text | Critical | cat.text-alternatives, wcag2a, wcag111, section508, section508.22.a | true |
| 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 |
| 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 |
| label | Ensures every form element has a label | Minor, Critical | cat.forms, wcag2a, wcag332, wcag131, section508, section508.22.n | true |
| landmark-banner-is-top-level | Ensures the banner landmark is at top level | Moderate | cat.semantics, best-practice | true |
Expand Down
49 changes: 49 additions & 0 deletions lib/checks/label/label-content-name-mismatch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
const { text } = axe.commons;

const accText = text.accessibleText(node).toLowerCase();
if (text.isHumanInterpretable(accText) < 1) {
return undefined;
}

const visibleText = text
.sanitize(text.visibleVirtual(virtualNode))
.toLowerCase();
if (text.isHumanInterpretable(visibleText) < 1) {
if (isStringContained(visibleText, accText)) {
return true;
}
return undefined;
}

return isStringContained(visibleText, accText);

/**
* Check if a given text exists in another
*
* @param {String} compare given text to check
* @param {String} compareWith text against which to be compared
* @returns {Boolean}
*/
function isStringContained(compare, compareWith) {
const curatedCompareWith = curateString(compareWith);
const curatedCompare = curateString(compare);
if (!curatedCompareWith || !curatedCompare) {
return false;
}
return curatedCompareWith.includes(curatedCompare);
}

/**
* Curate given text, by removing emoji's, punctuations, unicode and trim whitespace.
*
* @param {String} str given text to curate
* @returns {String}
*/
function curateString(str) {
const noUnicodeStr = text.removeUnicode(str, {
emoji: true,
nonBmp: true,
punctuations: true
});
return text.sanitize(noUnicodeStr);
}
11 changes: 11 additions & 0 deletions lib/checks/label/label-content-name-mismatch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"id": "label-content-name-mismatch",
"evaluate": "label-content-name-mismatch.js",
"metadata": {
"impact": "serious",
"messages": {
"pass": "Element contains visible text as part of it's accessible name",
"fail": "Text inside the element is not included in the accessible name"
}
}
}
42 changes: 42 additions & 0 deletions lib/commons/aria/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2357,3 +2357,45 @@ lookupTable.evaluateRoleForElement = {
return out;
}
};

/**
* Reference -> https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques#Widget_roles
* The current lookupTable.role['widget'] widget, yeilds
* ->
* [
* "alert", "alertdialog", "button", "checkbox", "dialog", "gridcell", "link", "log", "marquee", "menuitem", "menuitemcheckbox",
* "menuitemradio", "option", "progressbar", "radio", "scrollbar", "searchbox", "slider", "spinbutton", "status", "switch", "tab", "tabpanel",
* "textbox", "timer", "tooltip", "treeitem"
* ]
* There are some differences against specs, hence the below listing was made
*/
lookupTable.rolesOfType = {
widget: [
'button',
'checkbox',
'dialog',
'gridcell',
'heading',
'link',
'log',
'marquee',
'menuitem',
'menuitemcheckbox',
'menuitemradio',
'option',
'progressbar',
'radio',
'scrollbar',
'slider',
'spinbutton',
'status',
'switch',
'tab',
'tabpanel',
'textbox',
'timer',
'tooltip',
'tree',
'treeitem'
]
};
51 changes: 51 additions & 0 deletions lib/commons/text/is-human-interpretable.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/* global text */

/**
* Determines if a given text is human friendly and interpretable
*
* @method isHumanInterpretable
* @memberof axe.commons.text
* @instance
* @param {String} str text to be validated
* @returns {Number} Between 0 and 1, (0 -> not interpretable, 1 -> interpretable)
*/
text.isHumanInterpretable = function(str) {
/**
* Steps:
* 1) Check for single character edge cases
* a) handle if character is alphanumeric & within the given icon mapping
* eg: x (close), i (info)
*
* 3) handle unicode from astral (non bilingual multi plane) unicode, emoji & punctuations
* eg: Windings font
* eg: '💪'
* eg: I saw a shooting 💫
* eg: ? (help), > (next arrow), < (back arrow), need help ?
*/

if (!str.length) {
return 0;
}

// Step 1
const alphaNumericIconMap = [
'x', // close
'i' // info
];
// Step 1a
if (alphaNumericIconMap.includes(str)) {
return 0;
}

// Step 2
const noUnicodeStr = text.removeUnicode(str, {
emoji: true,
nonBmp: true,
punctuations: true
});
if (!text.sanitize(noUnicodeStr)) {
return 0;
}

return 1;
};
117 changes: 117 additions & 0 deletions lib/commons/text/unicode.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/* global text */

/**
* Determine if a given string contains unicode characters, specified in options
*
* @method hasUnicode
* @memberof axe.commons.text
* @instance
* @param {String} str string to verify
* @param {Object} options config containing which unicode character sets to verify
* @property {Boolean} options.emoji verify emoji unicode
* @property {Boolean} options.nonBmp verify nonBmp unicode
* @property {Boolean} options.punctuations verify punctuations unicode
* @returns {Boolean}
*/
text.hasUnicode = function hasUnicode(str, options) {
const { emoji, nonBmp, punctuations } = options;
if (emoji) {
return axe.imports.emojiRegexText().test(str);
}
if (nonBmp) {
return getUnicodeNonBmpRegExp().test(str);
}
if (punctuations) {
return getPunctuationRegExp().test(str);
}
return false;
};

/**
* Remove specified type(s) unicode characters
*
* @method removeUnicode
* @memberof axe.commons.text
* @instance
* @param {String} str string to operate on
* @param {Object} options config containing which unicode character sets to remove
* @property {Boolean} options.emoji remove emoji unicode
* @property {Boolean} options.nonBmp remove nonBmp unicode
* @property {Boolean} options.punctuations remove punctuations unicode
* @returns {String}
*/
text.removeUnicode = function removeUnicode(str, options) {
const { emoji, nonBmp, punctuations } = options;

if (emoji) {
str = str.replace(axe.imports.emojiRegexText(), '');
}
if (nonBmp) {
str = str.replace(getUnicodeNonBmpRegExp(), '');
}
if (punctuations) {
str = str.replace(getPunctuationRegExp(), '');
}

return str;
};

/**
* Regex for matching unicode values out of Basic Multilingual Plane (BMP)
* Reference:
* - https://github.com/mathiasbynens/regenerate
* - https://unicode-table.com/
* - https://mathiasbynens.be/notes/javascript-unicode
*
* @returns {RegExp}
*/
function getUnicodeNonBmpRegExp() {
/**
* Regex for matching astral plane unicode
* - http://kourge.net/projects/regexp-unicode-block
*/
return new RegExp(
'[' +
'\u1D00-\u1D7F' + // Phonetic Extensions
'\u1D80-\u1DBF' + // Phonetic Extensions Supplement
'\u1DC0-\u1DFF' + // Combining Diacritical Marks Supplement
// '\u2000-\u206F' + // General punctuation - handled in -> getPunctuationRegExp
'\u20A0-\u20CF' + // Currency symbols
'\u20D0-\u20FF' + // Combining Diacritical Marks for Symbols
'\u2100-\u214F' + // Letter like symbols
'\u2150-\u218F' + // Number forms (eg: Roman numbers)
'\u2190-\u21FF' + // Arrows
'\u2200-\u22FF' + // Mathematical operators
'\u2300-\u23FF' + // Misc Technical
'\u2400-\u243F' + // Control pictures
'\u2440-\u245F' + // OCR
'\u2460-\u24FF' + // Enclosed alpha numerics
'\u2500-\u257F' + // Box Drawing
'\u2580-\u259F' + // Block Elements
'\u25A0-\u25FF' + // Geometric Shapes
'\u2600-\u26FF' + // Misc Symbols
'\u2700-\u27BF' + // Dingbats
']'
);
}

/**
* Get regular expression for matching punctuations
*
* @returns {RegExp}
*/
function getPunctuationRegExp() {
/**
* Reference: http://kunststube.net/encoding/
* US-ASCII
* -> !"#$%&'()*+,-./:;<=>?@[\]^_`{|}~
*
* General Punctuation block
* -> \u2000-\u206F
*
* Supplemental Punctuation block
* Reference: https://en.wikipedia.org/wiki/Supplemental_Punctuation
* -> \u2E00-\u2E7F Reference
*/
return /[\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,\-.\/:;<=>?@\[\]^_`{|}~]/g;
}
3 changes: 2 additions & 1 deletion lib/core/imports/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ require('es6-promise').polyfill();
*/
axe.imports = {
axios: require('axios'),
CssSelectorParser: require('css-selector-parser').CssSelectorParser,
doT: require('dot'),
CssSelectorParser: require('css-selector-parser').CssSelectorParser
emojiRegexText: require('emoji-regex')
};
42 changes: 42 additions & 0 deletions lib/rules/label-content-name-mismatch-matches.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Applicability:
* Rule applies to any element that has
* a) a semantic role that is `widget` that supports name from content
* b) has visible text content
* c) has accessible name (eg: `aria-label`)
*/
const { aria, text } = axe.commons;

const role = aria.getRole(node);
if (!role) {
return false;
}

const isWidgetType = aria.lookupTable.rolesOfType.widget.includes(role);
if (!isWidgetType) {
return false;
}

const rolesWithNameFromContents = aria.getRolesWithNameFromContents();
if (!rolesWithNameFromContents.includes(role)) {
return false;
}

/**
* if no `aria-label` or `aria-labelledby` attribute - ignore `node`
*/
if (
!text.sanitize(aria.arialabelText(node)) &&
!text.sanitize(aria.arialabelledbyText(node))
) {
return false;
}

/**
* if no `contentText` - ignore `node`
*/
if (!text.sanitize(text.visibleVirtual(virtualNode))) {
return false;
}

return true;
12 changes: 12 additions & 0 deletions lib/rules/label-content-name-mismatch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"id": "label-content-name-mismatch",
"matches": "label-content-name-mismatch-matches.js",
"tags": ["wcag21a", "wcag253", "experimental"],
"metadata": {
"description": "Ensures that elements labelled through their content must have their visible text as part of their accessible name",
"help": "Elements must have their visible text as part of their accessible name"
},
"all": [],
"any": ["label-content-name-mismatch"],
"none": []
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
"clone": "~2.1.1",
"css-selector-parser": "^1.3.0",
"dot": "~1.1.2",
"emoji-regex": "7.0.3",
"es6-promise": "^4.2.6",
"eslint": "^5.14.0",
"eslint-config-prettier": "^3.4.0",
Expand Down
Loading

0 comments on commit a4255da

Please sign in to comment.