diff --git a/lighthouse-core/audits/seo/tap-targets.js b/lighthouse-core/audits/seo/tap-targets.js new file mode 100644 index 000000000000..d1f9084b0727 --- /dev/null +++ b/lighthouse-core/audits/seo/tap-targets.js @@ -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; diff --git a/lighthouse-core/gather/gatherers/seo/tap-targets.js b/lighthouse-core/gather/gatherers/seo/tap-targets.js new file mode 100644 index 000000000000..d2786e551851 --- /dev/null +++ b/lighthouse-core/gather/gatherers/seo/tap-targets.js @@ -0,0 +1,350 @@ +/** + * @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'; + +/* global getComputedStyle, getElementsInDocument, Node, getNodePath, getNodeSelector */ + +const Gatherer = require('../gatherer'); +const pageFunctions = require('../../../lib/page-functions.js'); +const {rectContainsString, rectContains} = require('../../../lib/client-rect-functions'); + +const TARGET_SELECTORS = [ + '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]', +]; + +/** + * @param {LH.Artifacts.Rect[]} clientRects + */ +/* istanbul ignore next */ +function allClientRectsEmpty(clientRects) { + return ( + clientRects.length === 0 || + clientRects.every(cr => cr.width === 0 && cr.height === 0) + ); +} + +function nodeIsVisible(node) { + const { + overflowX, + overflowY, + display, + visibility, + } = getComputedStyle(node); + + if ( + display === 'none' || + (visibility === 'collapse' && ['TR', 'TBODY', 'COL', 'COLGROUP'].includes(node.tagName)) + ) { + // Element not displayed + return false; + } + + 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 (node.clientWidth === 0 && overflowX === 'hidden') { + return false; + } + if (node.clientHeight === 0 && overflowY === 'hidden') { + return false; + } + } + + const parent = node.parentElement; + if ( + parent && + parent.tagName !== 'HTML' && + !nodeIsVisible(parent) + ) { + // if a parent is invisible then the current node is also invisible + return false; + } + + return true; +} + +/** + * @param {Element} node + */ +function getVisibleClientRects(node) { + if (!nodeIsVisible(node)) { + return []; + } + + const { + overflowX, + overflowY, + } = getComputedStyle(node); + let clientRects = getClientRects(node, true); + + if (allClientRectsEmpty(clientRects)) { + if ((overflowX === 'hidden' && overflowY === 'hidden') || node.children.length === 0) { + // own size is 0x0 and there's no visible child content + return []; + } + } + + // Treating overflowing content in scroll containers as invisible could mean that + // most of a given page is deemed invisible. But: + // - tap targets audit doesn't consider different containers/layers + // - having most content in an explicit scroll container is rare + // - treating them as hidden only generates false passes, which is better than false failures + clientRects = filterClientRectsWithinAncestorsVisibleScrollArea(node, clientRects); + + return clientRects; +} + + +// /** +// * @param {Element} node +// * @param {LH.Artifacts.Rect[]} clientRects +// * @returns {boolean} +// */ +// /* istanbul ignore next */ +// function isWithinAncestorsVisibleScrollArea(node, clientRects) { +// const parent = node.parentElement; +// if (!parent) { +// return true; +// } +// if (getComputedStyle(parent).overflowY !== 'visible') { +// for (let i = 0; i < clientRects.length; i++) { +// const clientRect = clientRects[i]; +// if (!rectContains(parent.getBoundingClientRect(), clientRect)) { +// return false; +// } +// } +// } +// if (parent.parentElement && parent.parentElement.tagName !== 'HTML') { +// return isWithinAncestorsVisibleScrollArea( +// parent.parentElement, +// clientRects +// ); +// } +// return true; +// } + +/** + * @param {string} str + * @param {number} maxLength + * @returns {string} + */ +/* istanbul ignore next */ +function truncate(str, maxLength) { + if (str.length <= maxLength) { + return str; + } + return str.slice(0, maxLength - 1) + '…'; +} + +/** + * @param {Element} node + * @param {boolean} includeChildren + * @returns {LH.Artifacts.Rect[]} + */ +/* istanbul ignore next */ +function getClientRects(node, includeChildren = true) { + /** @type {LH.Artifacts.Rect[]} */ + let clientRects = Array.from( + node.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}; + }); + if (includeChildren) { + for (const child of node.children) { + clientRects = clientRects.concat(getClientRects(child)); + } + } + + return clientRects; +} + +/** + * Check if node is in a block of text, such as paragraph with a bunch of links in it. + * Makes a reasonable guess, but for example gets it wrong if the element is surounded by other + * HTML elements instead of direct text nodes. + * @param {Node} node + * @returns {boolean} + */ +/* istanbul ignore next */ +function nodeIsInTextBlock(node) { + /** + * @param {Node} node + * @returns {boolean} + */ + function isInline(node) { + if (node.nodeType === Node.TEXT_NODE) { + return true; + } + if (node.nodeType !== Node.ELEMENT_NODE) { + return false; + } + const element = /** @type {Element} */ (node); + return ( + getComputedStyle(element).display === 'inline' || + getComputedStyle(element).display === 'inline-block' + ); + } + + /** + * @param {Node} node + */ + function hasTextNodeSiblingsFormingTextBlock(node) { + if (!node.parentElement) { + return false; + } + + const parentElement = node.parentElement; + + const nodeText = node.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; + } + + const potentialSiblings = node.parentElement.childNodes; + for (let i = 0; i < potentialSiblings.length; i++) { + const sibling = potentialSiblings[i]; + if (sibling === node) { + continue; + } + const siblingTextContent = (sibling.textContent || '').trim(); + if ( + sibling.nodeType === Node.TEXT_NODE && + siblingTextContent.length > 0 + ) { + return true; + } + } + + return false; + } + + if (!isInline(node)) { + return false; + } + + if (hasTextNodeSiblingsFormingTextBlock(node)) { + return true; + } else { + if (node.parentElement) { + return nodeIsInTextBlock(node.parentElement); + } else { + return false; + } + } +} + +/** + * @returns {LH.Artifacts.TapTarget[]} + */ +/* istanbul ignore next */ +function gatherTapTargets() { + const selector = TARGET_SELECTORS.join(','); + + /** @type {LH.Artifacts.TapTarget[]} */ + const targets = []; + + // @ts-ignore - getElementsInDocument put into scope via stringification + Array.from(getElementsInDocument(selector)).forEach(node => { + if (nodeIsInTextBlock(node)) { + return; + } + + const visibleClientRects = getVisibleClientRects(node); + if (visibleClientRects.length === 0) { + return; + } + + targets.push({ + clientRects: visibleClientRects, + snippet: truncate(node.outerHTML, 700), + // @ts-ignore - getNodePath put into scope via stringification + path: getNodePath(node), + // @ts-ignore - getNodeSelector put into scope via stringification + selector: getNodeSelector(node), + href: node.getAttribute('href') || '', + }); + }); + + return targets; +} + +/** + * @param {function} fn + * @param {(args: any[]) => any} getCacheKey + */ +/* istanbul ignore next */ +function memoize(fn, getCacheKey) { + const cache = new Map(); + /** + * @this {any} + * @param {...any} args + */ + function fnWithCaching(...args) { + const cacheKey = getCacheKey(args); + if (cache.get(cacheKey)) { + return cache.get(cacheKey); + } + + const result = fn.apply(this, args); + cache.set(cacheKey, result); + return result; + } + return fnWithCaching; +} + +class TapTargets extends Gatherer { + /** + * @param {LH.Gatherer.PassContext} passContext + * @return {Promise} All visible tap targets with their positions and sizes + */ + afterPass(passContext) { + const expression = `(function() { + ${pageFunctions.getElementsInDocumentString}; + ${filterClientRectsWithinAncestorsVisibleScrollArea.toString()}; + ${nodeIsVisible.toString()}; + ${getVisibleClientRects.toString()}; + ${truncate.toString()}; + ${getClientRects.toString()}; + ${nodeIsInTextBlock.toString()}; + ${allClientRectsEmpty.toString()}; + ${rectContainsString}; + ${pageFunctions.getNodePathString}; + ${pageFunctions.getNodeSelectorString}; + ${gatherTapTargets.toString()}; + + const TARGET_SELECTORS = ${JSON.stringify(TARGET_SELECTORS)}; + memoize(nodeIsVisible) + + return gatherTapTargets(); + + })()`; + + return passContext.driver.evaluateAsync(expression); + } +} + +module.exports = TapTargets; diff --git a/lighthouse-core/lib/client-rect-functions.js b/lighthouse-core/lib/client-rect-functions.js deleted file mode 100644 index f058ecae0388..000000000000 --- a/lighthouse-core/lib/client-rect-functions.js +++ /dev/null @@ -1,325 +0,0 @@ -/** - * @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 - */ -// We sometimes run this as a part of a gatherer script injected into the page, so prevent -// renaming the function for code coverage. -/* 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 - */ -// We sometimes run this as a part of a gatherer script injected into the page, so prevent -// renaming the function for code coverage. -/* istanbul ignore next */ -function rectContains(cr1, cr2) { - return ( - // top left corner - rectContainsPoint(cr1, { - x: cr2.left, - y: cr2.top, - }) && - // top right corner - rectContainsPoint(cr1, { - x: cr2.right, - y: cr2.top, - }) && - // bottom left corner - rectContainsPoint(cr1, { - x: cr2.left, - y: cr2.bottom, - }) && - // bottom right corner - rectContainsPoint(cr1, { - x: cr2.right, - y: cr2.bottom, - }) - ); -} - -/** - * Merge client rects together and remove small ones. 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) { - // If all client rects are tiny don't remove them, so later in the code we don't - // need to deal with elements that don't have client rects. - return clientRects; - } - return nonTinyClientRects; -} - -const rectContainsString = ` - ${rectContainsPoint.toString()} - ${rectContains.toString()}; -`; - -/** - * @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) { - // Sometimes a child will reach out of the parent by - // 1px or 2px, so be somewhat tolerant for merging - return Math.abs(a - b) <= 2; -} - -/** - * @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 clientRectsTouchOrOverlap(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} crA - * @param {LH.Artifacts.ClientRect} crB - */ -function getBoundingRect(crA, crB) { - 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); - return addRectWidthAndHeight({ - left, - right, - top, - 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]; - - /** - * 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 = - clientRectsTouchOrOverlap(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; -} - -/** - * @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) - ); -} - -/** - * @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 getRectOverlapArea(rect1, rect2) { - return getRectXOverlap(rect1, rect2) * getRectYOverlap(rect1, rect2); -} - - -/** - * @param {LH.Artifacts.ClientRect} clientRect - * @param {number} fingerSize - */ -function getRectAtCenter(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.ClientRect[]} clientRects - */ -function getLargestClientRect(clientRects) { - let largestCr = clientRects[0]; - for (const cr of 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; -} - -module.exports = { - rectContains, - rectContainsString, - simplifyClientRects, - addRectWidthAndHeight, - getRectXOverlap, - getRectYOverlap, - getRectOverlapArea, - getRectAtCenter, - getLargestClientRect, - allClientRectsContainedWithinEachOther, -}; diff --git a/lighthouse-core/lib/rect-helpers.js b/lighthouse-core/lib/rect-helpers.js new file mode 100644 index 000000000000..d82c7b995049 --- /dev/null +++ b/lighthouse-core/lib/rect-helpers.js @@ -0,0 +1,257 @@ +/** + * @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.Rect} rect + * @param {{x:number, y:number}} point + */ +// We sometimes run this as a part of a gatherer script injected into the page, so prevent +// renaming the function for code coverage. +/* istanbul ignore next */ +function rectContainsPoint(rect, {x, y}) { + return rect.left <= x && rect.right >= x && rect.top <= y && rect.bottom >= y; +} + +/** + * @param {LH.Artifacts.Rect} rect1 + * @param {LH.Artifacts.Rect} rect2 + */ +// We sometimes run this as a part of a gatherer script injected into the page, so prevent +// renaming the function for code coverage. +/* istanbul ignore next */ +function rectContains(rect1, rect2) { + return ( + // top left corner + rectContainsPoint(rect1, { + x: rect2.left, + y: rect2.top, + }) && + // top right corner + rectContainsPoint(rect1, { + x: rect2.right, + y: rect2.top, + }) && + // bottom left corner + rectContainsPoint(rect1, { + x: rect2.left, + y: rect2.bottom, + }) && + // bottom right corner + rectContainsPoint(rect1, { + x: rect2.right, + y: rect2.bottom, + }) + ); +} + +/** + * @param {LH.Artifacts.Rect[]} rects + * @returns {LH.Artifacts.Rect[]} + */ +function filterOutTinyRects(rects) { + return rects.filter( + rect => rect.width > 1 && rect.height > 1 + ); +} + +const rectContainsString = ` + ${rectContainsPoint.toString()} + ${rectContains.toString()}; +`; + +/** + * @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; +} + +module.exports = { + rectContainsPoint, + rectContains, + rectContainsString, + addRectWidthAndHeight, + addRectTopAndBottom, + getRectXOverlap, + getRectYOverlap, + getRectOverlapArea, + getRectAtCenter, + getLargestRect, + getRectCenterPoint, + getBoundingRect, + rectsTouchOrOverlap, + allRectsContainedWithinEachOther, + filterOutRectsContainedByOthers, + filterOutTinyRects, +}; diff --git a/lighthouse-core/lib/tap-target-rect-helpers.js b/lighthouse-core/lib/tap-target-rect-helpers.js new file mode 100644 index 000000000000..2879e0f89038 --- /dev/null +++ b/lighthouse-core/lib/tap-target-rect-helpers.js @@ -0,0 +1,100 @@ +/** + * @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'; + +const { + filterOutRectsContainedByOthers, + filterOutTinyRects, + rectsTouchOrOverlap, + rectContainsPoint, + getBoundingRect, + getRectCenterPoint, +} = require('./rect-helpers'); + +/** + * Merge client rects together and remove small ones. This may result in a larger overall + * size than that of the individual client rects. + * @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; +} + +/** + * @param {number} a + * @param {number} b + */ +function almostEqual(a, b) { + // Sometimes a child will reach out of the parent by + // 1px or 2px, so be somewhat tolerant for merging + return Math.abs(a - b) <= 2; +} + +/** + * @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]; + + /** + * 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; +} + +module.exports = { + getTappableRectsFromClientRects, +}; diff --git a/lighthouse-core/test/lib/rect-helpers-test.js b/lighthouse-core/test/lib/rect-helpers-test.js new file mode 100644 index 000000000000..22c0a19be04e --- /dev/null +++ b/lighthouse-core/test/lib/rect-helpers-test.js @@ -0,0 +1,87 @@ +/** + * @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'; + +/* eslint-env jest */ + +const { + addRectTopAndBottom, + getRectOverlapArea, + getLargestRect, + getRectAtCenter, + allRectsContainedWithinEachOther, +} = require('../../lib/rect-helpers'); + +describe('Rect Helpers', () => { + it('getRectOverlapArea', () => { + const overlapArea = getRectOverlapArea( + addRectTopAndBottom({ + x: 0, + y: 0, + width: 10, + height: 10, + }), + addRectTopAndBottom({ + x: 8, + y: 6, + width: 10, + height: 10, + }) + ); + expect(overlapArea).toBe(8); + }); + + it('getLargestRect', () => { + const rect1 = addRectTopAndBottom({ + x: 0, + y: 0, + width: 5, + height: 5, + }); + const rect2 = addRectTopAndBottom({ + x: 0, + y: 0, + width: 10, + height: 10, + }); + const largestRect = getLargestRect([rect1, rect2]); + expect(largestRect).toBe(rect2); + }); + + it('getRectAtCenter', () => { + const rect = addRectTopAndBottom({ + x: 0, + y: 0, + width: 100, + height: 100, + }); + const largestRect = getRectAtCenter(rect, 10); + expect(largestRect).toEqual( + addRectTopAndBottom({ + x: 45, + y: 45, + width: 10, + height: 10, + }) + ); + }); + + describe('allRectsContainedWithinEachOther', () => { + it('Returns true if the rect lists are both empty', () => { + expect(allRectsContainedWithinEachOther([], [])).toBe(true); + }); + it('Returns true if rects are contained in each other', () => { + const rect1 = addRectTopAndBottom({x: 0, y: 0, width: 100, height: 100}); + const rect2 = addRectTopAndBottom({x: 40, y: 40, width: 20, height: 20}); + expect(allRectsContainedWithinEachOther([rect1], [rect2])).toBe(true); + }); + it('Returns true if rects aren\'t contained in each other', () => { + const rect1 = addRectTopAndBottom({x: 0, y: 0, width: 100, height: 100}); + const rect2 = addRectTopAndBottom({x: 200, y: 200, width: 20, height: 20}); + expect(allRectsContainedWithinEachOther([rect1], [rect2])).toBe(false); + }); + }); +}); diff --git a/lighthouse-core/test/lib/client-rect-functions-test.js b/lighthouse-core/test/lib/tap-target-rect-helpers-test.js similarity index 71% rename from lighthouse-core/test/lib/client-rect-functions-test.js rename to lighthouse-core/test/lib/tap-target-rect-helpers-test.js index 44a8a3b80bc1..face00265e67 100644 --- a/lighthouse-core/test/lib/client-rect-functions-test.js +++ b/lighthouse-core/test/lib/tap-target-rect-helpers-test.js @@ -7,53 +7,43 @@ /* eslint-env jest */ -const {simplifyClientRects, getRectOverlapArea} = require('../../lib/client-rect-functions'); +const {addRectTopAndBottom} = require('../../lib/rect-helpers'); +const {getTappableRectsFromClientRects} = require('../../lib/tap-target-rect-helpers'); const assert = require('assert'); -function makeClientRect({x, y, width, height}) { - return { - left: x, - top: y, - right: x + width, - bottom: y + height, - width, - height, - }; -} - -describe('simplifyClientRects', () => { +describe('getTappableRectsFromClientRects', () => { it('Merges rects if a smaller rect is inside a larger one', () => { - const containingRect = makeClientRect({ + const containingRect = addRectTopAndBottom({ x: 10, y: 10, width: 100, height: 10, }); - const containedRect = makeClientRect({ + const containedRect = addRectTopAndBottom({ x: 10, y: 10, width: 50, height: 10, }); - assert.deepEqual(simplifyClientRects([ + assert.deepEqual(getTappableRectsFromClientRects([ containingRect, containedRect, ]), [containingRect]); - assert.deepEqual(simplifyClientRects([ + assert.deepEqual(getTappableRectsFromClientRects([ containedRect, containingRect, ]), [containingRect]); }); it('Merges two horizontally adjacent client rects', () => { - const res = simplifyClientRects([ - makeClientRect({ + const res = getTappableRectsFromClientRects([ + addRectTopAndBottom({ x: 10, y: 10, width: 100, height: 10, }), - makeClientRect({ + addRectTopAndBottom({ x: 110, y: 10, width: 100, @@ -61,7 +51,7 @@ describe('simplifyClientRects', () => { }), ]); assert.deepEqual(res, [ - makeClientRect({ + addRectTopAndBottom({ x: 10, y: 10, width: 200, @@ -71,20 +61,20 @@ describe('simplifyClientRects', () => { }); it('Merges three horizontally adjacent client rects', () => { - const res = simplifyClientRects([ - makeClientRect({ + const res = getTappableRectsFromClientRects([ + addRectTopAndBottom({ x: 10, y: 10, width: 100, height: 10, }), - makeClientRect({ + addRectTopAndBottom({ x: 110, y: 10, width: 100, height: 10, }), - makeClientRect({ + addRectTopAndBottom({ x: 210, y: 10, width: 100, @@ -92,7 +82,7 @@ describe('simplifyClientRects', () => { }), ]); assert.deepEqual(res, [ - makeClientRect({ + addRectTopAndBottom({ x: 10, y: 10, width: 300, @@ -102,20 +92,20 @@ describe('simplifyClientRects', () => { }); it('Merges client rects correctly if one is duplicated', () => { - const res = simplifyClientRects([ - makeClientRect({ + const res = getTappableRectsFromClientRects([ + addRectTopAndBottom({ x: 10, y: 10, width: 90, height: 10, }), - makeClientRect({ + addRectTopAndBottom({ x: 10, y: 10, width: 90, height: 10, }), - makeClientRect({ + addRectTopAndBottom({ x: 100, y: 10, width: 10, @@ -123,7 +113,7 @@ describe('simplifyClientRects', () => { }), ]); assert.deepEqual(res, [ - makeClientRect({ + addRectTopAndBottom({ x: 10, y: 10, width: 100, @@ -136,14 +126,14 @@ describe('simplifyClientRects', () => { // We do this because to fix issues with children (e.g. images) inside links. // The link itself might be small, so if we put a finger on it directly then it's // likely to overlap with something. - const res = simplifyClientRects([ - makeClientRect({ + const res = getTappableRectsFromClientRects([ + addRectTopAndBottom({ x: 10, y: 10, width: 100, height: 10, }), - makeClientRect({ + addRectTopAndBottom({ x: 10, y: 15, width: 200, @@ -151,7 +141,7 @@ describe('simplifyClientRects', () => { }), ]); assert.deepEqual(res, [ - makeClientRect({ + addRectTopAndBottom({ x: 10, y: 10, width: 200, @@ -161,14 +151,14 @@ describe('simplifyClientRects', () => { }); it('Does not merge if the center of the merged rect wouldn\'t be in the original rects', () => { - const res = simplifyClientRects([ - makeClientRect({ + const res = getTappableRectsFromClientRects([ + addRectTopAndBottom({ x: 10, y: 10, width: 10, height: 100, }), - makeClientRect({ + addRectTopAndBottom({ x: 10, y: 10, width: 200, @@ -180,14 +170,14 @@ describe('simplifyClientRects', () => { it('Merges two horizontally adjacent client rects that don\'t line up exactly', () => { // 2px difference is ok, often there are cases where an image is a 1px or 2px out of the main link client rect - const res = simplifyClientRects([ - makeClientRect({ + const res = getTappableRectsFromClientRects([ + addRectTopAndBottom({ x: 10, y: 10, width: 100, height: 10, }), - makeClientRect({ + addRectTopAndBottom({ x: 110, y: 12, width: 100, @@ -195,7 +185,7 @@ describe('simplifyClientRects', () => { }), ]); assert.deepEqual(res, [ - makeClientRect({ + addRectTopAndBottom({ x: 10, y: 10, width: 200, @@ -205,14 +195,14 @@ describe('simplifyClientRects', () => { }); it('Merges two identical client rects into one', () => { - const res = simplifyClientRects([ - makeClientRect({ + const res = getTappableRectsFromClientRects([ + addRectTopAndBottom({ x: 10, y: 10, width: 10, height: 10, }), - makeClientRect({ + addRectTopAndBottom({ x: 10, y: 10, width: 10, @@ -220,7 +210,7 @@ describe('simplifyClientRects', () => { }), ]); assert.deepEqual(res, [ - makeClientRect({ + addRectTopAndBottom({ x: 10, y: 10, width: 10, @@ -230,14 +220,14 @@ describe('simplifyClientRects', () => { }); it('Removes tiny 1x1px client rects', () => { - const res = simplifyClientRects([ - makeClientRect({ + const res = getTappableRectsFromClientRects([ + addRectTopAndBottom({ x: 10, y: 10, width: 100, height: 100, }), - makeClientRect({ + addRectTopAndBottom({ x: 5, y: 5, width: 1, @@ -245,7 +235,7 @@ describe('simplifyClientRects', () => { }), ]); assert.deepEqual(res, [ - makeClientRect({ + addRectTopAndBottom({ x: 10, y: 10, width: 100, @@ -254,22 +244,3 @@ describe('simplifyClientRects', () => { ]); }); }); - -describe('getRectOverlapArea', () => { - it('Works in a basic example', () => { - const overlapArea = getRectOverlapArea( - makeClientRect({ - x: 0, - y: 0, - width: 10, - height: 10, - }), makeClientRect({ - x: 8, - y: 6, - width: 10, - height: 10, - }) - ); - expect(overlapArea).toBe(8); - }); -}); diff --git a/tap-targets.js b/tap-targets.js new file mode 100644 index 000000000000..7d0780f01880 --- /dev/null +++ b/tap-targets.js @@ -0,0 +1,320 @@ +(function() { + function getElementsInDocument(selector) { + const realMatchesFn = window.__ElementMatches || window.Element.prototype.matches; + /** @type {Array} */ + const results = []; + + /** @param {NodeListOf} 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 filterClientRectsWithinAncestorsVisibleScrollArea(node, clientRects) { + const parent = node.parentElement; + if (!parent) { + return clientRects; + } + if (getComputedStyle(parent).overflowY !== 'visible') { + const parentBCR = parent.getBoundingClientRect(); + clientRects = clientRects.filter(cr => rectContains(parentBCR, cr)); + } + if (parent.parentElement && parent.parentElement.tagName !== 'HTML') { + return filterClientRectsWithinAncestorsVisibleScrollArea( + parent.parentElement, + clientRects + ); + } + return clientRects; + } + function nodeIsVisible(node) { + const { + overflowX, + overflowY, + display, + visibility, + } = getComputedStyle(node); + + if ( + display === 'none' || + (visibility === 'collapse' && ['TR', 'TBODY', 'COL', 'COLGROUP'].includes(node.tagName)) + ) { + // Element not displayed + return false; + } + + 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 (node.clientWidth === 0 && overflowX === 'hidden') { + return false; + } + if (node.clientHeight === 0 && overflowY === 'hidden') { + return false; + } + } + + const parent = node.parentElement; + if ( + parent && + parent.tagName !== 'HTML' && + !nodeIsVisible(parent) + ) { + // if a parent is invisible then the current node is also invisible + return false; + } + + return true; + } + function getVisibleClientRects(node) { + if (!nodeIsVisible(node)) { + return []; + } + + const { + overflowX, + overflowY, + } = getComputedStyle(node); + let clientRects = getClientRects(node, true); + + if (allClientRectsEmpty(clientRects)) { + if ((overflowX === 'hidden' && overflowY === 'hidden') || node.children.length === 0) { + // own size is 0x0 and there's no visible child content + return []; + } + } + + // Treating overflowing content in scroll containers as invisible could mean that + // most of a given page is deemed invisible. But: + // - tap targets audit doesn't consider different containers/layers + // - having most content in an explicit scroll container is rare + // - treating them as hidden only generates false passes, which is better than false failures + clientRects = filterClientRectsWithinAncestorsVisibleScrollArea(node, clientRects); + + return clientRects; + } + function truncate(str, maxLength) { + if (str.length <= maxLength) { + return str; + } + return str.slice(0, maxLength - 1) + '…'; + } + function getClientRects(node, includeChildren = true) { + /** @type {LH.Artifacts.Rect[]} */ + let clientRects = Array.from( + node.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}; + }); + if (includeChildren) { + for (const child of node.children) { + clientRects = clientRects.concat(getClientRects(child)); + } + } + + return clientRects; + } + function nodeIsInTextBlock(node) { + /** + * @param {Node} node + * @returns {boolean} + */ + function isInline(node) { + if (node.nodeType === Node.TEXT_NODE) { + return true; + } + if (node.nodeType !== Node.ELEMENT_NODE) { + return false; + } + const element = /** @type {Element} */ (node); + return ( + getComputedStyle(element).display === 'inline' || + getComputedStyle(element).display === 'inline-block' + ); + } + + /** + * @param {Node} node + */ + function hasTextNodeSiblingsFormingTextBlock(node) { + if (!node.parentElement) { + return false; + } + + const parentElement = node.parentElement; + + const nodeText = node.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; + } + + const potentialSiblings = node.parentElement.childNodes; + for (let i = 0; i < potentialSiblings.length; i++) { + const sibling = potentialSiblings[i]; + if (sibling === node) { + continue; + } + const siblingTextContent = (sibling.textContent || '').trim(); + if ( + sibling.nodeType === Node.TEXT_NODE && + siblingTextContent.length > 0 + ) { + return true; + } + } + + return false; + } + + if (!isInline(node)) { + return false; + } + + if (hasTextNodeSiblingsFormingTextBlock(node)) { + return true; + } else { + if (node.parentElement) { + return nodeIsInTextBlock(node.parentElement); + } else { + return false; + } + } + } + function allClientRectsEmpty(clientRects) { + return ( + clientRects.length === 0 || + clientRects.every(cr => cr.width === 0 && cr.height === 0) + ); + } + + function rectContainsPoint(cr, {x, y}) { + return cr.left <= x && cr.right >= x && cr.top <= y && cr.bottom >= y; + } + 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) + ); + } +; + 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() { + const selector = TARGET_SELECTORS.join(','); + + /** @type {LH.Artifacts.TapTarget[]} */ + const targets = []; + + // @ts-ignore - getElementsInDocument put into scope via stringification + Array.from(getElementsInDocument(selector)).forEach(node => { + if (nodeIsInTextBlock(node)) { + return; + } + + const visibleClientRects = getVisibleClientRects(node); + if (visibleClientRects.length === 0) { + return; + } + + targets.push({ + clientRects: visibleClientRects, + snippet: truncate(node.outerHTML, 700), + // @ts-ignore - getNodePath put into scope via stringification + path: getNodePath(node), + // @ts-ignore - getNodeSelector put into scope via stringification + selector: getNodeSelector(node), + href: node.getAttribute('href') || '', + }); + }); + + return targets; + } + + const TARGET_SELECTORS = ['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]']; + + + return gatherTapTargets(); +})(); diff --git a/types/artifacts.d.ts b/types/artifacts.d.ts index 1789d83a06b7..d37048300f07 100644 --- a/types/artifacts.d.ts +++ b/types/artifacts.d.ts @@ -278,7 +278,7 @@ declare global { }; } - export interface ClientRect { + export interface Rect { width: number; height: number; top: number;