diff --git a/doc/rule-descriptions.md b/doc/rule-descriptions.md index 9525e12313..c92d5e08c3 100644 --- a/doc/rule-descriptions.md +++ b/doc/rule-descriptions.md @@ -33,6 +33,7 @@ | input-image-alt | Ensures <input type="image"> elements have alternate text | cat.text-alternatives, wcag2a, wcag111, section508, section508.22.a | true | | label-title-only | Ensures that every form element is not solely labeled using the title or aria-describedby attributes | cat.forms, best-practice | false | | label | Ensures every form element has a label | cat.forms, wcag2a, wcag332, wcag131, section508, section508.22.n | true | +| landmark-one-main | Ensures a navigation point to the primary content of the page. If the page contains iframes, each iframe should contain either no main landmarks or just one. | best-practice | true | | landmark-main-is-top-level | The main landmark should not be contained in another landmark | best-practice | true | | layout-table | Ensures presentational <table> elements do not use <th>, <caption> elements or the summary attribute | cat.semantics, wcag2a, wcag131 | true | | link-in-text-block | Links can be distinguished without relying on color | cat.color, experimental, wcag2a, wcag141 | true | diff --git a/lib/checks/keyboard/has-at-least-one-main-after.js b/lib/checks/keyboard/has-at-least-one-main-after.js new file mode 100644 index 0000000000..32027fe1ad --- /dev/null +++ b/lib/checks/keyboard/has-at-least-one-main-after.js @@ -0,0 +1,16 @@ +var hasMain = false; + +//iterate through results from each document +//stops if any document contains a main landmark +for (var i = 0; i < results.length && !hasMain; i++) { + hasMain = results[i].data; +} + +//if any document contains a main landmark, set all documents to pass the check +//otherwise, fail all documents +//since this is a page level rule, all documents either pass or fail the requirement +for (var i = 0; i < results.length; i++) { + results[i].result = hasMain; +} + +return results; diff --git a/lib/checks/keyboard/has-at-least-one-main.js b/lib/checks/keyboard/has-at-least-one-main.js new file mode 100644 index 0000000000..52542995a0 --- /dev/null +++ b/lib/checks/keyboard/has-at-least-one-main.js @@ -0,0 +1,3 @@ +const mains = axe.utils.querySelectorAll(virtualNode, 'main,[role=main]'); +this.data(!!mains[0]); +return !!mains[0]; \ No newline at end of file diff --git a/lib/checks/keyboard/has-at-least-one-main.json b/lib/checks/keyboard/has-at-least-one-main.json new file mode 100644 index 0000000000..6cd6a4cd62 --- /dev/null +++ b/lib/checks/keyboard/has-at-least-one-main.json @@ -0,0 +1,12 @@ +{ + "id": "has-at-least-one-main", + "evaluate": "has-at-least-one-main.js", + "after": "has-at-least-one-main-after.js", + "metadata": { + "impact": "moderate", + "messages": { + "pass": "Document has at least one main landmark", + "fail": "Document has no main landmarks" + } + } +} \ No newline at end of file diff --git a/lib/checks/keyboard/has-no-more-than-one-main.js b/lib/checks/keyboard/has-no-more-than-one-main.js new file mode 100644 index 0000000000..abd418d9e4 --- /dev/null +++ b/lib/checks/keyboard/has-no-more-than-one-main.js @@ -0,0 +1,2 @@ +const mains = axe.utils.querySelectorAll(virtualNode, 'main,[role=main]'); +return mains.length<=1; \ No newline at end of file diff --git a/lib/checks/keyboard/has-no-more-than-one-main.json b/lib/checks/keyboard/has-no-more-than-one-main.json new file mode 100644 index 0000000000..c4d9fb3707 --- /dev/null +++ b/lib/checks/keyboard/has-no-more-than-one-main.json @@ -0,0 +1,11 @@ +{ + "id": "has-no-more-than-one-main", + "evaluate": "has-no-more-than-one-main.js", + "metadata": { + "impact": "moderate", + "messages": { + "pass": "Document has no more than one main landmark", + "fail": "Document has more than one main landmark" + } + } +} \ No newline at end of file diff --git a/lib/rules/landmark-one-main.json b/lib/rules/landmark-one-main.json new file mode 100644 index 0000000000..7cb68a8117 --- /dev/null +++ b/lib/rules/landmark-one-main.json @@ -0,0 +1,17 @@ +{ + "id": "landmark-one-main", + "selector": "html", + "tags": [ + "best-practice" + ], + "metadata": { + "description": "Ensures a navigation point to the primary content of the page. If the page contains iframes, each iframe should contain either no main landmarks or just one.", + "help": "Page must contain one main landmark." + }, + "all": [ + "has-at-least-one-main", + "has-no-more-than-one-main" + ], + "any": [], + "none": [] +} \ No newline at end of file diff --git a/test/checks/keyboard/has-at-least-one-main.js b/test/checks/keyboard/has-at-least-one-main.js new file mode 100644 index 0000000000..6df5b93111 --- /dev/null +++ b/test/checks/keyboard/has-at-least-one-main.js @@ -0,0 +1,58 @@ +describe('has-at-least-one-main', function () { + 'use strict'; + + var fixture = document.getElementById('fixture'); + var checkContext = new axe.testUtils.MockCheckContext(); + var checkSetup = axe.testUtils.checkSetup; + + afterEach(function () { + fixture.innerHTML = ''; + checkContext.reset(); + }); + + it('should return false if no div has role property', function() { + var params = checkSetup('
No role
'); + var mainIsFound = checks['has-at-least-one-main'].evaluate.apply(checkContext, params); + assert.isFalse(mainIsFound); + assert.deepEqual(checkContext._data, mainIsFound); + }); + + it('should return false if div has empty role', function() { + var params = checkSetup('
Empty role
'); + var mainIsFound = checks['has-at-least-one-main'].evaluate.apply(checkContext, params); + assert.isFalse(mainIsFound); + assert.equal(checkContext._data, mainIsFound); + }); + + it('should return false if div has role not equal to main', function() { + var params = checkSetup('
Wrong role
'); + var mainIsFound = checks['has-at-least-one-main'].evaluate.apply(checkContext, params); + assert.isFalse(mainIsFound); + assert.equal(checkContext._data, mainIsFound); + }); + + it('should return true if main landmark exists', function(){ + var params = checkSetup('
main landmark
'); + var mainIsFound = checks['has-at-least-one-main'].evaluate.apply(checkContext, params); + assert.isTrue(mainIsFound); + assert.equal(checkContext._data, mainIsFound); + }); + + it('should return true if one div has role equal to main', function() { + var params = checkSetup('
Div with role main
'); + var mainIsFound = checks['has-at-least-one-main'].evaluate.apply(checkContext, params); + assert.isTrue(mainIsFound); + assert.equal(checkContext._data, mainIsFound); + }); + + it('should return true if any document has a main landmark', function() { + var results = [{data: false, result: false}, {data: true, result: true}]; + assert.isTrue(checks['has-at-least-one-main'].after(results)[0].result && checks['has-at-least-one-main'].after(results)[1].result); + }); + + it('should return false if no document has a main landmark', function() { + var results = [{data: false, result: false}, {data: false, result: false}]; + assert.isFalse(checks['has-at-least-one-main'].after(results)[0].result && checks['has-at-least-one-main'].after(results)[1].result); + }); + +}); \ No newline at end of file diff --git a/test/checks/keyboard/has-no-more-than-one-main.js b/test/checks/keyboard/has-no-more-than-one-main.js new file mode 100644 index 0000000000..d3618d4b9e --- /dev/null +++ b/test/checks/keyboard/has-no-more-than-one-main.js @@ -0,0 +1,45 @@ +describe('has-no-more-than-one-main', function () { + 'use strict'; + + var fixture = document.getElementById('fixture'); + var checkContext = new axe.testUtils.MockCheckContext(); + var checkSetup = axe.testUtils.checkSetup; + var shadowCheckSetup = axe.testUtils.shadowCheckSetup; + var shadowSupported = axe.testUtils.shadowSupport.v1; + + afterEach(function () { + fixture.innerHTML = ''; + checkContext.reset(); + }); + + it('should return false if there is more than one element with role main', function () { + var params = checkSetup('
'); + assert.isFalse(checks['has-no-more-than-one-main'].evaluate.apply(checkContext, params)); + + }); + + it('should return false if there is more than one main element', function () { + var params = checkSetup('
'); + assert.isFalse(checks['has-no-more-than-one-main'].evaluate.apply(checkContext, params)); + }); + + it('should return true if there is only one element with role main', function(){ + var params = checkSetup('
'); + assert.isTrue(checks['has-no-more-than-one-main'].evaluate.apply(checkContext, params)); + }); + + it('should return true if there is only one main element', function(){ + var params = checkSetup('
'); + assert.isTrue(checks['has-no-more-than-one-main'].evaluate.apply(checkContext, params)); + }); + + (shadowSupported ? it : xit) + ('should return false if there is a second main element inside the shadow dom', function () { + var params = shadowCheckSetup( + '
', + '
' + ); + assert.isFalse(checks['has-no-more-than-one-main'].evaluate.apply(checkContext, params)); + }); + +}); \ No newline at end of file diff --git a/test/integration/full/landmark-one-main/frames/level1-fail.html b/test/integration/full/landmark-one-main/frames/level1-fail.html new file mode 100644 index 0000000000..e398796507 --- /dev/null +++ b/test/integration/full/landmark-one-main/frames/level1-fail.html @@ -0,0 +1,10 @@ + + + + + + + +

No main content here either

+ + diff --git a/test/integration/full/landmark-one-main/frames/level1.html b/test/integration/full/landmark-one-main/frames/level1.html new file mode 100644 index 0000000000..3856ff3b03 --- /dev/null +++ b/test/integration/full/landmark-one-main/frames/level1.html @@ -0,0 +1,12 @@ + + + + + + + +

No main content here either

+ + + + diff --git a/test/integration/full/landmark-one-main/frames/level2-a.html b/test/integration/full/landmark-one-main/frames/level2-a.html new file mode 100644 index 0000000000..7c3745a65a --- /dev/null +++ b/test/integration/full/landmark-one-main/frames/level2-a.html @@ -0,0 +1,12 @@ + + + + + + + +
+

Main landmark created with main tag

+
+ + diff --git a/test/integration/full/landmark-one-main/frames/level2.html b/test/integration/full/landmark-one-main/frames/level2.html new file mode 100644 index 0000000000..64d8a0c398 --- /dev/null +++ b/test/integration/full/landmark-one-main/frames/level2.html @@ -0,0 +1,10 @@ + + + + + + + +

No main content in this iframe

+ + diff --git a/test/integration/full/landmark-one-main/landmark-one-main-fail.html b/test/integration/full/landmark-one-main/landmark-one-main-fail.html new file mode 100644 index 0000000000..3474eff7b6 --- /dev/null +++ b/test/integration/full/landmark-one-main/landmark-one-main-fail.html @@ -0,0 +1,24 @@ + + + + + + + + + + + +

No main content here

+ +
+ + + + diff --git a/test/integration/full/landmark-one-main/landmark-one-main-fail.js b/test/integration/full/landmark-one-main/landmark-one-main-fail.js new file mode 100644 index 0000000000..b44a999ddf --- /dev/null +++ b/test/integration/full/landmark-one-main/landmark-one-main-fail.js @@ -0,0 +1,46 @@ +describe('landmark-one-main test failure', function () { + 'use strict'; + var results; + before(function (done) { + function start() { + axe.run({ runOnly: { type: 'rule', values: ['landmark-one-main'] } }, function (err, r) { + assert.isNull(err); + results = r; + done(); + }); + } + if (document.readyState !== 'complete') { + window.addEventListener('load', start); + } else { + start(); + } + }); + + describe('violations', function () { + it('should find 1', function () { + assert.lengthOf(results.violations[0].nodes, 2); + }); + + it('should find #frame1', function () { + assert.deepEqual(results.violations[0].nodes[0].target, ['#fail1']); + }); + + it('should find #frame1, #violation2', function () { + assert.deepEqual(results.violations[0].nodes[1].target, ['#frame1', '#violation2']); + }); + }); + + describe('passes', function () { + it('should find 0', function () { + assert.lengthOf(results.passes, 0); + }); + }); + + it('should find 0 inapplicable', function () { + assert.lengthOf(results.inapplicable, 0); + }); + + it('should find 0 incomplete', function () { + assert.lengthOf(results.incomplete, 0); + }); +}); diff --git a/test/integration/full/landmark-one-main/landmark-one-main-pass.html b/test/integration/full/landmark-one-main/landmark-one-main-pass.html new file mode 100644 index 0000000000..f21d44f322 --- /dev/null +++ b/test/integration/full/landmark-one-main/landmark-one-main-pass.html @@ -0,0 +1,24 @@ + + + + + + + + + + + +

No main content

+ +
+ + + + diff --git a/test/integration/full/landmark-one-main/landmark-one-main-pass.js b/test/integration/full/landmark-one-main/landmark-one-main-pass.js new file mode 100644 index 0000000000..a038bf2105 --- /dev/null +++ b/test/integration/full/landmark-one-main/landmark-one-main-pass.js @@ -0,0 +1,55 @@ +describe('landmark-one-main test pass', function () { + 'use strict'; + var results; + before(function (done) { + function start() { + axe.run({ runOnly: { type: 'rule', values: ['landmark-one-main'] } }, function (err, r) { + assert.isNull(err); + results = r; + done(); + }); + } + if (document.readyState !== 'complete') { + window.addEventListener('load', start); + } else { + start(); + } + }); + + describe('violations', function () { + it('should find 0', function () { + assert.lengthOf(results.violations, 0); + }); + }); + + describe('passes', function () { + it('should find 4', function () { + assert.lengthOf(results.passes[0].nodes, 4); + }); + + it('should find #pass1', function () { + assert.deepEqual(results.passes[0].nodes[0].target, ['#pass1']); + }); + + it('should find #frame1, #pass2', function () { + assert.deepEqual(results.passes[0].nodes[1].target, ['#frame1', '#pass2']); + }); + + it('should find #frame1, #frame2, #pass3', function () { + assert.deepEqual(results.passes[0].nodes[2].target, ['#frame1', '#frame2', '#pass3']); + }); + + it('should find #frame1, #frame3, #pass4', function () { + assert.deepEqual(results.passes[0].nodes[3].target, ['#frame1', '#frame3', '#pass4']); + }); + }); + + it('should find 0 inapplicable', function () { + assert.lengthOf(results.inapplicable, 0); + }); + + it('should find 0 incomplete', function () { + assert.lengthOf(results.incomplete, 0); + }); + +}); diff --git a/test/testutils.js b/test/testutils.js index c686de5049..119a72fbf7 100644 --- a/test/testutils.js +++ b/test/testutils.js @@ -40,4 +40,54 @@ testUtils.fixtureSetup = function (content) { return fixture; }; + +/** + * Create check arguments with Shadow DOM + * + * @param Node|String Stuff to go into the fixture (html or node) + * @param Node|String Stuff to go into the shadow boundary (html or node) + * @param Object Options argument for the check (optional, default: {}) + * @param String Target for the check, CSS selector (default: '#target') + * @return Array + */ +testUtils.shadowCheckSetup = function (content, shadowContent, options, target) { + 'use strict'; + // Normalize the params + if (typeof options !== 'object') { + target = options; + options = {}; + } + // Normalize target, allow it to be the inserted node or '#target' + target = target || (content instanceof Node ? content : '#target'); + testUtils.fixtureSetup(content); + + // wrap contents in a DIV to make it easy to attach a shadow + // ensure we attach it to the target, and not the outer fixture + var fixture = document.querySelector(target); + if (typeof shadowContent === 'string') { + fixture.innerHTML = '
'; + } else if (content instanceof Node) { + var shadowHost = document.createElement('div'); + shadowHost.setAttribute('id', 'shadowHost'); + fixture.appendChild(shadowHost); + } + + // attach a shadowRoot with the content provided + var shadowRoot = fixture.querySelector('#shadowHost').attachShadow({ mode: 'open' }); + shadowRoot.innerHTML = shadowContent; + + // query the composed tree AFTER shadowDOM has been attached + axe._tree = axe.utils.getFlattenedTree(fixture); + var node; + if (typeof target === 'string') { + node = axe.utils.querySelectorAll(axe._tree[0], target)[0]; + } else if (target instanceof Node) { + node = axe.utils.getNodeFromTree(axe._tree[0], target); + } else { + node = target; + } + return [node.actualNode, options, node]; +}; + + axe.testUtils = testUtils; \ No newline at end of file