Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] feat(rule): label content name mismatch #1159

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/rule-descriptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,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 interactive elements labelled through their content must have their visible label as part of their accessible name | Serious | wcag21a, wcag253 | 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, Serious, 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
34 changes: 34 additions & 0 deletions lib/checks/label/label-content-name-mismatch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
function getAriaText(elm) {
if (elm.getAttribute('aria-label')) {
return elm
.getAttribute('aria-label')
.toLowerCase()
.trim();
}
if (elm.getAttribute('aria-labelledby')) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aria-labelledby is an element reference. You're just picking up the ID of the element, not the actual text. We have some code for this, but its not isolated properly. Let me complete my accessible name update before completing this. One of the things I'm doing there is to create a getAriaLabelledbyName function.

return elm
.getAttribute('aria-labelledby')
.toLowerCase()
.trim();
}
return null;
}

const accessibleText = getAriaText(node);
// if no accessible text - fail
if (!accessibleText || !accessibleText.length) {
return false;
}

const contentText = node.textContent.toLowerCase().trim();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should use text.sanitize. It takes things like double spaces into account as well. Related question, does textContent solve for HTML entities like  ? We should probably have a test that makes sure HTML Entities aren't seen as different characters.

// if no text content - fail
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why wouldn't we ignore these types of elements?

if (!contentText || !contentText.length) {
return false;
}

// if text content is not part of accessible text - fail
if (!accessibleText.includes(contentText)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know that we have a string polyfill of .includes. Make sure this works on IE.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return false;
}

return true;
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": "Interactive element contains visible label as part of it's accessible name",
"fail": "Interactive element does not contain visible label as part of it's accessible name"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest: The visible text of the element is different from its accessible name

}
}
}
33 changes: 33 additions & 0 deletions lib/rules/label-content-name-mismatch-matches.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Applicability:
* This rule applies to any element that has:
* - a semantic role that is a widget that supports name from content, and
* - visible text content, and
* - an aria-label or aria-labelledby attribute.
*/
const { aria } = axe.commons;
const role = aria.getRole(node, { noImplicit: false, fallback: true });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because of our accessibility support baseline, axe-core shouldn't support fallback roles for the time being. Also noImplicit: false is the default, so you don't need to set this options object at all.


// no role - exclude
if (!role) {
return false;
}

const rolesWithNameFromContents = aria.getRolesWithNameFromContents();

// if role is not one of roles with name from contents - exclude
if (!rolesWithNameFromContents.includes(role)) {
return false;
}

// if no accessible name attributes - exclude
if (!node.getAttribute('aria-labelledby') && !node.getAttribute('aria-label')) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should probably use .trim() on these to make sure they're actually empty.

return false;
}

// if no text content - exclude
if (node.textContent.toLowerCase().trim().length <= 0) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think `.textContent is reliable enough for this. We don't want to check against hidden text. I think we have a visibleText method (not sure about the name) that gets you only text that's displayed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean axe.commons.text.accessibleText?

return false;
}

return true;
17 changes: 17 additions & 0 deletions lib/rules/label-content-name-mismatch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"id": "label-content-name-mismatch",
"matches": "label-content-name-mismatch-matches.js",
"tags": [
"wcag21a",
"wcag253"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should make this rule experimental for the time being. I don't trust that this isn't going to give false positives.

],
"metadata": {
"description": "Ensures that interactive elements labelled through their content must have their visible label as part of their accessible name",
"help": "Interactive elements must have their visible label as part of their accessible name"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Name from content isn't just for interactive elements. I'm struggling to come up with a short but still descriptive text for this rule. Maybe talk to Jean or Carrie for suggestions?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jeankaplansky - can you help with a better help text here?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep. I'm on it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@WilcoFiers , @JKODU : Is there a specific reason we cannot say "Ensures that ELEMENTS labeled through their content must have their visible label as part of their accessible name"? Ditto with the value for help: "ELEMENTS must have their visible label as part of their accessible name."

This approach accounts for all elements regardless of functional semantic intent (interactive or plain content).

},
"all": [],
"any": [
"label-content-name-mismatch"
],
"none": []
}
127 changes: 127 additions & 0 deletions test/checks/label/label-content-name-mismatch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
describe('label-content-name-mismatch', function() {
'use strict';

var fixture = document.getElementById('fixture');
var check = undefined;
var options = undefined;
// var checkSetup = axe.testUtils.checkSetup;
// var shadowSupport = axe.testUtils.shadowSupport;

beforeEach(function() {
check = checks['label-content-name-mismatch'];
});

afterEach(function() {
fixture.innerHTML = '';
check = undefined;
axe._tree = undefined;
});

it('returns false when element has an empty aria-label', function() {
fixture.innerHTML =
'<button id="target" name="link" aria-label="">The full label</button>';
var node = fixture.querySelector('#target');
var tree = (axe._tree = axe.utils.getFlattenedTree(fixture));
var vNode = axe.utils.getNodeFromTree(tree[0], node);
var actual = check.evaluate(node, options, vNode);
assert.isFalse(actual);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If aria-label is empty / whitespace only it ignored as the accessible name, and the content will be used as label instead. This case should pass.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getAriaLabelledbyText authored for accessible name computation, does not take that into account, and does not return the content as text :/.

});

it('returns false when element has an empty aria-labelledby', function() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, empty aria-labelledby will be ignored by the name computation. This shouldn't fail.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment as above, repeating:

getAriaLabelledbyText authored for accessible name computation, does not take that into account, and does not return the content as text :/.

Perhaps the accessible name computation should take this into consideration. Correct me if I am wrong.

fixture.innerHTML =
'<button id="target" name="link" aria-labelledby="">The full label</button>';
var node = fixture.querySelector('#target');
var tree = (axe._tree = axe.utils.getFlattenedTree(fixture));
var actual = check.evaluate(
node,
options,
axe.utils.getNodeFromTree(tree[0], node)
);
assert.isFalse(actual);
});

it('returns false when element has no text content', function() {
fixture.innerHTML =
'<button id="target" name="link" aria-label="Ok"></button>';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This too, if there is no content, there is no mismatch and so this shouldn't fail.

var node = fixture.querySelector('#target');
var tree = (axe._tree = axe.utils.getFlattenedTree(fixture));
var actual = check.evaluate(
node,
options,
axe.utils.getNodeFromTree(tree[0], node)
);
assert.isFalse(actual);
});

it('returns false when accessible text does not contain text content', function() {
fixture.innerHTML =
'<button id="target" name="link" aria-label="Ok">test page</button>';
var node = fixture.querySelector('#target');
var tree = (axe._tree = axe.utils.getFlattenedTree(fixture));
var actual = check.evaluate(
node,
options,
axe.utils.getNodeFromTree(tree[0], node)
);
assert.isFalse(actual);
});

it('returns true when visible label and accessible name matches when trailing white spaces are removed', function() {
fixture.innerHTML =
'<div id="target" role="link" aria-label="next page ">next page</div>';
var node = fixture.querySelector('#target');
var tree = (axe._tree = axe.utils.getFlattenedTree(fixture));
var actual = check.evaluate(
node,
options,
axe.utils.getNodeFromTree(tree[0], node)
);
assert.isTrue(actual);
});

it('returns true when visible label and accessible name matches after handling character insensitivity', function() {
fixture.innerHTML =
'<div id="target" role="link" aria-label="Next Page">next page</div>';
var node = fixture.querySelector('#target');
var tree = (axe._tree = axe.utils.getFlattenedTree(fixture));
var actual = check.evaluate(
node,
options,
axe.utils.getNodeFromTree(tree[0], node)
);
assert.isTrue(actual);
});

it('returns true when full visible label is contained in the accessible name', function() {
fixture.innerHTML =
'<button id="target" name="link" aria-label="Next Page in the list">Next Page</button>';
var node = fixture.querySelector('#target');
var tree = (axe._tree = axe.utils.getFlattenedTree(fixture));
var actual = check.evaluate(
node,
options,
axe.utils.getNodeFromTree(tree[0], node)
);
assert.isTrue(actual);
});

it('returns false when visible label doesn’t match accessible name', function() {
fixture.innerHTML =
'<div id="target" role="link" aria-label="OK">Next</div>';
var node = fixture.querySelector('#target');
var tree = (axe._tree = axe.utils.getFlattenedTree(fixture));
var vNode = axe.utils.getNodeFromTree(tree[0], node);
var actual = check.evaluate(node, options, vNode);
assert.isFalse(actual);
});

it('returns false when not all of visible label is included in accessible name', function() {
fixture.innerHTML =
'<button id="target" name="link" aria-label="the full">The full label</button>';
var node = fixture.querySelector('#target');
var tree = (axe._tree = axe.utils.getFlattenedTree(fixture));
var vNode = axe.utils.getNodeFromTree(tree[0], node);
var actual = check.evaluate(node, options, vNode);
assert.isFalse(actual);
});
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're missing quite a few tests here:

  • aria-labelledby with reference to an element that matches / that doesn't match
  • aria-labelledby with references to multiple elements
  • aria-labelledby with references to non-existing elements
  • Elements with hidden texts like: `? help
  • Elements with ASCII as non-text content <button aria-label="close">X</button>
  • ...

Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<!-- Pass -->
<div id='pass1' role="link" aria-label="next page ">next page</div>
<div id='pass2' role="link" aria-label="Next Page">next page</div>
<button id='pass3' name="link" aria-label="Next Page in the list">Next Page</button>
<!-- Fail -->
<div id="fail1" role="link" aria-label="OK">Next</div>
<button id="fail2" name="link" aria-label="the full">The full label</button>
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"description": "label-content-name-mismatch test",
"rule": "label-content-name-mismatch",
"violations": [
["#fail1"],
["#fail2"]
],
"passes": [
["#pass1"],
["#pass2"],
["#pass3"]
]
}
87 changes: 87 additions & 0 deletions test/rule-matches/label-content-name-mismatch-matches.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
describe('label-content-name-mismatch-matches', function() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'use strict';

var fixture = document.getElementById('fixture');
var rule;

beforeEach(function() {
rule = axe._audit.rules.find(function(rule) {
return rule.id === 'label-content-name-mismatch';
});
});

it('returns false if given element has no role (including fallback)', function() {
// div has no fallback role
fixture.innerHTML = '<div></div>';
var target = fixture.querySelector('div');
var actual = rule.matches(target);
assert.isFalse(actual);
});

it('returns false if element role is not supported roles with name from contents', function() {
// textbox is not a supported role with name from content
fixture.innerHTML = '<textarea></textarea>';
var target = fixture.querySelector('textarea');
var actual = rule.matches(target);
assert.isFalse(actual);
});

it('returns false if element does not have accessible name attribute (aria-label or aria-labelledby)', function() {
fixture.innerHTML = '<button name="link">The full label</button>';
var target = fixture.querySelector('button');
var actual = rule.matches(target);
assert.isFalse(actual);
});

it('returns true if element has accessible name and supported role with name from contents', function() {
fixture.innerHTML =
'<button name="link" aria-label="The full">The full label</button>';
var target = fixture.querySelector('button');
var actual = rule.matches(target);
assert.isTrue(actual);
});

it('returns false as for non-widget role', function() {
fixture.innerHTML = '<a aria-label="OK">Next</a>';
var target = fixture.querySelector('a');
var actual = rule.matches(target);
assert.isFalse(actual);
});

it('returns false for widget role that does not support name from content', function() {
fixture.innerHTML =
'<input type="email" aria-label="E-mail" value="Contact">';
var target = fixture.querySelector('input');
var actual = rule.matches(target);
assert.isFalse(actual);
});

it('returns false for non-widget role that does support name from content', function() {
fixture.innerHTML = '<div role="doc-chapter" aria-label="OK">Next</div>';
var target = fixture.querySelector('div');
var actual = rule.matches(target);
assert.isFalse(actual);
});

it('returns true for non-widget role that does support name from content', function() {
fixture.innerHTML = '<div role="tooltip" aria-label="OK">Next</div>';
var target = fixture.querySelector('div');
var actual = rule.matches(target);
assert.isTrue(actual);
});

it('returns false for empty text content', function() {
fixture.innerHTML = '<button aria-label="close"></button>';
var target = fixture.querySelector('button');
var actual = rule.matches(target);
assert.isFalse(actual);
});

it('returns false non text content', function() {
fixture.innerHTML =
'<button aria-label="close"><i class="fa fa-icon-close"></i></button>';
var target = fixture.querySelector('button');
var actual = rule.matches(target);
assert.isFalse(actual);
});
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need tests for aria-labelledby.