diff --git a/lib/checks/mobile/target-size-evaluate.js b/lib/checks/mobile/target-size-evaluate.js index a0224c8ad2..3d692606e3 100644 --- a/lib/checks/mobile/target-size-evaluate.js +++ b/lib/checks/mobile/target-size-evaluate.js @@ -10,7 +10,7 @@ import { * Determine if an element has a minimum size, taking into account * any elements that may obscure it. */ -export default function targetSize(node, options, vNode) { +export default function targetSizeEvaluate(node, options, vNode) { const minSize = options?.minSize || 24; const nodeRect = vNode.boundingClientRect; const hasMinimumSize = rectHasMinimumSize.bind(null, minSize); @@ -21,8 +21,20 @@ export default function targetSize(node, options, vNode) { nearbyElms ); + // Target has overflowing content; + // and is either not fully obscured (so may not pass), + // or has insufficient space (and so may not fail) + if ( + overflowingContent.length && + (fullyObscuringElms.length || !hasMinimumSize(nodeRect)) + ) { + this.data({ minSize, messageKey: 'contentOverflow' }); + this.relatedNodes(mapActualNodes(overflowingContent)); + return undefined; + } + // Target is fully obscured and no overflowing content (which may not be obscured) - if (fullyObscuringElms.length && !overflowingContent.length) { + if (fullyObscuringElms.length) { this.relatedNodes(mapActualNodes(fullyObscuringElms)); this.data({ messageKey: 'obscured' }); return true; @@ -32,31 +44,34 @@ export default function targetSize(node, options, vNode) { const negativeOutcome = isInTabOrder(vNode) ? false : undefined; // Target is too small, and has no overflowing content that increases the size - if (!hasMinimumSize(nodeRect) && !overflowingContent.length) { + if (!hasMinimumSize(nodeRect)) { this.data({ minSize, ...toDecimalSize(nodeRect) }); return negativeOutcome; } // Figure out the largest space on the target, not obscured by other widgets const obscuredWidgets = filterFocusableWidgets(partialObscuringElms); + + // Target not obscured and has sufficient space + if (!obscuredWidgets.length) { + this.data({ minSize, ...toDecimalSize(nodeRect) }); + return true; + } + const largestInnerRect = getLargestUnobscuredArea(vNode, obscuredWidgets); + if (!largestInnerRect) { + this.data({ minSize, messageKey: 'tooManyRects' }); + return undefined; + } - // Target has overflowing content; - // and is either not fully obscured (so may not pass), - // or has insufficient space (and so may not fail) - if (overflowingContent.length) { - if ( - fullyObscuringElms.length || - !hasMinimumSize(largestInnerRect || nodeRect) - ) { + // Target is obscured, and insufficient space is left + if (!hasMinimumSize(largestInnerRect)) { + if (overflowingContent.length) { this.data({ minSize, messageKey: 'contentOverflow' }); this.relatedNodes(mapActualNodes(overflowingContent)); return undefined; } - } - // Target is obscured, and insufficient space is left - if (obscuredWidgets.length !== 0 && !hasMinimumSize(largestInnerRect)) { const allTabbable = obscuredWidgets.every(isInTabOrder); const messageKey = `partiallyObscured${allTabbable ? '' : 'NonTabbable'}`; @@ -65,7 +80,7 @@ export default function targetSize(node, options, vNode) { return allTabbable ? negativeOutcome : undefined; } - // Target not obscured, or has sufficient space + // Target has sufficient space this.data({ minSize, ...toDecimalSize(largestInnerRect || nodeRect) }); this.relatedNodes(mapActualNodes(obscuredWidgets)); return true; @@ -106,13 +121,14 @@ function filterByElmsOverlap(vNode, nearbyElms) { // Find areas of the target that are not obscured function getLargestUnobscuredArea(vNode, obscuredNodes) { const nodeRect = vNode.boundingClientRect; - if (obscuredNodes.length === 0) { - return null; - } const obscuringRects = obscuredNodes.map( ({ boundingClientRect: rect }) => rect ); const unobscuredRects = splitRects(nodeRect, obscuringRects); + if (unobscuredRects.length === 0) { + return null; + } + // Of the unobscured inner rects, work out the largest return getLargestRect(unobscuredRects); } diff --git a/lib/checks/mobile/target-size.json b/lib/checks/mobile/target-size.json index 0fff548a65..649d075ae8 100644 --- a/lib/checks/mobile/target-size.json +++ b/lib/checks/mobile/target-size.json @@ -19,7 +19,8 @@ "default": "Element with negative tabindex has insufficient size (${data.width}px by ${data.height}px, should be at least ${data.minSize}px by ${data.minSize}px). Is this a target?", "contentOverflow": "Element size could not be accurately determined due to overflow content", "partiallyObscured": "Element with negative tabindex has insufficient size because it is partially obscured (smallest space is ${data.width}px by ${data.height}px, should be at least ${data.minSize}px by ${data.minSize}px). Is this a target?", - "partiallyObscuredNonTabbable": "Target has insufficient size because it is partially obscured by a neighbor with negative tabindex (smallest space is ${data.width}px by ${data.height}px, should be at least ${data.minSize}px by ${data.minSize}px). Is the neighbor a target?" + "partiallyObscuredNonTabbable": "Target has insufficient size because it is partially obscured by a neighbor with negative tabindex (smallest space is ${data.width}px by ${data.height}px, should be at least ${data.minSize}px by ${data.minSize}px). Is the neighbor a target?", + "tooManyRects": "Could not get the target size because there are too many overlapping elements" } } } diff --git a/lib/commons/math/split-rects.js b/lib/commons/math/split-rects.js index c7d45bafda..ca4ef232a6 100644 --- a/lib/commons/math/split-rects.js +++ b/lib/commons/math/split-rects.js @@ -13,6 +13,13 @@ export default function splitRects(outerRect, overlapRects) { uniqueRects = uniqueRects.reduce((rects, inputRect) => { return rects.concat(splitRect(inputRect, overlapRect)); }, []); + + // exit early if we get too many rects that it starts causing + // a performance bottleneck + // @see https://github.com/dequelabs/axe-core/issues/4359 + if (uniqueRects.length > 4000) { + return []; + } } return uniqueRects; } diff --git a/locales/_template.json b/locales/_template.json index 1ce6e1c658..3a55869ed4 100644 --- a/locales/_template.json +++ b/locales/_template.json @@ -885,7 +885,8 @@ "default": "Element with negative tabindex has insufficient size (${data.width}px by ${data.height}px, should be at least ${data.minSize}px by ${data.minSize}px). Is this a target?", "contentOverflow": "Element size could not be accurately determined due to overflow content", "partiallyObscured": "Element with negative tabindex has insufficient size because it is partially obscured (smallest space is ${data.width}px by ${data.height}px, should be at least ${data.minSize}px by ${data.minSize}px). Is this a target?", - "partiallyObscuredNonTabbable": "Target has insufficient size because it is partially obscured by a neighbor with negative tabindex (smallest space is ${data.width}px by ${data.height}px, should be at least ${data.minSize}px by ${data.minSize}px). Is the neighbor a target?" + "partiallyObscuredNonTabbable": "Target has insufficient size because it is partially obscured by a neighbor with negative tabindex (smallest space is ${data.width}px by ${data.height}px, should be at least ${data.minSize}px by ${data.minSize}px). Is the neighbor a target?", + "tooManyRects": "Could not get the target size because there are too many overlapping elements" } }, "header-present": { diff --git a/test/checks/mobile/target-offset.js b/test/checks/mobile/target-offset.js index f160baa10f..47ff219834 100644 --- a/test/checks/mobile/target-offset.js +++ b/test/checks/mobile/target-offset.js @@ -120,6 +120,36 @@ describe('target-offset tests', () => { assert.deepEqual(relatedIds, ['#left', '#right']); }); + it('returns false if there are too many focusable widgets', () => { + let html = ''; + for (let i = 0; i < 100; i++) { + html += ` +