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): Crash on page with iframes but no headings #2965

Merged
merged 7 commits into from
Jun 2, 2021
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 .eslintrc.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
module.exports = {
extends: ['prettier'],
parserOptions: {
ecmaVersion: 9
ecmaVersion: 2021
},
env: {
node: true,
Expand Down
2 changes: 1 addition & 1 deletion doc/rule-descriptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ Rules that do not necessarily conform to WCAG success criterion but are industry
| [empty-heading](https://dequeuniversity.com/rules/axe/4.2/empty-heading?application=RuleDescription) | Ensures headings have discernible text | Minor | cat.name-role-value, best-practice | failure, needs review | |
| [frame-tested](https://dequeuniversity.com/rules/axe/4.2/frame-tested?application=RuleDescription) | Ensures <iframe> and <frame> elements contain the axe-core script | Critical | cat.structure, review-item, best-practice | failure, needs review | |
| [frame-title-unique](https://dequeuniversity.com/rules/axe/4.2/frame-title-unique?application=RuleDescription) | Ensures <iframe> and <frame> elements contain a unique title attribute | Serious | cat.text-alternatives, best-practice | failure | |
| [heading-order](https://dequeuniversity.com/rules/axe/4.2/heading-order?application=RuleDescription) | Ensures the order of headings is semantically correct | Moderate | cat.semantics, best-practice | failure | |
| [heading-order](https://dequeuniversity.com/rules/axe/4.2/heading-order?application=RuleDescription) | Ensures the order of headings is semantically correct | Moderate | cat.semantics, best-practice | failure, needs review | |
| [identical-links-same-purpose](https://dequeuniversity.com/rules/axe/4.2/identical-links-same-purpose?application=RuleDescription) | Ensure that links with the same accessible name serve a similar purpose | Minor | cat.semantics, wcag2aaa, wcag249, best-practice | needs review | [b20e66](https://act-rules.github.io/rules/b20e66), [fd3a94](https://act-rules.github.io/rules/fd3a94) |
| [image-redundant-alt](https://dequeuniversity.com/rules/axe/4.2/image-redundant-alt?application=RuleDescription) | Ensure image alternative is not repeated as text | Minor | cat.text-alternatives, best-practice | failure | |
| [label-title-only](https://dequeuniversity.com/rules/axe/4.2/label-title-only?application=RuleDescription) | Ensures that every form element is not solely labeled using the title or aria-describedby attributes | Serious | cat.forms, best-practice | failure | |
Expand Down
232 changes: 110 additions & 122 deletions lib/checks/navigation/heading-order-after.js
Original file line number Diff line number Diff line change
@@ -1,144 +1,132 @@
const joinStr = ' > ';
export default function headingOrderAfter(results) {
// Construct a map of all headings on the page
const headingOrder = getHeadingOrder(results);
results.forEach(result => {
result.result = getHeadingOrderOutcome(result, headingOrder)
});
return results;
}

/**
* Flatten an ancestry path of an iframe result into a string.
* Determine check outcome, based on the position of the result in the headingOrder
*/
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);
function getHeadingOrderOutcome(result, headingOrder) {
const index = findHeadingOrderIndex(headingOrder, result.node.ancestry)
const currLevel = headingOrder[index]?.level ?? -1;
const prevLevel = headingOrder[index - 1]?.level ?? -1;

// First heading always passes
if (index === 0) {
return true
};
// Heading not in the map
if (currLevel === -1) {
return undefined;
}

return ancestry.join(joinStr);
// Check if a heading is skipped
return (currLevel - prevLevel <= 1)
}

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

/**
* 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
const pageResult = results.find(result => !result.node._fromFrame);
let headingOrder = pageResult.data.headingOrder.map(heading => {
return {
...heading,
ancestry: getFramePath(pageResult.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)
};
});
/**
* Generate a flattened heading order map, from the data property
* of heading-order results
*/
function getHeadingOrder(results) {
// Ensure parent frames are handled first
results = [...results];
results.sort(({ node: nodeA }, { node: nodeB }) => {
return nodeA.ancestry.length - nodeB.ancestry.length;
});
// push or splice result.data into headingOrder
const headingOrder = results.reduce(mergeHeadingOrder, []);
// Remove all frame placeholders
return headingOrder.filter(({ level }) => level !== -1);
}

/**
* 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);
}
/**
* Add the data of a heading-order result to the headingOrder map
*/
function mergeHeadingOrder(mergedHeadingOrder, result) {
const frameHeadingOrder = result.data?.headingOrder;
const frameAncestry = shortenArray(result.node.ancestry, 1);

/**
* Replace an iframe placeholder with its results
*/
function replaceFrameWithResults(index, result) {
headingOrder.splice(index, 1, ...result.data.headingOrder);
// Only the first result in each frame has a headingOrder. Ignore the rest
if (!frameHeadingOrder) {
return mergedHeadingOrder;
}

// 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;
// Prepend node ancestry to each heading.ancestry
const normalizedHeadingOrder = frameHeadingOrder.map(heading => {
return addFrameToHeadingAncestry(heading, frameAncestry);
});

// remove the nested result from the list
nestedResults.splice(i, 1);
} else {
i++;
}
}
// Find if the result is from a frame previously processed
const index = getFrameIndex(mergedHeadingOrder, frameAncestry);
// heading is not in a frame, stick 'm in at the end.
if (index === -1) {
mergedHeadingOrder.push(...normalizedHeadingOrder);
} else {
mergedHeadingOrder.splice(index, 0, ...normalizedHeadingOrder);
}
return mergedHeadingOrder;
}

// 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');
/**
* Determine where the iframe results fit into the top-level heading order
*
* If a frame has no headings, but it does have iframes we might not have a result.
* We can account for this by finding the closest ancestor we do know about.
*/
function getFrameIndex(headingOrder, frameAncestry) {
while (frameAncestry.length) {
const index = findHeadingOrderIndex(headingOrder, frameAncestry);
if (index !== -1) {
return index;
}
frameAncestry = shortenArray(frameAncestry, 1)
}
return -1;
}

// 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);
if (index > -1) {
headingOrder.splice(index, 1, {
level: headingOrder[index].level,
result
});
}
/**
* Find the index of a heading in the headingOrder by matching ancestries
*/
function findHeadingOrderIndex(headingOrder, ancestry) {
return headingOrder.findIndex(heading => {
return matchAncestry(heading.ancestry, ancestry);
});
}

// remove any iframes that aren't in context (level == -1)
headingOrder = headingOrder.filter(heading => heading.level > 0);
/**
* Prepend the frame ancestry of a node to heading.ancestry
*/
function addFrameToHeadingAncestry(heading, frameAncestry) {
const ancestry = frameAncestry.concat(heading.ancestry);
return { ...heading, ancestry };
}

// 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;
}
/**
* Check if two ancestries are identical
*/
function matchAncestry(ancestryA, ancestryB) {
if (ancestryA.length !== ancestryB.length) {
return false;
}

return results;
return ancestryA.every((selectorA, index) => {
const selectorB = ancestryB[index];
if (!Array.isArray(selectorA)) {
return selectorA === selectorB;
}
if (selectorA.length !== selectorB.length) {
return false;
}
return selectorA.every((str, index) => selectorB[index] === str);
});
}

export default headingOrderAfter;
/**
* Shorten an array by some number of items
*/
function shortenArray(arr, spliceLength) {
return arr.slice(0, arr.length - spliceLength);
}
3 changes: 2 additions & 1 deletion lib/checks/navigation/heading-order.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"impact": "moderate",
"messages": {
"pass": "Heading order valid",
"fail": "Heading order invalid"
"fail": "Heading order invalid",
"incomplete": "Unable to determine previous heading"
}
}
}
24 changes: 24 additions & 0 deletions lib/core/utils/pollyfills.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,30 @@ if (!Array.prototype.find) {
});
}

if (!Array.prototype.findIndex) {
Object.defineProperty(Array.prototype, 'findIndex', {
value: function(predicate, thisArg) {
if (this === null) {
throw new TypeError('Array.prototype.find called on null or undefined');
}
if (typeof predicate !== 'function') {
throw new TypeError('predicate must be a function');
}
var list = Object(this);
var length = list.length >>> 0;
var value;

for (var i = 0; i < length; i++) {
value = list[i];
if (predicate.call(thisArg, value, i, list)) {
return i;
}
}
return -1;
}
});
}

// Spelled incorrectly intentionally (backwards compatibility).
export function pollyfillElementsFromPoint() {
if (document.elementsFromPoint) return document.elementsFromPoint;
Expand Down
Loading