StyleManager: track overscroll-behavior

Chaining a wheel out of a saturated container is exactly what sites use
`overscroll-behavior: contain` to prevent, so the cascade needs to know
about it before the wheel can chain.

Two flags follow the overflow-x/overflow-y pattern: a shorthand arm in
Slots.apply covers both fold paths, and overscrollContainAxes is the
probe. `contain` and `none` both stop propagation, only `auto` lets it
through. Props was exactly full at u8.

splitOverflow serves two shorthands now, so it is splitAxisPair.
This commit is contained in:
Adrià Arrufat committed 2026-09-21 17:37:26 +02:00
1 parent 9b5434e25a
commit e4df45a967
3 files changed
+57 -13

No files matched your search

+50 -7
View File
@@ -654,7 +654,7 @@ fn rebuildIfDirty(self: *StyleManager) !void {
/// Own-element cascade result, resolved for every property at once so one
/// entry serves any probe.
const Props = packed struct(u8) {
const Props = packed struct(u10) {
// Author value (inline or sheet). Without `author_display` it's the UA
// fallback: .none when matchesUaDisplayNoneRule, else .other.
display: Display = .other,
@@ -664,6 +664,8 @@ const Props = packed struct(u8) {
pointer_events_none: bool = false,
overflow_x_scrolls: bool = false,
overflow_y_scrolls: bool = false,
overscroll_x_contains: bool = false,
overscroll_y_contains: bool = false,
fn probe(self: Props, comptime what: Probe, options: CheckVisibilityOptions) bool {
return switch (what) {
@@ -737,6 +739,15 @@ pub fn overflowAxes(self: *StyleManager, el: *Element) Element.ScrollAxes {
return .{ .x = p.overflow_x_scrolls, .y = p.overflow_y_scrolls };
}
/// The axes along which `el` keeps a scroll from chaining out of it: its own
/// computed overscroll-behavior on that axis is contain or none. No ancestor
/// walk.
pub fn overscrollContainAxes(self: *StyleManager, el: *Element) Element.ScrollAxes {
self.rebuildIfDirty() catch return .{};
const p = self.ownProps(el);
return .{ .x = p.overscroll_x_contains, .y = p.overscroll_y_contains };
}
fn anyInChain(self: *StyleManager, el: *Element, comptime what: Probe, options: CheckVisibilityOptions) bool {
var current: ?*Element = el;
while (current) |elem| : (current = elem.parentElement()) {
@@ -776,6 +787,8 @@ const Priorities = struct {
pointer_events_none: u64 = 0,
overflow_x_scrolls: u64 = 0,
overflow_y_scrolls: u64 = 0,
overscroll_x_contains: u64 = 0,
overscroll_y_contains: u64 = 0,
};
fn compute(self: *StyleManager, el: *Element) Props {
@@ -1020,7 +1033,7 @@ fn getBucketKey(compound: Selector.Compound) ?BucketKey {
}
// The declaration names behind TrackedProperties, in field order.
const property_names = [_][]const u8{ "display", "visibility", "opacity", "pointer-events", "overflow-x", "overflow-y" };
const property_names = [_][]const u8{ "display", "visibility", "opacity", "pointer-events", "overflow-x", "overflow-y", "overscroll-behavior-x", "overscroll-behavior-y" };
/// Extracts the tracked properties from a style declaration. The object holds
/// one entry per name in first-declared order, so folding it in order gives a
@@ -1113,6 +1126,8 @@ const TrackedProperties = struct {
pointer_events_none: ?bool = null,
overflow_x_scrolls: ?bool = null,
overflow_y_scrolls: ?bool = null,
overscroll_x_contains: ?bool = null,
overscroll_y_contains: ?bool = null,
fn apply(self: *TrackedProperties, name: []const u8, value: []const u8) void {
if (std.ascii.eqlIgnoreCase(name, "display")) {
@@ -1127,6 +1142,10 @@ const TrackedProperties = struct {
self.overflow_x_scrolls = overflowScrolls(value);
} else if (std.ascii.eqlIgnoreCase(name, "overflow-y")) {
self.overflow_y_scrolls = overflowScrolls(value);
} else if (std.ascii.eqlIgnoreCase(name, "overscroll-behavior-x")) {
self.overscroll_x_contains = overscrollContains(value);
} else if (std.ascii.eqlIgnoreCase(name, "overscroll-behavior-y")) {
self.overscroll_y_contains = overscrollContains(value);
}
}
@@ -1137,6 +1156,13 @@ const TrackedProperties = struct {
std.ascii.eqlIgnoreCase(value, "overlay");
}
// `contain` keeps the scroll in the box, `none` also kills the bounce we
// don't render anyway; only `auto` lets a scroll chain outward.
fn overscrollContains(value: []const u8) bool {
return std.ascii.eqlIgnoreCase(value, "contain") or
std.ascii.eqlIgnoreCase(value, "none");
}
fn isRelevant(self: TrackedProperties) bool {
inline for (property_fields) |field| {
if (@field(self, field) != null) {
@@ -1277,6 +1303,12 @@ fn foldDeclarations(block: []const u8, customs: ?*CustomSink) !TrackedProperties
return slots.props();
}
// The `<x> [<y>]` shorthands the cascade expands into the tracked longhands.
const axis_shorthands = [_]struct { name: []const u8, x: []const u8, y: []const u8 }{
.{ .name = "overflow", .x = "overflow-x", .y = "overflow-y" },
.{ .name = "overscroll-behavior", .x = "overscroll-behavior-x", .y = "overscroll-behavior-y" },
};
/// One block's winning value per tracked property, folded in declaration
/// order.
const Slots = struct {
@@ -1300,11 +1332,13 @@ const Slots = struct {
slots: [property_names.len]Slot = @splat(.{}),
fn apply(self: *Slots, name: []const u8, value: []const u8, important: bool) void {
if (std.ascii.eqlIgnoreCase(name, "overflow")) {
const values = CssParser.splitOverflow(value) orelse return;
self.apply("overflow-x", values.x, important);
self.apply("overflow-y", values.y, important);
return;
for (axis_shorthands) |shorthand| {
if (std.ascii.eqlIgnoreCase(name, shorthand.name)) {
const values = CssParser.splitAxisPair(value) orelse return;
self.apply(shorthand.x, values.x, important);
self.apply(shorthand.y, values.y, important);
return;
}
}
for (property_names, &self.slots) |tracked, *slot| {
if (std.ascii.eqlIgnoreCase(name, tracked)) {
@@ -1752,6 +1786,15 @@ test "StyleManager: memo: reuse and invalidation" {
try (try b.getOrCreateStyle(frame)).asCSSStyleDeclaration().setProperty("overflow", "hidden", null, frame);
try testing.expectEqual(Element.ScrollAxes{}, sm.overflowAxes(b));
// overscroll-behavior expands the same way; only `auto` chains outward.
try b.setStyle("overscroll-behavior: contain auto", frame);
try testing.expectEqual(Element.ScrollAxes{ .x = true, .y = false }, sm.overscrollContainAxes(b));
try testing.expectEqual(Element.ScrollAxes{}, sm.overscrollContainAxes(p));
try b.setStyle("overscroll-behavior-y: none", frame);
try testing.expectEqual(Element.ScrollAxes{ .x = false, .y = true }, sm.overscrollContainAxes(b));
try b.setStyle("overscroll-behavior: contain; overscroll-behavior-x: auto", frame);
try testing.expectEqual(Element.ScrollAxes{ .x = false, .y = true }, sm.overscrollContainAxes(b));
// A stylesheet change resets the memo
sm.sheetModified();
try testing.expectEqual(false, sm.isHidden(p, .{}));
+5 -4
View File
@@ -25,11 +25,12 @@ pub const Declaration = struct {
important: bool,
};
pub const OverflowValues = struct { x: []const u8, y: []const u8 };
pub const AxisPair = struct { x: []const u8, y: []const u8 };
/// `overflow: <x> [<y>]`; a single value applies to both axes. More than two
/// values is invalid and null, as is an empty declaration.
pub fn splitOverflow(value: []const u8) ?OverflowValues {
/// An `<x> [<y>]` axis shorthand such as `overflow` or `overscroll-behavior`;
/// a single value applies to both axes. More than two values is invalid and
/// null, as is an empty declaration.
pub fn splitAxisPair(value: []const u8) ?AxisPair {
var it = std.mem.tokenizeAny(u8, value, &std.ascii.whitespace);
const x = it.next() orelse return null;
const y = it.next() orelse x;
@@ -241,7 +241,7 @@ pub fn setProperty(self: *CSSStyleDeclaration, property_name: []const u8, value:
fn applyParsedDeclaration(self: *CSSStyleDeclaration, declaration: CssParser.Declaration, frame: *Frame) !void {
const normalized = normalizePropertyName(declaration.name, &frame.buf);
if (overflow_shorthand.eqlSlice(normalized)) {
const values = CssParser.splitOverflow(declaration.value) orelse return;
const values = CssParser.splitAxisPair(declaration.value) orelse return;
try self.applyParsedDeclaration(.{ .name = "overflow-x", .value = values.x, .important = declaration.important }, frame);
try self.applyParsedDeclaration(.{ .name = "overflow-y", .value = values.y, .important = declaration.important }, frame);
return;
@@ -267,7 +267,7 @@ fn setPropertyImpl(self: *CSSStyleDeclaration, property_name: []const u8, value:
const normalized = normalizePropertyName(property_name, &frame.buf);
if (overflow_shorthand.eqlSlice(normalized)) {
const values = CssParser.splitOverflow(value) orelse return false;
const values = CssParser.splitAxisPair(value) orelse return false;
const x = try self.setPropertyImpl("overflow-x", values.x, important, frame);
const y = try self.setPropertyImpl("overflow-y", values.y, important, frame);
return x or y;