-
Notifications
You must be signed in to change notification settings - Fork 791
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix(aria-hidden-focus): mark as needs review if a modal is open (#1995)
* fix(aria-hidden-focus): mark as needs review if a modal is open * message * fix tests * fix windows * resolve changes * tests
- Loading branch information
Showing
13 changed files
with
404 additions
and
2 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
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 @@ | ||
const tabbableElements = virtualNode.tabbableElements.map( | ||
({ actualNode }) => actualNode | ||
); | ||
|
||
if (!tabbableElements || !tabbableElements.length) { | ||
return true; | ||
} | ||
|
||
if (axe.commons.dom.isModalOpen()) { | ||
this.relatedNodes(tabbableElements); | ||
return undefined; | ||
} | ||
|
||
return true; |
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-modal-open", | ||
"evaluate": "focusable-modal-open.js", | ||
"metadata": { | ||
"impact": "serious", | ||
"messages": { | ||
"pass": "No focusable elements while a modal is open", | ||
"incomplete": "Check that focusable elements are not tabbable in the current state" | ||
} | ||
} | ||
} |
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,98 @@ | ||
/* global dom, axe */ | ||
|
||
/** | ||
* Determines if there is a modal currently open. | ||
* @method isModalOpen | ||
* @memberof axe.commons.dom | ||
* @instance | ||
* @return {Boolean|undefined} True if we know (or our best guess) that a modal is open, undefined if we can't tell (doesn't mean there isn't one open) | ||
*/ | ||
dom.isModalOpen = function isModalOpen(options) { | ||
options = options || {}; | ||
let modalPercent = options.modalPercent || 0.75; | ||
|
||
// there is no "definitive" way to code a modal so detecting when one is open | ||
// is a bit of a guess. a modal won't always be accessible, so we can't rely | ||
// on the `role` attribute, and relying on a class name as a convention is | ||
// unreliable. we also cannot rely on the body/html not scrolling. | ||
// | ||
// because of this, we will look for two different types of modals: | ||
// "definitely a modal" and "could be a modal." | ||
// | ||
// "definitely a modal" is any visible element that is coded to be a modal | ||
// by using one of the following criteria: | ||
// | ||
// - has the attribute `role=dialog` | ||
// - has the attribute `aria-modal=true` | ||
// - is the dialog element | ||
// | ||
// "could be a modal" is a visible element that takes up more than 75% of | ||
// the screen (though typically full width/height) and is the top-most element | ||
// in the viewport. since we aren't sure if it is or is not a modal this is | ||
// just our best guess of being one based on convention. | ||
|
||
if (axe._cache.get('isModalOpen')) { | ||
return axe._cache.get('isModalOpen'); | ||
} | ||
|
||
const definiteModals = axe.utils.querySelectorAllFilter( | ||
axe._tree[0], | ||
'dialog, [role=dialog], [aria-modal=true]', | ||
vNode => dom.isVisible(vNode.actualNode) | ||
); | ||
|
||
if (definiteModals.length) { | ||
axe._cache.set('isModalOpen', true); | ||
return true; | ||
} | ||
|
||
// to find a "could be a modal" we will take the element stack from each of | ||
// four corners and one from the middle of the viewport (total of 5). if each | ||
// stack contains an element whose width/height is >= 75% of the screen, we | ||
// found a "could be a modal" | ||
const viewport = dom.getViewportSize(window); | ||
const percentWidth = viewport.width * modalPercent; | ||
const percentHeight = viewport.height * modalPercent; | ||
const x = (viewport.width - percentWidth) / 2; | ||
const y = (viewport.height - percentHeight) / 2; | ||
|
||
const points = [ | ||
// top-left corner | ||
{ x, y }, | ||
// top-right corner | ||
{ x: viewport.width - x, y }, | ||
// center | ||
{ x: viewport.width / 2, y: viewport.height / 2 }, | ||
// bottom-left corner | ||
{ x, y: viewport.height - y }, | ||
// bottom-right corner | ||
{ x: viewport.width - x, y: viewport.height - y } | ||
]; | ||
|
||
const stacks = points.map(point => { | ||
return Array.from(document.elementsFromPoint(point.x, point.y)); | ||
}); | ||
|
||
for (let i = 0; i < stacks.length; i++) { | ||
// a modal isn't guaranteed to be the top most element so we'll have to | ||
// find the first element in the stack that meets the modal criteria | ||
// and make sure it's in the other stacks | ||
const modalElement = stacks[i].find(elm => { | ||
const style = window.getComputedStyle(elm); | ||
return ( | ||
parseInt(style.width, 10) >= percentWidth && | ||
parseInt(style.height, 10) >= percentHeight && | ||
style.getPropertyValue('pointer-events') !== 'none' && | ||
(style.position === 'absolute' || style.position === 'fixed') | ||
); | ||
}); | ||
|
||
if (modalElement && stacks.every(stack => stack.includes(modalElement))) { | ||
axe._cache.set('isModalOpen', true); | ||
return true; | ||
} | ||
} | ||
|
||
axe._cache.set('isModalOpen', undefined); | ||
return undefined; | ||
}; |
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
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,55 @@ | ||
describe('focusable-modal-open', function() { | ||
'use strict'; | ||
|
||
var check; | ||
var fixture = document.getElementById('fixture'); | ||
var checkContext = axe.testUtils.MockCheckContext(); | ||
var checkSetup = axe.testUtils.checkSetup; | ||
|
||
before(function() { | ||
check = checks['focusable-modal-open']; | ||
}); | ||
|
||
afterEach(function() { | ||
fixture.innerHTML = ''; | ||
axe._tree = undefined; | ||
axe._selectorData = undefined; | ||
checkContext.reset(); | ||
}); | ||
|
||
it('returns true when no modal is open', function() { | ||
var params = checkSetup( | ||
'<div id="target" aria-hidden="true">' + | ||
'<button>Some button</button>' + | ||
'</div>' | ||
); | ||
var actual = check.evaluate.apply(checkContext, params); | ||
assert.isTrue(actual); | ||
}); | ||
|
||
it('returns undefined if a modal is open', function() { | ||
var params = checkSetup( | ||
'<div id="target" aria-hidden="true">' + | ||
'<button>Some button</button>' + | ||
'</div>' + | ||
'<div role="dialog">Modal</div>' | ||
); | ||
var actual = check.evaluate.apply(checkContext, params); | ||
assert.isUndefined(actual); | ||
}); | ||
|
||
it('sets the tabbable elements as related nodes', function() { | ||
var params = checkSetup( | ||
'<div id="target" aria-hidden="true">' + | ||
'<button>Some button</button>' + | ||
'</div>' + | ||
'<div role="dialog">Modal</div>' | ||
); | ||
check.evaluate.apply(checkContext, params); | ||
assert.lengthOf(checkContext._relatedNodes, 1); | ||
assert.deepEqual( | ||
checkContext._relatedNodes, | ||
Array.from(fixture.querySelectorAll('button')) | ||
); | ||
}); | ||
}); |
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,89 @@ | ||
describe('dom.isModalOpen', function() { | ||
'use strict'; | ||
|
||
var fixtureSetup = axe.testUtils.fixtureSetup; | ||
var isModalOpen = axe.commons.dom.isModalOpen; | ||
var dialogElSupport = | ||
typeof document.createElement('dialog').open !== 'undefined'; | ||
|
||
afterEach(function() { | ||
fixtureSetup(''); | ||
}); | ||
|
||
it('returns true if there is a visible element with role=dialog', function() { | ||
fixtureSetup('<div role="dialog">Modal</div>'); | ||
assert.isTrue(isModalOpen()); | ||
}); | ||
|
||
it('returns true if there is a visible element with aria-modal=true', function() { | ||
fixtureSetup('<div aria-modal="true">Modal</div>'); | ||
assert.isTrue(isModalOpen()); | ||
}); | ||
|
||
(dialogElSupport ? it : xit)( | ||
'returns true if there is a visible dialog element', | ||
function() { | ||
fixtureSetup('<dialog open><div>Modal</div></dialog>'); | ||
assert.isTrue(isModalOpen()); | ||
} | ||
); | ||
|
||
it('returns true if there is a visible absolutely positioned element with >= 75% width/height', function() { | ||
fixtureSetup( | ||
'<div style="position: absolute; top: 0; bottom: 0; left: 0; right: 0">Modal</div>' | ||
); | ||
assert.isTrue(isModalOpen()); | ||
}); | ||
|
||
it('returns true if there is a visible absolutely positioned element with >= 75% width/height and is not the top most element', function() { | ||
fixtureSetup( | ||
'<div style="position: fixed; top: 0; bottom: 0; width: 100%; height: 100%; z-index: 99999; background: rgba(0,0,0,0.5);">' + | ||
'<div style="display: flex; align-items: center; justify-content: center; height: 100%;">' + | ||
'<div style="padding: 2rem; border: 1px solid; background: white;">Modal</div>' + | ||
'</div>' + | ||
'</div>' | ||
); | ||
assert.isTrue(isModalOpen()); | ||
}); | ||
|
||
it('returns true if modal opens like a drawer', function() { | ||
fixtureSetup( | ||
'<div style="position: fixed; top: 0; bottom: 0; width: 100%; height: 100%; z-index: 99999; background: rgba(0,0,0,0.5);">' + | ||
'<div style="width: 25%; height: 100%;">' + | ||
'<div style="padding: 2rem; border-right: 1px solid; background: white; height: 100%;">Modal</div>' + | ||
'</div>' + | ||
'</div>' | ||
); | ||
assert.isTrue(isModalOpen()); | ||
}); | ||
|
||
it('returns undefined if there is no modal', function() { | ||
fixtureSetup('<div>Modal</div>'); | ||
assert.isUndefined(isModalOpen()); | ||
}); | ||
|
||
it('returns undefined if there is a hidden element with role=dialog', function() { | ||
fixtureSetup('<div role="dialog" style="display: none">Modal</div>'); | ||
assert.isUndefined(isModalOpen()); | ||
}); | ||
|
||
it('returns undefined if there is a hidden element with aria-modal=true', function() { | ||
fixtureSetup('<div aria-modal="true" style="display: none">Modal</div>'); | ||
assert.isUndefined(isModalOpen()); | ||
}); | ||
|
||
(dialogElSupport ? it : xit)( | ||
'returns undefined if there is a hidden dialog element', | ||
function() { | ||
fixtureSetup('<dialog><div>Modal</div></dialog>'); | ||
assert.isUndefined(isModalOpen()); | ||
} | ||
); | ||
|
||
it('returns undefined if there is a visible absolutely positioned element with < 75% width/height', function() { | ||
fixtureSetup( | ||
'<div style="position: fixed; top: 0; bottom: 0; width: 50%; height: 50%; margin: 0 auto; transform: translateY(-50%);>Modal</div>' | ||
); | ||
assert.isUndefined(isModalOpen()); | ||
}); | ||
}); |
Oops, something went wrong.