Skip to content

Commit

Permalink
Split rect helpers and tap targets specific code
Browse files Browse the repository at this point in the history
  • Loading branch information
mattzeunert committed Dec 7, 2018
1 parent 43c6259 commit c930e79
Show file tree
Hide file tree
Showing 9 changed files with 1,442 additions and 395 deletions.
287 changes: 287 additions & 0 deletions lighthouse-core/audits/seo/tap-targets.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
/**
* @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';

/**
* @fileoverview Checks that links, buttons, etc. are sufficiently large and don't overlap.
*/
const Audit = require('../audit');
const ViewportAudit = require('../viewport');
const {
simplifyClientRects,
getRectOverlap,
getRectXOverlap,
getRectYOverlap,
getFingerAtCenter,
getLargestClientRect,
allClientRectsContainedWithinEachOther,
} = require('../../lib/client-rect-functions');
const FINGER_SIZE_PX = 48;


/**
* @param {LH.Artifacts.Rect} targetCR
* @param {LH.Artifacts.Rect} maybeOverlappingCR
*/
function getOverlapFailure(targetCR, maybeOverlappingCR) {
const fingerRect = getFingerAtCenter(targetCR, FINGER_SIZE_PX);
// Score indicates how much area of each target the finger overlaps with
// when the user taps on the targetCR
const tapTargetScore = getRectOverlap(fingerRect, targetCR);
const maybeOverlappingScore = getRectOverlap(fingerRect, maybeOverlappingCR);

const scoreRatio = maybeOverlappingScore / tapTargetScore;
if (scoreRatio < 0.25) {
// 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;
}

const overlapAreaExcess = Math.ceil(
maybeOverlappingScore - tapTargetScore / 2
);

const xMovementNeededToFix =
overlapAreaExcess / getRectXOverlap(fingerRect, maybeOverlappingCR);
const yMovementNeededToFix =
overlapAreaExcess / getRectYOverlap(fingerRect, maybeOverlappingCR);
const extraDistanceNeeded = Math.min(
xMovementNeededToFix,
yMovementNeededToFix
);

return {
extraDistanceNeeded: Math.ceil(extraDistanceNeeded),
tapTargetScore,
overlappingTargetScore: maybeOverlappingScore,
};
}

/**
*
* @param {LH.Artifacts.TapTarget} tapTarget
* @param {LH.Artifacts.TapTarget[]} allTargets
*/
function getTooCloseTargets(tapTarget, allTargets) {
/** @type LH.Audit.TapTargetOverlapDetail[] */
const failures = [];

for (let i = 0; i < allTargets.length; i++) {
if (allTargets[i] === tapTarget) {
// checking the same target with itself, skip
continue;
}

const maybeOverlappingTarget = allTargets[i];
if (
/https?:\/\//.test(tapTarget.href) &&
tapTarget.href === maybeOverlappingTarget.href
) {
// no overlap because same target action
continue;
}

/** @type LH.Audit.TapTargetOverlapDetail | null */
let greatestFailure = null;
const simplifiedTapTargetCRs = simplifyClientRects(tapTarget.clientRects);
simplifiedTapTargetCRs.forEach(targetCR => {
if (allClientRectsContainedWithinEachOther(
simplifiedTapTargetCRs,
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)
return;
}

maybeOverlappingTarget.clientRects.forEach(maybeOverlappingCR => {
const failure = getOverlapFailure(targetCR, maybeOverlappingCR);
if (failure) {
// only update our state if this was the biggest failure we've seen for this pair
if (
!greatestFailure ||
failure.extraDistanceNeeded > greatestFailure.extraDistanceNeeded
) {
greatestFailure = {
...failure,
tapTarget,
overlappingTarget: maybeOverlappingTarget,
};
}
}
});
});

if (greatestFailure) {
failures.push(greatestFailure);
}
}

return failures;
}

/**
* @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[]} */
const failures = [];

tooSmallTargets.forEach(target => {
const overlappingTargets = getTooCloseTargets(
target,
allTargets
);

if (overlappingTargets.length > 0) {
overlappingTargets.forEach(
(targetOverlapDetail) => {
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,
};
}

class TapTargets extends Audit {
/**
* @return {LH.Audit.Meta}
*/
static get meta() {
return {
id: 'tap-targets',
title: 'Tap targets are sized appropriately',
failureTitle: 'Tap targets are not sized appropriately',
description:
'Interactive elements like buttons and links should be large enough (48x48px), and have enough space around them, to be easy enough to tap without overlapping onto other elements. [Learn more](https://developers.google.com/web/fundamentals/accessibility/accessible-styles#multi-device_responsive_design).',
requiredArtifacts: ['Viewport', 'TapTargets'],
};
}

/**
* @param {LH.Artifacts} artifacts
* @return {LH.Audit.Product}
*/
static audit(artifacts) {
const hasViewportSet = ViewportAudit.audit(artifacts).rawValue;
if (!hasViewportSet) {
return {
rawValue: false,
explanation:
'Tap targets are too small because of a missing viewport config',
};
}

const tooSmallTargets = getTooSmallTargets(artifacts.TapTargets);
const overlapFailures = getOverlapFailures(tooSmallTargets, artifacts.TapTargets);
const tableItems = getTableItems(overlapFailures);

const headings = [
{key: 'tapTarget', itemType: 'node', text: 'Tap Target'},
{key: 'size', itemType: 'text', text: 'Size'},
{key: 'overlappingTarget', itemType: 'node', text: 'Overlapping Target'},
];

const details = Audit.makeTableDetails(headings, tableItems);

const tapTargetCount = artifacts.TapTargets.length;
const failingTapTargetCount = new Set(overlapFailures.map(f => f.tapTarget)).size;
const passingTapTargetCount = tapTargetCount - failingTapTargetCount;

const score = tapTargetCount > 0 ? passingTapTargetCount / tapTargetCount : 1;
const displayValue = Math.round(score * 100) + '% appropriately sized tap targets';

return {
rawValue: tableItems.length === 0,
score,
details,
displayValue,
};
}
}

TapTargets.FINGER_SIZE_PX = FINGER_SIZE_PX;

module.exports = TapTargets;
Loading

0 comments on commit c930e79

Please sign in to comment.