diff --git a/axe.d.ts b/axe.d.ts index 675d9bd595..1887c03b69 100644 --- a/axe.d.ts +++ b/axe.d.ts @@ -50,8 +50,8 @@ declare namespace axe { type CrossFrameSelector = CrossTreeSelector[]; type ContextObject = { - include?: BaseSelector | Array; - exclude?: BaseSelector | Array; + include?: Node | BaseSelector | Array; + exclude?: Node | BaseSelector | Array; }; type RunCallback = (error: Error, results: AxeResults) => void; diff --git a/doc/API.md b/doc/API.md index 22dbd2ae95..a6dc0dd2fc 100644 --- a/doc/API.md +++ b/doc/API.md @@ -327,7 +327,7 @@ By default, `axe.run` will test the entire document. The context object is an op The include exclude object is a JSON object with two attributes: include and exclude. Either include or exclude is required. If only `exclude` is specified; include will default to the entire `document`. - A node, or -- An array of arrays of [CSS selectors](./developer-guide.md#supported-css-selectors) +- An array of Nodes or an array of arrays of [CSS selectors](./developer-guide.md#supported-css-selectors) - If the nested array contains a single string, that string is the CSS selector - If the nested array contains multiple strings - The last string is the final CSS selector diff --git a/lib/checks/aria/aria-allowed-attr-evaluate.js b/lib/checks/aria/aria-allowed-attr-evaluate.js index 9d309437bf..c72f7bb70a 100644 --- a/lib/checks/aria/aria-allowed-attr-evaluate.js +++ b/lib/checks/aria/aria-allowed-attr-evaluate.js @@ -1,5 +1,6 @@ -import { uniqueArray, closest } from '../../core/utils'; +import { uniqueArray, closest, isHtmlElement } from '../../core/utils'; import { getRole, allowedAttr, validateAttr } from '../../commons/aria'; +import { isFocusable } from '../../commons/dom'; import cache from '../../core/base/cache'; /** @@ -69,7 +70,7 @@ function ariaAllowedAttrEvaluate(node, options, virtualNode) { ariaAttr.forEach(attr => { preChecks[attr] = validateRowAttrs; }); - if (role && allowed) { + if (allowed) { for (let i = 0; i < attrs.length; i++) { const attrName = attrs[i]; if (validateAttr(attrName) && preChecks[attrName]?.()) { @@ -82,6 +83,11 @@ function ariaAllowedAttrEvaluate(node, options, virtualNode) { if (invalid.length) { this.data(invalid); + + if (!isHtmlElement(virtualNode) && !role && !isFocusable(virtualNode)) { + return undefined; + } + return false; } diff --git a/lib/checks/aria/aria-allowed-attr.json b/lib/checks/aria/aria-allowed-attr.json index 4079b3ebcb..cf39faf10f 100644 --- a/lib/checks/aria/aria-allowed-attr.json +++ b/lib/checks/aria/aria-allowed-attr.json @@ -16,7 +16,8 @@ "fail": { "singular": "ARIA attribute is not allowed: ${data.values}", "plural": "ARIA attributes are not allowed: ${data.values}" - } + }, + "incomplete": "Check that there is no problem if the ARIA attribute is ignored on this element: ${data.values}" } } } diff --git a/lib/checks/label/label-content-name-mismatch-evaluate.js b/lib/checks/label/label-content-name-mismatch-evaluate.js index ba25035c9c..5e77ce0867 100644 --- a/lib/checks/label/label-content-name-mismatch-evaluate.js +++ b/lib/checks/label/label-content-name-mismatch-evaluate.js @@ -1,8 +1,7 @@ import { accessibleText, isHumanInterpretable, - visibleTextNodes, - isIconLigature, + subtreeText, sanitize, removeUnicode } from '../../commons/text'; @@ -46,15 +45,14 @@ function labelContentNameMismatchEvaluate(node, options, virtualNode) { return undefined; } - const textVNodes = visibleTextNodes(virtualNode); - const nonLigatureText = textVNodes - .filter( - textVNode => - !isIconLigature(textVNode, pixelThreshold, occuranceThreshold) - ) - .map(textVNode => textVNode.actualNode.nodeValue) - .join(''); - const visibleText = sanitize(nonLigatureText).toLowerCase(); + const visibleText = sanitize( + subtreeText(virtualNode, { + subtreeDescendant: true, + ignoreIconLigature: true, + pixelThreshold, + occuranceThreshold + }) + ).toLowerCase(); if (!visibleText) { return true; } diff --git a/lib/commons/text/accessible-text-virtual.js b/lib/commons/text/accessible-text-virtual.js index c0407f6113..09a00e4d77 100644 --- a/lib/commons/text/accessible-text-virtual.js +++ b/lib/commons/text/accessible-text-virtual.js @@ -6,6 +6,7 @@ import subtreeText from './subtree-text'; import titleText from './title-text'; import sanitize from './sanitize'; import isVisible from '../dom/is-visible'; +import isIconLigature from '../text/is-icon-ligature'; /** * Finds virtual node and calls accessibleTextVirtual() @@ -26,6 +27,11 @@ function accessibleTextVirtual(virtualNode, context = {}) { return ''; } + // Ignore ligature icons + if (shouldIgnoreIconLigature(virtualNode, context)) { + return ''; + } + const computationSteps = [ arialabelledbyText, // Step 2B.1 arialabelText, // Step 2C @@ -91,6 +97,21 @@ function shouldIgnoreHidden({ actualNode }, context) { return !isVisible(actualNode, true); } +/** + * Check if a ligature icon should be ignored + * @param {VirtualNode} element + * @param {VirtualNode} element + * @param {Object} context + * @return {Boolean} + */ +function shouldIgnoreIconLigature(virtualNode, context) { + const { ignoreIconLigature, pixelThreshold, occuranceThreshold } = context; + if (virtualNode.props.nodeType !== 3 || !ignoreIconLigature) { + return false; + } + return isIconLigature(virtualNode, pixelThreshold, occuranceThreshold); +} + /** * Apply defaults to the context * @param {VirtualNode} element diff --git a/lib/commons/text/visible-text-nodes.js b/lib/commons/text/visible-text-nodes.js index 5b3fb60bc2..b82b2b0d3e 100644 --- a/lib/commons/text/visible-text-nodes.js +++ b/lib/commons/text/visible-text-nodes.js @@ -8,6 +8,7 @@ import isVisible from '../dom/is-visible'; * @instance * @param {VirtualNode} vNode * @return {VitrualNode[]} + * @deprecated */ function visibleTextNodes(vNode) { const parentVisible = isVisible(vNode.actualNode); diff --git a/test/aria-practices/apg.spec.js b/test/aria-practices/apg.spec.js index bc50ba64b1..524568d9d2 100644 --- a/test/aria-practices/apg.spec.js +++ b/test/aria-practices/apg.spec.js @@ -6,11 +6,13 @@ const { getWebdriver, connectToChromeDriver } = require('./run-server'); const { assert } = require('chai'); const globby = require('globby'); -describe('aria-practices', function () { +describe('aria-practices', function() { // Use path.resolve rather than require.resolve because APG has no package.json const apgPath = path.resolve(__dirname, '../../node_modules/aria-practices/'); - const filePaths = globby.sync(`${apgPath}/examples/**/*.html`) - const testFiles = filePaths.map(fileName => fileName.split('/aria-practices/examples/')[1]) + const filePaths = globby.sync(`${apgPath}/examples/**/*.html`); + const testFiles = filePaths.map( + fileName => fileName.split('/aria-practices/examples/')[1] + ); const port = 9515; const addr = `http://localhost:9876/node_modules/aria-practices/`; let driver, axeSource; @@ -36,22 +38,24 @@ describe('aria-practices', function () { 'color-contrast', 'heading-order', // w3c/aria-practices#2119 'list', // w3c/aria-practices#2118 - 'scrollable-region-focusable', // w3c/aria-practices#2114 + 'scrollable-region-focusable' // w3c/aria-practices#2114 ], 'feed/feedDisplay.html': ['page-has-heading-one'], // w3c/aria-practices#2120 // "page within a page" type thing going on 'menubar/menubar-navigation.html': [ 'aria-allowed-role', 'landmark-banner-is-top-level', - 'landmark-contentinfo-is-top-level', + 'landmark-contentinfo-is-top-level' ], // "page within a page" type thing going on 'treeview/treeview-navigation.html': [ 'aria-allowed-role', 'landmark-banner-is-top-level', 'landmark-contentinfo-is-top-level' - ] - } + ], + //https://github.com/w3c/aria-practices/issues/2199 + 'button/button_idl.html': ['aria-allowed-attr'] + }; // Not an actual content file const skippedPages = [ @@ -60,19 +64,24 @@ describe('aria-practices', function () { 'toolbar/help.html' // Embedded into another page ]; - testFiles.filter(filePath => !skippedPages.includes(filePath)).forEach(filePath => { - it(`finds no issue in "${filePath}"`, async () => { - await driver.get(`${addr}/examples/${filePath}`); - - const builder = new AxeBuilder(driver, axeSource); - builder.disableRules([ - ...disabledRules['*'], - ...(disabledRules[filePath] || []), - ]); - - const { violations } = await builder.analyze(); - const issues = violations.map(({ id, nodes }) => ({ id, issues: nodes.length })) - assert.lengthOf(issues, 0); + testFiles + .filter(filePath => !skippedPages.includes(filePath)) + .forEach(filePath => { + it(`finds no issue in "${filePath}"`, async () => { + await driver.get(`${addr}/examples/${filePath}`); + + const builder = new AxeBuilder(driver, axeSource); + builder.disableRules([ + ...disabledRules['*'], + ...(disabledRules[filePath] || []) + ]); + + const { violations } = await builder.analyze(); + const issues = violations.map(({ id, nodes }) => ({ + id, + issues: nodes.length + })); + assert.lengthOf(issues, 0); + }); }); - }); }); diff --git a/test/checks/aria/allowed-attr.js b/test/checks/aria/allowed-attr.js index dc36f5fde9..177e7bda8c 100644 --- a/test/checks/aria/allowed-attr.js +++ b/test/checks/aria/allowed-attr.js @@ -59,7 +59,7 @@ describe('aria-allowed-attr', function() { assert.isNull(checkContext._data); }); - it.skip('should return false for non-global attributes if there is no role', function() { + it('should return false for non-global attributes if there is no role', function() { var vNode = queryFixture( '
' ); @@ -72,9 +72,9 @@ describe('aria-allowed-attr', function() { assert.deepEqual(checkContext._data, ['aria-selected="true"']); }); - it('should return true for non-global attributes if there is no role', function() { + it('should not report on invalid attributes', function() { var vNode = queryFixture( - '
' + '' ); assert.isTrue( @@ -82,11 +82,12 @@ describe('aria-allowed-attr', function() { .getCheckEvaluate('aria-allowed-attr') .call(checkContext, null, null, vNode) ); + assert.isNull(checkContext._data); }); - it('should not report on invalid attributes', function() { + it('should not report on allowed attributes', function() { var vNode = queryFixture( - '' + '' ); assert.isTrue( @@ -97,18 +98,45 @@ describe('aria-allowed-attr', function() { assert.isNull(checkContext._data); }); - it('should not report on allowed attributes', function() { + it('should return undefined for custom element that has no role and is not focusable', function() { var vNode = queryFixture( - '' + '' ); - assert.isTrue( + assert.isUndefined( axe.testUtils .getCheckEvaluate('aria-allowed-attr') .call(checkContext, null, null, vNode) ); - assert.isNull(checkContext._data); + assert.isNotNull(checkContext._data); }); + + it("should return false for custom element that has a role which doesn't allow the attribute", function() { + var vNode = queryFixture( + '' + ); + + assert.isFalse( + axe.testUtils + .getCheckEvaluate('aria-allowed-attr') + .call(checkContext, null, null, vNode) + ); + assert.isNotNull(checkContext._data); + }); + + it('should return false for custom element that is focusable', function() { + var vNode = queryFixture( + '' + ); + + assert.isFalse( + axe.testUtils + .getCheckEvaluate('aria-allowed-attr') + .call(checkContext, null, null, vNode) + ); + assert.isNotNull(checkContext._data); + }); + describe('invalid aria-attributes when used on role=row as a descendant of a table or a grid', function() { [ 'aria-posinset="1"', diff --git a/test/checks/label/label-content-name-mismatch.js b/test/checks/label/label-content-name-mismatch.js index ae20e8284f..7e3b235173 100644 --- a/test/checks/label/label-content-name-mismatch.js +++ b/test/checks/label/label-content-name-mismatch.js @@ -178,4 +178,12 @@ describe('label-content-name-mismatch tests', function() { var actual = check.evaluate(vNode.actualNode, options, vNode); assert.isFalse(actual); }); + + it('returns true when text contains
', function() { + var vNode = queryFixture( + '' + ); + var actual = check.evaluate(vNode.actualNode, options, vNode); + assert.isTrue(actual); + }); }); diff --git a/test/commons/text/accessible-text.js b/test/commons/text/accessible-text.js index 30553b8742..8ba085396a 100644 --- a/test/commons/text/accessible-text.js +++ b/test/commons/text/accessible-text.js @@ -588,6 +588,36 @@ describe('text.accessibleTextVirtual', function() { assert.equal(axe.commons.text.accessibleTextVirtual(target), 'Hello World'); }); + (!!document.fonts ? it : xit)( + 'should allow ignoring icon ligatures', + function(done) { + var materialFont = new FontFace( + 'Material Icons', + 'url(https://fonts.gstatic.com/s/materialicons/v48/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2)' + ); + materialFont.load().then(function() { + document.fonts.add(materialFont); + + fixture.innerHTML = + ''; + axe.testUtils.flatTreeSetup(fixture); + + var target = axe.utils.querySelectorAll(axe._tree, 'button')[0]; + try { + assert.equal( + axe.commons.text.accessibleTextVirtual(target, { + ignoreIconLigature: true + }), + 'Hello World' + ); + done(); + } catch (err) { + done(err); + } + }); + } + ); + (shadowSupport.v1 ? it : xit)( 'should only find aria-labelledby element in the same context ', function() { diff --git a/test/integration/rules/aria-allowed-attr/failures.html b/test/integration/rules/aria-allowed-attr/failures.html index ff18ac4af8..c12fd3c7e0 100644 --- a/test/integration/rules/aria-allowed-attr/failures.html +++ b/test/integration/rules/aria-allowed-attr/failures.html @@ -28,12 +28,12 @@ - +>
diff --git a/test/integration/rules/aria-allowed-attr/failures.json b/test/integration/rules/aria-allowed-attr/failures.json index da4956aa20..ccedf0b10f 100644 --- a/test/integration/rules/aria-allowed-attr/failures.json +++ b/test/integration/rules/aria-allowed-attr/failures.json @@ -31,6 +31,7 @@ ["#fail27"], ["#fail28"], ["#fail29"], + ["#fail30"], ["#fail31"], ["#fail32"], ["#fail33"], diff --git a/test/integration/rules/aria-allowed-attr/incomplete.html b/test/integration/rules/aria-allowed-attr/incomplete.html index 20c2fedb6c..e187dba1c6 100644 --- a/test/integration/rules/aria-allowed-attr/incomplete.html +++ b/test/integration/rules/aria-allowed-attr/incomplete.html @@ -1,3 +1,4 @@
Foo
Foo
-
Foo
+Foo +
Foo
diff --git a/test/integration/rules/aria-allowed-attr/incomplete.json b/test/integration/rules/aria-allowed-attr/incomplete.json index 1d8c7e3fd7..fa37187123 100644 --- a/test/integration/rules/aria-allowed-attr/incomplete.json +++ b/test/integration/rules/aria-allowed-attr/incomplete.json @@ -1,5 +1,10 @@ { "description": "aria-allowed-attr incomplete tests", "rule": "aria-allowed-attr", - "incomplete": [["#incomplete0"], ["#incomplete1"], ["#incomplete2"]] + "incomplete": [ + ["#incomplete0"], + ["#incomplete1"], + ["#incomplete2"], + ["#incomplete3"] + ] } diff --git a/test/integration/virtual-rules/aria-allowed-attr.js b/test/integration/virtual-rules/aria-allowed-attr.js index ee42253088..b53d188397 100644 --- a/test/integration/virtual-rules/aria-allowed-attr.js +++ b/test/integration/virtual-rules/aria-allowed-attr.js @@ -55,7 +55,7 @@ describe('aria-allowed-attr virtual-rule', function() { assert.lengthOf(results.incomplete, 0); }); - it.skip('should fail for non-global attributes and element with no role', function() { + it('should fail for non-global attributes and element with no role', function() { var results = axe.runVirtualRule('aria-allowed-attr', { nodeName: 'div', attributes: { @@ -119,4 +119,17 @@ describe('aria-allowed-attr virtual-rule', function() { assert.lengthOf(results.violations, 1); assert.lengthOf(results.incomplete, 0); }); + + it('should incomplete for non-global attributes and custom element', function() { + var results = axe.runVirtualRule('aria-allowed-attr', { + nodeName: 'custom-elm1', + attributes: { + 'aria-checked': true + } + }); + + assert.lengthOf(results.passes, 0); + assert.lengthOf(results.violations, 0); + assert.lengthOf(results.incomplete, 1); + }); }); diff --git a/typings/axe-core/axe-core-tests.ts b/typings/axe-core/axe-core-tests.ts index a572c6c095..32f3e67a72 100644 --- a/typings/axe-core/axe-core-tests.ts +++ b/typings/axe-core/axe-core-tests.ts @@ -1,7 +1,7 @@ import * as axe from '../../axe'; var context: any = document; -var $fixture: any = {}; +var $fixture = [document]; var options = { iframes: false, selectors: false, elementRef: false }; axe.run(context, {}, (error: Error, results: axe.AxeResults) => {