From 9491e094fe82c4dba45eb253ed18bf0a0165197c Mon Sep 17 00:00:00 2001 From: Wilco Fiers Date: Fri, 10 Jan 2020 15:14:38 +0100 Subject: [PATCH] feat(svg-img-alt): rule for when svg needs a title (#1953) * feat(svg-img-alt): rule for when svg needs a title * chore: update from feedback --- doc/rule-descriptions.md | 1 + lib/checks/shared/svg-non-empty-title.js | 4 + lib/checks/shared/svg-non-empty-title.json | 11 + lib/rules/html-namespace-matches.js | 1 + lib/rules/role-img-alt.json | 3 +- lib/rules/svg-img-alt.json | 24 ++ lib/rules/svg-namespace-matches.js | 1 + test/checks/shared/svg-non-empty-title.js | 56 +++++ .../rules/role-img-alt/role-img-alt.html | 11 + .../rules/svg-img-alt/svg-img-alt.html | 236 ++++++++++++++++++ .../rules/svg-img-alt/svg-img-alt.json | 28 +++ test/rule-matches/html-namespace-matches.js | 48 ++++ test/rule-matches/svg-namespace-matches.js | 48 ++++ 13 files changed, 471 insertions(+), 1 deletion(-) create mode 100644 lib/checks/shared/svg-non-empty-title.js create mode 100644 lib/checks/shared/svg-non-empty-title.json create mode 100644 lib/rules/html-namespace-matches.js create mode 100644 lib/rules/svg-img-alt.json create mode 100644 lib/rules/svg-namespace-matches.js create mode 100644 test/checks/shared/svg-non-empty-title.js create mode 100644 test/integration/rules/svg-img-alt/svg-img-alt.html create mode 100644 test/integration/rules/svg-img-alt/svg-img-alt.json create mode 100644 test/rule-matches/html-namespace-matches.js create mode 100644 test/rule-matches/svg-namespace-matches.js diff --git a/doc/rule-descriptions.md b/doc/rule-descriptions.md index 5c534a0bbe..289dff15c5 100644 --- a/doc/rule-descriptions.md +++ b/doc/rule-descriptions.md @@ -77,6 +77,7 @@ | scrollable-region-focusable | Elements that have scrollable content should be accessible by keyboard | Moderate | wcag2a, wcag211 | true | true | false | | server-side-image-map | Ensures that server-side image maps are not used | Minor | cat.text-alternatives, wcag2a, wcag211, section508, section508.22.f | true | false | true | | skip-link | Ensure all skip links have a focusable target | Moderate | cat.keyboard, best-practice | true | true | true | +| svg-img-alt | Ensures svg elements with an img, graphics-document or graphics-symbol role have an accessible text | Serious | cat.text-alternatives, wcag2a, wcag111, section508, section508.22.a | true | true | false | | tabindex | Ensures tabindex attribute values are not greater than 0 | Serious | cat.keyboard, best-practice | true | true | false | | table-duplicate-name | Ensure that tables do not have the same summary and caption | Minor | cat.tables, best-practice | true | true | false | | table-fake-caption | Ensure that tables with a caption use the <caption> element. | Serious | cat.tables, experimental, wcag2a, wcag131, section508, section508.22.g | true | true | false | diff --git a/lib/checks/shared/svg-non-empty-title.js b/lib/checks/shared/svg-non-empty-title.js new file mode 100644 index 0000000000..3b099d686b --- /dev/null +++ b/lib/checks/shared/svg-non-empty-title.js @@ -0,0 +1,4 @@ +const titleNode = virtualNode.children.find(({ props }) => { + return props.nodeName === 'title'; +}); +return !!titleNode && titleNode.actualNode.textContent.trim() !== ''; diff --git a/lib/checks/shared/svg-non-empty-title.json b/lib/checks/shared/svg-non-empty-title.json new file mode 100644 index 0000000000..9f929992cc --- /dev/null +++ b/lib/checks/shared/svg-non-empty-title.json @@ -0,0 +1,11 @@ +{ + "id": "svg-non-empty-title", + "evaluate": "svg-non-empty-title.js", + "metadata": { + "impact": "serious", + "messages": { + "pass": "element has a child that is a title", + "fail": "element has no child that is a title" + } + } +} diff --git a/lib/rules/html-namespace-matches.js b/lib/rules/html-namespace-matches.js new file mode 100644 index 0000000000..1dafa700da --- /dev/null +++ b/lib/rules/html-namespace-matches.js @@ -0,0 +1 @@ +return node.namespaceURI === 'http://www.w3.org/1999/xhtml'; diff --git a/lib/rules/role-img-alt.json b/lib/rules/role-img-alt.json index 49ec8eaabe..53700d8532 100644 --- a/lib/rules/role-img-alt.json +++ b/lib/rules/role-img-alt.json @@ -1,6 +1,7 @@ { "id": "role-img-alt", - "selector": "[role='img']:not(svg):not(img):not(area):not(input):not(object)", + "selector": "[role='img']:not(img):not(area):not(input):not(object)", + "matches": "html-namespace-matches.js", "tags": [ "cat.text-alternatives", "wcag2a", diff --git a/lib/rules/svg-img-alt.json b/lib/rules/svg-img-alt.json new file mode 100644 index 0000000000..45d76a69b4 --- /dev/null +++ b/lib/rules/svg-img-alt.json @@ -0,0 +1,24 @@ +{ + "id": "svg-img-alt", + "selector": "[role=\"img\"], [role=\"graphics-symbol\"], svg[role=\"graphics-document\"]", + "matches": "svg-namespace-matches.js", + "tags": [ + "cat.text-alternatives", + "wcag2a", + "wcag111", + "section508", + "section508.22.a" + ], + "metadata": { + "description": "Ensures svg elements with an img, graphics-document or graphics-symbol role have an accessible text", + "help": "svg elements with an img role have an alternative text" + }, + "all": [], + "any": [ + "svg-non-empty-title", + "aria-label", + "aria-labelledby", + "non-empty-title" + ], + "none": [] +} diff --git a/lib/rules/svg-namespace-matches.js b/lib/rules/svg-namespace-matches.js new file mode 100644 index 0000000000..6be1468d52 --- /dev/null +++ b/lib/rules/svg-namespace-matches.js @@ -0,0 +1 @@ +return node.namespaceURI === 'http://www.w3.org/2000/svg'; diff --git a/test/checks/shared/svg-non-empty-title.js b/test/checks/shared/svg-non-empty-title.js new file mode 100644 index 0000000000..24a773d308 --- /dev/null +++ b/test/checks/shared/svg-non-empty-title.js @@ -0,0 +1,56 @@ +describe('svg-non-empty-title tests', function() { + 'use strict'; + + var fixture = document.getElementById('fixture'); + var checkSetup = axe.testUtils.checkSetup; + var check = checks['svg-non-empty-title']; + + afterEach(function() { + fixture.innerHTML = ''; + }); + + it('returns true if the element has a `title` child', function() { + var checkArgs = checkSetup( + 'Time II: Party' + ); + assert.isTrue(check.evaluate.apply(null, checkArgs)); + }); + + it('returns true if the `title` child has text nested in another element', function() { + var checkArgs = checkSetup( + 'Time II: Party' + ); + assert.isTrue(check.evaluate.apply(null, checkArgs)); + }); + + it('returns false if the element has no `title` child', function() { + var checkArgs = checkSetup(''); + assert.isFalse(check.evaluate.apply(null, checkArgs)); + }); + + it('returns false if the `title` child is empty', function() { + var checkArgs = checkSetup(''); + assert.isFalse(check.evaluate.apply(null, checkArgs)); + }); + + it('returns false if the `title` is a grandchild', function() { + var checkArgs = checkSetup( + 'Time II: Party' + ); + assert.isFalse(check.evaluate.apply(null, checkArgs)); + }); + + it('returns false if the `title` child has only whitespace', function() { + var checkArgs = checkSetup( + ' \t\r\n ' + ); + assert.isFalse(check.evaluate.apply(null, checkArgs)); + }); + + it('returns false if there are multiple titles, and the first is empty', function() { + var checkArgs = checkSetup( + 'Time II: Party' + ); + assert.isFalse(check.evaluate.apply(null, checkArgs)); + }); +}); diff --git a/test/integration/rules/role-img-alt/role-img-alt.html b/test/integration/rules/role-img-alt/role-img-alt.html index 8edbb4801b..e430fdbde6 100644 --- a/test/integration/rules/role-img-alt/role-img-alt.html +++ b/test/integration/rules/role-img-alt/role-img-alt.html @@ -11,3 +11,14 @@ + + + I am a circle + + diff --git a/test/integration/rules/svg-img-alt/svg-img-alt.html b/test/integration/rules/svg-img-alt/svg-img-alt.html new file mode 100644 index 0000000000..9826bb9070 --- /dev/null +++ b/test/integration/rules/svg-img-alt/svg-img-alt.html @@ -0,0 +1,236 @@ +

Passed

+ + + I am a circle + + + + + + + + + + + +I am a circle + + + + + + I am a circle + + + + + + + + + + + + + + + + + + I am a circle + + + + + + I am a circle + + + +

Failed

+ + + + + + + + + + + + + + + + + + + + I am a circle + + + + + + I am a circle + + + + + + + I am a circle + + + + + I am a circle + + + + + + + + +

Inapplicable

+ + + + + + + + + + + + diff --git a/test/integration/rules/svg-img-alt/svg-img-alt.json b/test/integration/rules/svg-img-alt/svg-img-alt.json new file mode 100644 index 0000000000..d21c9a647a --- /dev/null +++ b/test/integration/rules/svg-img-alt/svg-img-alt.json @@ -0,0 +1,28 @@ +{ + "description": "svg-img-alt tests", + "rule": "svg-img-alt", + "violations": [ + ["#violation1"], + ["#violation2"], + ["#violation3"], + ["#violation4"], + ["#violation5"], + ["#violation6"], + ["#violation7"], + ["#violation8"], + ["#violation9"], + ["#violation10"] + ], + "passes": [ + ["#pass1"], + ["#pass2"], + ["#pass3"], + ["#pass4"], + ["#pass5"], + ["#pass6"], + ["#pass7"], + ["#pass8"], + ["#pass9"], + ["#pass10"] + ] +} diff --git a/test/rule-matches/html-namespace-matches.js b/test/rule-matches/html-namespace-matches.js new file mode 100644 index 0000000000..5da2a7750f --- /dev/null +++ b/test/rule-matches/html-namespace-matches.js @@ -0,0 +1,48 @@ +describe('html-namespace-matches', function() { + 'use strict'; + var rule; + var fixture; + var axeFixtureSetup; + + beforeEach(function() { + fixture = document.getElementById('fixture'); + axeFixtureSetup = axe.testUtils.fixtureSetup; + rule = axe._audit.rules.find(function(rule) { + return rule.id === 'role-img-alt'; + }); + }); + + afterEach(function() { + fixture.innerHTML = ''; + }); + + it('returns true when passed an HTML element', function() { + axeFixtureSetup('

Hello world

'); + var node = fixture.querySelector('h1'); + var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node); + assert.isTrue(rule.matches(node, virtualNode)); + }); + + it('returns true when passed a custom HTML element', function() { + axeFixtureSetup('Hello world'); + var node = fixture.querySelector('xx-heading'); + var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node); + assert.isTrue(rule.matches(node, virtualNode)); + }); + + it('returns false when passed an SVG element', function() { + axeFixtureSetup('Pretty picture'); + var node = fixture.querySelector('svg'); + var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node); + assert.isFalse(rule.matches(node, virtualNode)); + }); + + it('returns false when passed an SVG circle element', function() { + axeFixtureSetup( + 'Pretty picture' + ); + var node = fixture.querySelector('circle'); + var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node); + assert.isFalse(rule.matches(node, virtualNode)); + }); +}); diff --git a/test/rule-matches/svg-namespace-matches.js b/test/rule-matches/svg-namespace-matches.js new file mode 100644 index 0000000000..2d463b3af8 --- /dev/null +++ b/test/rule-matches/svg-namespace-matches.js @@ -0,0 +1,48 @@ +describe('svg-namespace-matches', function() { + 'use strict'; + var rule; + var fixture; + var axeFixtureSetup; + + beforeEach(function() { + fixture = document.getElementById('fixture'); + axeFixtureSetup = axe.testUtils.fixtureSetup; + rule = axe._audit.rules.find(function(rule) { + return rule.id === 'svg-img-alt'; + }); + }); + + afterEach(function() { + fixture.innerHTML = ''; + }); + + it('returns true when passed an SVG element', function() { + axeFixtureSetup('Pretty picture'); + var node = fixture.querySelector('svg'); + var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node); + assert.isTrue(rule.matches(node, virtualNode)); + }); + + it('returns true when passed an SVG circle element', function() { + axeFixtureSetup( + 'Pretty picture' + ); + var node = fixture.querySelector('circle'); + var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node); + assert.isTrue(rule.matches(node, virtualNode)); + }); + + it('returns false when passed an HTML element', function() { + axeFixtureSetup('

Hello world

'); + var node = fixture.querySelector('h1'); + var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node); + assert.isFalse(rule.matches(node, virtualNode)); + }); + + it('returns false when passed a custom HTML element', function() { + axeFixtureSetup('Hello world'); + var node = fixture.querySelector('xx-heading'); + var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node); + assert.isFalse(rule.matches(node, virtualNode)); + }); +});