Skip to content

Commit

Permalink
feat(new-rule): Add WCAG 2.2 target-size rule (#3616)
Browse files Browse the repository at this point in the history
* 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
WilcoFiers authored Sep 21, 2022
1 parent 1ae2ac0 commit 691f1b6
Show file tree
Hide file tree
Showing 51 changed files with 2,915 additions and 604 deletions.
14 changes: 14 additions & 0 deletions doc/check-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
- [p-as-heading](#p-as-heading)
- [avoid-inline-spacing](#avoid-inline-spacing)
- [scope-value](#scope-value)
- [target-offset](#target-offset)
- [target-size](#target-size)
- [region](#region)
- [inline-style-property](#inline-style-property)

Expand Down Expand Up @@ -487,6 +489,18 @@ h6:not([role]),
| -------- | :-------------------------------------------------------- | :------------------------- |
| `values` | <pre lang=js>['row', 'col', 'rowgroup', 'colgroup']</pre> | List of valid scope values |

### target-offset

| Option | Default | Description |
| ----------- | :------ | :--------------------------------------------------------------------------------------------------------- |
| `minOffset` | `24` | Minimum space required from the farthest edge of the target, to the closest edge of the neighboring target |

### target-size

| Option | Default | Description |
| --------- | :------ | :------------------------------------------------------------------------------------------------------- |
| `minSize` | `24` | Minimum width and height a component should have, that is not obscured by some other interactive element |

### region

| Option | Default | Description |
Expand Down
9 changes: 5 additions & 4 deletions doc/rule-descriptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,11 @@

## WCAG 2.1 Level A & AA Rules

| Rule ID | Description | Impact | Tags | Issue Type | ACT Rules |
| :----------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------- | :------ | :------------------------------------- | :--------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [autocomplete-valid](https://dequeuniversity.com/rules/axe/4.4/autocomplete-valid?application=RuleDescription) | Ensure the autocomplete attribute is correct and suitable for the form field | Serious | cat.forms, wcag21aa, wcag135, ACT | failure | [73f2c2](https://act-rules.github.io/rules/73f2c2) |
| [avoid-inline-spacing](https://dequeuniversity.com/rules/axe/4.4/avoid-inline-spacing?application=RuleDescription) | Ensure that text spacing set through style attributes can be adjusted with custom stylesheets | Serious | cat.structure, wcag21aa, wcag1412, ACT | failure | [24afc2](https://act-rules.github.io/rules/24afc2), [9e45ec](https://act-rules.github.io/rules/9e45ec), [78fd32](https://act-rules.github.io/rules/78fd32) |
| Rule ID | Description | Impact | Tags | Issue Type | ACT Rules |
| :----------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------- | :------ | :------------------------------------------- | :--------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [autocomplete-valid](https://dequeuniversity.com/rules/axe/4.4/autocomplete-valid?application=RuleDescription) | Ensure the autocomplete attribute is correct and suitable for the form field | Serious | cat.forms, wcag21aa, wcag135, ACT | failure | [73f2c2](https://act-rules.github.io/rules/73f2c2) |
| [avoid-inline-spacing](https://dequeuniversity.com/rules/axe/4.4/avoid-inline-spacing?application=RuleDescription) | Ensure that text spacing set through style attributes can be adjusted with custom stylesheets | Serious | cat.structure, wcag21aa, wcag1412, ACT | failure | [24afc2](https://act-rules.github.io/rules/24afc2), [9e45ec](https://act-rules.github.io/rules/9e45ec), [78fd32](https://act-rules.github.io/rules/78fd32) |
| [target-size](https://dequeuniversity.com/rules/axe/4.4/target-size?application=RuleDescription) | Ensure touch target have sufficient size and space | Serious | wcag22aa, sc258, cat.sensory-and-visual-cues | failure | |

## Best Practices Rules

Expand Down
5 changes: 2 additions & 3 deletions lib/checks/keyboard/no-focusable-content-evaluate.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import isFocusable from '../../commons/dom/is-focusable';
import { getRole, getRoleType } from '../../commons/aria';
import { getRoleType } from '../../commons/aria';

export default function noFocusableContentEvaluate(node, options, virtualNode) {
if (!virtualNode.children) {
Expand Down Expand Up @@ -41,8 +41,7 @@ function getFocusableDescendants(vNode) {

const retVal = [];
vNode.children.forEach(child => {
const role = getRole(child);
if (getRoleType(role) === 'widget' && isFocusable(child)) {
if (getRoleType(child) === 'widget' && isFocusable(child)) {
retVal.push(child);
} else {
retVal.push(...getFocusableDescendants(child));
Expand Down
11 changes: 9 additions & 2 deletions lib/checks/label/multiple-label-evaluate.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { getRootNode, isVisibleOnScreen, isVisibleForScreenreader, idrefs } from '../../commons/dom';
import {
getRootNode,
isVisibleOnScreen,
isVisibleForScreenreader,
idrefs
} from '../../commons/dom';
import { escapeSelector } from '../../core/utils';

function multipleLabelEvaluate(node) {
Expand Down Expand Up @@ -27,7 +32,9 @@ function multipleLabelEvaluate(node) {

// more than 1 CSS visible label
if (labels.length > 1) {
const ATVisibleLabels = labels.filter(label => isVisibleForScreenreader(label));
const ATVisibleLabels = labels.filter(label =>
isVisibleForScreenreader(label)
);
// more than 1 AT visible label will fail IOS/Safari/VO even with aria-labelledby
if (ATVisibleLabels.length > 1) {
return undefined;
Expand Down
33 changes: 33 additions & 0 deletions lib/checks/mobile/target-offset-evaluate.js
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;
}
14 changes: 14 additions & 0 deletions lib/checks/mobile/target-offset.json
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)"
}
}
}
104 changes: 104 additions & 0 deletions lib/checks/mobile/target-size-evaluate.js
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
};
}
20 changes: 20 additions & 0 deletions lib/checks/mobile/target-size.json
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)"
}
}
}
}
5 changes: 4 additions & 1 deletion lib/checks/navigation/skip-link-evaluate.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { getElementByReference, isVisibleForScreenreader } from '../../commons/dom';
import {
getElementByReference,
isVisibleForScreenreader
} from '../../commons/dom';

function skipLinkEvaluate(node) {
const target = getElementByReference(node, 'href');
Expand Down
16 changes: 9 additions & 7 deletions lib/commons/aria/get-role-type.js
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;
Loading

0 comments on commit 691f1b6

Please sign in to comment.