Skip to content

Instantly share code, notes, and snippets.

@mattzeunert
Last active April 25, 2019 12:59
Show Gist options
  • Save mattzeunert/f597a14b05136acfc6929a6ac3bf0188 to your computer and use it in GitHub Desktop.
Save mattzeunert/f597a14b05136acfc6929a6ac3bf0188 to your computer and use it in GitHub Desktop.
var FINGER_SIZE_PX = 48;
/**
* Merge client rects together and remove small ones. This may result in a larger overall
* size than that of the individual client rects.
* We use this to simulate a finger tap on those targets later on.
* @param {LH.Artifacts.Rect[]} clientRects
*/
window.getTappableRectsFromClientRects = getTappableRectsFromClientRects;
/**
* Merge client rects together and remove small ones. This may result in a larger overall
* size than that of the individual client rects.
* We use this to simulate a finger tap on those targets later on.
* @param {LH.Artifacts.Rect[]} clientRects
*/
function getTappableRectsFromClientRects(clientRects) {
// 1x1px rect shouldn't be reason to treat the rect as something the user should tap on.
// Often they're made invisble in some obscure way anyway, and only exist for e.g. accessibiliity.
clientRects = filterOutTinyRects(clientRects);
clientRects = filterOutRectsContainedByOthers(clientRects);
clientRects = mergeTouchingClientRects(clientRects);
return clientRects;
}
/**
* Sometimes a child will reach out of the parent by a few px, but still
* clearly belong to the same tap area in the users's eyes.
* We can be quite generous here, since merging too much tends to cause false
* passes instead of false failures (because there are more fingers)
* @param {number} a
* @param {number} b
*/
function almostEqual(a, b) {
return Math.abs(a - b) <= 10;
}
/**
* Merge touching rects based on what appears as one tappable area to the user.
* @param {LH.Artifacts.Rect[]} clientRects
* @returns {LH.Artifacts.Rect[]}
*/
function mergeTouchingClientRects(clientRects) {
for (let i = 0; i < clientRects.length; i++) {
for (let j = i + 1; j < clientRects.length; j++) {
const crA = clientRects[i];
const crB = clientRects[j];
/**
* We try to determine whether the rects appear as a single tappable
* area to the user, so that they'd tap in the middle of the merged rect.
* Examples of what we want to merge:
*
* AAABBB
*
* AAA
* AAA
* BBBBB
*/
const rectsLineUpHorizontally =
almostEqual(crA.top, crB.top) || almostEqual(crA.bottom, crB.bottom);
const rectsLineUpVertically =
almostEqual(crA.left, crB.left) || almostEqual(crA.right, crB.right);
const canMerge =
rectsTouchOrOverlap(crA, crB) &&
(rectsLineUpHorizontally || rectsLineUpVertically);
if (canMerge) {
const replacementClientRect = getBoundingRect(crA, crB);
const mergedRectCenter = getRectCenterPoint(replacementClientRect);
if (
!(
rectContainsPoint(crA, mergedRectCenter) ||
rectContainsPoint(crB, mergedRectCenter)
)
) {
// Don't merge because the new shape is too different from the
// merged rects, and tapping in the middle wouldn't actually hit
// either rect
continue;
}
// Replace client rects with merged version
clientRects = clientRects.filter(cr => cr !== crA && cr !== crB);
clientRects.push(replacementClientRect);
// Start over so we don't have to handle complexity introduced by array mutation.
// Client rect ararys rarely contain more than 5 rects, so starting again doesn't cause perf issues.
return mergeTouchingClientRects(clientRects);
}
}
}
return clientRects;
}
function drawFinger(bcr, debugInfo, color = "rgba(0,0,255,0.2)") {
const point = document.createElement("div");
point.bcr = bcr;
point.style.position = "absolute";
point.style.left = bcr.left + "px";
point.style.top = bcr.top + "px";
point.style.width = bcr.right - bcr.left + "px";
point.style.height = bcr.bottom - bcr.top + "px";
point.style.background = color;
point.style.zIndex = "1000000000";
point.setAttribute("finger", "f");
point.setAttribute(
"debugInfo",
JSON.stringify(debugInfo || "").slice(0, 500)
);
document.body.appendChild(point);
return point;
}
/**
* Merge touching rects based on what appears as one tappable area to the user.
* @param {LH.Artifacts.Rect[]} clientRects
* @returns {LH.Artifacts.Rect[]}
*/
function mergeTouchingClientRects(clientRects) {
for (let i = 0; i < clientRects.length; i++) {
for (let j = i + 1; j < clientRects.length; j++) {
const crA = clientRects[i];
const crB = clientRects[j];
/**
* We try to determine whether the rects appear as a single tappable
* area to the user, so that they'd tap in the middle of the merged rect.
* Examples of what we want to merge:
*
* AAABBB
*
* AAA
* AAA
* BBBBB
*/
const rectsLineUpHorizontally =
almostEqual(crA.top, crB.top) || almostEqual(crA.bottom, crB.bottom);
const rectsLineUpVertically =
almostEqual(crA.left, crB.left) || almostEqual(crA.right, crB.right);
const canMerge =
rectsTouchOrOverlap(crA, crB) &&
(rectsLineUpHorizontally || rectsLineUpVertically);
if (canMerge) {
const replacementClientRect = getBoundingRect(crA, crB);
const mergedRectCenter = getRectCenterPoint(replacementClientRect);
if (
!(
rectContainsPoint(crA, mergedRectCenter) ||
rectContainsPoint(crB, mergedRectCenter)
)
) {
// Don't merge because the new shape is too different from the
// merged rects, and tapping in the middle wouldn't actually hit
// either rect
continue;
}
// Replace client rects with merged version
clientRects = clientRects.filter(cr => cr !== crA && cr !== crB);
clientRects.push(replacementClientRect);
// Start over so we don't have to handle complexity introduced by array mutation.
// Client rect ararys rarely contain more than 5 rects, so starting again doesn't cause perf issues.
return mergeTouchingClientRects(clientRects);
}
}
}
return clientRects;
}
// Ratio of the finger area tapping on an unintended element
// to the finger area tapping on the intended element
var MAX_ACCEPTABLE_OVERLAP_SCORE_RATIO = 0.25;
/**
* @param {LH.Artifacts.Rect} cr
*/
function clientRectMeetsMinimumSize(cr) {
return cr.width >= FINGER_SIZE_PX && cr.height >= FINGER_SIZE_PX;
}
/**
* @param {LH.Artifacts.TapTarget} target
*/
function targetIsTooSmall(target) {
for (const cr of target.clientRects) {
if (clientRectMeetsMinimumSize(cr)) {
return false;
}
}
return true;
}
/**
*
* @param {LH.Artifacts.TapTarget[]} targets
*/
function getTooSmallTargets(targets) {
return targets.filter(targetIsTooSmall);
}
/**
*
* @param {LH.Artifacts.TapTarget[]} tooSmallTargets
* @param {LH.Artifacts.TapTarget[]} allTargets
*/
function getOverlapFailures(tooSmallTargets, allTargets) {
/** @type {LH.Audit.TapTargetOverlapDetail[]} */
let failures = [];
tooSmallTargets.forEach(target => {
const overlappingTargets = getTooCloseTargets(target, allTargets);
if (overlappingTargets.length > 0) {
failures = failures.concat(overlappingTargets);
}
});
return failures;
}
/**
* @param {LH.Artifacts.TapTarget} tapTarget
* @param {LH.Artifacts.TapTarget} maybeOverlappingTarget
* @returns {LH.Audit.TapTargetOverlapDetail | null}
*/
function getTargetTooCloseFailure(tapTarget, maybeOverlappingTarget) {
// convert client rects to unique tappable areas from a user's perspective
const tappableRects = getTappableRectsFromClientRects(tapTarget.clientRects);
const isHttpOrHttpsLink = /https?:\/\//.test(tapTarget.href);
if (isHttpOrHttpsLink && tapTarget.href === maybeOverlappingTarget.href) {
// no overlap because same target action
return null;
}
/** @type LH.Audit.TapTargetOverlapDetail | null */
let greatestFailure = null;
for (const targetCR of tappableRects) {
if (
allRectsContainedWithinEachOther(
tappableRects,
maybeOverlappingTarget.clientRects
)
) {
// If one tap target is fully contained within the other that's
// probably intentional (e.g. an item with a delete button inside)
continue;
}
for (const maybeOverlappingCR of maybeOverlappingTarget.clientRects) {
const failure = getOverlapFailure(targetCR, maybeOverlappingCR);
if (failure) {
targetCR.node && (targetCR.node.style.outline = "red");
// only update our state if this was the biggest failure we've seen for this pair
if (
!greatestFailure ||
failure.overlapScoreRatio > greatestFailure.overlapScoreRatio
) {
greatestFailure = {
...failure,
tapTarget,
overlappingTarget: maybeOverlappingTarget
};
}
}
}
}
return greatestFailure;
}
/**
* @param {LH.Artifacts.Rect} targetCR
* @param {LH.Artifacts.Rect} maybeOverlappingCR
*/
function getOverlapFailure(targetCR, maybeOverlappingCR) {
const fingerRect = getRectAtCenter(targetCR, FINGER_SIZE_PX);
// Score indicates how much of the finger area overlaps each target when the user
// taps on the center of targetCR
const tapTargetScore = getRectOverlapArea(fingerRect, targetCR);
const maybeOverlappingScore = getRectOverlapArea(
fingerRect,
maybeOverlappingCR
);
const overlapScoreRatio = maybeOverlappingScore / tapTargetScore;
if (overlapScoreRatio < MAX_ACCEPTABLE_OVERLAP_SCORE_RATIO) {
// low score means it's clear that the user tried to tap on the targetCR,
// rather than the other tap target client rect
return null;
}
return {
overlapScoreRatio,
tapTargetScore,
overlappingTargetScore: maybeOverlappingScore
};
}
var tapTargets = runGatherer();
console.log({ tapTargets });
(function() {
const artifacts = { TapTargets: tapTargets };
// Augment the targets with padded bounding rects for quick intersection testing.
const boundedTapTargets = getBoundedTapTargets(artifacts.TapTargets);
const tooSmallTargets = getTooSmallTargets(boundedTapTargets);
const overlapFailures = getAllOverlapFailures(tooSmallTargets, boundedTapTargets);
document.documentElement.scrollTop = 0
overlapFailures.forEach(
(targetOverlapDetail) => {
drawFinger(targetOverlapDetail.tapTarget.clientRects[0], "", "rgba(255,0,0,0.4)")
}
);
const overlapFailuresForDisplay = mergeSymmetricFailures(overlapFailures);
const tableItems = getTableItems(overlapFailuresForDisplay);
const tapTargetCount = artifacts.TapTargets.length;
const failingTapTargetCount = new Set(overlapFailures.map(f => f.tapTarget)).size;
const passingTapTargetCount = tapTargetCount - failingTapTargetCount;
let score = 1;
let passingTapTargetRatio = 1;
if (failingTapTargetCount > 0) {
passingTapTargetRatio = (passingTapTargetCount / tapTargetCount);
// If there are any failures then we don't want the audit to pass,
// so keep the score below 90.
score = passingTapTargetRatio * 0.89;
}
for (const tapTarget of tapTargets) {
const tappableRects = getTappableRectsFromClientRects(
tapTarget.clientRects
);
for (const targetCR of tappableRects) {
drawFinger(targetCR, "", (color = "rgba(0,0,0,0.2)"));
const fingerRect = getRectAtCenter(targetCR, FINGER_SIZE_PX);
drawFinger(fingerRect, "", (color = "rgba(0,0,255,0.2)"));
}
}
console.log({ tableItems, passingTapTargetRatio, score });
})();
/**
* @param {LH.Artifacts.Rect[]} tappableRects
* @param {LH.Artifacts.Rect[]} maybeOverlappingRects
* @returns {ClientRectOverlapFailure | null}
*/
function getOverlapFailureForTargetPair(tappableRects, maybeOverlappingRects) {
/** @type ClientRectOverlapFailure | null */
let greatestFailure = null;
for (const targetCR of tappableRects) {
const fingerRect = getRectAtCenter(targetCR, FINGER_SIZE_PX);
// Score indicates how much of the finger area overlaps each target when the user
// taps on the center of targetCR
const tapTargetScore = getRectOverlapArea(fingerRect, targetCR);
for (const maybeOverlappingCR of maybeOverlappingRects) {
const overlappingTargetScore = getRectOverlapArea(fingerRect, maybeOverlappingCR);
const overlapScoreRatio = overlappingTargetScore / tapTargetScore;
if (overlapScoreRatio < MAX_ACCEPTABLE_OVERLAP_SCORE_RATIO) {
// low score means it's clear that the user tried to tap on the targetCR,
// rather than the other tap target client rect
continue;
}
// only update our state if this was the biggest failure we've seen for this pair
if (!greatestFailure || overlapScoreRatio > greatestFailure.overlapScoreRatio) {
greatestFailure = {
overlapScoreRatio,
tapTargetScore,
overlappingTargetScore,
};
}
}
}
return greatestFailure;
}
/**
* Only report one failure if two targets overlap each other
* @param {TapTargetOverlapFailure[]} overlapFailures
* @returns {TapTargetOverlapFailure[]}
*/
function mergeSymmetricFailures(overlapFailures) {
/** @type TapTargetOverlapFailure[] */
const failuresAfterMerging = [];
overlapFailures.forEach((failure, overlapFailureIndex) => {
const symmetricFailure = overlapFailures.find(f =>
f.tapTarget === failure.overlappingTarget &&
f.overlappingTarget === failure.tapTarget
);
if (!symmetricFailure) {
failuresAfterMerging.push(failure);
return;
}
const {overlapScoreRatio: failureOSR} = failure;
const {overlapScoreRatio: symmetricOSR} = symmetricFailure;
// Push if:
// - the current failure has a higher OSR
// - OSRs are the same, and the current failure comes before its symmetric partner in the list
// Otherwise do nothing and let the symmetric partner be pushed later.
if (failureOSR > symmetricOSR || (
failureOSR === symmetricOSR &&
overlapFailureIndex < overlapFailures.indexOf(symmetricFailure)
)) {
failuresAfterMerging.push(failure);
}
});
return failuresAfterMerging;
}
/**
* @param {BoundedTapTarget[]} tooSmallTargets
* @param {BoundedTapTarget[]} allTargets
* @returns {TapTargetOverlapFailure[]}
*/
function getAllOverlapFailures(tooSmallTargets, allTargets) {
/** @type {TapTargetOverlapFailure[]} */
const failures = [];
tooSmallTargets.forEach(target => {
// Convert client rects to unique tappable areas from a user's perspective
const tappableRects = getTappableRectsFromClientRects(target.tapTarget.clientRects);
for (const maybeOverlappingTarget of allTargets) {
if (maybeOverlappingTarget === target) {
// Checking the same target with itself, skip.
continue;
}
if (!rectsTouchOrOverlap(target.paddedBoundsRect, maybeOverlappingTarget.paddedBoundsRect)) {
// Bounding boxes (padded with half FINGER_SIZE_PX) don't overlap, skip.
continue;
}
if (target.tapTarget.href === maybeOverlappingTarget.tapTarget.href) {
const isHttpOrHttpsLink = /https?:\/\//.test(target.tapTarget.href);
if (isHttpOrHttpsLink) {
// No overlap because same target action, skip.
continue;
}
}
const maybeOverlappingRects = maybeOverlappingTarget.tapTarget.clientRects;
if (allRectsContainedWithinEachOther(tappableRects, maybeOverlappingRects)) {
// If one tap target is fully contained within the other that's
// probably intentional (e.g. an item with a delete button inside).
// We'll miss some problems because of this, but that's better
// than falsely reporting a failure.
continue;
}
const rectFailure = getOverlapFailureForTargetPair(tappableRects, maybeOverlappingRects);
if (rectFailure) {
failures.push({
...rectFailure,
tapTarget: target.tapTarget,
overlappingTarget: maybeOverlappingTarget.tapTarget,
});
}
}
});
return failures;
}
/**
* @param {LH.Artifacts.ClientRect} cr
*/
function clientRectMeetsMinimumSize(cr) {
return cr.width >= FINGER_SIZE_PX && cr.height >= FINGER_SIZE_PX;
}
/**
* @param {LH.Artifacts.TapTarget} target
*/
function targetIsTooSmall(target) {
for (const cr of target.clientRects) {
if (clientRectMeetsMinimumSize(cr)) {
return false;
}
}
return true;
}
/**
* @param {LH.Artifacts.Rect} cr
*/
function clientRectBelowMinimumSize(cr) {
return cr.width < FINGER_SIZE_PX || cr.height < FINGER_SIZE_PX;
}
/**
* A target is "too small" if none of its clientRects are at least the size of a finger.
* @param {BoundedTapTarget[]} targets
* @returns {BoundedTapTarget[]}
*/
function getTooSmallTargets(targets) {
return targets.filter(target => {
return target.tapTarget.clientRects.every(clientRectBelowMinimumSize);
});
}
/**
*
* @param {LH.Artifacts.TapTarget[]} tooSmallTargets
* @param {LH.Artifacts.TapTarget[]} allTargets
*/
function getOverlapFailures(tooSmallTargets, allTargets) {
/** @type {LH.Audit.TapTargetOverlapDetail[]} */
const failures = [];
tooSmallTargets.forEach(target => {
const overlappingTargets = getTooCloseTargets(target, allTargets);
if (overlappingTargets.length > 0) {
overlappingTargets.forEach(targetOverlapDetail => {
drawFinger(target.clientRects[0], "", "rgba(255,0,0,0.4)");
failures.push(targetOverlapDetail);
});
}
});
return failures;
}
/**
* @param {LH.Audit.TapTargetOverlapDetail[]} overlapFailures
*/
function getTableItems(overlapFailures) {
const tableItems = overlapFailures.map(
({
tapTarget,
overlappingTarget,
extraDistanceNeeded,
overlappingTargetScore,
tapTargetScore
}) => {
const largestCr = getLargestClientRect(tapTarget);
const width = Math.floor(largestCr.width);
const height = Math.floor(largestCr.height);
const size = width + "x" + height;
return {
tapTarget: targetToTableNode(tapTarget),
overlappingTarget: targetToTableNode(overlappingTarget),
size,
extraDistanceNeeded,
width,
height,
overlappingTargetScore,
tapTargetScore
};
}
);
tableItems.sort((a, b) => {
return b.extraDistanceNeeded - a.extraDistanceNeeded;
});
return tableItems;
}
/**
* @param {LH.Artifacts.TapTarget} target
* @returns {LH.Audit.DetailsRendererNodeDetailsJSON}
*/
function targetToTableNode(target) {
return {
type: "node",
snippet: target.snippet,
path: target.path,
selector: target.selector
};
}
/**
* @param {LH.Artifacts.Rect[]} rects
* @returns {LH.Artifacts.Rect[]}
*/
function filterOutTinyRects(rects) {
return rects.filter(rect => rect.width > 1 && rect.height > 1);
}
/**
*
* @param {LH.Artifacts.TapTarget} tapTarget
* @param {LH.Artifacts.TapTarget[]} allTapTargets
*/
function getTooCloseTargets(tapTarget, allTapTargets) {
/** @type LH.Audit.TapTargetOverlapDetail[] */
const failures = [];
for (const maybeOverlappingTarget of allTapTargets) {
if (maybeOverlappingTarget === tapTarget) {
// checking the same target with itself, skip
continue;
}
const failure = getTargetTooCloseFailure(tapTarget, maybeOverlappingTarget);
if (failure) {
failures.push(failure);
}
}
return failures;
}
/**
* @license Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
("use strict");
/**
* @param {LH.Artifacts.ClientRect} cr
* @param {{x:number, y:number}} point
*/
/* istanbul ignore next */
function rectContainsPoint(cr, { x, y }) {
return cr.left <= x && cr.right >= x && cr.top <= y && cr.bottom >= y;
}
/**
* @param {LH.Artifacts.ClientRect} cr1
* @param {LH.Artifacts.ClientRect} cr2
*/
/* istanbul ignore next */
function rectContains(cr1, cr2) {
const topLeft = {
x: cr2.left,
y: cr2.top
};
const topRight = {
x: cr2.right,
y: cr2.top
};
const bottomLeft = {
x: cr2.left,
y: cr2.bottom
};
const bottomRight = {
x: cr2.right,
y: cr2.bottom
};
return (
rectContainsPoint(cr1, topLeft) &&
rectContainsPoint(cr1, topRight) &&
rectContainsPoint(cr1, bottomLeft) &&
rectContainsPoint(cr1, bottomRight)
);
}
/**
* Merge client rects together. This may result in a larger overall size than that of the individual client rects.
* @param {LH.Artifacts.ClientRect[]} clientRects
*/
function simplifyClientRects(clientRects) {
clientRects = filterOutTinyClientRects(clientRects);
clientRects = filterOutClientRectsContainedByOthers(clientRects);
clientRects = mergeTouchingClientRects(clientRects);
return clientRects;
}
/**
* @param {LH.Artifacts.ClientRect[]} clientRects
* @returns {LH.Artifacts.ClientRect[]}
*/
function filterOutTinyClientRects(clientRects) {
// 1x1px rect shouldn't be reason to treat the rect as something the user should tap on.
// Often they're made invisble in some obscure way anyway, and only exist for e.g. accessibiliity.
const nonTinyClientRects = clientRects.filter(
rect => rect.width > 1 && rect.height > 1
);
if (nonTinyClientRects.length > 0) {
return nonTinyClientRects;
}
return clientRects;
}
/**
* @param {LH.Artifacts.ClientRect[]} clientRects
* @returns {LH.Artifacts.ClientRect[]}
*/
function filterOutClientRectsContainedByOthers(clientRects) {
const rectsToKeep = new Set(clientRects);
for (const cr of clientRects) {
for (const possiblyContainingRect of clientRects) {
if (cr === possiblyContainingRect) continue;
if (!rectsToKeep.has(possiblyContainingRect)) continue;
if (rectContains(possiblyContainingRect, cr)) {
rectsToKeep.delete(cr);
break;
}
}
}
return Array.from(rectsToKeep);
}
/**
* @param {number} a
* @param {number} b
*/
function almostEqual(a, b) {
return Math.abs(a - b) <= 10;
}
/**
* @param {LH.Artifacts.ClientRect} rect
*/
function getRectCenterPoint(rect) {
return {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2
};
}
/**
* @param {LH.Artifacts.ClientRect} crA
* @param {LH.Artifacts.ClientRect} crB
* @returns {boolean}
*/
function clientRectsTouch(crA, crB) {
// https://stackoverflow.com/questions/2752349/fast-rectangle-to-rectangle-intersection
return (
crA.left <= crB.right &&
crB.left <= crA.right &&
crA.top <= crB.bottom &&
crB.top <= crA.bottom
);
}
/**
* @param {LH.Artifacts.ClientRect[]} clientRects
* @returns {LH.Artifacts.ClientRect[]}
*/
function mergeTouchingClientRects(clientRects) {
for (let i = 0; i < clientRects.length; i++) {
for (let j = i + 1; j < clientRects.length; j++) {
const crA = clientRects[i];
const crB = clientRects[j];
let canMerge = false;
const rectsLineUpHorizontally =
almostEqual(crA.top, crB.top) || almostEqual(crA.bottom, crB.bottom);
const rectsLineUpVertically =
almostEqual(crA.left, crB.left) || almostEqual(crA.right, crB.right);
if (
clientRectsTouch(crA, crB) &&
(rectsLineUpHorizontally || rectsLineUpVertically)
) {
canMerge = true;
}
if (canMerge) {
const left = Math.min(crA.left, crB.left);
const right = Math.max(crA.right, crB.right);
const top = Math.min(crA.top, crB.top);
const bottom = Math.max(crA.bottom, crB.bottom);
const replacementClientRect = addRectWidthAndHeight({
left,
right,
top,
bottom
});
const mergedRectCenter = getRectCenterPoint(replacementClientRect);
if (
!(
rectContainsPoint(crA, mergedRectCenter) ||
rectContainsPoint(crB, mergedRectCenter)
)
) {
// Don't merge because the new shape is too different from the
// merged rects, and putting a tap target in the finger
// wouldn't hit either actual rect
continue;
}
clientRects.push(replacementClientRect);
clientRects.splice(i, 1);
if (i < j) {
j--; // update index after delete
}
clientRects.splice(j, 1);
return mergeTouchingClientRects(clientRects);
}
}
}
return clientRects;
}
/**
* @param {{left:number, top:number, right:number, bottom: number}} rect
* @return {LH.Artifacts.ClientRect}
*/
function addRectWidthAndHeight({ left, top, right, bottom }) {
return {
left,
top,
right,
bottom,
width: right - left,
height: bottom - top
};
}
/**
* @param {LH.Artifacts.ClientRect} rect1
* @param {LH.Artifacts.ClientRect} rect2
*/
function getRectXOverlap(rect1, rect2) {
// https:// stackoverflow.com/a/9325084/1290545
return Math.max(
0,
Math.min(rect1.right, rect2.right) - Math.max(rect1.left, rect2.left)
);
}
/**
* Returns a bounding rect for all the passed in rects, with padded with half of
* `minimumSize` on all sides.
* @param {LH.Artifacts.Rect[]} rects
* @param {number} minimumSize
* @return {LH.Artifacts.Rect}
*/
function getBoundingRectWithPadding(rects, minimumSize) {
if (rects.length === 0) {
throw new Error('No rects to take bounds of');
}
let left = Number.MAX_VALUE;
let right = -Number.MAX_VALUE;
let top = Number.MAX_VALUE;
let bottom = -Number.MAX_VALUE;
for (const rect of rects) {
left = Math.min(left, rect.left);
right = Math.max(right, rect.right);
top = Math.min(top, rect.top);
bottom = Math.max(bottom, rect.bottom);
}
// Pad on all sides.
const halfMinSize = minimumSize / 2;
left -= halfMinSize;
right += halfMinSize;
top -= halfMinSize;
bottom += halfMinSize;
return {
left,
right,
top,
bottom,
width: right - left,
height: bottom - top,
};
}
/**
* Returns a tap target augmented with a bounding rect for quick overlapping
* rejections. Rect contains all the client rects, padded to half FINGER_SIZE_PX.
* @param {LH.Artifacts.TapTarget[]} targets
* @return {BoundedTapTarget[]}
*/
function getBoundedTapTargets(targets) {
return targets.map(tapTarget => {
return {
tapTarget,
paddedBoundsRect: getBoundingRectWithPadding(tapTarget.clientRects, FINGER_SIZE_PX),
};
});
}
/**
* @param {LH.Artifacts.ClientRect} rect1
* @param {LH.Artifacts.ClientRect} rect2
*/
function getRectYOverlap(rect1, rect2) {
// https:// stackoverflow.com/a/9325084/1290545
return Math.max(
0,
Math.min(rect1.bottom, rect2.bottom) - Math.max(rect1.top, rect2.top)
);
}
/**
* @param {LH.Artifacts.ClientRect} rect1
* @param {LH.Artifacts.ClientRect} rect2
*/
function getRectOverlap(rect1, rect2) {
return getRectXOverlap(rect1, rect2) * getRectYOverlap(rect1, rect2);
}
/**
* @param {LH.Artifacts.ClientRect} clientRect
* @param {number} fingerSize
*/
function getFingerAtCenter(clientRect, fingerSize) {
return addRectWidthAndHeight({
left: clientRect.left + clientRect.width / 2 - fingerSize / 2,
top: clientRect.top + clientRect.height / 2 - fingerSize / 2,
right: clientRect.right - clientRect.width / 2 + fingerSize / 2,
bottom: clientRect.bottom - clientRect.height / 2 + fingerSize / 2
});
}
/**
* @param {LH.Artifacts.ClientRect} cr
*/
function getClientRectArea(cr) {
return cr.width * cr.height;
}
/**
* @param {LH.Artifacts.TapTarget} target
*/
function getLargestClientRect(target) {
let largestCr = target.clientRects[0];
for (const cr of target.clientRects) {
if (getClientRectArea(cr) > getClientRectArea(largestCr)) {
largestCr = cr;
}
}
return largestCr;
}
/**
*
* @param {LH.Artifacts.ClientRect[]} crListA
* @param {LH.Artifacts.ClientRect[]} crListB
*/
function allClientRectsContainedWithinEachOther(crListA, crListB) {
for (const crA of crListA) {
for (const crB of crListB) {
if (!rectContains(crA, crB) && !rectContains(crB, crA)) {
return false;
}
}
}
return true;
}
console.timeEnd("start");
/**
* @param {LH.Artifacts.Rect[]} rects
* @returns {LH.Artifacts.Rect[]}
*/
function filterOutRectsContainedByOthers(rects) {
const rectsToKeep = new Set(rects);
for (const rect of rects) {
for (const possiblyContainingRect of rects) {
if (rect === possiblyContainingRect) continue;
if (!rectsToKeep.has(possiblyContainingRect)) continue;
if (rectContains(possiblyContainingRect, rect)) {
rectsToKeep.delete(rect);
break;
}
}
}
return Array.from(rectsToKeep);
}
/**
* @param {LH.Artifacts.Rect} rect
*/
function getRectCenterPoint(rect) {
return {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2
};
}
/**
* @param {LH.Artifacts.Rect} rectA
* @param {LH.Artifacts.Rect} rectB
* @returns {boolean}
*/
function rectsTouchOrOverlap(rectA, rectB) {
// https://stackoverflow.com/questions/2752349/fast-rectangle-to-rectangle-intersection
return (
rectA.left <= rectB.right &&
rectB.left <= rectA.right &&
rectA.top <= rectB.bottom &&
rectB.top <= rectA.bottom
);
}
/**
* @param {LH.Artifacts.Rect} rectA
* @param {LH.Artifacts.Rect} rectB
*/
function getBoundingRect(rectA, rectB) {
const left = Math.min(rectA.left, rectB.left);
const right = Math.max(rectA.right, rectB.right);
const top = Math.min(rectA.top, rectB.top);
const bottom = Math.max(rectA.bottom, rectB.bottom);
return addRectWidthAndHeight({
left,
right,
top,
bottom
});
}
/**
* @param {{left:number, top:number, right:number, bottom: number}} rect
* @return {LH.Artifacts.Rect}
*/
function addRectWidthAndHeight({ left, top, right, bottom }) {
return {
left,
top,
right,
bottom,
width: right - left,
height: bottom - top
};
}
/**
* @param {{x:number, y:number, width:number, height: number}} rect
* @return {LH.Artifacts.Rect}
*/
function addRectTopAndBottom({ x, y, width, height }) {
return {
left: x,
top: y,
right: x + width,
bottom: y + height,
width,
height
};
}
/**
* @param {LH.Artifacts.Rect} rect1
* @param {LH.Artifacts.Rect} rect2
*/
function getRectXOverlap(rect1, rect2) {
// https://stackoverflow.com/a/9325084/1290545
return Math.max(
0,
Math.min(rect1.right, rect2.right) - Math.max(rect1.left, rect2.left)
);
}
/**
* @param {LH.Artifacts.Rect} rect1
* @param {LH.Artifacts.Rect} rect2
*/
function getRectYOverlap(rect1, rect2) {
// https://stackoverflow.com/a/9325084/1290545
return Math.max(
0,
Math.min(rect1.bottom, rect2.bottom) - Math.max(rect1.top, rect2.top)
);
}
/**
* @param {LH.Artifacts.Rect} rect1
* @param {LH.Artifacts.Rect} rect2
*/
function getRectOverlapArea(rect1, rect2) {
return getRectXOverlap(rect1, rect2) * getRectYOverlap(rect1, rect2);
}
/**
* @param {LH.Artifacts.Rect} rect
* @param {number} centerRectSize
*/
function getRectAtCenter(rect, centerRectSize) {
return addRectWidthAndHeight({
left: rect.left + rect.width / 2 - centerRectSize / 2,
top: rect.top + rect.height / 2 - centerRectSize / 2,
right: rect.right - rect.width / 2 + centerRectSize / 2,
bottom: rect.bottom - rect.height / 2 + centerRectSize / 2
});
}
/**
* @param {LH.Artifacts.Rect} rect
*/
function getRectArea(rect) {
return rect.width * rect.height;
}
/**
* @param {LH.Artifacts.Rect[]} rects
*/
function getLargestRect(rects) {
let largestRect = rects[0];
for (const rect of rects) {
if (getRectArea(rect) > getRectArea(largestRect)) {
largestRect = rect;
}
}
return largestRect;
}
/**
*
* @param {LH.Artifacts.Rect[]} rectListA
* @param {LH.Artifacts.Rect[]} rectListB
*/
function allRectsContainedWithinEachOther(rectListA, rectListB) {
for (const rectA of rectListA) {
for (const rectB of rectListB) {
if (!rectContains(rectA, rectB) && !rectContains(rectB, rectA)) {
return false;
}
}
}
return true;
}
function runGatherer() {
return (function() {
const tapTargetsSelector = "button,a,input,textarea,select,option,[role=button],[role=checkbox],[role=link],[role=menuitem],[role=menuitemcheckbox],[role=menuitemradio],[role=option],[role=scrollbar],[role=slider],[role=spinbutton]";
function getElementsInDocument(selector) {
const realMatchesFn = window.__ElementMatches || window.Element.prototype.matches;
/** @type {Array<Element>} */
const results = [];
/** @param {NodeListOf<Element>} nodes */
const _findAllElements = nodes => {
for (let i = 0, el; el = nodes[i]; ++i) {
if (!selector || realMatchesFn.call(el, selector)) {
results.push(el);
}
// If the element has a shadow root, dig deeper.
if (el.shadowRoot) {
_findAllElements(el.shadowRoot.querySelectorAll('*'));
}
}
};
_findAllElements(document.querySelectorAll('*'));
return results;
};
function disableFixedAndStickyElementPointerEvents() {
const className = 'lighthouse-disable-pointer-events';
const styleTag = document.createElement('style');
styleTag.textContent = `.${className} { pointer-events: none !important }`;
document.body.appendChild(styleTag);
document.querySelectorAll('*').forEach(el => {
const position = getComputedStyle(el).position;
if (position === 'fixed' || position === 'sticky') {
el.classList.add(className);
}
});
return function undo() {
Array.from(document.getElementsByClassName(className)).forEach(el => {
el.classList.remove(className);
});
styleTag.remove();
};
};
function elementIsVisible(element) {
const {overflowX, overflowY, display, visibility} = getComputedStyle(element);
if (
display === 'none' ||
(visibility === 'collapse' && ['TR', 'TBODY', 'COL', 'COLGROUP'].includes(element.tagName))
) {
// Element not displayed
return false;
}
// only for block and inline-block, since clientWidth/Height are always 0 for inline elements
if (display === 'block' || display === 'inline-block') {
// if height/width is 0 and no overflow in that direction then
// there's no content that the user can see and tap on
if ((element.clientWidth === 0 && overflowX === 'hidden') ||
(element.clientHeight === 0 && overflowY === 'hidden')) {
return false;
}
}
const parent = element.parentElement;
if (parent && parent.tagName !== 'BODY') {
// if a parent is invisible then the current element is also invisible
return elementIsVisible(parent);
}
return true;
};
function elementHasAncestorTapTarget(element) {
if (!element.parentElement) {
return false;
}
if (element.parentElement.matches(tapTargetsSelector)) {
return true;
}
return elementHasAncestorTapTarget(element.parentElement);
};
function elementCenterIsAtZAxisTop(el, elCenterPoint) {
const viewportHeight = window.innerHeight;
const targetScrollY = Math.floor(elCenterPoint.y / viewportHeight) * viewportHeight;
if (window.scrollY !== targetScrollY) {
window.scrollTo(0, targetScrollY);
}
const topEl = document.elementFromPoint(
elCenterPoint.x,
elCenterPoint.y - window.scrollY
);
return topEl === el || el.contains(topEl);
}
function truncate(str, maxLength) {
if (str.length <= maxLength) {
return str;
}
return str.slice(0, maxLength - 1) + '…';
};
function getClientRects(element) {
const clientRects = Array.from(
element.getClientRects()
).map(clientRect => {
// Contents of DOMRect get lost when returned from Runtime.evaluate call,
// so we convert them to plain objects.
const {width, height, left, top, right, bottom} = clientRect;
return {width, height, left, top, right, bottom};
});
for (const child of element.children) {
clientRects.push(...getClientRects(child));
}
return clientRects;
};
function hasTextNodeSiblingsFormingTextBlock(element) {
if (!element.parentElement) {
return false;
}
const parentElement = element.parentElement;
const nodeText = element.textContent || '';
const parentText = parentElement.textContent || '';
if (parentText.length - nodeText.length < 5) {
// Parent text mostly consists of this node, so the parent
// is not a text block container
return false;
}
for (const sibling of element.parentElement.childNodes) {
if (sibling === element) {
continue;
}
const siblingTextContent = (sibling.textContent || '').trim();
// Only count text in text nodes so that a series of e.g. buttons isn't counted
// as a text block.
// This works reasonably well, but means we miss text blocks where all text is e.g.
// wrapped in spans
if (sibling.nodeType === Node.TEXT_NODE && siblingTextContent.length > 0) {
return true;
}
}
return false;
};
function elementIsInTextBlock(element) {
const {display} = getComputedStyle(element);
if (display !== 'inline' && display !== 'inline-block') {
return false;
}
if (hasTextNodeSiblingsFormingTextBlock(element)) {
return true;
} else if (element.parentElement) {
return elementIsInTextBlock(element.parentElement);
} else {
return false;
}
};
function getRectArea(rect) {
return rect.width * rect.height;
};
function getLargestRect(rects) {
let largestRect = rects[0];
for (const rect of rects) {
if (getRectArea(rect) > getRectArea(largestRect)) {
largestRect = rect;
}
}
return largestRect;
};
function getRectCenterPoint(rect) {
return {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
};
};
function rectContains(rect1, rect2) {
return rect2.top >= rect1.top &&
rect2.right <= rect1.right &&
rect2.bottom <= rect1.bottom &&
rect2.left >= rect1.left;
};
function getNodePath(node) {
/** @param {Node} node */
function getNodeIndex(node) {
let index = 0;
let prevNode;
while (prevNode = node.previousSibling) {
node = prevNode;
// skip empty text nodes
if (node.nodeType === Node.TEXT_NODE && node.textContent &&
node.textContent.trim().length === 0) continue;
index++;
}
return index;
}
const path = [];
while (node && node.parentNode) {
const index = getNodeIndex(node);
path.push([index, node.nodeName]);
node = node.parentNode;
}
path.reverse();
return path.join(',');
};
function getNodeSelector(node) {
/**
* @param {Element} node
*/
function getSelectorPart(node) {
let part = node.tagName.toLowerCase();
if (node.id) {
part += '#' + node.id;
} else if (node.classList.length > 0) {
part += '.' + node.classList[0];
}
return part;
}
const parts = [];
while (parts.length < 4) {
parts.unshift(getSelectorPart(node));
if (!node.parentElement) {
break;
}
node = node.parentElement;
if (node.tagName === 'HTML') {
break;
}
}
return parts.join(' > ');
};
function gatherTapTargets() {
/** @type {LH.Artifacts.TapTarget[]} */
const targets = [];
// Capture element positions relative to the top of the page
window.scrollTo(0, 0);
/** @type {Element[]} */
// @ts-ignore - getElementsInDocument put into scope via stringification
const tapTargetElements = getElementsInDocument(tapTargetsSelector);
/** @type {{
tapTargetElement: Element,
clientRects: ClientRect[]
}[]} */
const tapTargetsWithClientRects = [];
tapTargetElements.forEach(tapTargetElement => {
// Filter out tap targets that are likely to cause false failures:
if (elementHasAncestorTapTarget(tapTargetElement)) {
// This is usually intentional, either the tap targets trigger the same action
// or there's a child with a related action (like a delete button for an item)
return;
}
if (elementIsInTextBlock(tapTargetElement)) {
// Links inside text blocks cause a lot of failures, and there's also an exception for them
// in the Web Content Accessibility Guidelines https://www.w3.org/TR/WCAG21/#target-size
return;
}
if (!elementIsVisible(tapTargetElement)) {
return;
}
tapTargetsWithClientRects.push({
tapTargetElement,
clientRects: getClientRects(tapTargetElement),
});
});
// Disable pointer events so that tap targets below them don't get
// detected as non-tappable (they are tappable, just not while the viewport
// is at the current scroll position)
const reenableFixedAndStickyElementPointerEvents = disableFixedAndStickyElementPointerEvents();
/** @type {{
tapTargetElement: Element,
visibleClientRects: ClientRect[]
}[]} */
const tapTargetsWithVisibleClientRects = [];
// We use separate loop here to get visible client rects because that involves
// scrolling around the page for elementCenterIsAtZAxisTop, which would affect the
// client rect positions.
tapTargetsWithClientRects.forEach(({tapTargetElement, clientRects}) => {
// Filter out empty client rects
let visibleClientRects = clientRects.filter(cr => cr.width !== 0 && cr.height !== 0);
// Filter out client rects that are invisible, e.g because they are in a position absolute element
// with a lower z-index than the main content.
// This will also filter out all position fixed or sticky tap targets elements because we disable pointer
// events on them before running this. That's the correct behavior because whether a position fixed/stick
// element overlaps with another tap target depends on the scroll position.
visibleClientRects = visibleClientRects.filter(rect => {
// Just checking the center can cause false failures for large partially hidden tap targets,
// but that should be a rare edge case
const rectCenterPoint = getRectCenterPoint(rect);
return elementCenterIsAtZAxisTop(tapTargetElement, rectCenterPoint);
});
if (visibleClientRects.length > 0) {
tapTargetsWithVisibleClientRects.push({
tapTargetElement,
visibleClientRects,
});
}
});
for (const {tapTargetElement, visibleClientRects} of tapTargetsWithVisibleClientRects) {
targets.push({
clientRects: visibleClientRects,
snippet: truncate(tapTargetElement.outerHTML, 300),
// @ts-ignore - getNodePath put into scope via stringification
path: getNodePath(tapTargetElement),
// @ts-ignore - getNodeSelector put into scope via stringification
selector: getNodeSelector(tapTargetElement),
href: /** @type {HTMLAnchorElement} */ (tapTargetElement)['href'] || '',
});
}
reenableFixedAndStickyElementPointerEvents();
return targets;
};
return gatherTapTargets();
})()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment