commit c9a84948bfd20daf2c9b3e3b7dc3ab9948506421
parent 1088cf8db2287670a01b1581de66257a523d013f
Author: Daniil Sakhapov <sakhapov@chromium.org>
Date: Thu, 27 Nov 2025 15:27:41 +0000
Bug 2002580 [wpt PR 56299] - Implement border-shape hit testing, a=testonly
Automatic update from web-platform-tests
Implement border-shape hit testing
So far it's specified not to affect layout, but we want it to be
hit-testable. Existing hit-testing code normally does a simple border
box check first before going into any clipping details inside, but,
since the border-shape can be painted outside the border box, we need
to check its border-shape bounding box for hit-testing.
To do so, and also not to lose the border when the shape is painted
outside the viewport, this CL computes scrollable (layout) overflow
and uses that box for hit-testing purposes. That box is propagated up,
meaning all the ancestors will now account for scrollable overflow
of a descendant element with border-shape.
The hit test of PaintLayer will be done in the follow-up and tracked
in crbug.com/456675133
Bug: 370041145
Change-Id: I9f548620db37732c198243940325216c85e8f34e
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/6862996
Reviewed-by: Philip Rogers <pdr@chromium.org>
Commit-Queue: Daniil Sakhapov <sakhapov@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1550496}
--
wpt-commits: 00a31a92138affe472b297761f07e9329bb90b38
wpt-pr: 56299
Diffstat:
5 files changed, 317 insertions(+), 0 deletions(-)
diff --git a/testing/web-platform/tests/css/css-borders/tentative/border-shape-circle-hit-test-overflow-clip-margin.html b/testing/web-platform/tests/css/css-borders/tentative/border-shape-circle-hit-test-overflow-clip-margin.html
@@ -0,0 +1,55 @@
+<!DOCTYPE html>
+<title>CSS Borders Test: hit testing border-shape circle with overflow clip parent</title>
+<link rel="help" href="https://drafts.csswg.org/css-borders-4/#border-shape">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<style>
+ #outer {
+ width: 100px;
+ height: 100px;
+ overflow: clip;
+ overflow-clip-margin: 50px;
+ }
+ #target {
+ width: 100px;
+ height: 100px;
+ border-shape: circle(45px at 50% 50%);
+ border: 10px solid purple;
+ background: green;
+ }
+</style>
+<div id="outer">
+ <div id="target"></div>
+</div>
+<script>
+ function getCenter(el) {
+ const rect = el.getBoundingClientRect();
+ return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
+ }
+
+ function polarToXY(center, radius, angleRad) {
+ return {
+ x: Math.round(center.x + radius * Math.cos(angleRad)),
+ y: Math.round(center.y + radius * Math.sin(angleRad))
+ };
+ }
+
+ promise_test(async t => {
+ const target = document.getElementById('target');
+ const center = getCenter(target);
+ const strokeWidthHalf = 5; // 10px border-width
+ const circleRadius = 45 + strokeWidthHalf;
+
+ // Check a point on the edge of the circle (should hit due to overflow-clip-margin).
+ const { x, y } = polarToXY(center, circleRadius - 2, 0); // angle 0deg, right edge of the circle, -2px just to make it inside the border-shape contour.
+ let hit = false;
+ let hitBody = false;
+ target.addEventListener('pointerdown', () => { hit = true; }, { once: true });
+ document.body.addEventListener('pointerdown', () => { hitBody = true; }, { once: true });
+ await new test_driver.Actions().pointerMove(x, y).pointerDown().pointerUp().send();
+ assert_true(hit, 'Point outside the clipped part should hit the border-shape due to overflow-clip-margin');
+ });
+</script>
diff --git a/testing/web-platform/tests/css/css-borders/tentative/border-shape-circle-hit-test-overflow-clip.html b/testing/web-platform/tests/css/css-borders/tentative/border-shape-circle-hit-test-overflow-clip.html
@@ -0,0 +1,56 @@
+<!DOCTYPE html>
+<title>CSS Borders Test: hit testing border-shape circle with overflow clip parent</title>
+<link rel="help" href="https://drafts.csswg.org/css-borders-4/#border-shape">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<style>
+ #outer {
+ width: 100px;
+ height: 100px;
+ overflow: clip;
+ }
+ #target {
+ width: 100px;
+ height: 100px;
+ border-shape: circle(50% at 50% 50%);
+ stroke: purple;
+ stroke-width: 10px;
+ background: green;
+ }
+</style>
+<div id="outer">
+ <div id="target"></div>
+</div>
+<script>
+ function getCenter(el) {
+ const rect = el.getBoundingClientRect();
+ return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
+ }
+
+ function polarToXY(center, radius, angleRad) {
+ return {
+ x: Math.round(center.x + radius * Math.cos(angleRad)),
+ y: Math.round(center.y + radius * Math.sin(angleRad))
+ };
+ }
+
+ promise_test(async t => {
+ const target = document.getElementById('target');
+ const center = getCenter(target);
+ const strokeWidthHalf = 5; // 10px stroke-width
+ const circleRadius = 50 + strokeWidthHalf; // 100px box, circle(50%) => 50px radius
+
+ // Check a point on the edge of the circle (shouldn't hit as clipped).
+ const { x, y } = polarToXY(center, circleRadius - 2, 0); // angle 0deg, right edge of the circle, -2px just to make it inside the border-shape contour.
+ let hit = false;
+ let hitBody = false;
+ target.addEventListener('pointerdown', () => { hit = true; }, { once: true });
+ document.body.addEventListener('pointerdown', () => { hitBody = true; }, { once: true });
+ await new test_driver.Actions().pointerMove(x, y).pointerDown().pointerUp().send();
+ assert_false(hit, 'Point outside the clipped part should not hit the border-shape');
+ assert_true(hitBody, 'Point outside the clipped part should hit body');
+ });
+</script>
diff --git a/testing/web-platform/tests/css/css-borders/tentative/border-shape-circle-hit-test-siblings.html b/testing/web-platform/tests/css/css-borders/tentative/border-shape-circle-hit-test-siblings.html
@@ -0,0 +1,101 @@
+<!DOCTYPE html>
+<title>CSS Borders Test: hit testing border-shape circle depth order with siblings</title>
+<link rel="help" href="https://drafts.csswg.org/css-borders-4/#border-shape">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<style>
+ .container {
+ position: relative;
+ width: 200px;
+ height: 100px;
+ }
+
+ .sibling {
+ position: absolute;
+ width: 100px;
+ height: 100px;
+ top: 0;
+ left: 0;
+ background: blue;
+ opacity: 0.5;
+ }
+
+ #target {
+ position: absolute;
+ left: 50px;
+ width: 80px;
+ height: 80px;
+ border-shape: circle(45px at 50% 50%);
+ border: 10px solid purple;
+ background: green;
+ }
+
+ .sibling2 {
+ position: absolute;
+ left: 100px;
+ width: 100px;
+ height: 100px;
+ background: red;
+ opacity: 0.5;
+ }
+</style>
+<div class="container">
+ <div class="sibling" id="before"></div>
+ <div id="target"></div>
+ <div class="sibling2" id="after"></div>
+</div>
+<script>
+ function getRect(el) {
+ return el.getBoundingClientRect();
+ }
+ function getCenter(el) {
+ const r = getRect(el);
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
+ }
+ function polarToXY(center, radius, angleRad) {
+ return {
+ x: Math.round(center.x + radius * Math.cos(angleRad)),
+ y: Math.round(center.y + radius * Math.sin(angleRad))
+ };
+ }
+
+ let hit = null;
+ function handler(e) { hit = e.currentTarget.id; }
+
+ const before = document.getElementById('before');
+ const target = document.getElementById('target');
+ const after = document.getElementById('after');
+ const center = getCenter(target);
+ const strokeWidthHalf = 5; // 10px stroke-width
+ const radius = 45 + strokeWidthHalf;
+
+ promise_test(async t => {
+ // Checking hit on upper left part of the circle border (should be before).
+ hit = null;
+ before.addEventListener('pointerdown', handler, { once: true });
+ const { x, y } = polarToXY(center, radius + 1, (3 * Math.PI) / 4); // angle 135deg, top-left edge of the circle
+ await new test_driver.Actions().pointerMove(x, y).pointerDown().pointerUp().send();
+ assert_equals(hit, 'before', 'Before sibling hit while not overlapping');
+ });
+
+ promise_test(async t => {
+ // Checking hit on left part of the circle border (should be target).
+ hit = null;
+ target.addEventListener('pointerdown', handler, { once: true });
+ const { x, y } = polarToXY(center, radius, Math.PI); // angle 180deg, left edge of the circle
+ await new test_driver.Actions().pointerMove(x, y).pointerDown().pointerUp().send();
+ assert_equals(hit, 'target', 'Target has higher z-index than before sibling while overlapping');
+ });
+
+ promise_test(async t => {
+ // Checking hit on right part of the circle (should be after).
+ hit = null;
+ after.addEventListener('pointerdown', handler, { once: true });
+ const { x, y } = polarToXY(center, radius, 0); // angle 0deg, right edge of the circle
+ await new test_driver.Actions().pointerMove(x, y).pointerDown().pointerUp().send();
+ assert_equals(hit, 'after', 'Target has lower z-index than after sibling while overlapping');
+ });
+</script>
diff --git a/testing/web-platform/tests/css/css-borders/tentative/border-shape-circle-hit-test.html b/testing/web-platform/tests/css/css-borders/tentative/border-shape-circle-hit-test.html
@@ -0,0 +1,55 @@
+<!DOCTYPE html>
+<title>CSS Borders Test: hit testing border-shape circle</title>
+<link rel="help" href="https://drafts.csswg.org/css-borders-4/#border-shape">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<style>
+ #target {
+ width: 100px;
+ height: 100px;
+ border-shape: circle(45px at 50% 50%);
+ border: 10px solid purple;
+ background: green;
+ }
+</style>
+<div id="target"></div>
+<script>
+ function getCenter(el) {
+ const rect = el.getBoundingClientRect();
+ return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
+ }
+
+ function polarToXY(center, radius, angleRad) {
+ return {
+ x: Math.round(center.x + radius * Math.cos(angleRad)),
+ y: Math.round(center.y + radius * Math.sin(angleRad))
+ };
+ }
+
+ promise_test(async t => {
+ const target = document.getElementById('target');
+ const center = getCenter(target);
+ const strokeWidthHalf = 5; // 10px border-width
+ const circleRadius = 45 + strokeWidthHalf;
+
+ // Check points from center to the edge of the circle (should hit).
+ for (let r = 0; r < circleRadius; r += 5) {
+ let angle = Math.random() * 2 * Math.PI;
+ const { x, y } = polarToXY(center, r, angle);
+ let hit = false;
+ target.addEventListener('pointerdown', () => { hit = true; }, { once: true });
+ await new test_driver.Actions().pointerMove(x, y).pointerDown().pointerUp().send();
+ assert_true(hit, `Point at radius ${r} should hit the element`);
+ }
+
+ // Check a point just outside the circle (should not hit).
+ const { x: outX, y: outY } = polarToXY(center, circleRadius + 1, Math.PI / 4);
+ let hit = false;
+ target.addEventListener('pointerdown', () => { hit = true; }, { once: true });
+ await new test_driver.Actions().pointerMove(outX, outY).pointerDown().pointerUp().send();
+ assert_false(hit, 'Point outside the border-shape should not hit the element');
+ });
+</script>
diff --git a/testing/web-platform/tests/css/css-borders/tentative/border-shape/border-shape-hit-test-overflow.html b/testing/web-platform/tests/css/css-borders/tentative/border-shape/border-shape-hit-test-overflow.html
@@ -0,0 +1,50 @@
+<!DOCTYPE html>
+<title>CSS Borders Test: hit testing border-shape with overflow</title>
+<link rel="help" href="https://drafts.csswg.org/css-borders-4/#border-shape">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<style>
+ #bs-target {
+ width: 200px;
+ height: 200px;
+ border-shape: circle(50% at 50% 50%);
+ border: 20px solid purple;
+ background: green;
+ }
+
+ #bs-target:hover {
+ border-color: orange;
+ }
+
+ #overflower {
+ width: 400px;
+ height: 25px;
+ background: lightblue;
+ text-align: end;
+ }
+</style>
+
+border-shape:<br>
+<div id="bs-target">
+ <div id="overflower">hover here</div>
+</div>
+<script>
+ promise_test(async t => {
+ let eventTarget;
+ const target = document.getElementById('bs-target');
+ const overflower = document.getElementById('overflower');
+ const x = 350, y = 60;
+ const rect = target.getBoundingClientRect();
+ assert_false(
+ x >= rect.left && x <= rect.right &&
+ y >= rect.top && y <= rect.bottom,
+ 'Point should be outside the border-shape element');
+
+ target.addEventListener('mouseover', (e) => { eventTarget = e.target; }, { once: true });
+ await new test_driver.Actions().pointerMove(x, y).send();
+ assert_equals(eventTarget, overflower, 'Event target should be the overflowing element');
+ });
+</script>