diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 919b7ed59e..10d9538dca 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -245,10 +245,8 @@ return rects; } - function textRectFor(element, directOnly) { - const rects = textClientRects(element, directOnly); + function unionRects(rects) { if (rects.length === 0) return null; - const union = rects.reduce( (acc, rect) => ({ left: Math.min(acc.left, rect.left), @@ -263,7 +261,6 @@ bottom: Number.NEGATIVE_INFINITY, }, ); - return toRect({ ...union, width: union.right - union.left, @@ -271,6 +268,38 @@ }); } + function visibleTextClientRects(element, directOnly) { + // Range rects stay geometrically present outside an overflow clip. Reduce + // them in viewport coordinates so overlap measures only paintable text. + let rects = textClientRects(element, directOnly).map(toRect); + for ( + let ancestor = element.parentElement; + ancestor && rects.length > 0; + ancestor = ancestor.parentElement + ) { + const style = getComputedStyle(ancestor); + const clipX = clipsOverflowValue(style.overflowX || style.overflow); + const clipY = clipsOverflowValue(style.overflowY || style.overflow); + if (!clipX && !clipY) continue; + const clip = toRect(ancestor.getBoundingClientRect()); + rects = rects + .map((rect) => { + const left = clipX ? Math.max(rect.left, clip.left) : rect.left; + const right = clipX ? Math.min(rect.right, clip.right) : rect.right; + const top = clipY ? Math.max(rect.top, clip.top) : rect.top; + const bottom = clipY ? Math.min(rect.bottom, clip.bottom) : rect.bottom; + if (right - left <= 0.5 || bottom - top <= 0.5) return null; + return toRect({ left, right, top, bottom, width: right - left, height: bottom - top }); + }) + .filter(Boolean); + } + return rects; + } + + function textRectFor(element, directOnly) { + return unionRects(textClientRects(element, directOnly)); + } + function parsePx(value) { const parsed = Number.parseFloat(value); return Number.isFinite(parsed) ? parsed : 0; @@ -317,9 +346,13 @@ return hasBackground || hasImage || hasBorder || hasRadius; } + function clipsOverflowValue(value) { + return value && value !== "visible" && value !== "clip visible"; + } + function clipsOverflow(style) { - return [style.overflowX, style.overflowY, style.overflow].some( - (value) => value && value !== "visible" && value !== "clip visible", + return [style.overflowX, style.overflowY, style.overflow].some((value) => + clipsOverflowValue(value), ); } @@ -587,8 +620,8 @@ const blocks = []; for (const element of Array.from(root.querySelectorAll("*"))) { if (!isSolidTextBlock(element)) continue; - const rects = textClientRects(element, true); - const rect = textRectFor(element, true); + const rects = visibleTextClientRects(element, true); + const rect = unionRects(rects); if (rect) blocks.push({ element, rect, rects }); } return blocks; diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index 783b5e877a..daba1ee03f 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -1303,6 +1303,31 @@ describe("layout-audit.browser content overlap", () => { }); expect(issues.some((issue) => issue.code === "content_overlap")).toBe(true); }); + + it.each(["hidden", "clip", "auto", "scroll"])( + "excludes fully clipped text under overflow:%s", + (overflow) => { + const issues = auditOverflowClippedOverlap({ + overflow, + clipRect: rect({ left: 0, top: 0, width: 640, height: 100 }), + aTextRect: rect({ left: 100, top: 180, width: 300, height: 80 }), + bTextRect: rect({ left: 120, top: 190, width: 300, height: 80 }), + }); + + expect(issues.some((issue) => issue.code === "content_overlap")).toBe(false); + }, + ); + + it("uses the painted fragment area after partial overflow clipping", () => { + const issues = auditOverflowClippedOverlap({ + overflow: "hidden", + clipRect: rect({ left: 390, top: 0, width: 250, height: 200 }), + aTextRect: rect({ left: 100, top: 50, width: 300, height: 100 }), + bTextRect: rect({ left: 397, top: 50, width: 50, height: 100 }), + }); + + expect(issues.some((issue) => issue.code === "content_overlap")).toBe(true); + }); }); describe("contrast-audit.browser clip-path visibility", () => { @@ -1721,9 +1746,33 @@ function auditOverlapScene(options: { return runAudit(); } +function auditOverflowClippedOverlap(options: { + overflow: string; + clipRect: DOMRect; + aTextRect: DOMRect; + bTextRect: DOMRect; +}): ReturnType { + document.body.innerHTML = ` +
+
Block A copy
+
Block B copy
+
+ `; + const textRects = { a: [options.aTextRect], b: [options.bTextRect] }; + installOverlapStyles( + { a: "rgb(0, 0, 0)", b: "rgb(0, 0, 0)" }, + { a: "none", b: "none" }, + { clip: options.overflow }, + ); + installOverlapGeometry(textRects, { clip: options.clipRect }); + installAuditScript(); + return runAudit(); +} + function installOverlapStyles( colors: Record, clipPaths: Record, + overflows: Record = {}, ): void { vi.spyOn(window, "getComputedStyle").mockImplementation((element) => { const id = (element as Element).id; @@ -1733,6 +1782,9 @@ function installOverlapStyles( opacity: "1", color: colors[id] ?? "rgb(0, 0, 0)", clipPath: clipPaths[id] ?? "none", + overflow: overflows[id] ?? "visible", + overflowX: overflows[id] ?? "visible", + overflowY: overflows[id] ?? "visible", } as unknown as CSSStyleDeclaration; }); @@ -1745,10 +1797,14 @@ function installOverlapStyles( }; } -function installOverlapGeometry(textRects: Record): void { +function installOverlapGeometry( + textRects: Record, + elementRects: Record = {}, +): void { for (const element of Array.from(document.querySelectorAll("*"))) { vi.spyOn(element, "getBoundingClientRect").mockReturnValue( - boundingTextRect(textRects[element.id]) ?? + elementRects[element.id] ?? + boundingTextRect(textRects[element.id]) ?? rect({ left: 0, top: 0, width: 1920, height: 1080 }), ); }