diff --git a/lib/checks/navigation/heading-order-after.js b/lib/checks/navigation/heading-order-after.js
index ce0de44f09..fd7f73da19 100644
--- a/lib/checks/navigation/heading-order-after.js
+++ b/lib/checks/navigation/heading-order-after.js
@@ -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) {
+ 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);
+ 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;
diff --git a/lib/checks/navigation/heading-order-evaluate.js b/lib/checks/navigation/heading-order-evaluate.js
index 2ece58357a..73d4f05f64 100644
--- a/lib/checks/navigation/heading-order-evaluate.js
+++ b/lib/checks/navigation/heading-order-evaluate.js
@@ -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;
}
diff --git a/test/checks/navigation/heading-order.js b/test/checks/navigation/heading-order.js
index 879fc64f2e..488d06ed6d 100644
--- a/test/checks/navigation/heading-order.js
+++ b/test/checks/navigation/heading-order.js
@@ -8,26 +8,54 @@ describe('heading-order', function() {
checkContext.reset();
});
- it('should store the correct header level for aria-level and return true', function() {
+ it('should store the heading order path and level for [role=heading] elements and return true', function() {
var vNode = queryFixture(
- '