Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support the dialog element #3902

Merged
merged 6 commits into from
Feb 21, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 120 additions & 8 deletions lib/commons/dom/is-inert.js
Original file line number Diff line number Diff line change
@@ -1,37 +1,149 @@
import memoize from '../../core/utils/memoize';
import { querySelectorAllFilter, contains } from '../../core/utils';
import isVisibleOnScreen from './is-visible-on-screen';
import createGrid from './create-grid';

/**
* Determines if an element is inside an inert subtree.
* @param {VirtualNode} vNode
* @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)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does tell me that if we have nested open modals, we should grab the outer-most one. The way you implemented that should work though, since querySelectorAll is in that order already.

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;
}

if (!vNode.parent) {
return false;
}

return isInertAncestors(vNode.parent);
return isInertAncestors(vNode.parent, true);
});

/**
straker marked this conversation as resolved.
Show resolved Hide resolved
* 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() {
straker marked this conversation as resolved.
Show resolved Hide resolved
// 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;
}

const dialogs = querySelectorAllFilter(
// TODO: es-module-_tree
axe._tree[0],
'dialog[open]',
isVisibleOnScreen
);

if (!dialogs.length) {
return;
}

// for Chrome and Firefox, look to see if
// elementsFromPoint returns the dialog
// when checking outside its bounds
let modalDialog = dialogs.find(dialog => {
const rect = dialog.boundingClientRect;
const stack = document.elementsFromPoint(rect.left - 10, rect.top - 10);

return stack.includes(dialog.actualNode);
});

// fallback for Safari, look at the grid to
// find a node to check as elementsFromPoint
// does not return inert nodes
if (!modalDialog) {
modalDialog = dialogs.find(dialog => {
const vNode = getNodeFromGrid(dialog);
if (!vNode) {
return false;
}

const rect = vNode.boundingClientRect;
const stack = document.elementsFromPoint(rect.left + 1, rect.top + 1);
straker marked this conversation as resolved.
Show resolved Hide resolved

return !stack.includes(vNode.actualNode);
});
}

return modalDialog;
});

/**
* Find the first non-html from the grid to use as a test for elementsFromPoint
* @return {VirtualNode|Null}
*/
function getNodeFromGrid(dialog) {
createGrid();
// TODO: es-module-_tree
const grid = axe._tree[0]._grid;

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;
}

const vNode = cells.find(virtualNode => {
return (
// html is always returned from elementsFromPoint
virtualNode.props.nodeName !== 'html' &&
virtualNode !== dialog &&
virtualNode.getComputedStylePropertyValue('pointer-events') !== 'none'
);
});

if (vNode) {
return vNode;
}
}
}
}
5 changes: 4 additions & 1 deletion lib/commons/dom/is-visible-for-screenreader.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
97 changes: 96 additions & 1 deletion test/commons/dom/is-inert.js
Original file line number Diff line number Diff line change
@@ -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('<div id="target" inert="false"></div>');
Expand Down Expand Up @@ -28,6 +29,86 @@ describe('dom.isInert', () => {
assert.isTrue(isInert(vNode));
});

it('returns false for closed dialog', () => {
const vNode = queryFixture(`
<dialog><span>Hello</span></dialog>
<div id="target">World</div>
`);

assert.isFalse(isInert(vNode));
});

it('returns false for non-modal dialog', () => {
const vNode = queryFixture(`
<dialog open><span>Hello</span></dialog>
<div id="target">World</div>
`);

assert.isFalse(isInert(vNode));
});

it('returns true for modal dialog', () => {
fixture.innerHTML = `
<dialog id="modal"><span>Hello</span></dialog>
<div id="target">World</div>
`;
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 = `
<dialog id="target"><span>Hello</span></dialog>
`;
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 = `
<dialog id="modal"><span id="target">Hello</span></dialog>
`;
document.querySelector('#modal').showModal();
const tree = flatTreeSetup(fixture);
const vNode = axe.utils.querySelectorAll(tree, '#target')[0];

assert.isFalse(isInert(vNode));
});

describe('fallback', () => {
it('returns true for modal dialog when elementsFromPoint does not return the dialog', () => {
fixture.innerHTML = `
<style>#modal::backdrop { display: none; }</style>
<dialog id="modal"><span>Hello</span></dialog>
<div id="target">World</div>
`;
document.querySelector('#modal').showModal();
const tree = flatTreeSetup(fixture);
const vNode = axe.utils.querySelectorAll(tree, '#target')[0];

assert.isTrue(isInert(vNode));
});

it('skips checking elements with pointer-events: none', () => {
fixture.innerHTML = `
<style>body { pointer-events: none; } #modal::backdrop { display: none; }</style>
<dialog id="modal"><span>Hello</span></dialog>
<div id="target">World</div>
`;
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(
Expand All @@ -37,4 +118,18 @@ describe('dom.isInert', () => {
assert.isFalse(isInert(vNode, { skipAncestors: true }));
});
});

describe('options.isAncestor', () => {
it('return false for modal dialog', () => {
fixture.innerHTML = `
<dialog id="modal"><span>Hello</span></dialog>
<div id="target">World</div>
`;
document.querySelector('#modal').showModal();
const tree = flatTreeSetup(fixture);
const vNode = axe.utils.querySelectorAll(tree, '#target')[0];

assert.isFalse(isInert(vNode, { isAncestor: true }));
});
});
});