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

fix(heading-order): allow partial context to pass #2622

Merged
merged 9 commits into from
Nov 24, 2020
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
133 changes: 128 additions & 5 deletions lib/checks/navigation/heading-order-after.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,138 @@
const joinStr = ' > ';

/**
* Flatten an ancestry path of an iframe result into a string.
*/
function getFramePath(ancestry, nodePath) {
// remove the last path so we're only left with iframe paths
ancestry = ancestry.slice(0, ancestry.length - 1);

if (nodePath) {
ancestry = ancestry.concat(nodePath);
}

return ancestry.join(joinStr);
}

function headingOrderAfter(results) {
if (results.length < 2) {
return results;
}

var prevLevel = results[0].data;
/**
* In order to correctly return heading order results (even for
* headings that may be out of the current context) we need to
* construct an in-order list of all headings on the page,
* including headings from iframes.
*
* To do this we will find all nested headingOrders (i.e. those
* from iframes) and then determine where those results fit into
* the top-level heading order. once we've put all the heading
* orders into their proper place, we can then determine which
* headings are not in the correct order.
**/

// start by replacing all array ancestry paths with a flat string
// path
let headingOrder = results[0].data.headingOrder.map(heading => {
return {
...heading,
ancestry: getFramePath(results[0].node.ancestry, heading.ancestry)
};
});

// find all nested headindOrders
const nestedResults = results.filter(result => {
return result.data && result.data.headingOrder && result.node._fromFrame;
});

// update the path of nodes to include the iframe path
nestedResults.forEach(result => {
result.data.headingOrder = result.data.headingOrder.map(heading => {
return {
...heading,
ancestry: getFramePath(result.node.ancestry, heading.ancestry)
};
});
});

/**
* Determine where the iframe results fit into the top-level
* heading order
*/
function getFrameIndex(result) {
const path = getFramePath(result.node.ancestry);
const heading = headingOrder.find(heading => {
return heading.ancestry === path;
});
return headingOrder.indexOf(heading);
}

/**
* Replace an iframe placeholder with its results
*/
function replaceFrameWithResults(index, result) {
headingOrder.splice(index, 1, ...result.data.headingOrder);
}

// replace each iframe in the top-level heading order with its
// results.
// since nested iframe results can appear before their parent
// iframe, we will just loop over the nested results and
// piece-meal replace each iframe in the top-level heading order
// with their results until we no longer have results to replace
let replaced = false;
while (nestedResults.length) {
straker marked this conversation as resolved.
Show resolved Hide resolved
straker marked this conversation as resolved.
Show resolved Hide resolved
for (let i = 0; i < nestedResults.length; ) {
const nestedResult = nestedResults[i];
const index = getFrameIndex(nestedResult);

if (index !== -1) {
replaceFrameWithResults(index, nestedResult);
replaced = true;

// remove the nested result from the list
nestedResults.splice(i, 1);
} else {
i++;
}
}

// something went wrong if we can't replace an iframe in
// the top-level results
if (!replaced) {
throw new Error('Unable to find parent iframe of heading-order results');
}
}

// replace the ancestry path with information about the result
results.forEach(result => {
const path = result.node.ancestry.join(joinStr);
const heading = headingOrder.find(heading => {
return heading.ancestry === path;
});
const index = headingOrder.indexOf(heading);
WilcoFiers marked this conversation as resolved.
Show resolved Hide resolved
headingOrder.splice(index, 1, {
level: headingOrder[index].level,
result
});
});

// remove any iframes that aren't in context (level == -1)
headingOrder = headingOrder.filter(heading => heading.level > 0);

for (var i = 1; i < results.length; i++) {
if (results[i].result && results[i].data > prevLevel + 1) {
results[i].result = false;
// now make sure all headings are in the correct order
for (let i = 1; i < results.length; i++) {
const result = results[i];
const heading = headingOrder.find(heading => {
return heading.result === result;
});
const index = headingOrder.indexOf(heading);
const currLevel = headingOrder[index].level;
const prevLevel = headingOrder[index - 1].level;
if (currLevel - prevLevel > 1) {
result.result = false;
}
prevLevel = results[i].data;
}

return results;
Expand Down
56 changes: 47 additions & 9 deletions lib/checks/navigation/heading-order-evaluate.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,57 @@
function headingOrderEvaluate(node, options, virtualNode) {
const ariaHeadingLevel = virtualNode.attr('aria-level');
const nodeName = virtualNode.props.nodeName;
import cache from '../../core/base/cache';
import { querySelectorAllFilter, getAncestry } from '../../core/utils';
import { isVisible } from '../../commons/dom';

if (ariaHeadingLevel !== null) {
this.data(parseInt(ariaHeadingLevel, 10));
return true;
}
function getLevel(vNode) {
const role = vNode.attr('role');
if (role && role.includes('heading')) {
const ariaHeadingLevel = vNode.attr('aria-level');
const level = parseInt(ariaHeadingLevel, 10);

const headingLevel = nodeName.toUpperCase().match(/H(\d)/);
// default aria-level for a heading is 2 if it is
// not set or set to an incorrect value
// @see https://www.w3.org/TR/wai-aria-1.1/#heading
if (isNaN(level) || level < 1 || level > 6) {
return 2;
}

return level;
}

const headingLevel = vNode.props.nodeName.match(/h(\d)/);
if (headingLevel) {
this.data(parseInt(headingLevel[1], 10));
return parseInt(headingLevel[1], 10);
}

return -1;
}

function headingOrderEvaluate() {
let headingOrder = cache.get('headingOrder');
if (headingOrder) {
return true;
}

// find all headings, even ones that are outside the current
// context. also need to know where iframes are so we can insert
// the results of any in-context iframes into their proper order
// @see https://github.com/dequelabs/axe-core/issues/728
const selector = 'h1, h2, h3, h4, h5, h6, [role=heading], iframe, frame';
// TODO: es-modules_tree
const vNodes = querySelectorAllFilter(axe._tree[0], selector, vNode =>
isVisible(vNode.actualNode, true)
);

headingOrder = vNodes.map(vNode => {
// save the path so we can reconstruct the heading order
return {
ancestry: [getAncestry(vNode.actualNode)],
level: getLevel(vNode)
};
});

this.data({ headingOrder });
cache.set('headingOrder', vNodes);
return true;
}

Expand Down
Loading