diff --git a/lib/checks/navigation/region-after.js b/lib/checks/navigation/region-after.js deleted file mode 100644 index c60b163cf3..0000000000 --- a/lib/checks/navigation/region-after.js +++ /dev/null @@ -1 +0,0 @@ -return [results[0]]; diff --git a/lib/checks/navigation/region.js b/lib/checks/navigation/region.js index 0e886ec29b..2a829f5d7e 100644 --- a/lib/checks/navigation/region.js +++ b/lib/checks/navigation/region.js @@ -2,6 +2,11 @@ const { dom, aria } = axe.commons; const landmarkRoles = aria.getRolesByType('landmark'); const implicitAriaLiveRoles = ['alert', 'log', 'status']; +let regionlessNodes = axe._cache.get('regionlessNodes'); +if (regionlessNodes) { + return !regionlessNodes.includes(virtualNode); +} + // Create a list of nodeNames that have a landmark as an implicit role const implicitLandmarks = landmarkRoles .reduce((arr, role) => arr.concat(aria.implicitNodes(role)), []) @@ -52,11 +57,18 @@ function findRegionlessElms(virtualNode) { dom.getElementByReference(virtualNode.actualNode, 'href')) || !dom.isVisible(node, true) ) { + // Mark each parent node as having region descendant + let vNode = virtualNode; + while (vNode) { + vNode._hasRegionDescendant = true; + vNode = vNode.parent; + } + return []; // Return the node is a content element } else if (dom.hasContent(node, /* noRecursion: */ true)) { - return [node]; + return [virtualNode]; // Recursively look at all child elements } else { @@ -67,7 +79,24 @@ function findRegionlessElms(virtualNode) { } } -var regionlessNodes = findRegionlessElms(virtualNode); -this.relatedNodes(regionlessNodes); +regionlessNodes = findRegionlessElms(axe._tree[0]) + // Find first parent marked as having region descendant (or body) and + // return the node right before it as the "outer" element + .map(vNode => { + while ( + vNode.parent && + !vNode.parent._hasRegionDescendant && + vNode.parent.actualNode !== document.body + ) { + vNode = vNode.parent; + } + + return vNode; + }) + // Remove duplicate containers + .filter((vNode, index, array) => { + return array.indexOf(vNode) === index; + }); +axe._cache.set('regionlessNodes', regionlessNodes); -return regionlessNodes.length === 0; +return !regionlessNodes.includes(virtualNode); diff --git a/lib/checks/navigation/region.json b/lib/checks/navigation/region.json index bd3a69b2cc..98b5612cf8 100644 --- a/lib/checks/navigation/region.json +++ b/lib/checks/navigation/region.json @@ -1,7 +1,6 @@ { "id": "region", "evaluate": "region.js", - "after": "region-after.js", "metadata": { "impact": "moderate", "messages": { diff --git a/lib/rules/region.json b/lib/rules/region.json index e43f17d59f..dea8ed46e5 100644 --- a/lib/rules/region.json +++ b/lib/rules/region.json @@ -1,7 +1,6 @@ { "id": "region", - "selector": "html", - "pageLevel": true, + "selector": "body *", "tags": ["cat.keyboard", "best-practice"], "metadata": { "description": "Ensures all page content is contained by landmarks", diff --git a/test/checks/navigation/region.js b/test/checks/navigation/region.js index caf5896f58..6f1b5f7f64 100644 --- a/test/checks/navigation/region.js +++ b/test/checks/navigation/region.js @@ -4,6 +4,7 @@ describe('region', function() { var fixture = document.getElementById('fixture'); var shadowSupport = axe.testUtils.shadowSupport; var checkSetup = axe.testUtils.checkSetup; + var fixtureSetup = axe.testUtils.fixtureSetup; var checkContext = new axe.testUtils.MockCheckContext(); @@ -12,94 +13,81 @@ describe('region', function() { checkContext.reset(); }); - it('should return true when all content is inside the region', function() { + it('should return true when content is inside the region', function() { var checkArgs = checkSetup( - '
Click Here

Introduction

' + '
Click Here

Introduction

' ); assert.isTrue(checks.region.evaluate.apply(checkContext, checkArgs)); - assert.equal(checkContext._relatedNodes.length, 0); }); it('should return false when img content is outside the region', function() { var checkArgs = checkSetup( - '

Introduction

' + '

Introduction

' ); assert.isFalse(checks.region.evaluate.apply(checkContext, checkArgs)); - assert.equal(checkContext._relatedNodes.length, 1); }); it('should return true when textless text content is outside the region', function() { var checkArgs = checkSetup( - '

Introduction

' + '

Introduction

' ); assert.isTrue(checks.region.evaluate.apply(checkContext, checkArgs)); - assert.equal(checkContext._relatedNodes.length, 0); }); it('should return true when wrapper content is outside the region', function() { var checkArgs = checkSetup( - '

Introduction

' + '

Introduction

' ); assert.isTrue(checks.region.evaluate.apply(checkContext, checkArgs)); - assert.equal(checkContext._relatedNodes.length, 0); }); it('should return true when invisible content is outside the region', function() { var checkArgs = checkSetup( - '

Click Here

Introduction

' + '

Introduction

' ); assert.isTrue(checks.region.evaluate.apply(checkContext, checkArgs)); - assert.equal(checkContext._relatedNodes.length, 0); }); it('should return true when there is a skiplink', function() { var checkArgs = checkSetup( - '
Click Here

Introduction

' + 'Click Here

Introduction

' ); assert.isTrue(checks.region.evaluate.apply(checkContext, checkArgs)); - assert.equal(checkContext._relatedNodes.length, 0); }); it('should return true when there is an Angular skiplink', function() { var checkArgs = checkSetup( - '
Click Here

Introduction

' + 'Click Here

Introduction

' ); assert.isTrue(checks.region.evaluate.apply(checkContext, checkArgs)); - assert.equal(checkContext._relatedNodes.length, 0); }); it('should return false when there is a non-region element', function() { var checkArgs = checkSetup( - '
This is random content.

Introduction

' + '
This is random content.

Introduction

' ); assert.isFalse(checks.region.evaluate.apply(checkContext, checkArgs)); - assert.equal(checkContext._relatedNodes.length, 1); - }); - - it('should return the first item when after is called', function() { - assert.equal(checks.region.after([2, 3, 1]), 2); }); it('should return false when there is a non-skiplink', function() { var checkArgs = checkSetup( - '
Click Here

Introduction

' + 'Click Here

Introduction

' ); assert.isFalse(checks.region.evaluate.apply(checkContext, checkArgs)); - assert.equal(checkContext._relatedNodes.length, 1); }); it('should return true if the non-region element is a script', function() { var checkArgs = checkSetup( - '
Content
' + '
Content
' ); assert.isTrue(checks.region.evaluate.apply(checkContext, checkArgs)); @@ -107,57 +95,66 @@ describe('region', function() { it('should considered aria labelled elements as content', function() { var checkArgs = checkSetup( - '
Content
' + '
Content
' ); assert.isFalse(checks.region.evaluate.apply(checkContext, checkArgs)); - assert.deepEqual(checkContext._relatedNodes, [ - fixture.querySelector('div[aria-label]') - ]); }); - it('should allow native landmark elements', function() { + it('should allow native header elements', function() { + var checkArgs = checkSetup( + '
branding
Content
' + ); + + assert.isTrue(checks.region.evaluate.apply(checkContext, checkArgs)); + }); + + it('should allow native main elements', function() { + var checkArgs = checkSetup( + '
branding
Content
' + ); + + assert.isTrue(checks.region.evaluate.apply(checkContext, checkArgs)); + }); + + it('should allow native aside elements', function() { + var checkArgs = checkSetup( + '
branding
Content
' + ); + + assert.isTrue(checks.region.evaluate.apply(checkContext, checkArgs)); + }); + + it('should allow native footer elements', function() { var checkArgs = checkSetup( - '
branding
Content
' + '
branding
Content
' ); assert.isTrue(checks.region.evaluate.apply(checkContext, checkArgs)); - assert.lengthOf(checkContext._relatedNodes, 0); }); it('ignores native landmark elements with an overwriting role', function() { var checkArgs = checkSetup( - '
Content
' + '
Content
Content
' ); assert.isFalse(checks.region.evaluate.apply(checkContext, checkArgs)); - assert.lengthOf(checkContext._relatedNodes, 1); - assert.deepEqual(checkContext._relatedNodes, [ - fixture.querySelector('main') - ]); }); it('returns false for content outside of form tags with accessible names', function() { var checkArgs = checkSetup( - '

Text

' + '

Text

' ); assert.isFalse(checks.region.evaluate.apply(checkContext, checkArgs)); - assert.lengthOf(checkContext._relatedNodes, 1); - assert.deepEqual(checkContext._relatedNodes, [fixture.querySelector('p')]); }); it('ignores unlabeled forms as they are not landmarks', function() { var checkArgs = checkSetup( - '

Text

foo
' + '
foo
Content
' ); assert.isFalse(checks.region.evaluate.apply(checkContext, checkArgs)); - assert.lengthOf(checkContext._relatedNodes, 2); - assert.deepEqual(checkContext._relatedNodes, [ - fixture.querySelector('p'), - fixture.querySelector('fieldset') - ]); }); it('treats with aria label as landmarks', function() { @@ -176,22 +173,18 @@ describe('region', function() { it('treats forms without aria label as not a landmarks', function() { var checkArgs = checkSetup( - '

This is random content.

' + '

This is random content.

Content
' ); assert.isFalse(checks.region.evaluate.apply(checkContext, checkArgs)); - assert.lengthOf(checkContext._relatedNodes, 1); - assert.deepEqual(checkContext._relatedNodes, [fixture.querySelector('p')]); }); it('treats forms with an empty aria label as not a landmarks', function() { var checkArgs = checkSetup( - '

This is random content.

' + '

This is random content.

Content
' ); assert.isFalse(checks.region.evaluate.apply(checkContext, checkArgs)); - assert.lengthOf(checkContext._relatedNodes, 1); - assert.deepEqual(checkContext._relatedNodes, [fixture.querySelector('p')]); }); it('treats forms with non empty titles as landmarks', function() { @@ -204,12 +197,10 @@ describe('region', function() { it('treats forms with empty titles not as landmarks', function() { var checkArgs = checkSetup( - '

This is random content.

' + '

This is random content.

Content
' ); assert.isFalse(checks.region.evaluate.apply(checkContext, checkArgs)); - assert.lengthOf(checkContext._relatedNodes, 1); - assert.deepEqual(checkContext._relatedNodes, [fixture.querySelector('p')]); }); it('treats ARIA forms with no label or title as landmarks', function() { @@ -236,7 +227,7 @@ describe('region', function() { it('does not allow content in aria-live=off', function() { var checkArgs = checkSetup( - '

This is random content.

' + '

This is random content.

Content
' ); assert.isFalse(checks.region.evaluate.apply(checkContext, checkArgs)); }); @@ -284,14 +275,30 @@ describe('region', function() { assert.isTrue(checks.region.evaluate.apply(checkContext, checkArgs)); }); + it('returns the outermost element as the error', function() { + var checkArgs = checkSetup( + '

This is random content.

Introduction

' + ); + + assert.isFalse(checks.region.evaluate.apply(checkContext, checkArgs)); + }); + (shadowSupport.v1 ? it : xit)('should test Shadow tree content', function() { var div = document.createElement('div'); var shadow = div.attachShadow({ mode: 'open' }); shadow.innerHTML = 'Some text'; - var checkArgs = checkSetup(div); - - assert.isFalse(checks.region.evaluate.apply(checkContext, checkArgs)); - assert.deepEqual(checkContext._relatedNodes, [div]); + fixtureSetup(div); + var virutalNode = axe._tree[0]; + + // fixture is the outermost element + assert.isFalse( + checks.region.evaluate.call( + checkContext, + virutalNode.actualNode, + null, + virutalNode + ) + ); }); (shadowSupport.v1 ? it : xit)('should test slotted content', function() { @@ -302,21 +309,28 @@ describe('region', function() { var checkArgs = checkSetup(div); assert.isTrue(checks.region.evaluate.apply(checkContext, checkArgs)); - assert.lengthOf(checkContext._relatedNodes, 0); }); (shadowSupport.v1 ? it : xit)( 'should ignore skiplink targets inside shadow trees', function() { var div = document.createElement('div'); - div.innerHTML = 'skiplink
Content
'; + div.innerHTML = + 'skiplink
Content
'; var shadow = div.querySelector('div').attachShadow({ mode: 'open' }); shadow.innerHTML = '
'; - var checkArgs = checkSetup(div); - - assert.isFalse(checks.region.evaluate.apply(checkContext, checkArgs)); - assert.deepEqual(checkContext._relatedNodes, [div.querySelector('a')]); + fixtureSetup(div); + var virutalNode = axe.utils.getNodeFromTree(div.querySelector('#target')); + + assert.isFalse( + checks.region.evaluate.call( + checkContext, + virutalNode.actualNode, + null, + virutalNode + ) + ); } ); diff --git a/test/integration/full/region/region-fail.js b/test/integration/full/region/region-fail.js index 20f2c4723d..21e6871159 100644 --- a/test/integration/full/region/region-fail.js +++ b/test/integration/full/region/region-fail.js @@ -17,22 +17,8 @@ describe('region fail test', function() { assert.lengthOf(results.violations[0].nodes, 1); }); - it('should find html', function() { - assert.deepEqual(results.violations[0].nodes[0].target, ['html']); - }); - - it('should have all text content as related nodes', function() { - var wrapper = document.querySelector('#wrapper'); - assert.equal( - results.violations[0].nodes[0].any[0].relatedNodes.length, - wrapper.querySelectorAll('h1, li, p, a').length - ); - }); - }); - - describe('passes', function() { - it('should find none', function() { - assert.lengthOf(results.passes, 0); + it('should find wrapper', function() { + assert.deepEqual(results.violations[0].nodes[0].target, ['#wrapper']); }); }); }); diff --git a/test/integration/full/region/region-pass.js b/test/integration/full/region/region-pass.js index b1a1325fa3..a6406bb1f5 100644 --- a/test/integration/full/region/region-pass.js +++ b/test/integration/full/region/region-pass.js @@ -13,12 +13,10 @@ describe('region pass test', function() { }); describe('passes', function() { - it('should find one', function() { - assert.lengthOf(results.passes[0].nodes, 1); - }); - - it('should find html', function() { - assert.deepEqual(results.passes[0].nodes[0].target, ['html']); + it('should pass nodes', function() { + // it seems CircleCI and localhost have different number of DOM nodes, + // so as long as everything passes and nothing fails, the rule is working + assert.isTrue(results.passes[0].nodes.length > 0); }); });