Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

new_audit(tap-targets): check that tap targets are big enough and don't overlap #5846

Merged
merged 107 commits into from
Jan 28, 2019

Conversation

kdzwinel
Copy link
Collaborator

@kdzwinel kdzwinel commented Aug 16, 2018

Summary
This audit checks if all potential tap targets (see 1) are sized appropriately (see 2) and are far enough from each other to be unambiguous (see 3).

Limitations

  1. we take potential tap targets into account (a, button, input, …) - this list may not include some tap targets that are made "tappable" via JS (e.g. some div used as a button) and may include some tap targets that don't cause any action (e.g. dummy button that does nothing)
  2. We define finger size to be 48x48px
  3. Implemented algorithm is simpler from what Chrome does (e.g. Chrome is often able to figure out which link was clicked even if tap area covers two links). The algorithm boils down to checking if there is some other tap target located < 32px from the center of the currently audited one.

Demo (note that visual output is not part of this audit):

TODO

  • descriptions
  • linting
  • tests

Related Issues/PRs
Closes #4358

@googlebot
Copy link

So there's good news and bad news.

👍 The good news is that everyone that needs to sign a CLA (the pull request submitter and all commit authors) have done so. Everything is all good there.

😕 The bad news is that it appears that one or more commits were authored or co-authored by someone other than the pull request submitter. We need to confirm that all authors are ok with their commits being contributed to this project. Please have them confirm that here in the pull request.

Note to project maintainer: This is a terminal state, meaning the cla/google commit status will not change from this state. It's up to you to confirm consent of all the commit author(s), set the cla label to yes (if enabled on your project), and then merge this pull request when appropriate.

Copy link
Collaborator

@patrickhulce patrickhulce left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll do a more thorough lookthrough of the approach soon, just flushing some initial impressions!

lighthouse-core/gather/gatherers/seo/tap-targets-test.js Outdated Show resolved Hide resolved
/**
* @param {Element} node
*/
function isVisible({node, bcrs = getBCRs(node, false)}) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a nice little generic helper! might be nice in page-functions.js

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we've also been trying to keep all our in-page functions there, but we're going to have so many for this function it's probably worth creating a dedicated file for them.

do you think it's easiest for it to be basically one giant in-page evaluation?

I ask because it's generally hard to debug and figure out what's going on in the in-page functions than the node side, but it looks like you've worked out a decent method of development here with the fs call. There might also be some opportunity to share some BCR logic between audit and gatherer?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We've mostly been using a manually synced gist for development. It also gives a visual output, which we're hoping to add to the DevTools Audit panel one day.

Will move the BCR logic to a shared file.

lighthouse-core/gather/gatherers/seo/tap-targets.js Outdated Show resolved Hide resolved
lighthouse-core/lib/page-functions.js Outdated Show resolved Hide resolved
Copy link
Collaborator

@patrickhulce patrickhulce left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just trying to get a handle on a few things that might change impl quite a bit

lighthouse-core/audits/seo/tap-targets.js Outdated Show resolved Hide resolved
lighthouse-core/test/lib/page-functions-test.js Outdated Show resolved Hide resolved
lighthouse-core/gather/gatherers/seo/tap-targets.js Outdated Show resolved Hide resolved
lighthouse-core/audits/seo/tap-targets.js Outdated Show resolved Hide resolved
@mattzeunert mattzeunert force-pushed the tap-targets branch 3 times, most recently from 57704d7 to 6afbf13 Compare November 8, 2018 14:54
@mattzeunert
Copy link
Contributor

mattzeunert commented Nov 8, 2018

@patrickhulce For reference, here's a drawing that Rick made for how we detect a failure-causing overlap between two tap targets. The approach is taken from the Mobile-Friendly Test implementation.

screen shot 2018-11-08 at 16 20 08

The blue dotted box is the finger tap area centered around the top anchor. The bottom anchor overlaps the tap area and that shaded area is more than half the shaded area of the top anchor. So this is a violation.

Also, I think you noticed already, but the code is still very much work-in-progress, so don't spend too much time on the details 😂.

@mattzeunert
Copy link
Contributor

Thanks for the initial feedback @brendankenny!

Looks good but biiiiiiiig :)

Let me know if you see a good way I could have split the PR into smaller bits. Might make future PRs easier 🙂.

@patrickhulce already told me to split off the two smaller PRs for rect helpers and page functions.

/**
* @param {Element} element
*/
function hasTextNodeSiblingsFormingTextBlock(element) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hasTextNodeSiblingsFormingTextBlock doesn't rely on being closed over by elementIsInTextBlock, so move outside to a top-level function?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, I was trying to avoid having another function that needs to be imported in the evalAsync string, but this is cleaner 👍

continue;
}
const siblingTextContent = (sibling.textContent || '').trim();
if (sibling.nodeType === Node.TEXT_NODE && siblingTextContent.length > 0) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will add a comment to the code.

great, that definitely helps

overflowX,
overflowY,
} = getComputedStyle(element);
if ((overflowX === 'hidden' && overflowY === 'hidden') || element.children.length === 0) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if all the client rects are size 0, does it matter whether or not the children would be visible since their client rects are also size 0 (since they're included in the allClientRectsEmpty(clientRects) check)?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No it shouldn't matter, and from trying it out on a few sites it doesn't matter. Will remove the if.

/** The value of the <meta name="theme=color">'s content attribute, or null. */
ThemeColor: string|null;
/** The value of the <meta name="viewport">'s content attribute, or null. */
Viewport: string|null;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ThemeColor and Viewport accidentally got merged back in here

Copy link
Member

@brendankenny brendankenny left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

feeling good :)

Some of the n^2 stuff in the audit could be an issue for nastier pages, but let's get good correctness coverage in the audit test and it'll be easy to follow up later to speed it up in those cases

* @return {LH.Audit.Product}
*/
static audit(artifacts) {
const hasViewportSet = ViewportAudit.audit(artifacts).rawValue;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you file an issue to move the guts of the viewport audit to a computed artifact? We should avoid audits calling each other if at all possible, but not worth adding to this PR

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return {
rawValue: false,
explanation:
'Tap targets are too small because of a missing viewport config',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this only happen with the missing meta tag? The audit rawValue will also be false if the width/initial-scale check fails.

(if not, may need to tweak the explanation)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah it should fail if the viewport tag doesn't make it work for mobile. What do you think of this explanation:

Tap targets are too small because there's no viewport meta tag optimized for mobile screens

Changed it here and in the font size audit.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think of this explanation

sounds great!

* @returns {LH.Artifacts.TapTarget[]}
*/
function getTooSmallTargets(targets) {
return targets.filter(targetIsTooSmall);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it seems wasteful to split this up so much. Maybe

/**
 * @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 {LH.Artifacts.TapTarget[]} targets
 * @returns {LH.Artifacts.TapTarget[]}
 */
function getTooSmallTargets(targets) {
  return targets.filter(target => {
    return target.clientRects.every(clientRectBelowMinimumSize);
  });
}

*/
function getOverlapFailureForTargetPair(tapTarget, maybeOverlappingTarget) {
// Convert client rects to unique tappable areas from a user's perspective
const tappableRects = getTappableRectsFromClientRects(tapTarget.clientRects);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

move this below the href checks to where it's used (and maybe save a little getTappableRects work :)

allTargets
);

if (overlappingTargets.length > 0) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: since overlappingTargets is always an array, no need to check the length before concating

tapTargetScore,
overlappingTargetScore,
overlapScoreRatio,
}) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style nit: I don't personally find this very readable, not sure how others feel about it. You've lost track of the map() by the time you get to the bottom of the destructured props :) I'd rather just have something like

const tableItems = overlapFailures.map(failure => {
  const largestCR = getLargestRect(failure.tapTarget.clientRects);
  // ...
});

types/audit.d.ts Outdated
@@ -70,17 +70,32 @@ declare global {
wastedPercent?: number;
}

export interface TapTargetTableItem extends DetailsObject {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can this be moved to tap-targets.js as well? Since it's a DetailsObject and the table renderer can handle it, seems like it's ok to be internal to the audit until/unless we need it externally

TapTargets: tapTargets,
MetaElements: [{
name: 'viewport',
content: 'width=device-width',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are a few cases that still need covering, e.g. need viewport failure test(s). See the red lines in https://coveralls.io/builds/21238984/source?filename=lighthouse-core%2Faudits%2Fseo%2Ftap-targets.js#L42

@mattzeunert
Copy link
Contributor

Thanks for the comments @brendankenny, can you take another look?

@brendankenny brendankenny changed the title [WIP] new-audit(tap-targets): check if all tap targets big enough and don't overlap new_audit(tap-targets): check that tap targets are big enough and don't overlap Jan 28, 2019
Copy link
Member

@brendankenny brendankenny left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🖐 🎯 📐

LGTM!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

8 participants