Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 77 additions & 60 deletions packages/blitz-dom/src/node/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1144,6 +1144,7 @@ impl Node {
) -> Option<HitResult> {
use style::computed_values::pointer_events::T as PointerEvents;
use style::computed_values::visibility::T as Visibility;
use style::values::computed::Overflow;

// Don't hit on visbility:hidden elements
if let Some(style) = self.primary_styles() {
Expand Down Expand Up @@ -1206,6 +1207,20 @@ impl Node {
return None;
}

// When overflow is hidden or clip on an axis, children are visually
// clipped to the border box. A child with a transform may escape the
// clip region geometrically, so we must prevent hit testing from
// matching it. If the point is outside the clip region on a clipped
// axis, we skip child hit testing entirely (but still allow the node
// itself to be hit, e.g. for scrollbar interaction).
let hit_children = !self.primary_styles().is_some_and(|style| {
let outside_x = matches!(style.clone_overflow_x(), Overflow::Hidden | Overflow::Clip)
&& (x < 0.0 || x > size.width + self.scroll_offset().x as f32);
let outside_y = matches!(style.clone_overflow_y(), Overflow::Hidden | Overflow::Clip)
&& (y < 0.0 || y > size.height + self.scroll_offset().y as f32);
outside_x || outside_y
});

// Descendants overwrite, so the innermost scroll container's thumb
// wins. Thumb coords are border-box relative (unscrolled).
if matches_self
Expand All @@ -1217,77 +1232,79 @@ impl Node {
*scrollbar = Some(sb);
}

if self.flags.is_inline_root() {
let content_box_offset = taffy::Point {
x: self.final_layout().padding.left + self.final_layout().border.left,
y: self.final_layout().padding.top + self.final_layout().border.top,
};
x -= content_box_offset.x;
y -= content_box_offset.y;
}
if hit_children {
if self.flags.is_inline_root() {
let content_box_offset = taffy::Point {
x: self.final_layout().padding.left + self.final_layout().border.left,
y: self.final_layout().padding.top + self.final_layout().border.top,
};
x -= content_box_offset.x;
y -= content_box_offset.y;
}

// Positive z_index hoisted children
if matches_hoisted_content {
if let Some(hoisted) = &self.stacking_context {
for hoisted_child in hoisted.pos_z_hoisted_children().rev() {
let x = x - hoisted_child.position.x;
let y = y - hoisted_child.position.y;
if let Some(hit) = self
.with(hoisted_child.node_id)
.hit_inner(x, y, scale, scrollbar)
{
return Some(hit);
// Positive z_index hoisted children
if matches_hoisted_content {
if let Some(hoisted) = &self.stacking_context {
for hoisted_child in hoisted.pos_z_hoisted_children().rev() {
let x = x - hoisted_child.position.x;
let y = y - hoisted_child.position.y;
if let Some(hit) = self
.with(hoisted_child.node_id)
.hit_inner(x, y, scale, scrollbar)
{
return Some(hit);
}
}
}
}
}

// Call `.hit()` on each child in turn. If any return `Some` then return that value. Else return `Some(self.id).
for child_id in self.paint_children.borrow().iter().flatten().rev() {
if let Some(hit) = self.with(*child_id).hit_inner(x, y, scale, scrollbar) {
return Some(hit);
// Call `.hit()` on each child in turn. If any return `Some` then return that value. Else return `Some(self.id).
for child_id in self.paint_children.borrow().iter().flatten().rev() {
if let Some(hit) = self.with(*child_id).hit_inner(x, y, scale, scrollbar) {
return Some(hit);
}
}
}

// Negative z_index hoisted children
if matches_hoisted_content {
if let Some(hoisted) = &self.stacking_context {
for hoisted_child in hoisted.neg_z_hoisted_children().rev() {
let x = x - hoisted_child.position.x;
let y = y - hoisted_child.position.y;
if let Some(hit) = self
.with(hoisted_child.node_id)
.hit_inner(x, y, scale, scrollbar)
{
return Some(hit);
// Negative z_index hoisted children
if matches_hoisted_content {
if let Some(hoisted) = &self.stacking_context {
for hoisted_child in hoisted.neg_z_hoisted_children().rev() {
let x = x - hoisted_child.position.x;
let y = y - hoisted_child.position.y;
if let Some(hit) = self
.with(hoisted_child.node_id)
.hit_inner(x, y, scale, scrollbar)
{
return Some(hit);
}
}
}
}
}

// Inline children
if self.flags.is_inline_root() {
let element_data = &self.element_data().unwrap();
if let Some(ild) = element_data.inline_layout_data.as_ref() {
let layout = &ild.layout;
let scale = layout.scale();

if let Some((cluster, _side)) =
Cluster::from_point_exact(layout, x * scale, y * scale)
{
let style_index = cluster.glyphs().next()?.style_index();
let node_id = layout.styles()[style_index].brush.id;
let text_pointer_events_none = self
.with(node_id)
.primary_styles()
.is_some_and(|style| style.clone_pointer_events() == PointerEvents::None);
if !text_pointer_events_none {
return Some(HitResult {
node_id,
x,
y,
is_text: true,
});
// Inline children
if self.flags.is_inline_root() {
let element_data = &self.element_data().unwrap();
if let Some(ild) = element_data.inline_layout_data.as_ref() {
let layout = &ild.layout;
let scale = layout.scale();

if let Some((cluster, _side)) =
Cluster::from_point_exact(layout, x * scale, y * scale)
{
let style_index = cluster.glyphs().next()?.style_index();
let node_id = layout.styles()[style_index].brush.id;
let text_pointer_events_none =
self.with(node_id).primary_styles().is_some_and(|style| {
style.clone_pointer_events() == PointerEvents::None
});
if !text_pointer_events_none {
return Some(HitResult {
node_id,
x,
y,
is_text: true,
});
}
}
}
}
Expand Down
116 changes: 116 additions & 0 deletions tests/blitz-tests/tests/overflow_clip_hit_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
//! When a child has a CSS transform that moves it outside its parent's
//! `overflow: hidden` clip region, the child is visually clipped and must
//! not be hit-tested. Previously, `scrollable_overflow` extended beyond
//! the border box, so hit testing matched the visually-clipped child,
//! causing incorrect cursor and hover state.

use blitz_test_harness::{Harness, HarnessOptions};

fn harness(html: &str) -> Harness {
Harness::from_html_with(
html,
HarnessOptions {
width: 400,
height: 400,
..Default::default()
},
)
}

#[test]
fn overflow_hidden_clips_transformed_child_from_hit_testing() {
// The parent is a 100×100 box with overflow:hidden at (0,0).
// The child is 50×50, positioned at (0,0) but translated 200px right,
// so it paints at (200,0) -- far outside the parent's clip region.
// A click at (200, 25) should NOT hit the child.
let harness = harness(
r#"<html><body style="margin:0">
<div id="parent" style="overflow:hidden; width:100px; height:100px;">
<div id="child" style="width:50px; height:50px; transform:translateX(200px);"></div>
</div>
<div id="sibling" style="width:100px; height:100px; margin-top:50px;"></div>
</body></html>"#,
);

let parent = harness.node("#parent");
let child = harness.node("#child");

// (50, 50) is inside the parent's clip region but where the child would
// be *without* the transform. With the transform, the child is at
// (200..250, 0..50). (50, 50) should not hit the child.
let hit = harness.hit(50.0, 50.0);
if let Some(hit) = hit {
assert_ne!(
hit.node_id, child,
"hit at (50, 50) should not match the transformed-away child"
);
}

// (210, 25) is where the child visually appears, but it is outside the
// parent's overflow:hidden clip, so it must not be hit-tested.
let hit = harness.hit(210.0, 25.0);
if let Some(hit) = hit {
assert_ne!(
hit.node_id, child,
"hit at (210, 25) should not match the clipped child"
);
assert_ne!(
hit.node_id, parent,
"hit at (210, 25) should not match the parent (outside clip)"
);
}
}

#[test]
fn overflow_visible_does_not_clip_children() {
// Same layout but with overflow:visible -- the child is not clipped
// and should be hit-testable at its transformed position.
let harness = harness(
r#"<html><body style="margin:0">
<div id="parent" style="overflow:visible; width:100px; height:100px;">
<div id="child" style="width:50px; height:50px; transform:translateX(200px);"></div>
</div>
</body></html>"#,
);

let child = harness.node("#child");

// (210, 25) is where the child visually appears with overflow:visible.
let hit = harness.hit(210.0, 25.0);
assert!(
hit.is_some(),
"expected a hit at (210, 25) with overflow:visible"
);
let hit = hit.unwrap();
assert_eq!(
hit.node_id, child,
"hit at (210, 25) should match the child when overflow is visible"
);
}

#[test]
fn overflow_hidden_child_inside_clip_region_is_still_hittable() {
// The child is inside the parent's clip region, so it should be
// hit-testable normally.
let harness = harness(
r#"<html><body style="margin:0">
<div id="parent" style="overflow:hidden; width:200px; height:200px;">
<div id="child" style="width:50px; height:50px; margin:25px;"></div>
</div>
</body></html>"#,
);

let child = harness.node("#child");

// (50, 50) is inside the child's box (at 25..75, 25..75 within parent).
let hit = harness.hit(50.0, 50.0);
assert!(
hit.is_some(),
"expected a hit at (50, 50) inside the clip region"
);
let hit = hit.unwrap();
assert_eq!(
hit.node_id, child,
"hit at (50, 50) should match the child inside the clip region"
);
}
Loading