Skip to content

Commit

Permalink
fix(heading-order): Prevent crash on page with iframes but no headings (
Browse files Browse the repository at this point in the history
#2965)

* fix(heading-order): Crash on page with iframes but no headings

* chore: Fix linter issues

* chore: fewer things undefined

* chore: attempt to fix IE stuff

* chore: refactor

* chore: tweak heading-order test

* fix: heading-order properly filter placeholders
  • Loading branch information
WilcoFiers committed Jun 2, 2021
1 parent fcb3bb6 commit 99e7f0c
Show file tree
Hide file tree
Showing 13 changed files with 1,310 additions and 1,017 deletions.
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

0 comments on commit 99e7f0c

Please sign in to comment.