Skip to content

Commit 49eaa0e

Browse files
strakerWilcoFiers
andauthored
fix(target-size): update to match new spacing requirements (#4117)
* fix(target-size): update to match new spacing requirements * working * tests * finalize? * tests * revert playground * 🤖 Automated formatting fixes * Apply suggestions from code review Co-authored-by: Wilco Fiers <WilcoFiers@users.noreply.github.com> * 🤖 Automated formatting fixes * udpate tests * dont half minOffset but double return from getOffset * Apply suggestions from code review Co-authored-by: Wilco Fiers <WilcoFiers@users.noreply.github.com> * 🤖 Automated formatting fixes * fix tests * fix test --------- Co-authored-by: straker <straker@users.noreply.github.com> Co-authored-by: Wilco Fiers <WilcoFiers@users.noreply.github.com>
1 parent fcf76e0 commit 49eaa0e

12 files changed

Lines changed: 359 additions & 346 deletions

File tree

lib/checks/mobile/target-offset-evaluate.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ export default function targetOffsetEvaluate(node, options, vNode) {
1212
if (getRoleType(vNeighbor) !== 'widget' || !isFocusable(vNeighbor)) {
1313
continue;
1414
}
15-
const offset = roundToSingleDecimal(getOffset(vNode, vNeighbor));
15+
// the offset code works off radius but we want our messaging to reflect diameter
16+
const offset =
17+
roundToSingleDecimal(getOffset(vNode, vNeighbor, minOffset / 2)) * 2;
1618
if (offset + roundingMargin >= minOffset) {
1719
continue;
1820
}

lib/checks/mobile/target-offset.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@
77
"metadata": {
88
"impact": "serious",
99
"messages": {
10-
"pass": "Target has sufficient offset from its closest neighbor (${data.closestOffset}px should be at least ${data.minOffset}px)",
11-
"fail": "Target has insufficient offset from its closest neighbor (${data.closestOffset}px should be at least ${data.minOffset}px)",
10+
"pass": "Target has sufficient space from its closest neighbors (${data.closestOffset}px should be at least ${data.minOffset}px)",
11+
"fail": "Target has insufficient space to its closest neighbors. Safe clickable space has a diameter of {$data.closestOffset}px instead of at least ${data.minOffset}px)",
1212
"incomplete": {
13-
"default": "Element with negative tabindex has insufficient offset from its closest neighbor (${data.closestOffset}px should be at least ${data.minOffset}px). Is this a target?",
14-
"nonTabbableNeighbor": "Target has insufficient offset from a neighbor with negative tabindex (${data.closestOffset}px should be at least ${data.minOffset}px). Is the neighbor a target?"
13+
"default": "Element with negative tabindex has insufficient space to its closest neighbors. Safe clickable space has a diameter of {$data.closestOffset}px instead of at least ${data.minOffset}px). Is this a target?",
14+
"nonTabbableNeighbor": "Target has insufficient space to its closest neighbors. Safe clickable space has a diameter of {$data.closestOffset}px instead of at least ${data.minOffset}px). Is the neighbor a target?"
1515
}
1616
}
1717
}

lib/commons/dom/get-target-size.js

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import findNearbyElms from './find-nearby-elms';
2+
import { splitRects, hasVisualOverlap } from '../math';
3+
import memoize from '../../core/utils/memoize';
4+
5+
const roundingMargin = 0.05;
6+
7+
export default memoize(getTargetSize);
8+
9+
/**
10+
* Compute the target size of an element.
11+
* @see https://www.w3.org/TR/WCAG22/#dfn-targets
12+
*/
13+
function getTargetSize(vNode, minSize) {
14+
const nodeRect = vNode.boundingClientRect;
15+
const overlappingVNodes = findNearbyElms(vNode).filter(vNeighbor => {
16+
return (
17+
vNeighbor.getComputedStylePropertyValue('pointer-events') !== 'none' &&
18+
hasVisualOverlap(vNode, vNeighbor)
19+
);
20+
});
21+
22+
if (!overlappingVNodes.length) {
23+
return nodeRect;
24+
}
25+
26+
return getLargestUnobscuredArea(vNode, overlappingVNodes, minSize);
27+
}
28+
29+
// Find areas of the target that are not obscured
30+
function getLargestUnobscuredArea(vNode, obscuredNodes, minSize) {
31+
const nodeRect = vNode.boundingClientRect;
32+
if (obscuredNodes.length === 0) {
33+
return null;
34+
}
35+
const obscuringRects = obscuredNodes.map(
36+
({ boundingClientRect: rect }) => rect
37+
);
38+
const unobscuredRects = splitRects(nodeRect, obscuringRects);
39+
if (!unobscuredRects.length) {
40+
return null;
41+
}
42+
43+
// Of the unobscured inner rects, work out the largest
44+
return getLargestRect(unobscuredRects, minSize);
45+
}
46+
47+
// Find the largest rectangle in the array, prioritize ones that meet a minimum size
48+
function getLargestRect(rects, minSize) {
49+
return rects.reduce((rectA, rectB) => {
50+
const rectAisMinimum = rectHasMinimumSize(minSize, rectA);
51+
const rectBisMinimum = rectHasMinimumSize(minSize, rectB);
52+
// Prioritize rects that pass the minimum
53+
if (rectAisMinimum !== rectBisMinimum) {
54+
return rectAisMinimum ? rectA : rectB;
55+
}
56+
const areaA = rectA.width * rectA.height;
57+
const areaB = rectB.width * rectB.height;
58+
return areaA > areaB ? rectA : rectB;
59+
});
60+
}
61+
62+
function rectHasMinimumSize(minSize, { width, height }) {
63+
return (
64+
width + roundingMargin >= minSize && height + roundingMargin >= minSize
65+
);
66+
}

lib/commons/dom/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export { default as getOverflowHiddenAncestors } from './get-overflow-hidden-anc
1717
export { default as getRootNode } from './get-root-node';
1818
export { default as getScrollOffset } from './get-scroll-offset';
1919
export { default as getTabbableElements } from './get-tabbable-elements';
20+
export { default as getTargetSize } from './get-target-size';
2021
export { default as getTextElementStack } from './get-text-element-stack';
2122
export { default as getViewportSize } from './get-viewport-size';
2223
export { default as getVisibleChildTextRects } from './get-visible-child-text-rects';

lib/commons/math/get-offset.js

Lines changed: 44 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -1,91 +1,61 @@
1+
import { getTargetSize } from '../dom';
2+
13
/**
24
* Get the offset between node A and node B
35
* @method getOffset
46
* @memberof axe.commons.math
57
* @param {VirtualNode} vNodeA
68
* @param {VirtualNode} vNodeB
9+
* @param {Number} radius
710
* @returns {number}
811
*/
9-
export default function getOffset(vNodeA, vNodeB) {
10-
const rectA = vNodeA.boundingClientRect;
11-
const rectB = vNodeB.boundingClientRect;
12-
const pointA = getFarthestPoint(rectA, rectB);
13-
const pointB = getClosestPoint(pointA, rectA, rectB);
14-
return pointDistance(pointA, pointB);
15-
}
16-
17-
/**
18-
* Get a point on rectA that is farthest away from rectB
19-
* @param {Rect} rectA
20-
* @param {Rect} rectB
21-
* @returns {Point}
22-
*/
23-
function getFarthestPoint(rectA, rectB) {
24-
const dimensionProps = [
25-
['x', 'left', 'right', 'width'],
26-
['y', 'top', 'bottom', 'height']
27-
];
28-
const farthestPoint = {};
29-
dimensionProps.forEach(([axis, start, end, diameter]) => {
30-
if (rectB[start] < rectA[start] && rectB[end] > rectA[end]) {
31-
farthestPoint[axis] = rectA[start] + rectA[diameter] / 2; // center | middle
32-
return;
33-
}
34-
// Work out which edge of A is farthest away from the center of B
35-
const centerB = rectB[start] + rectB[diameter] / 2;
36-
const startDistance = Math.abs(centerB - rectA[start]);
37-
const endDistance = Math.abs(centerB - rectA[end]);
38-
if (startDistance >= endDistance) {
39-
farthestPoint[axis] = rectA[start]; // left | top
40-
} else {
41-
farthestPoint[axis] = rectA[end]; // right | bottom
42-
}
43-
});
44-
return farthestPoint;
45-
}
12+
export default function getOffset(vNodeA, vNodeB, minRadiusNeighbour = 12) {
13+
const rectA = getTargetSize(vNodeA);
14+
const rectB = getTargetSize(vNodeB);
4615

47-
/**
48-
* Get a point on the adjacentRect, that is as close the point given from ownRect
49-
* @param {Point} ownRectPoint
50-
* @param {Rect} ownRect
51-
* @param {Rect} adjacentRect
52-
* @returns {Point}
53-
*/
54-
function getClosestPoint({ x, y }, ownRect, adjacentRect) {
55-
if (pointInRect({ x, y }, adjacentRect)) {
56-
// Check if there is an opposite corner inside the adjacent rectangle
57-
const closestPoint = getCornerInAdjacentRect(
58-
{ x, y },
59-
ownRect,
60-
adjacentRect
61-
);
62-
if (closestPoint !== null) {
63-
return closestPoint;
64-
}
65-
adjacentRect = ownRect;
16+
// one of the rects is fully obscured
17+
if (rectA === null || rectB === null) {
18+
return 0;
6619
}
6720

68-
const { top, right, bottom, left } = adjacentRect;
69-
// Is the adjacent rect horizontally or vertically aligned
70-
const xAligned = x >= left && x <= right;
71-
const yAligned = y >= top && y <= bottom;
72-
// Find the closest edge of the adjacent rect
73-
const closestX = Math.abs(left - x) < Math.abs(right - x) ? left : right;
74-
const closestY = Math.abs(top - y) < Math.abs(bottom - y) ? top : bottom;
21+
const centerA = {
22+
x: rectA.x + rectA.width / 2,
23+
y: rectA.y + rectA.height / 2
24+
};
25+
const centerB = {
26+
x: rectB.x + rectB.width / 2,
27+
y: rectB.y + rectB.height / 2
28+
};
29+
const sideB = getClosestPoint(centerA, rectB);
7530

76-
if (!xAligned && yAligned) {
77-
return { x: closestX, y }; // Closest horizontal point
78-
} else if (xAligned && !yAligned) {
79-
return { x, y: closestY }; // Closest vertical point
80-
} else if (!xAligned && !yAligned) {
81-
return { x: closestX, y: closestY }; // Closest diagonal corner
31+
return Math.min(
32+
// subtract the radius of the circle from the distance
33+
pointDistance(centerA, centerB) - minRadiusNeighbour,
34+
pointDistance(centerA, sideB)
35+
);
36+
}
37+
38+
function getClosestPoint(point, rect) {
39+
let x;
40+
let y;
41+
42+
if (point.x < rect.left) {
43+
x = rect.left;
44+
} else if (point.x > rect.right) {
45+
x = rect.right;
46+
} else {
47+
x = point.x;
8248
}
83-
// ownRect (partially) obscures adjacentRect
84-
if (Math.abs(x - closestX) < Math.abs(y - closestY)) {
85-
return { x: closestX, y }; // Inside, closest edge is horizontal
49+
50+
if (point.y < rect.top) {
51+
y = rect.top;
52+
} else if (point.y > rect.bottom) {
53+
y = rect.bottom;
8654
} else {
87-
return { x, y: closestY }; // Inside, closest edge is vertical
55+
y = point.y;
8856
}
57+
58+
return { x, y };
8959
}
9060

9161
/**
@@ -95,55 +65,5 @@ function getClosestPoint({ x, y }, ownRect, adjacentRect) {
9565
* @returns {number}
9666
*/
9767
function pointDistance(pointA, pointB) {
98-
const xDistance = Math.abs(pointA.x - pointB.x);
99-
const yDistance = Math.abs(pointA.y - pointB.y);
100-
if (!xDistance || !yDistance) {
101-
return xDistance || yDistance; // If either is 0, return the other
102-
}
103-
return Math.sqrt(Math.pow(xDistance, 2) + Math.pow(yDistance, 2));
104-
}
105-
106-
/**
107-
* Return if a point is within a rect
108-
* @param {Point} point
109-
* @param {Rect} rect
110-
* @returns {boolean}
111-
*/
112-
function pointInRect({ x, y }, rect) {
113-
return y >= rect.top && x <= rect.right && y <= rect.bottom && x >= rect.left;
114-
}
115-
116-
/**
117-
*
118-
* @param {Point} ownRectPoint
119-
* @param {Rect} ownRect
120-
* @param {Rect} adjacentRect
121-
* @returns {Point | null} With x and y
122-
*/
123-
function getCornerInAdjacentRect({ x, y }, ownRect, adjacentRect) {
124-
let closestX, closestY;
125-
// Find the opposite corner, if it is inside the adjacent rect;
126-
if (x === ownRect.left && ownRect.right < adjacentRect.right) {
127-
closestX = ownRect.right;
128-
} else if (x === ownRect.right && ownRect.left > adjacentRect.left) {
129-
closestX = ownRect.left;
130-
}
131-
if (y === ownRect.top && ownRect.bottom < adjacentRect.bottom) {
132-
closestY = ownRect.bottom;
133-
} else if (y === ownRect.bottom && ownRect.top > adjacentRect.top) {
134-
closestY = ownRect.top;
135-
}
136-
137-
if (!closestX && !closestY) {
138-
return null; // opposite corners are outside the rect, or {x,y} was a center point
139-
} else if (!closestY) {
140-
return { x: closestX, y };
141-
} else if (!closestX) {
142-
return { x, y: closestY };
143-
}
144-
if (Math.abs(x - closestX) < Math.abs(y - closestY)) {
145-
return { x: closestX, y };
146-
} else {
147-
return { x, y: closestY };
148-
}
68+
return Math.hypot(pointA.x - pointB.x, pointA.y - pointB.y);
14969
}

lib/commons/math/split-rects.js

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* @memberof axe.commons.math
66
* @param {DOMRect} outerRect
77
* @param {DOMRect[]} overlapRects
8-
* @returns {Rect[]} Unique array of rects
8+
* @returns {DOMRect[]} Unique array of rects
99
*/
1010
export default function splitRects(outerRect, overlapRects) {
1111
let uniqueRects = [outerRect];
@@ -37,19 +37,33 @@ function splitRect(inputRect, clipRect) {
3737
rects.push({ top, left, bottom, right: clipRect.left });
3838
}
3939
if (rects.length === 0) {
40+
// Fully overlapping
41+
if (isEnclosedRect(inputRect, clipRect)) {
42+
return [];
43+
}
44+
4045
rects.push(inputRect); // No intersection
4146
}
47+
4248
return rects.map(computeRect); // add x / y / width / height
4349
}
4450

4551
const between = (num, min, max) => num > min && num < max;
4652

4753
function computeRect(baseRect) {
48-
return {
49-
...baseRect,
50-
x: baseRect.left,
51-
y: baseRect.top,
52-
height: baseRect.bottom - baseRect.top,
53-
width: baseRect.right - baseRect.left
54-
};
54+
return new window.DOMRect(
55+
baseRect.left,
56+
baseRect.top,
57+
baseRect.right - baseRect.left,
58+
baseRect.bottom - baseRect.top
59+
);
60+
}
61+
62+
function isEnclosedRect(rectA, rectB) {
63+
return (
64+
rectA.top >= rectB.top &&
65+
rectA.left >= rectB.left &&
66+
rectA.bottom <= rectB.bottom &&
67+
rectA.right <= rectB.right
68+
);
5569
}

locales/_template.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -862,11 +862,11 @@
862862
"fail": "${data} on <meta> tag disables zooming on mobile devices"
863863
},
864864
"target-offset": {
865-
"pass": "Target has sufficient offset from its closest neighbor (${data.closestOffset}px should be at least ${data.minOffset}px)",
866-
"fail": "Target has insufficient offset from its closest neighbor (${data.closestOffset}px should be at least ${data.minOffset}px)",
865+
"pass": "Target has sufficient space from its closest neighbors (${data.closestOffset}px should be at least ${data.minOffset}px)",
866+
"fail": "Target has insufficient space to its closest neighbors. Safe clickable space has a diameter of {$data.closestOffset}px instead of at least ${data.minOffset}px)",
867867
"incomplete": {
868-
"default": "Element with negative tabindex has insufficient offset from its closest neighbor (${data.closestOffset}px should be at least ${data.minOffset}px). Is this a target?",
869-
"nonTabbableNeighbor": "Target has insufficient offset from a neighbor with negative tabindex (${data.closestOffset}px should be at least ${data.minOffset}px). Is the neighbor a target?"
868+
"default": "Element with negative tabindex has insufficient space to its closest neighbors. Safe clickable space has a diameter of {$data.closestOffset}px instead of at least ${data.minOffset}px). Is this a target?",
869+
"nonTabbableNeighbor": "Target has insufficient space to its closest neighbors. Safe clickable space has a diameter of {$data.closestOffset}px instead of at least ${data.minOffset}px). Is the neighbor a target?"
870870
}
871871
},
872872
"target-size": {

0 commit comments

Comments
 (0)