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 = ` + + `; + 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 = ` + + `; + flatTreeSetup(fixture); + + assert.isNull(getModalDialog()); + }); + + it('returns null for a closed dialog', () => { + fixture.innerHTML = ` + + `; + flatTreeSetup(fixture); + + assert.isNull(getModalDialog()); + }); + + it('returns null when there is no dialog', () => { + fixture.innerHTML = ` +