-
Notifications
You must be signed in to change notification settings - Fork 793
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(new-rule): Add WCAG 2.2 target-size rule (#3616)
* feat(new-rule): Add WCAG 2.2 target-size rule * chore: handle rounding errors * Fix has-visual-overlap test * Fix failing tests? * Rename target-size.json file * Disable target-spacing rule on APG * Improve target-size messaging * Update is-in-text-block * Add target-size matches method * Add integration tests for target-size * Document target-size check options * put create-grid into its own file * Make all links focusable * Simplify target-size full rest * Try to fix IE issue * More cleanup and testing * Solve oddities with IE * Rename minSpacing option to minSize * Remove IE11 xit * Fix off screen grid issues * Simplify using role types * cleanup * Handle fully obscuring elements * fix failing test * Resolve last comments * fix getRoleType throwing in NodeJS context * Editorial test fix * Address review
- Loading branch information
1 parent
1ae2ac0
commit 691f1b6
Showing
51 changed files
with
2,915 additions
and
604 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
import { findNearbyElms, isFocusable } from '../../commons/dom'; | ||
import { getRoleType } from '../../commons/aria'; | ||
import { getOffset } from '../../commons/math'; | ||
|
||
const roundingMargin = 0.05; | ||
|
||
export default function targetOffsetEvaluate(node, options, vNode) { | ||
const minOffset = options?.minOffset || 24; | ||
const closeNeighbors = []; | ||
let closestOffset = minOffset; | ||
for (const vNeighbor of findNearbyElms(vNode, minOffset)) { | ||
if (getRoleType(vNeighbor) !== 'widget' || !isFocusable(vNeighbor)) { | ||
continue; | ||
} | ||
const offset = roundToSingleDecimal(getOffset(vNode, vNeighbor)); | ||
if (offset + roundingMargin >= minOffset) { | ||
continue; | ||
} | ||
closestOffset = Math.min(closestOffset, offset); | ||
closeNeighbors.push(vNeighbor.actualNode); | ||
} | ||
|
||
this.data({ closestOffset, minOffset }); | ||
if (closeNeighbors.length > 0) { | ||
this.relatedNodes(closeNeighbors); | ||
return false; | ||
} | ||
return true; | ||
} | ||
|
||
function roundToSingleDecimal(num) { | ||
return Math.round(num * 10) / 10; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
{ | ||
"id": "target-offset", | ||
"evaluate": "target-offset-evaluate", | ||
"options": { | ||
"minOffset": 24 | ||
}, | ||
"metadata": { | ||
"impact": "serious", | ||
"messages": { | ||
"pass": "Target has sufficient offset from its closest neighbor (${data.closestOffset}px should be at least ${data.minOffset}px)", | ||
"fail": "Target has insufficient offset from its closest neighbor (${data.closestOffset}px should be at least ${data.minOffset}px)" | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,104 @@ | ||
import { findNearbyElms, isFocusable } from '../../commons/dom'; | ||
import { getRoleType } from '../../commons/aria'; | ||
import { splitRects, hasVisualOverlap } from '../../commons/math'; | ||
|
||
const roundingMargin = 0.05; | ||
|
||
/** | ||
* Determine if an element has a minimum size, taking into account | ||
* any elements that may obscure it. | ||
*/ | ||
export default function targetSize(node, options, vNode) { | ||
const minSize = options?.minSize || 24; | ||
const nodeRect = vNode.boundingClientRect; | ||
const hasMinimumSize = ({ width, height }) => { | ||
return ( | ||
width + roundingMargin >= minSize && height + roundingMargin >= minSize | ||
); | ||
}; | ||
|
||
const obscuringElms = []; | ||
for (const vNeighbor of findNearbyElms(vNode)) { | ||
if ( | ||
!hasVisualOverlap(vNode, vNeighbor) || | ||
getCssPointerEvents(vNeighbor) === 'none' | ||
) { | ||
continue; | ||
} | ||
if (isEnclosedRect(vNode, vNeighbor)) { | ||
this.relatedNodes([vNeighbor.actualNode]); | ||
this.data({ messageKey: 'obscured' }); | ||
return true; | ||
} | ||
obscuringElms.push(vNeighbor); | ||
} | ||
|
||
if (!hasMinimumSize(nodeRect)) { | ||
this.data({ minSize, ...toDecimalSize(nodeRect) }); | ||
return false; | ||
} | ||
|
||
const obscuredWidgets = obscuringElms.filter( | ||
vNeighbor => getRoleType(vNeighbor) === 'widget' && isFocusable(vNeighbor) | ||
); | ||
|
||
if (obscuredWidgets.length === 0) { | ||
this.data({ minSize, ...toDecimalSize(nodeRect) }); | ||
return true; // No obscuring elements; pass | ||
} | ||
this.relatedNodes(obscuredWidgets.map(({ actualNode }) => actualNode)); | ||
|
||
// Find areas of the target that are not obscured | ||
const obscuringRects = obscuredWidgets.map( | ||
({ boundingClientRect: rect }) => rect | ||
); | ||
const unobscuredRects = splitRects(nodeRect, obscuringRects); | ||
|
||
// Of the unobscured inner rects, work out the largest | ||
const largestInnerRect = unobscuredRects.reduce((rectA, rectB) => { | ||
const rectAisMinimum = hasMinimumSize(rectA); | ||
const rectBisMinimum = hasMinimumSize(rectB); | ||
// Prioritize rects that pass the minimum | ||
if (rectAisMinimum !== rectBisMinimum) { | ||
return rectAisMinimum ? rectA : rectB; | ||
} | ||
const areaA = rectA.width * rectA.height; | ||
const areaB = rectB.width * rectB.height; | ||
return areaA > areaB ? rectA : rectB; | ||
}); | ||
|
||
if (!hasMinimumSize(largestInnerRect)) { | ||
// Element is (partially?) obscured, with insufficient space | ||
this.data({ | ||
messageKey: 'partiallyObscured', | ||
minSize, | ||
...toDecimalSize(largestInnerRect) | ||
}); | ||
return false; | ||
} | ||
|
||
this.data({ minSize, ...toDecimalSize(largestInnerRect) }); | ||
return true; | ||
} | ||
|
||
function isEnclosedRect(vNodeA, vNodeB) { | ||
const rectA = vNodeA.boundingClientRect; | ||
const rectB = vNodeB.boundingClientRect; | ||
return ( | ||
rectA.top >= rectB.top && | ||
rectA.left >= rectB.left && | ||
rectA.bottom <= rectB.bottom && | ||
rectA.right <= rectB.right | ||
); | ||
} | ||
|
||
function getCssPointerEvents(vNode) { | ||
return vNode.getComputedStylePropertyValue('pointer-events'); | ||
} | ||
|
||
function toDecimalSize(rect) { | ||
return { | ||
width: Math.round(rect.width * 10) / 10, | ||
height: Math.round(rect.height * 10) / 10 | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
{ | ||
"id": "target-size", | ||
"evaluate": "target-size-evaluate", | ||
"options": { | ||
"minSize": 24 | ||
}, | ||
"metadata": { | ||
"impact": "serious", | ||
"messages": { | ||
"pass": { | ||
"default": "Control has sufficient size (${data.width}px by ${data.height}px, should be at least ${data.minSize}px by ${data.minSize}px)", | ||
"obscured": "Control is ignored because it is fully obscured and thus not clickable" | ||
}, | ||
"fail": { | ||
"default": "Element has insufficient size (${data.width}px by ${data.height}px, should be at least ${data.minSize}px by ${data.minSize}px)", | ||
"partiallyObscured": "Element 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)" | ||
} | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,21 +1,23 @@ | ||
import standards from '../../standards'; | ||
import AbstractVirtualNode from '../../core/base/virtual-node/abstract-virtual-node'; | ||
|
||
/** | ||
* Get the "type" of role; either widget, composite, abstract, landmark or `null` | ||
* @method getRoleType | ||
* @memberof axe.commons.aria | ||
* @instance | ||
* @param {String} role The role to check | ||
* @param {String|Null|Node|Element} role The role to check, or element to check the role of | ||
* @return {Mixed} String if a matching role and its type are found, otherwise `null` | ||
*/ | ||
function getRoleType(role) { | ||
const roleDef = standards.ariaRoles[role]; | ||
|
||
if (!roleDef) { | ||
return null; | ||
if ( | ||
role instanceof AbstractVirtualNode || | ||
(window?.Node && role instanceof window.Node) | ||
) { | ||
role = axe.commons.aria.getRole(role); | ||
} | ||
|
||
return roleDef.type; | ||
const roleDef = standards.ariaRoles[role]; | ||
return roleDef?.type || null; | ||
} | ||
|
||
export default getRoleType; |
Oops, something went wrong.