-
Notifications
You must be signed in to change notification settings - Fork 783
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(rule): New aria-hidden-focus rule (#1166)
Rule: Aria Hidden Focus. Spec for rule - Spec: [auto-wcag.github.io/auto-wcag/rules/SC4-1-2-aria-hidden-focus.html](https://auto-wcag.github.io/auto-wcag/rules/SC4-1-2-aria-hidden-focus.html) Closes issue: - #1150 ## Reviewer checks **Required fields, to be filled out by PR reviewer(s)** - [x] Follows the commit message policy, appropriate for next version - [x] Has documentation updated, a DU ticket, or requires no documentation change - [x] Includes new tests, or was unnecessary - [x] Code is reviewed for security by: @WilcoFiers
- Loading branch information
1 parent
5f834ed
commit 4489965
Showing
17 changed files
with
710 additions
and
32 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
const elementsThatCanBeDisabled = [ | ||
'BUTTON', | ||
'FIELDSET', | ||
'INPUT', | ||
'SELECT', | ||
'TEXTAREA' | ||
]; | ||
|
||
const tabbableElements = virtualNode.tabbableElements; | ||
|
||
if (!tabbableElements || !tabbableElements.length) { | ||
return true; | ||
} | ||
|
||
const relatedNodes = tabbableElements.reduce((out, { actualNode: el }) => { | ||
const nodeName = el.nodeName.toUpperCase(); | ||
// populate nodes that can be disabled | ||
if (elementsThatCanBeDisabled.includes(nodeName)) { | ||
out.push(el); | ||
} | ||
return out; | ||
}, []); | ||
this.relatedNodes(relatedNodes); | ||
|
||
return relatedNodes.length === 0; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
{ | ||
"id": "focusable-disabled", | ||
"evaluate": "focusable-disabled.js", | ||
"metadata": { | ||
"impact": "serious", | ||
"messages": { | ||
"pass": "No focusable elements contained within element", | ||
"fail": "Focusable content should be disabled or be removed from the DOM" | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
const elementsThatCanBeDisabled = [ | ||
'BUTTON', | ||
'FIELDSET', | ||
'INPUT', | ||
'SELECT', | ||
'TEXTAREA' | ||
]; | ||
|
||
const tabbableElements = virtualNode.tabbableElements; | ||
|
||
if (!tabbableElements || !tabbableElements.length) { | ||
return true; | ||
} | ||
|
||
const relatedNodes = tabbableElements.reduce((out, { actualNode: el }) => { | ||
const nodeName = el.nodeName.toUpperCase(); | ||
// populate nodes that cannot be disabled | ||
if (!elementsThatCanBeDisabled.includes(nodeName)) { | ||
out.push(el); | ||
} | ||
return out; | ||
}, []); | ||
this.relatedNodes(relatedNodes); | ||
|
||
return relatedNodes.length === 0; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
{ | ||
"id": "focusable-not-tabbable", | ||
"evaluate": "focusable-not-tabbable.js", | ||
"metadata": { | ||
"impact": "serious", | ||
"messages": { | ||
"pass": "No focusable elements contained within element", | ||
"fail": "Focusable content should have tabindex='-1' or be removed from the DOM" | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
/* global dom */ | ||
|
||
/** | ||
* Get all elements (including given node) that are part if the tab order | ||
* @method getTabbableElements | ||
* @memberof axe.commons.dom | ||
* @instance | ||
* @param {Object} virtualNode The virtualNode to assess | ||
* @return {Boolean} | ||
*/ | ||
dom.getTabbableElements = function getTabbableElements(virtualNode) { | ||
const nodeAndDescendents = axe.utils.querySelectorAll(virtualNode, '*'); | ||
|
||
const tabbableElements = nodeAndDescendents.filter(vNode => { | ||
const isFocusable = vNode.isFocusable; | ||
let tabIndex = vNode.actualNode.getAttribute('tabindex'); | ||
tabIndex = | ||
tabIndex && !isNaN(parseInt(tabIndex, 10)) ? parseInt(tabIndex) : null; | ||
|
||
return tabIndex ? isFocusable && tabIndex >= 0 : isFocusable; | ||
}); | ||
|
||
return tabbableElements; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
const { getComposedParent } = axe.commons.dom; | ||
|
||
/** | ||
* Only match the outer-most `aria-hidden=true` element | ||
* @param {HTMLElement} el the HTMLElement to verify | ||
* @return {Boolean} | ||
*/ | ||
function shouldMatchElement(el) { | ||
if (!el) { | ||
return true; | ||
} | ||
if (el.getAttribute('aria-hidden') === 'true') { | ||
return false; | ||
} | ||
return shouldMatchElement(getComposedParent(el)); | ||
} | ||
|
||
return shouldMatchElement(getComposedParent(node)); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
{ | ||
"id": "aria-hidden-focus", | ||
"selector": "[aria-hidden=\"true\"]", | ||
"matches": "aria-hidden-focus-matches.js", | ||
"excludeHidden": false, | ||
"tags": ["cat.name-role-value", "wcag2a", "wcag412"], | ||
"metadata": { | ||
"description": "Ensures aria-hidden elements do not contain focusable elements", | ||
"help": "ARIA hidden element must not contain focusable elements" | ||
}, | ||
"all": ["focusable-disabled", "focusable-not-tabbable"], | ||
"any": [], | ||
"none": [] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,132 @@ | ||
describe('focusable-disabled', function() { | ||
'use strict'; | ||
|
||
var check; | ||
var fixture = document.getElementById('fixture'); | ||
var fixtureSetup = axe.testUtils.fixtureSetup; | ||
var shadowSupported = axe.testUtils.shadowSupport.v1; | ||
var checkContext = axe.testUtils.MockCheckContext(); | ||
var checkSetup = axe.testUtils.checkSetup; | ||
|
||
before(function() { | ||
check = checks['focusable-disabled']; | ||
}); | ||
|
||
afterEach(function() { | ||
fixture.innerHTML = ''; | ||
axe._tree = undefined; | ||
axe._selectorData = undefined; | ||
checkContext.reset(); | ||
}); | ||
|
||
it('returns true when content not focusable by default (no tabbable elements)', function() { | ||
var params = checkSetup('<p id="target" aria-hidden="true">Some text</p>'); | ||
var actual = check.evaluate.apply(checkContext, params); | ||
assert.isTrue(actual); | ||
}); | ||
|
||
it('returns true when content hidden through CSS (no tabbable elements)', function() { | ||
var params = checkSetup( | ||
'<div id="target" aria-hidden="true"><a href="/" style="display:none">Link</a></div>' | ||
); | ||
var actual = check.evaluate.apply(checkContext, params); | ||
assert.isTrue(actual); | ||
}); | ||
|
||
it('returns true when content made unfocusable through disabled (no tabbable elements)', function() { | ||
var params = checkSetup( | ||
'<input id="target" disabled aria-hidden="true" />' | ||
); | ||
var actual = check.evaluate.apply(checkContext, params); | ||
assert.isTrue(actual); | ||
}); | ||
|
||
it('returns true when focusable off screen link (cannot be disabled)', function() { | ||
var params = checkSetup( | ||
'<div id="target" aria-hidden="true"><a href="/" style="position:absolute; top:-999em">Link</a></div>' | ||
); | ||
var actual = check.evaluate.apply(checkContext, params); | ||
assert.isTrue(actual); | ||
assert.lengthOf(checkContext._relatedNodes, 0); | ||
}); | ||
|
||
it('returns false when focusable form field only disabled through ARIA', function() { | ||
var params = checkSetup( | ||
'<div id="target" aria-hidden="true"><input type="text" aria-disabled="true"/></div>' | ||
); | ||
var actual = check.evaluate.apply(checkContext, params); | ||
assert.isFalse(actual); | ||
assert.lengthOf(checkContext._relatedNodes, 1); | ||
assert.deepEqual( | ||
checkContext._relatedNodes, | ||
Array.from(fixture.querySelectorAll('input')) | ||
); | ||
}); | ||
|
||
it('returns false when focusable SELECT element that can be disabled', function() { | ||
var params = checkSetup( | ||
'<div id="target" aria-hidden="true">' + | ||
'<label>Choose:' + | ||
'<select>' + | ||
'<option selected="selected">Chosen</option>' + | ||
'<option>Not Selected</option>' + | ||
'</select>' + | ||
'</label>' + | ||
'</div>' | ||
); | ||
var actual = check.evaluate.apply(checkContext, params); | ||
assert.isFalse(actual); | ||
assert.lengthOf(checkContext._relatedNodes, 1); | ||
assert.deepEqual( | ||
checkContext._relatedNodes, | ||
Array.from(fixture.querySelectorAll('select')) | ||
); | ||
}); | ||
|
||
it('returns true when focusable AREA element (cannot be disabled)', function() { | ||
var params = checkSetup( | ||
'<main id="target" aria-hidden="true">' + | ||
'<map name="infographic">' + | ||
'<area shape="rect" coords="184,6,253,27" href="https://mozilla.org"' + | ||
'target="_blank" alt="Mozilla" />' + | ||
'</map>' + | ||
'</main>' | ||
); | ||
var actual = check.evaluate.apply(checkContext, params); | ||
assert.isTrue(actual); | ||
}); | ||
|
||
(shadowSupported ? it : xit)( | ||
'returns false when focusable content inside shadowDOM, that can be disabled', | ||
function() { | ||
// Note: | ||
// `testUtils.checkSetup` does not work for shadowDOM | ||
// as `axe._tree` and `axe._selectorData` needs to be updated after shadowDOM construction | ||
fixtureSetup('<div id="target"></div>'); | ||
var node = fixture.querySelector('#target'); | ||
var shadow = node.attachShadow({ mode: 'open' }); | ||
shadow.innerHTML = '<button>Some text</button>'; | ||
axe._tree = axe.utils.getFlattenedTree(fixture); | ||
axe._selectorData = axe.utils.getSelectorData(axe._tree); | ||
var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node); | ||
var actual = check.evaluate.call(checkContext, node, {}, virtualNode); | ||
assert.isFalse(actual); | ||
} | ||
); | ||
|
||
it('returns true when focusable target that cannot be disabled', function() { | ||
var params = checkSetup( | ||
'<div aria-hidden="true"><a id="target" href="">foo</a><button>bar</button></div>' | ||
); | ||
var actual = check.evaluate.apply(checkContext, params); | ||
assert.isTrue(actual); | ||
}); | ||
|
||
it('returns false when focusable target that can be disabled', function() { | ||
var params = checkSetup( | ||
'<div aria-hidden="true"><a href="">foo</a><button id="target">bar</button></div>' | ||
); | ||
var actual = check.evaluate.apply(checkContext, params); | ||
assert.isFalse(actual); | ||
}); | ||
}); |
Oops, something went wrong.