diff --git a/lib/checks/language/valid-lang-evaluate.js b/lib/checks/language/valid-lang-evaluate.js index 6a2460b440..b59970281d 100644 --- a/lib/checks/language/valid-lang-evaluate.js +++ b/lib/checks/language/valid-lang-evaluate.js @@ -1,5 +1,6 @@ import { isValidLang, getBaseLang } from '../../core/utils'; import { sanitize } from '../../commons/text'; +import { hasLangText } from '../../commons/dom' function validLangEvaluate(node, options, virtualNode) { const invalid = []; @@ -25,12 +26,17 @@ function validLangEvaluate(node, options, virtualNode) { } }); - if (invalid.length) { - this.data(invalid); - return true; + if (!invalid.length) { + return false; } - - return false; + if ( // Except for `html`, ignore elements with no text + virtualNode.props.nodeName !== 'html' && + !hasLangText(virtualNode) + ) { + return false; + } + this.data(invalid); + return true; } export default validLangEvaluate; diff --git a/lib/commons/dom/has-content-virtual.js b/lib/commons/dom/has-content-virtual.js index 8dfc7e74c7..9d28a05802 100644 --- a/lib/commons/dom/has-content-virtual.js +++ b/lib/commons/dom/has-content-virtual.js @@ -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() + }) } /** diff --git a/lib/commons/dom/has-lang-text.js b/lib/commons/dom/has-lang-text.js new file mode 100644 index 0000000000..30538f07c2 --- /dev/null +++ b/lib/commons/dom/has-lang-text.js @@ -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); + } + return virtualNode.children.some(child => ( + !child.attr('lang') && // non-empty lang + hasLangText(child) && // has text + isVisible(child, true) // Not hidden for AT + )); +} diff --git a/lib/commons/dom/index.js b/lib/commons/dom/index.js index 5acd13c076..6e60cec766 100644 --- a/lib/commons/dom/index.js +++ b/lib/commons/dom/index.js @@ -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'; diff --git a/lib/commons/dom/is-visual-content.js b/lib/commons/dom/is-visual-content.js index ef8b7c0aa8..3efef3c870 100644 --- a/lib/commons/dom/is-visual-content.js +++ b/lib/commons/dom/is-visual-content.js @@ -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', 'slider', 'spinbutton', 'textbox' @@ -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; } diff --git a/lib/rules/valid-lang.json b/lib/rules/valid-lang.json index 81df179ef8..382b15f949 100644 --- a/lib/rules/valid-lang.json +++ b/lib/rules/valid-lang.json @@ -1,7 +1,6 @@ { "id": "valid-lang", - "selector": "[lang], [xml\\:lang]", - "matches": "not-html-matches", + "selector": "[lang]:not(html), [xml\\:lang]:not(html)", "tags": ["cat.language", "wcag2aa", "wcag312", "ACT"], "actIds": ["de46e4"], "metadata": { diff --git a/test/checks/language/valid-lang.js b/test/checks/language/valid-lang.js index f4f553e77b..5ca9a56a04 100644 --- a/test/checks/language/valid-lang.js +++ b/test/checks/language/valid-lang.js @@ -12,7 +12,7 @@ describe('valid-lang', function() { }); it('should return false if a lang attribute is present in options', function() { - var params = checkSetup('
', { + var params = checkSetup('Paragraph!
'; - assert.isFalse(axe.commons.dom.isVisualContent(fixture.children[0])); + var virtualNode = queryFixture('Paragraph!
'); + assert.isFalse(isVisualContent(virtualNode)); }); }); }); diff --git a/test/integration/full/isolated-env/isolated-env.html b/test/integration/full/isolated-env/isolated-env.html index defb21a83c..b229f66bfd 100644 --- a/test/integration/full/isolated-env/isolated-env.html +++ b/test/integration/full/isolated-env/isolated-env.html @@ -79,7 +79,7 @@Not English
Mix
English
+ ++ +
+ English +
++ English +
diff --git a/test/integration/rules/valid-lang/valid-lang.json b/test/integration/rules/valid-lang/valid-lang.json index b8034e1631..6fa8341ffd 100644 --- a/test/integration/rules/valid-lang/valid-lang.json +++ b/test/integration/rules/valid-lang/valid-lang.json @@ -8,6 +8,11 @@ ["#pass3"], ["#pass4"], ["#pass5"], - ["#pass6"] + ["#pass6"], + ["#pass7"], + ["#pass8"], + ["#pass9"], + ["#pass10"], + ["#pass11"] ] } diff --git a/test/integration/virtual-rules/html-lang-valid.js b/test/integration/virtual-rules/html-lang-valid.js new file mode 100644 index 0000000000..d89d410ca6 --- /dev/null +++ b/test/integration/virtual-rules/html-lang-valid.js @@ -0,0 +1,95 @@ +describe('html-lang-valid virtual-rule', function() { + it('is inapplicable without lang or xml:lang', function() { + // Error caught by html-has-lang instead + var results = axe.runVirtualRule('html-lang-valid', { + nodeName: 'html', + attributes: {} + }); + + assert.lengthOf(results.inapplicable, 1); + assert.lengthOf(results.passes, 0); + assert.lengthOf(results.violations, 0); + assert.lengthOf(results.incomplete, 0); + }); + + it('should pass with a valid lang', function() { + var results = axe.runVirtualRule('html-lang-valid', { + nodeName: 'html', + attributes: { + lang: 'en' + } + }); + + assert.lengthOf(results.passes, 1); + assert.lengthOf(results.violations, 0); + assert.lengthOf(results.incomplete, 0); + }); + + it('should pass with a valid xml:lang', function() { + var results = axe.runVirtualRule('html-lang-valid', { + nodeName: 'html', + attributes: { + 'xml:lang': 'en' + } + }); + + assert.lengthOf(results.passes, 1); + assert.lengthOf(results.violations, 0); + assert.lengthOf(results.incomplete, 0); + }); + + it('should pass for both lang and xml:lang', function() { + var results = axe.runVirtualRule('html-lang-valid', { + nodeName: 'html', + attributes: { + lang: 'en', + 'xml:lang': 'en' + } + }); + + assert.lengthOf(results.passes, 1); + assert.lengthOf(results.violations, 0); + assert.lengthOf(results.incomplete, 0); + }); + + it('should fail with an invalid lang', function() { + var results = axe.runVirtualRule('html-lang-valid', { + nodeName: 'html', + attributes: { + lang: 'invalid' + } + }); + + assert.lengthOf(results.passes, 0); + assert.lengthOf(results.violations, 1); + assert.lengthOf(results.incomplete, 0); + }); + + it('should fail with an invalid xml:lang', function() { + var results = axe.runVirtualRule('html-lang-valid', { + nodeName: 'html', + attributes: { + 'xml:lang': 'invalid' + } + }); + + assert.lengthOf(results.passes, 0); + assert.lengthOf(results.violations, 1); + assert.lengthOf(results.incomplete, 0); + }); + + it('should fail with an invalid lang, and explicitly no children', function() { + var html = new axe.SerialVirtualNode({ + nodeName: 'html', + attributes: { + lang: 'invalid' + } + }); + html.children = []; + + var results = axe.runVirtualRule('html-lang-valid', html); + assert.lengthOf(results.passes, 0); + assert.lengthOf(results.violations, 1); + assert.lengthOf(results.incomplete, 0); + }); +}); diff --git a/test/rule-matches/not-html-matches.js b/test/rule-matches/not-html-matches.js deleted file mode 100644 index c850c94a9d..0000000000 --- a/test/rule-matches/not-html-matches.js +++ /dev/null @@ -1,20 +0,0 @@ -describe('not-html-matches', function() { - 'use strict'; - - var fixture = document.getElementById('fixture'); - var rule; - - beforeEach(function() { - rule = axe.utils.getRule('valid-lang'); - }); - - it('returns true when element is not the html element', function() { - var vNode = axe.setup(fixture); - assert.isTrue(rule.matches(null, vNode)); - }); - - it('returns false when element is the html element', function() { - var vNode = axe.setup(document.documentElement); - assert.isFalse(rule.matches(null, vNode)); - }); -});