diff --git a/packages/blitz-dom/src/node/node.rs b/packages/blitz-dom/src/node/node.rs index 2ed3eaa3e..4fa90b3e2 100644 --- a/packages/blitz-dom/src/node/node.rs +++ b/packages/blitz-dom/src/node/node.rs @@ -1144,6 +1144,7 @@ impl Node { ) -> Option { 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() { @@ -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 @@ -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, + }); + } } } } diff --git a/tests/blitz-tests/tests/overflow_clip_hit_test.rs b/tests/blitz-tests/tests/overflow_clip_hit_test.rs new file mode 100644 index 000000000..79e3003ce --- /dev/null +++ b/tests/blitz-tests/tests/overflow_clip_hit_test.rs @@ -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#" +
+
+
+
+ "#, + ); + + 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#" +
+
+
+ "#, + ); + + 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#" +
+
+
+ "#, + ); + + 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" + ); +}