diff --git a/lib/commons/dom/get-modal-dialog.js b/lib/commons/dom/get-modal-dialog.js new file mode 100644 index 0000000000..df9a87f9ba --- /dev/null +++ b/lib/commons/dom/get-modal-dialog.js @@ -0,0 +1,120 @@ +import memoize from '../../core/utils/memoize'; +import { querySelectorAllFilter } from '../../core/utils'; +import isVisibleOnScreen from './is-visible-on-screen'; +import createGrid from './create-grid'; +import getIntersectionRect from '../math/get-intersection-rect'; + +/** + * Determine if a dialog element is opened as a modal. Currently there are no APIs to determine this so we'll use a bit of a hacky solution that has known issues. + * This can tell us that a dialog element is open but it cannot tell us which one is the top layer, nor which one is visually on top. Nested dialogs that are opened using both `.show` and`.showModal` can cause issues as well. + * @see https://github.com/dequelabs/axe-core/issues/3463 + * @return {VirtualNode|Null} The modal dialog virtual node or null if none are found + */ +const getModalDialog = memoize(function getModalDialogMemoized() { + // this is here for tests so we don't have + // to set up the virtual tree when code + // isn't testing this bit + if (!axe._tree) { + return null; + } + + const dialogs = querySelectorAllFilter( + // TODO: es-module-_tree + axe._tree[0], + 'dialog[open]', + vNode => { + const rect = vNode.boundingClientRect; + const stack = document.elementsFromPoint(rect.left + 1, rect.top + 1); + return stack.includes(vNode.actualNode) && isVisibleOnScreen(vNode); + } + ); + + if (!dialogs.length) { + return null; + } + + // for Chrome and Firefox, look to see if + // elementsFromPoint returns the dialog + // when checking outside its bounds + const modalDialog = dialogs.find(dialog => { + const rect = dialog.boundingClientRect; + const stack = document.elementsFromPoint(rect.left - 10, rect.top - 10); + + return stack.includes(dialog.actualNode); + }); + + if (modalDialog) { + return modalDialog; + } + + // fallback for Safari, look at the grid to + // find a node to check as elementsFromPoint + // does not return inert nodes + return ( + dialogs.find(dialog => { + const { vNode, rect } = getNodeFromGrid(dialog) ?? {}; + if (!vNode) { + return false; + } + + const stack = document.elementsFromPoint(rect.left + 1, rect.top + 1); + return !stack.includes(vNode.actualNode); + }) ?? null + ); +}); +export default getModalDialog; + +/** + * Find the first non-html from the grid to use as a test for elementsFromPoint + * @return {Object} + */ +function getNodeFromGrid(dialog) { + createGrid(); + // TODO: es-module-_tree + const grid = axe._tree[0]._grid; + const viewRect = new window.DOMRect( + 0, + 0, + window.innerWidth, + window.innerHeight + ); + + if (!grid) { + return; + } + + for (let row = 0; row < grid.cells.length; row++) { + const cols = grid.cells[row]; + if (!cols) { + continue; + } + + for (let col = 0; col < cols.length; col++) { + const cells = cols[col]; + if (!cells) { + continue; + } + + for (let i = 0; i < cells.length; i++) { + const vNode = cells[i]; + const rect = vNode.boundingClientRect; + const intersection = getIntersectionRect(rect, viewRect); + + if ( + // html is always returned from + // elementsFromPoint + vNode.props.nodeName !== 'html' && + vNode !== dialog && + vNode.getComputedStylePropertyValue('pointer-events') !== 'none' && + // ensure the element is visible in + // the current viewport for + // elementsFromPoint so we don't have + // to scroll + intersection + ) { + return { vNode, rect: intersection }; + } + } + } + } +} diff --git a/lib/commons/dom/index.js b/lib/commons/dom/index.js index 99389d55f8..f1e6100161 100644 --- a/lib/commons/dom/index.js +++ b/lib/commons/dom/index.js @@ -12,6 +12,7 @@ export { default as getComposedParent } from './get-composed-parent'; export { default as getElementByReference } from './get-element-by-reference'; export { default as getElementCoordinates } from './get-element-coordinates'; export { default as getElementStack } from './get-element-stack'; +export { default as getModalDialog } from './get-modal-dialog'; export { default as getOverflowHiddenAncestors } from './get-overflow-hidden-ancestors'; export { default as getRootNode } from './get-root-node'; export { default as getScrollOffset } from './get-scroll-offset'; diff --git a/lib/commons/dom/is-inert.js b/lib/commons/dom/is-inert.js index fc4c821da5..e0a0270908 100644 --- a/lib/commons/dom/is-inert.js +++ b/lib/commons/dom/is-inert.js @@ -1,4 +1,6 @@ import memoize from '../../core/utils/memoize'; +import getModalDialog from './get-modal-dialog'; +import { contains } from '../../core/utils'; /** * Determines if an element is inside an inert subtree. @@ -6,26 +8,43 @@ import memoize from '../../core/utils/memoize'; * @param {Boolean} [options.skipAncestors] If the ancestor tree should not be used * @return {Boolean} The element's inert state */ -export default function isInert(vNode, { skipAncestors } = {}) { +export default function isInert(vNode, { skipAncestors, isAncestor } = {}) { if (skipAncestors) { - return isInertSelf(vNode); + return isInertSelf(vNode, isAncestor); } - return isInertAncestors(vNode); + return isInertAncestors(vNode, isAncestor); } /** * Check the element for inert */ -const isInertSelf = memoize(function isInertSelfMemoized(vNode) { - return vNode.hasAttr('inert'); +const isInertSelf = memoize(function isInertSelfMemoized(vNode, isAncestor) { + if (vNode.hasAttr('inert')) { + return true; + } + + if (!isAncestor && vNode.actualNode) { + // elements outside of an opened modal + // dialog are treated as inert by the + // browser + const modalDialog = getModalDialog(); + if (modalDialog && !contains(modalDialog, vNode)) { + return true; + } + } + + return false; }); /** * Check the element and ancestors for inert */ -const isInertAncestors = memoize(function isInertAncestorsMemoized(vNode) { - if (isInertSelf(vNode)) { +const isInertAncestors = memoize(function isInertAncestorsMemoized( + vNode, + isAncestor +) { + if (isInertSelf(vNode, isAncestor)) { return true; } @@ -33,5 +52,5 @@ const isInertAncestors = memoize(function isInertAncestorsMemoized(vNode) { return false; } - return isInertAncestors(vNode.parent); + return isInertAncestors(vNode.parent, true); }); diff --git a/lib/commons/dom/is-visible-for-screenreader.js b/lib/commons/dom/is-visible-for-screenreader.js index 79771e9129..8687c99e20 100644 --- a/lib/commons/dom/is-visible-for-screenreader.js +++ b/lib/commons/dom/is-visible-for-screenreader.js @@ -22,7 +22,10 @@ export default function isVisibleToScreenReaders(vNode) { */ const isVisibleToScreenReadersVirtual = memoize( function isVisibleToScreenReadersMemoized(vNode, isAncestor) { - if (ariaHidden(vNode) || isInert(vNode, { skipAncestors: true })) { + if ( + ariaHidden(vNode) || + isInert(vNode, { skipAncestors: true, isAncestor }) + ) { return false; } diff --git a/test/commons/dom/get-modal-dialog.js b/test/commons/dom/get-modal-dialog.js new file mode 100644 index 0000000000..08a5a09c2f --- /dev/null +++ b/test/commons/dom/get-modal-dialog.js @@ -0,0 +1,129 @@ +describe('dom.getModalDialog', () => { + const fixture = document.querySelector('#fixture'); + const getModalDialog = axe.commons.dom.getModalDialog; + const { flatTreeSetup } = axe.testUtils; + + it('returns a modal dialog', () => { + fixture.innerHTML = ` + Hello + `; + document.querySelector('#target').showModal(); + const tree = flatTreeSetup(fixture); + const vNode = axe.utils.querySelectorAll(tree, '#target')[0]; + + assert.equal(getModalDialog(), vNode); + }); + + it('returns null for an opened dialog', () => { + fixture.innerHTML = ` + Hello + `; + flatTreeSetup(fixture); + + assert.isNull(getModalDialog()); + }); + + it('returns null for a closed dialog', () => { + fixture.innerHTML = ` + Hello + `; + flatTreeSetup(fixture); + + assert.isNull(getModalDialog()); + }); + + it('returns null when there is no dialog', () => { + fixture.innerHTML = ` +
World
+ `; + flatTreeSetup(fixture); + + assert.isNull(getModalDialog()); + }); + + it('returns null if the modal dialog is not visible', () => { + fixture.innerHTML = ` + + Hello + `; + document.querySelector('#target').showModal(); + flatTreeSetup(fixture); + + assert.isNull(getModalDialog()); + }); + + describe('fallback', () => { + it('returns true for modal dialog when elementsFromPoint does not return the dialog', () => { + fixture.innerHTML = ` + + Hello +
World
+ `; + document.querySelector('#target').showModal(); + const tree = flatTreeSetup(fixture); + const vNode = axe.utils.querySelectorAll(tree, '#target')[0]; + + assert.equal(getModalDialog(), vNode); + }); + + it('skips checking elements with pointer-events: none', () => { + fixture.innerHTML = ` + + Hello +
World
+ `; + document.querySelector('#target').showModal(); + flatTreeSetup(fixture); + + assert.isNull(getModalDialog()); + }); + + it('takes into account a scrolled page', () => { + fixture.innerHTML = ` + +
+ Hello +
World
+ `; + document.querySelector('#scroll-target').scrollIntoView(); + document.querySelector('#target').showModal(); + const tree = flatTreeSetup(fixture); + const vNode = axe.utils.querySelectorAll(tree, '#target')[0]; + + assert.equal(getModalDialog(), vNode); + }); + + it('returns the modal dialog when two dialogs are open', () => { + fixture.innerHTML = ` + + Hello + Hello +
World
+ `; + document.querySelector('#target').showModal(); + const tree = flatTreeSetup(fixture); + const vNode = axe.utils.querySelectorAll(tree, '#target')[0]; + + assert.equal(getModalDialog(), vNode); + }); + + it('returns the outer modal when a dialog modal contains a non-dialog modal', () => { + fixture.innerHTML = ` + + + Hello + Open modal + +
World
+ `; + document.querySelector('#target').showModal(); + const tree = flatTreeSetup(fixture); + const vNode = axe.utils.querySelectorAll(tree, '#target')[0]; + + assert.equal(getModalDialog(), vNode); + }); + }); +}); diff --git a/test/commons/dom/is-inert.js b/test/commons/dom/is-inert.js index bd1d344c56..ca4ba92e8b 100644 --- a/test/commons/dom/is-inert.js +++ b/test/commons/dom/is-inert.js @@ -1,6 +1,7 @@ describe('dom.isInert', () => { + const fixture = document.querySelector('#fixture'); const isInert = axe.commons.dom.isInert; - const { queryFixture } = axe.testUtils; + const { queryFixture, flatTreeSetup } = axe.testUtils; it('returns true for element with "inert=false`', () => { const vNode = queryFixture('
'); @@ -28,6 +29,58 @@ describe('dom.isInert', () => { assert.isTrue(isInert(vNode)); }); + it('returns false for closed dialog', () => { + const vNode = queryFixture(` + Hello +
World
+ `); + + assert.isFalse(isInert(vNode)); + }); + + it('returns false for non-modal dialog', () => { + const vNode = queryFixture(` + Hello +
World
+ `); + + assert.isFalse(isInert(vNode)); + }); + + it('returns true for modal dialog', () => { + fixture.innerHTML = ` + Hello +
World
+ `; + document.querySelector('#modal').showModal(); + const tree = flatTreeSetup(fixture); + const vNode = axe.utils.querySelectorAll(tree, '#target')[0]; + + assert.isTrue(isInert(vNode)); + }); + + it('returns false for the modal dialog element', () => { + fixture.innerHTML = ` + Hello + `; + document.querySelector('#target').showModal(); + const tree = flatTreeSetup(fixture); + const vNode = axe.utils.querySelectorAll(tree, '#target')[0]; + + assert.isFalse(isInert(vNode)); + }); + + it('returns false for a descendant of the modal dialog', () => { + fixture.innerHTML = ` + Hello + `; + document.querySelector('#modal').showModal(); + const tree = flatTreeSetup(fixture); + const vNode = axe.utils.querySelectorAll(tree, '#target')[0]; + + assert.isFalse(isInert(vNode)); + }); + describe('options.skipAncestors', () => { it('returns false for ancestor with inert', () => { const vNode = queryFixture( @@ -37,4 +90,18 @@ describe('dom.isInert', () => { assert.isFalse(isInert(vNode, { skipAncestors: true })); }); }); + + describe('options.isAncestor', () => { + it('return false for modal dialog', () => { + fixture.innerHTML = ` + Hello +
World
+ `; + document.querySelector('#modal').showModal(); + const tree = flatTreeSetup(fixture); + const vNode = axe.utils.querySelectorAll(tree, '#target')[0]; + + assert.isFalse(isInert(vNode, { isAncestor: true })); + }); + }); }); diff --git a/test/integration/full/dialog/dialog.html b/test/integration/full/dialog/dialog.html new file mode 100644 index 0000000000..e1168db4cf --- /dev/null +++ b/test/integration/full/dialog/dialog.html @@ -0,0 +1,40 @@ + + + + dialog test + + + + + + + + +
+ +
+ Contrast failure +
+ + +
+ Contrast failure +
+
+
+
+ + + + + diff --git a/test/integration/full/dialog/dialog.js b/test/integration/full/dialog/dialog.js new file mode 100644 index 0000000000..228c2abed8 --- /dev/null +++ b/test/integration/full/dialog/dialog.js @@ -0,0 +1,51 @@ +describe('dialog tests', () => { + const dialog = document.querySelector('dialog'); + const target = document.querySelector('#target'); + + async function getViolations() { + const results = await axe.run(target); + const buttonName = results.violations.find( + ({ id }) => id === 'button-name' + ); + const colorContrast = results.violations.find( + ({ id }) => id === 'color-contrast' + ); + + return { buttonName, colorContrast }; + } + + afterEach(function () { + dialog.close(); + }); + + it('should not find violations inside a closed dialog', async () => { + const { buttonName, colorContrast } = await getViolations(); + + assert.lengthOf(buttonName.nodes, 1); + assert.deepEqual(buttonName.nodes[0].target, ['#root-button']); + assert.lengthOf(colorContrast.nodes, 1); + assert.deepEqual(colorContrast.nodes[0].target, ['#root-color']); + }); + + it('should not find violations outside a modal dialog', async () => { + dialog.showModal(); + const { buttonName, colorContrast } = await getViolations(); + + assert.lengthOf(buttonName.nodes, 1); + assert.deepEqual(buttonName.nodes[0].target, ['#dialog-button']); + assert.lengthOf(colorContrast.nodes, 1); + assert.deepEqual(colorContrast.nodes[0].target, ['#dialog-color']); + }); + + it('should find violations inside and outside an open dialog', async () => { + dialog.show(); + const { buttonName, colorContrast } = await getViolations(); + + assert.lengthOf(buttonName.nodes, 2); + assert.deepEqual(buttonName.nodes[0].target, ['#root-button']); + assert.deepEqual(buttonName.nodes[1].target, ['#dialog-button']); + assert.lengthOf(colorContrast.nodes, 2); + assert.deepEqual(colorContrast.nodes[0].target, ['#root-color']); + assert.deepEqual(colorContrast.nodes[1].target, ['#dialog-color']); + }); +});