Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion lib/commons/aria/get-role.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ function resolveImplicitRole(vNode, { chromium, ...explicitRoleOptions }) {
// we will return it as a list as that is the best option.
// Source: https://www.w3.org/TR/wai-aria-1.1/#conflict_resolution_presentation_none
// See also: https://github.com/w3c/aria/issues/1270
function hasConflictResolution(vNode) {
export function hasConflictResolution(vNode) {
const hasGlobalAria = getGlobalAriaAttrs().some(attr => vNode.hasAttr(attr));
return hasGlobalAria || isFocusable(vNode);
}
Expand Down
10 changes: 8 additions & 2 deletions lib/commons/matches/from-definition.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
import hasAccessibleName from './has-accessible-name';
import attributes from './attributes';
import condition from './condition';
import explicitRole from './explicit-role';
import hasAccessibleName from './has-accessible-name';
import hasChild from './has-child';
import implicitRole from './implicit-role';
import inSectioningContent from './in-sectioning-content';
import isSummaryForDetails from './is-summary-for-details';
import nodeName from './node-name';
import properties from './properties';
import semanticRole from './semantic-role';
import { nodeLookup, matches } from '../../core/utils';

const matchers = {
hasAccessibleName,
attributes,
condition,
explicitRole,
hasAccessibleName,
hasChild,
implicitRole,
inSectioningContent,
isSummaryForDetails,
nodeName,
properties,
semanticRole
Expand Down
2 changes: 1 addition & 1 deletion lib/commons/matches/has-accessible-name.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import fromPrimative from './from-primative';

/**
* Check if a virtual node has a non-empty accessible name
*``
*
* Note: matches.hasAccessibleName(vNode, true) can be indirectly used through
* matches(vNode, { hasAccessibleName: boolean })
*
Expand Down
21 changes: 21 additions & 0 deletions lib/commons/matches/has-child.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { matches } from '../../core/utils';

/**
* Check if a virtual node has a direct child that matches the selector
*
* Note: matches.hasChild(vNode, selector) can be indirectly used through
* matches(vNode, { hasChild: selector })
*
* Example:
* ```js
* matches.hasChild(vNode, 'main');
* matches.hasChild(vNode, 'button:not([role])');
* ```
*
* @param {VirtualNode} vNode
* @param {String} selector
* @returns {Boolean}
*/
export default function hasChild(vNode, selector) {
return vNode.children.some(node => matches(node, selector));
Comment thread
straker marked this conversation as resolved.
}
Comment thread
straker marked this conversation as resolved.
88 changes: 88 additions & 0 deletions lib/commons/matches/in-sectioning-content.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import getElementsByContentType from '../standards/get-elements-by-content-type';
import cache from '../../core/base/cache';
import getExplicitRole from '../aria/get-explicit-role';
import { hasConflictResolution } from '../aria/get-role';
import fromPrimative from './from-primative';

// sectioning roles are not the same as landmark roles so this is a hard coded list
const sectioningRoles = [
'article',
'complementary',
'main',
'navigation',
'region'
];

/**
* Check if a virtual node is a descendant of sectioning content
*
* Note: matches.inSectioningContent(vNode) can be indirectly used through
* matches(vNode, { inSectioningContent: boolean })
*
* Example:
* ```js
* matches.inSectioningContent(vNode, true);
* matches.inSectioningContent(vNode, false);
* ```
*
* @param {VirtualNode} vNode
* @param {Object} matcher
* @returns {Boolean}
*/
export default function inSectioningContent(vNode, matcher) {
// @see https://html.spec.whatwg.org/multipage/dom.html#sectioning-content
// main is not considered sectioning content so we need to add it
const sectioningElms = cache.get('sectioningElms', () =>
getElementsByContentType('sectioning').concat('main')
);

// the top node of the tree will have parent === null, so a undefined parent means
// we are in a disconnected tree
if (typeof vNode.parent === 'undefined') {
throw new TypeError('Cannot resolve parent for non-DOM nodes');
}

vNode = vNode.parent;
while (vNode) {
Comment thread
straker marked this conversation as resolved.
const { nodeName } = vNode.props;

/*
avoid calling into getRole (for now) in order to avoid an infinite loop of
calling into the html-elms spec. for example,
<section aria-labelledby="foo"><header id="foo"></header></section>, if using
getRole, would trigger looking at the accessible name through ariaLabelledby,
which would then look at the header which would try to get the role and see it
needs to be in sectioning content (when implemented), which would then loop to
checking the accessible name of the parent section, ad infinitum. this is solved
by the change suggested in https://github.com/dequelabs/axe-core/issues/5263.

unfortunately this means we need to handle element internals and conflict resolution
ourselves
*/
let role = getExplicitRole(vNode);
if (
['presentation', 'none'].includes(role) &&
hasConflictResolution(vNode)
) {
role = null;
}
if (!role && vNode.elementInternals?.role) {
role = vNode.elementInternals.role;
}

if (
(!role && sectioningElms.includes(nodeName)) ||
sectioningRoles.includes(role)
) {
return fromPrimative(true, matcher);
}

if (typeof vNode.parent === 'undefined') {
throw new TypeError('Cannot resolve parent for non-DOM nodes');
}

vNode = vNode.parent;
}

return fromPrimative(false, matcher);
}
10 changes: 8 additions & 2 deletions lib/commons/matches/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,35 @@
* @namespace commons.matches
* @memberof axe
*/
import hasAccessibleName from './has-accessible-name';
import attributes from './attributes';
import condition from './condition';
import explicitRole from './explicit-role';
import fromDefinition from './from-definition';
import fromFunction from './from-function';
import fromPrimative from './from-primative';
import fromPrimitive from './from-primitive';
import hasAccessibleName from './has-accessible-name';
import hasChild from './has-child';
import implicitRole from './implicit-role';
import inSectioningContent from './in-sectioning-content';
import isSummaryForDetails from './is-summary-for-details';
import matches from './matches';
import nodeName from './node-name';
import properties from './properties';
import semanticRole from './semantic-role';

matches.hasAccessibleName = hasAccessibleName;
matches.attributes = attributes;
matches.condition = condition;
matches.explicitRole = explicitRole;
matches.fromDefinition = fromDefinition;
matches.fromFunction = fromFunction;
matches.fromPrimative = fromPrimative;
matches.fromPrimitive = fromPrimitive;
matches.hasAccessibleName = hasAccessibleName;
matches.hasChild = hasChild;
matches.implicitRole = implicitRole;
matches.inSectioningContent = inSectioningContent;
matches.isSummaryForDetails = isSummaryForDetails;
matches.nodeName = nodeName;
matches.properties = properties;
matches.semanticRole = semanticRole;
Expand Down
34 changes: 34 additions & 0 deletions lib/commons/matches/is-summary-for-details.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import fromPrimative from './from-primative';

/**
* Check if a virtual node is the first summary child of a details element
*
* Note: matches.isSummaryForDetails(vNode) can be indirectly used through
* matches(vNode, { isSummaryForDetails: boolean })
*
* Example:
* ```js
* matches.isSummaryForDetails(vNode, true);
* matches.isSummaryForDetails(vNode, false);
* ```
*
* @param {VirtualNode} vNode
* @param {Object} matcher
* @returns {Boolean}
*/
export default function isSummaryForDetails(vNode, matcher) {
// the top node of the tree will have parent === null, so a undefined parent means
// we are in a disconnected tree
if (typeof vNode.parent === 'undefined') {
throw new TypeError('Cannot resolve parent for non-DOM nodes');
}

if (!vNode.parent || vNode.parent.props.nodeName !== 'details') {
return fromPrimative(false, matcher);
}
Comment thread
straker marked this conversation as resolved.

const firstMatch = vNode.parent.children.find(
node => node.props.nodeName === 'summary'
);
return fromPrimative(firstMatch === vNode, matcher);
}
50 changes: 50 additions & 0 deletions test/commons/matches/from-definition.js
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,56 @@ describe('matches.fromDefinition', () => {
);
});

it('matches a definition with an `inSectioningContent` property', () => {
const virtualNode = queryFixture(
'<nav><div><input id="target"/></div></nav><div id="other"></div>'
);
const otherNode = fixture.querySelector('#other');
const otherVNode = axe.utils.getNodeFromTree(otherNode);
assert.isTrue(
fromDefinition(virtualNode, {
inSectioningContent: true
})
);
assert.isFalse(
fromDefinition(otherVNode, {
inSectioningContent: true
})
);
});

it('matches a definition with an `hasChild` property', () => {
const virtualNode = queryFixture('<div id="target"><input/></div>');
assert.isTrue(
fromDefinition(virtualNode, {
hasChild: 'input'
})
);
assert.isFalse(
fromDefinition(virtualNode, {
hasChild: 'button'
})
);
});

it('matches a definition with an `isSummaryForDetails` property', () => {
const virtualNode = queryFixture(
'<details><summary id="target"></summary><summary id="other"></summary></details>'
);
const otherNode = fixture.querySelector('#other');
const otherVNode = axe.utils.getNodeFromTree(otherNode);
assert.isTrue(
fromDefinition(virtualNode, {
isSummaryForDetails: true
})
);
assert.isFalse(
fromDefinition(otherVNode, {
isSummaryForDetails: true
})
);
});

it('returns true when all matching properties return true', () => {
const virtualNode = queryFixture(
'<input id="target" value="bar" aria-disabled="true" />'
Expand Down
46 changes: 46 additions & 0 deletions test/commons/matches/has-child.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
describe('matches.hasChild', () => {
const hasChild = axe.commons.matches.hasChild;
const { html, queryFixture } = axe.testUtils;

it('returns true if has child', () => {
const vNode = queryFixture(html`<div id="target"><span></span></div>`);
assert.isTrue(hasChild(vNode, 'span'));
});

it('returns true if has child with complex selector', () => {
const vNode = queryFixture(
html`<div id="target">
<span></span>
<button></button>
</div>`
);
assert.isTrue(hasChild(vNode, 'button:not([role])'));
});

it('returns false if child does not match', () => {
const vNode = queryFixture(html`<div id="target"><span></span></div>`);
assert.isFalse(hasChild(vNode, 'button'));
});

it('returns false for descendant', () => {
const vNode = queryFixture(
html`<div id="target">
<div><span></span></div>
</div>`
);
assert.isFalse(hasChild(vNode, 'span'));
});

it('works with SerialVirtualNode', () => {
const serialNode = new axe.SerialVirtualNode({
nodeName: 'div'
});
const childNode = new axe.SerialVirtualNode({
nodeName: 'span'
});

childNode.parent = serialNode;
serialNode.children = [childNode];
assert.isTrue(hasChild(serialNode, 'span'));
});
});
Loading
Loading