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

fix(valid-lang): ignore lang on elements with no text #3523

Merged
merged 7 commits into from
Jul 7, 2022
Merged
Show file tree
Hide file tree
Changes from 6 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
37 changes: 21 additions & 16 deletions lib/commons/dom/has-content-virtual.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,30 @@ import isVisualContent from './is-visual-content';
import labelVirtual from '../aria/label-virtual';

const hiddenTextElms = [
'HEAD',
'TITLE',
'TEMPLATE',
'SCRIPT',
'STYLE',
'IFRAME',
'OBJECT',
'VIDEO',
'AUDIO',
'NOSCRIPT'
'head',
'title',
'template',
'script',
'style',
'iframe',
'object',
'video',
'audio',
'noscript'
];

function hasChildTextNodes(elm) {
if (!hiddenTextElms.includes(elm.actualNode.nodeName.toUpperCase())) {
return elm.children.some(
({ actualNode }) =>
actualNode.nodeType === 3 && actualNode.nodeValue.trim()
);
/**
* Test if the element has child nodes that are non-empty text nodes
* @param {VirtualNode} elm
* @returns boolean
*/
export function hasChildTextNodes(elm) {
if (hiddenTextElms.includes(elm.props.nodeName)) {
return false
}
return elm.children.some(({ props }) => {
return props.nodeType === 3 && props.nodeValue.trim()
})
}

/**
Expand Down
24 changes: 24 additions & 0 deletions lib/commons/dom/has-lang-text.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { hasChildTextNodes } from './has-content-virtual';
import isVisualContent from './is-visual-content';
import isVisible from './is-visible';

/**
* Check that a node has text, or an accessible name which language is defined by the
* nearest ancestor's lang attribute.
* @param {VirtualNode} virtualNode
* @return boolean
*/
export default function hasLangText(virtualNode) {
if (typeof virtualNode.children === 'undefined' || hasChildTextNodes(virtualNode)) {
return true;
}
if (virtualNode.props.nodeType === 1 && isVisualContent(virtualNode)) {
// See: https://github.com/dequelabs/axe-core/issues/3281
return !!axe.commons.text.accessibleTextVirtual(virtualNode);
Copy link
Contributor

Choose a reason for hiding this comment

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

this call here is going to be slow

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Possibly, although it'll be rare. You generally don't see people put lang attributes on images with aria-labelledby or whatever.

}
return virtualNode.children.some(child => (
!child.attr('lang') && // non-empty lang
hasLangText(child) && // has text
isVisible(child, true) // Not hidden for AT
));
}
1 change: 1 addition & 0 deletions lib/commons/dom/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export { default as getTextElementStack } from './get-text-element-stack';
export { default as getViewportSize } from './get-viewport-size';
export { default as hasContentVirtual } from './has-content-virtual';
export { default as hasContent } from './has-content';
export { default as hasLangText } from './has-lang-text';
export { default as idrefs } from './idrefs';
export { default as insertedIntoFocusOrder } from './inserted-into-focus-order';
export { default as isCurrentPageLink } from './is-current-page-link';
Expand Down
49 changes: 27 additions & 22 deletions lib/commons/dom/is-visual-content.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { getNodeFromTree } from '../../core/utils';
import AbstractVirtualNode from '../../core/base/virtual-node/abstract-virtual-node';

const visualRoles = [
'checkbox',
'img',
'meter',
'progressbar',
'scrollbar',
'radio',
'range',
Copy link
Contributor Author

@WilcoFiers WilcoFiers Jun 30, 2022

Choose a reason for hiding this comment

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

Range is an abstract role. This should've never been here. I replaced it with roles that inherit from range.

'slider',
'spinbutton',
'textbox'
Expand All @@ -13,34 +18,34 @@ const visualRoles = [
* @method isVisualContent
* @memberof axe.commons.dom
* @instance
* @param {Element} element The element to check
* @param {Element|VirtualNode} element The element to check
* @return {Boolean}
*/
function isVisualContent(element) {
/*eslint indent: 0*/
const role = element.getAttribute('role');
function isVisualContent(el) {
const vNode = el instanceof AbstractVirtualNode ? el : getNodeFromTree(el);
const role = axe.commons.aria.getExplicitRole(vNode);
if (role) {
return visualRoles.indexOf(role) !== -1;
}

switch (element.nodeName.toUpperCase()) {
case 'IMG':
case 'IFRAME':
case 'OBJECT':
case 'VIDEO':
case 'AUDIO':
case 'CANVAS':
case 'SVG':
case 'MATH':
case 'BUTTON':
case 'SELECT':
case 'TEXTAREA':
case 'KEYGEN':
case 'PROGRESS':
case 'METER':
switch (vNode.props.nodeName) {
case 'img':
case 'iframe':
case 'object':
case 'video':
case 'audio':
case 'canvas':
case 'svg':
case 'math':
case 'button':
case 'select':
case 'textarea':
case 'keygen':
case 'progress':
case 'meter':
return true;
case 'INPUT':
return element.type !== 'hidden';
case 'input':
return vNode.props.type !== 'hidden';
default:
return false;
}
Expand Down
7 changes: 7 additions & 0 deletions lib/rules/has-lang-text-matches.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { hasLangText } from '../commons/dom'

function hasLangTextMatches(node, virtualNode) {
return hasLangText(virtualNode);
}

export default hasLangTextMatches;
4 changes: 2 additions & 2 deletions lib/rules/valid-lang.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "valid-lang",
"selector": "[lang], [xml\\:lang]",
"matches": "not-html-matches",
"selector": "[lang]:not(html), [xml\\:lang]:not(html)",
"matches": "has-lang-text-matches",
Copy link
Contributor

Choose a reason for hiding this comment

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

Could we switch these around? I.e. only look for the text if the lang value is invalid?

"tags": ["cat.language", "wcag2aa", "wcag312", "ACT"],
"actIds": ["de46e4"],
"metadata": {
Expand Down
39 changes: 22 additions & 17 deletions test/commons/color/get-background-color.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ describe('color.getBackgroundColor', function() {
var fixture = document.getElementById('fixture');

var shadowSupported = axe.testUtils.shadowSupport.v1;
var isIE11 = axe.testUtils.isIE11;
var origBodyBg;
var origHtmlBg;

Expand Down Expand Up @@ -480,11 +481,11 @@ describe('color.getBackgroundColor', function() {
assert.equal(actual.alpha, expected.alpha);
});

it('handles nested inline elements in the middle of a text', function () {
it('handles nested inline elements in the middle of a text', function() {
fixture.innerHTML =
'<div style="height: 1em; overflow:auto; background: cyan">' +
' <br>' +
' <b id="target">Text <i style="display: inline-block">'+
' <b id="target">Text <i style="display: inline-block">' +
' <s><img width="100" height="16"></s>' +
' </i></b>' +
'</div>';
Expand All @@ -499,7 +500,7 @@ describe('color.getBackgroundColor', function() {
assert.equal(actual.alpha, 1);
});

it('should return null for inline elements with position:absolute', function () {
it('should return null for inline elements with position:absolute', function() {
fixture.innerHTML =
'<div style="height: 1em; overflow:auto; position: relative">' +
' <br>' +
Expand Down Expand Up @@ -725,21 +726,25 @@ describe('color.getBackgroundColor', function() {
assert.isNull(actual);
});

it('should return background color for inline elements that do not fit the viewport', function() {
var html = '';
for (var i = 0; i < 300; i++) {
html += 'foo<br />';
}
fixture.innerHTML = '<em>' + html + '</em>';
axe.testUtils.flatTreeSetup(fixture);
var actual = axe.commons.color.getBackgroundColor(fixture, []);
var expected = new axe.commons.color.Color(255, 255, 255, 1);
// Test is flakey in IE11, timing out regularly
(isIE11 ? xit : it)(
'should return background color for inline elements that do not fit the viewport',
function() {
var html = '';
for (var i = 0; i < 300; i++) {
html += 'foo<br />';
}
fixture.innerHTML = '<em>' + html + '</em>';
axe.testUtils.flatTreeSetup(fixture);
var actual = axe.commons.color.getBackgroundColor(fixture, []);
var expected = new axe.commons.color.Color(255, 255, 255, 1);

assert.closeTo(actual.red, expected.red, 0.5);
assert.closeTo(actual.green, expected.green, 0.5);
assert.closeTo(actual.blue, expected.blue, 0.5);
assert.closeTo(actual.alpha, expected.alpha, 0.1);
});
assert.closeTo(actual.red, expected.red, 0.5);
assert.closeTo(actual.green, expected.green, 0.5);
assert.closeTo(actual.blue, expected.blue, 0.5);
assert.closeTo(actual.alpha, expected.alpha, 0.1);
}
);

it('should return the body bgColor when content does not overlap', function() {
fixture.innerHTML =
Expand Down
83 changes: 83 additions & 0 deletions test/commons/dom/has-lang-text.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
describe('dom.hasLangText', function() {
'use strict';
var hasLangText = axe.commons.dom.hasLangText;
var fixture = document.getElementById('fixture');
var tree;

it('returns true when the element has a non-empty text node as its content', function () {
fixture.innerHTML = '<div id="target"> text </div>';
tree = axe.utils.getFlattenedTree(fixture);
var target = axe.utils.querySelectorAll(tree, '#target')[0];
assert.isTrue(hasLangText(target));
});

it('returns true when the element has nested text node as its content', function () {
fixture.innerHTML = '<div id="target"> <span> text </span> </div>';
tree = axe.utils.getFlattenedTree(fixture);
var target = axe.utils.querySelectorAll(tree, '#target')[0];
assert.isTrue(hasLangText(target));
});

it('returns false when the element has nested text is hidden', function () {
fixture.innerHTML = '<div id="target"> <span hidden> text </span> </div>';
tree = axe.utils.getFlattenedTree(fixture);
var target = axe.utils.querySelectorAll(tree, '#target')[0];
assert.isFalse(hasLangText(target));
});

it('returns false when the element has an empty text node as its content', function () {
fixture.innerHTML = '<div id="target"> <!-- comment --> </div>';
tree = axe.utils.getFlattenedTree(fixture);
var target = axe.utils.querySelectorAll(tree, '#target')[0];
assert.isFalse(hasLangText(target));
});

it('returns false if all text is in a child with a lang attribute', function () {
fixture.innerHTML = '<div id="target"><span lang="en"> text </span></div>';
tree = axe.utils.getFlattenedTree(fixture);
var target = axe.utils.querySelectorAll(tree, '#target')[0];
assert.isFalse(hasLangText(target));
});

it('does not skip if lang is on the starting node', function () {
fixture.innerHTML = '<div id="target" lang="en"><span> text </span></div>';
tree = axe.utils.getFlattenedTree(fixture);
var target = axe.utils.querySelectorAll(tree, '#target')[0];
assert.isTrue(hasLangText(target));
});

it('ignores empty lang attributes', function () {
fixture.innerHTML = '<div id="target"><span lang=""> text </span></div>';
tree = axe.utils.getFlattenedTree(fixture);
var target = axe.utils.querySelectorAll(tree, '#target')[0];
assert.isTrue(hasLangText(target));
});

it('ignores null lang attributes', function () {
fixture.innerHTML = '<div id="target"><span lang> text </span></div>';
tree = axe.utils.getFlattenedTree(fixture);
var target = axe.utils.querySelectorAll(tree, '#target')[0];
assert.isTrue(hasLangText(target));
});

it('true for non-text content with an accessible name', function () {
fixture.innerHTML = '<div id="target"><img alt="foo"></div>';
tree = axe.utils.getFlattenedTree(fixture);
var target = axe.utils.querySelectorAll(tree, '#target')[0];
assert.isTrue(hasLangText(target));
});

it('false for non-text content without accessible name', function () {
fixture.innerHTML = '<div id="target"><img alt=""></div>';
tree = axe.utils.getFlattenedTree(fixture);
var target = axe.utils.querySelectorAll(tree, '#target')[0];
assert.isFalse(hasLangText(target));
});

it('returns false for non-text content with a lang attr', function () {
fixture.innerHTML = '<div id="target"><img alt="foo" lang="en"></div>';
tree = axe.utils.getFlattenedTree(fixture);
var target = axe.utils.querySelectorAll(tree, '#target')[0];
assert.isFalse(hasLangText(target));
});
});
Loading