LibWeb: Support anchor() in calc() trees

Previously, `anchor()`  was only resolved when it appeared bare in an
inset property. We now allow it to appear anywhere inside a `calc()`
tree.
This commit is contained in:
Tim Ledbetter 2026-06-14 06:42:25 +01:00 committed by Sam Atkins
parent b62a099e91
commit 340ef361d8
10 changed files with 338 additions and 42 deletions

View file

@ -6,14 +6,23 @@
#pragma once
#include <AK/Optional.h>
#include <LibWeb/CSS/Angle.h>
#include <LibWeb/CSS/Frequency.h>
#include <LibWeb/CSS/Length.h>
#include <LibWeb/CSS/StyleValues/ComputationContext.h>
#include <LibWeb/CSS/Time.h>
#include <LibWeb/Forward.h>
#include <LibWeb/PixelUnits.h>
namespace Web::CSS {
class AnchorResolver {
public:
virtual ~AnchorResolver() = default;
virtual Optional<CSSPixels> resolve(AnchorStyleValue const&) const = 0;
};
struct CalculationResolutionContext {
using PercentageBasis = Variant<Empty, Angle, Frequency, Length, Time>;
@ -21,6 +30,8 @@ struct CalculationResolutionContext {
Optional<Length::ResolutionContext> length_resolution_context {};
Optional<DOM::AbstractElement> abstract_element {};
AnchorResolver const* anchor_resolver { nullptr };
static CalculationResolutionContext from_computation_context(ComputationContext const& computation_context, PercentageBasis percentage_basis = {})
{
return {

View file

@ -5148,6 +5148,10 @@ RefPtr<CalculationNode const> Parser::convert_to_calculation_node(CalcParsing::N
if (auto tree_counting_function = parse_tree_counting_function(tree_counting_function_tokens, TreeCountingFunctionStyleValue::ComputedType::Number))
return NonMathFunctionCalculationNode::create(tree_counting_function.release_nonnull(), NumericType {});
auto anchor_function_tokens = TokenStream<ComponentValue>::of_single_token(component_value);
if (auto anchor_function = parse_anchor(anchor_function_tokens))
return NonMathFunctionCalculationNode::create(anchor_function->as_anchor(), NumericType { NumericType::BaseType::Length, 1 });
// NOTE: If we get here, then we have a ComponentValue that didn't get replaced with something else,
// so the calc() is invalid.
ErrorReporter::the().report(InvalidValueError {

View file

@ -4,7 +4,9 @@
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <LibWeb/CSS/CalculationResolutionContext.h>
#include <LibWeb/CSS/StyleValues/AnchorStyleValue.h>
#include <LibWeb/CSS/StyleValues/CalculatedStyleValue.h>
namespace Web::CSS {
@ -19,7 +21,7 @@ ValueComparingNonnullRefPtr<AnchorStyleValue const> AnchorStyleValue::create(
AnchorStyleValue::AnchorStyleValue(Optional<FlyString> const& anchor_name,
ValueComparingNonnullRefPtr<StyleValue const> const& anchor_side,
ValueComparingRefPtr<StyleValue const> const& fallback_value)
: StyleValueWithDefaultOperators(Type::Anchor)
: AbstractNonMathCalcFunctionStyleValue(Type::Anchor)
, m_properties { .anchor_name = anchor_name, .anchor_side = anchor_side, .fallback_value = fallback_value }
{
}
@ -43,4 +45,38 @@ void AnchorStyleValue::serialize(StringBuilder& builder, SerializationMode seria
builder.append(')');
}
// https://drafts.csswg.org/css-anchor-position-1/#anchor-pos
RefPtr<CalculationNode const> AnchorStyleValue::resolve_to_calculation_node(CalculationContext const& calculation_context, CalculationResolutionContext const& calculation_resolution_context) const
{
if (!calculation_resolution_context.anchor_resolver)
return nullptr;
// An anchor() function representing a resolvable anchor function resolves at computed value time (using style &
// layout interleaving) to the <length> that would align the edge of the positioned boxes' inset-modified containing
// block corresponding to the property the function appears in with the specified edge of the target anchor
// elements anchor box.
if (auto side_px = calculation_resolution_context.anchor_resolver->resolve(*this); side_px.has_value())
return NumericCalculationNode::create(Length::make_px(side_px.release_value()), calculation_context);
// If any of these conditions are false, the anchor() function computes to its specified fallback value. If no
// fallback value is specified, it makes the declaration referencing it invalid at computed-value time.
auto const& fallback_value = m_properties.fallback_value;
if (!fallback_value)
return nullptr;
// NB: The fallback value can itself be an anchor(), which is resolved when the substituted tree is simplified.
NonnullRefPtr<CalculationNode const> fallback_node = fallback_value->is_anchor()
? static_cast<NonnullRefPtr<CalculationNode const>>(NonMathFunctionCalculationNode::create(fallback_value->as_anchor(), NumericType { NumericType::BaseType::Length, 1 }))
: CalculationNode::from_style_value(*fallback_value, calculation_context);
return simplify_a_calculation_tree(fallback_node, calculation_context, calculation_resolution_context);
}
bool AnchorStyleValue::equals(StyleValue const& other) const
{
if (type() != other.type())
return false;
return m_properties == other.as_anchor().m_properties;
}
}

View file

@ -8,12 +8,12 @@
#include <AK/FlyString.h>
#include <LibWeb/CSS/PercentageOr.h>
#include <LibWeb/CSS/StyleValues/StyleValue.h>
#include <LibWeb/CSS/StyleValues/AbstractNonMathCalcFunctionStyleValue.h>
namespace Web::CSS {
// https://drafts.csswg.org/css-anchor-position-1/#funcdef-anchor
class AnchorStyleValue final : public StyleValueWithDefaultOperators<AnchorStyleValue> {
class AnchorStyleValue final : public AbstractNonMathCalcFunctionStyleValue {
public:
static ValueComparingNonnullRefPtr<AnchorStyleValue const> create(Optional<FlyString> const& anchor_name,
ValueComparingNonnullRefPtr<StyleValue const> const& anchor_side,
@ -21,8 +21,9 @@ public:
virtual ~AnchorStyleValue() override = default;
virtual void serialize(StringBuilder&, SerializationMode) const override;
virtual RefPtr<CalculationNode const> resolve_to_calculation_node(CalculationContext const&, CalculationResolutionContext const&) const override;
bool properties_equal(AnchorStyleValue const& other) const { return m_properties == other.m_properties; }
virtual bool equals(StyleValue const& other) const override;
virtual bool is_computationally_independent() const override { return true; }

View file

@ -5,7 +5,9 @@
*/
#include <LibWeb/CSS/ComputedProperties.h>
#include <LibWeb/CSS/PropertyNameAndID.h>
#include <LibWeb/CSS/StyleValues/AnchorStyleValue.h>
#include <LibWeb/CSS/StyleValues/CalculatedStyleValue.h>
#include <LibWeb/CSS/StyleValues/KeywordStyleValue.h>
#include <LibWeb/CSS/StyleValues/PercentageStyleValue.h>
#include <LibWeb/DOM/Document.h>
@ -1549,6 +1551,58 @@ AbsposContainingBlockInfo FormattingContext::resolve_abspos_containing_block_inf
return { rect, horizontal_axis_mode, vertical_axis_mode, {}, {} };
}
static bool calculation_tree_contains_anchor(CSS::CalculationNode const& root)
{
if (root.type() == CSS::CalculationNode::Type::NonMathFunction && as<CSS::NonMathFunctionCalculationNode>(root).function()->is_anchor())
return true;
for (auto const& child : root.children()) {
if (calculation_tree_contains_anchor(child))
return true;
}
return false;
}
namespace {
template<typename ResolveAnchorSide>
class AnchorInsetResolver final : public CSS::AnchorResolver {
public:
AnchorInsetResolver(ResolveAnchorSide const& resolve_anchor_side, bool box_is_absolutely_positioned, bool is_from_end, bool is_horizontal_axis, CSSPixels containing_block_extent)
: m_resolve_anchor_side(resolve_anchor_side)
, m_box_is_absolutely_positioned(box_is_absolutely_positioned)
, m_is_from_end(is_from_end)
, m_is_horizontal_axis(is_horizontal_axis)
, m_containing_block_extent(containing_block_extent)
{
}
virtual Optional<CSSPixels> resolve(CSS::AnchorStyleValue const& anchor) const override
{
if (!m_box_is_absolutely_positioned)
return {};
auto side_px = m_resolve_anchor_side(anchor, m_is_from_end, m_is_horizontal_axis);
if (!side_px.has_value())
return {};
// For inset properties measuring from the end edge (right, bottom), the resolved length is the distance from
// the anchor side to the corresponding edge of the containing block's padding box.
if (m_is_from_end)
return m_containing_block_extent - side_px.value();
return side_px.value();
}
private:
ResolveAnchorSide const& m_resolve_anchor_side;
bool m_box_is_absolutely_positioned { false };
bool m_is_from_end { false };
bool m_is_horizontal_axis { false };
CSSPixels m_containing_block_extent { 0 };
};
}
// https://drafts.csswg.org/css-anchor-position-1/#anchor-pos
void FormattingContext::resolve_anchor_insets(Box& box) const
{
@ -1563,18 +1617,40 @@ void FormattingContext::resolve_anchor_insets(Box& box) const
// and we only resolve anchor() values in inset properties.
// FIXME: Support anchor-scope, position-try-fallbacks, anchor-size(), and other anchor positioning features.
auto const* element = as_if<DOM::Element>(box.dom_node());
// NB: Generated boxes for pseudo-elements are anonymous, so their anchor insets live in the generator element's
// computed properties for the relevant pseudo-element rather than on a DOM node of their own.
DOM::Element const* element = nullptr;
Optional<CSS::PseudoElement> pseudo_element;
if (box.is_generated_for_pseudo_element()) {
element = box.pseudo_element_generator();
pseudo_element = box.generated_for_pseudo_element();
} else {
element = as_if<DOM::Element>(box.dom_node());
}
if (!element)
return;
auto computed = element->computed_properties();
auto computed = element->computed_properties(pseudo_element);
if (!computed)
return;
auto const& top = computed->property(CSS::PropertyID::Top);
auto const& right = computed->property(CSS::PropertyID::Right);
auto const& bottom = computed->property(CSS::PropertyID::Bottom);
auto const& left = computed->property(CSS::PropertyID::Left);
if (!top.is_anchor() && !right.is_anchor() && !bottom.is_anchor() && !left.is_anchor())
auto style_value_contains_anchor = [](CSS::StyleValue const& value) {
if (value.is_anchor())
return true;
if (value.is_calculated())
return calculation_tree_contains_anchor(value.as_calculated().calculation());
return false;
};
bool top_contains_anchor = style_value_contains_anchor(top);
bool right_contains_anchor = style_value_contains_anchor(right);
bool bottom_contains_anchor = style_value_contains_anchor(bottom);
bool left_contains_anchor = style_value_contains_anchor(left);
if (!top_contains_anchor && !right_contains_anchor && !bottom_contains_anchor && !left_contains_anchor)
return;
auto containing_block = box.containing_block();
@ -1711,42 +1787,45 @@ void FormattingContext::resolve_anchor_insets(Box& box) const
return {};
};
auto resolve_anchor_for_inset = [&](CSS::AnchorStyleValue const& anchor, bool is_from_end, bool is_horizontal_axis)
-> CSS::LengthPercentageOrAuto {
auto maybe_side_px = resolve_anchor_side(anchor, is_from_end, is_horizontal_axis);
auto resolve_inset = [&](bool contains_anchor, CSS::StyleValue const& value, CSS::LengthPercentageOrAuto const& existing_value, CSS::PropertyID property_id, bool is_from_end, bool is_horizontal_axis) -> CSS::LengthPercentageOrAuto {
if (!contains_anchor)
return existing_value;
// If any of these conditions are false, the anchor() function computes to its specified fallback value. If no
// fallback value is specified, it makes the declaration referencing it invalid at computed-value time.
// NB: The fallback value can itself be an anchor(), so we walk the chain.
auto const* current = &anchor;
while (!maybe_side_px.has_value()) {
auto const& fallback = current->fallback_value();
if (!fallback)
auto containing_block_extent = is_horizontal_axis
? containing_block_state.padding_box_width()
: containing_block_state.padding_box_height();
AnchorInsetResolver anchor_resolver { resolve_anchor_side, box.is_absolutely_positioned(), is_from_end, is_horizontal_axis, containing_block_extent };
CSS::CalculationResolutionContext resolution_context {
.percentage_basis = CSS::Length::make_px(containing_block_extent),
.length_resolution_context = CSS::Length::ResolutionContext::for_layout_node(box),
.anchor_resolver = &anchor_resolver,
};
auto to_inset = [](Optional<CSS::Length> resolved_length) -> CSS::LengthPercentageOrAuto {
if (!resolved_length.has_value())
return CSS::LengthPercentageOrAuto::make_auto();
if (!fallback->is_anchor())
return CSS::LengthPercentageOrAuto::from_style_value(*fallback);
current = &fallback->as_anchor();
maybe_side_px = resolve_anchor_side(*current, is_from_end, is_horizontal_axis);
return { CSS::LengthPercentage { resolved_length.release_value() } };
};
// A bare anchor() inset is wrapped in a calculation so it resolves through the same path as calc(anchor()).
if (value.is_anchor()) {
auto calculation_context = CSS::CalculationContext::for_property(CSS::PropertyNameAndID::from_id(property_id));
auto calculation_node = CSS::NonMathFunctionCalculationNode::create(value.as_anchor(), CSS::NumericType { CSS::NumericType::BaseType::Length, 1 });
auto calculated_value = CSS::CalculatedStyleValue::create(calculation_node, CSS::NumericType { CSS::NumericType::BaseType::Length, 1 }, calculation_context);
return to_inset(calculated_value->resolve_length(resolution_context));
}
// For inset properties measuring from the end edge (right, bottom), the resolved length is the distance from
// the anchor side to the corresponding edge of the containing block's padding box.
auto side_px = maybe_side_px.release_value();
if (is_from_end) {
auto containing_block_extent = is_horizontal_axis
? containing_block_state.padding_box_width()
: containing_block_state.padding_box_height();
return { CSS::LengthPercentage { CSS::Length::make_px(containing_block_extent - side_px) } };
}
return { CSS::LengthPercentage { CSS::Length::make_px(side_px) } };
return to_inset(value.as_calculated().resolve_length(resolution_context));
};
auto const& existing_inset = box.computed_values().inset();
box.mutable_computed_values().set_inset({
top.is_anchor() ? resolve_anchor_for_inset(top.as_anchor(), false, false) : existing_inset.top(),
right.is_anchor() ? resolve_anchor_for_inset(right.as_anchor(), true, true) : existing_inset.right(),
bottom.is_anchor() ? resolve_anchor_for_inset(bottom.as_anchor(), true, false) : existing_inset.bottom(),
left.is_anchor() ? resolve_anchor_for_inset(left.as_anchor(), false, true) : existing_inset.left(),
resolve_inset(top_contains_anchor, top, existing_inset.top(), CSS::PropertyID::Top, false, false),
resolve_inset(right_contains_anchor, right, existing_inset.right(), CSS::PropertyID::Right, true, true),
resolve_inset(bottom_contains_anchor, bottom, existing_inset.bottom(), CSS::PropertyID::Bottom, true, false),
resolve_inset(left_contains_anchor, left, existing_inset.left(), CSS::PropertyID::Left, false, true),
});
}
@ -1989,6 +2068,21 @@ void FormattingContext::compute_height_for_absolutely_positioned_replaced_elemen
// https://www.w3.org/TR/css-position-3/#relpos-insets
void FormattingContext::compute_inset(NodeWithStyleAndBoxModelMetrics const& box, CSSPixelSize containing_block_size)
{
// anchor() functions are unresolvable in the insets of non-absolutely-positioned boxes. Substitute them with their
// fallback value (or auto) here so the resulting calc() does not reach length resolution unresolved and crash. This
// also covers sticky boxes, whose insets are read later from these computed values.
// NB: The box is logically mutable during layout (resolve_anchor_insets rewrites its computed insets), it is only
// passed as const& through the compute_inset() call chain.
if (auto const* anchored_box = as_if<Box>(box)) {
auto inset_contains_anchor = [](CSS::LengthPercentageOrAuto const& value) {
return value.is_calculated() && calculation_tree_contains_anchor(value.calculated()->calculation());
};
auto const& inset = anchored_box->computed_values().inset();
if (inset_contains_anchor(inset.top()) || inset_contains_anchor(inset.right())
|| inset_contains_anchor(inset.bottom()) || inset_contains_anchor(inset.left()))
resolve_anchor_insets(const_cast<Box&>(*anchored_box));
}
if (box.computed_values().position() != CSS::Positioning::Relative)
return;

View file

@ -0,0 +1,26 @@
<!DOCTYPE html>
<style>
.container {
display: grid;
grid-template-columns: repeat(3, 100px);
gap: 10px;
}
.box {
width: 100px;
height: 100px;
}
.green {
background-color: green;
}
</style>
<p>The test passes if there are three green boxes with no red.</p>
<div class="container">
<div class="box green"></div>
<div class="box green"></div>
<div class="box green"></div>
</div>

View file

@ -0,0 +1,63 @@
<!DOCTYPE html>
<link rel="help" href="https://drafts.csswg.org/css-anchor-position-1/#anchor-pos">
<link rel="author" href="mailto:kiet.ho@apple.com">
<link rel="match" href="../../../../expected/wpt-import/css/css-anchor-position/reference/anchor-in-css-min-max-function-ref.html">
<style>
.container {
display: grid;
grid-template-columns: repeat(3, 100px);
gap: 10px;
}
.box {
width: 100px;
height: 100px;
}
.green {
background-color: green;
}
.red {
background-color: red;
}
#anchor1 {
anchor-name: --anchor1;
}
#anchor2 {
anchor-name: --anchor2;
}
#anchor3 {
anchor-name: --anchor3;
}
#target-fail {
position: relative;
left: 300px;
}
#target {
position: absolute;
top: min(anchor(--anchor1 bottom), anchor(--anchor2 bottom), anchor(--anchor3 top));
left: max(anchor(--anchor1 left), anchor(--anchor2 left), anchor(--anchor3 left));
z-index: 1;
}
</style>
<p>The test passes if there are three green boxes with no red.</p>
<div class="container">
<!-- These boxes are 100px by 100px and sits next to each other in a row.
First two boxes are green and last box is red -->
<div class="box green" id="anchor1"></div>
<div class="box green" id="anchor2"></div>
<div class="box red" id="anchor3"></div>
</div>
<!-- If min()/max() works correctly, this green box should completely
cover the red box. -->
<div class="box green" id="target"></div>

View file

@ -0,0 +1,7 @@
Harness status: OK
Found 2 tests
2 Pass
Pass Initial anchored position
Pass Anchored position after moving

View file

@ -2,8 +2,7 @@ Harness status: OK
Found 2358 tests
2353 Pass
5 Fail
2358 Pass
Pass e.style['left'] = "anchor(inside)" should set the property value
Pass e.style['left'] = "anchor(inside, 1px)" should set the property value
Pass e.style['left'] = "anchor(inside, 50%)" should set the property value
@ -2356,9 +2355,9 @@ Pass e.style['inset-inline-end'] = "anchor(--foo min(50%, 100%), anchor(--bar le
Pass e.style['inset-inline-end'] = "anchor(min(50%, 100%) --foo, anchor(--bar left))" should set the property value
Pass e.style['inset-inline-end'] = "anchor(--foo min(50%, 100%), anchor(--bar left, anchor(--baz right)))" should set the property value
Pass e.style['inset-inline-end'] = "anchor(min(50%, 100%) --foo, anchor(--bar left, anchor(--baz right)))" should set the property value
Fail e.style['top'] = "calc((anchor(--foo top) + anchor(--bar bottom)) / 2)" should set the property value
Fail e.style['top'] = "calc(0.5 * (anchor(--foo top) + anchor(--bar bottom)))" should set the property value
Fail e.style['top'] = "anchor(--foo top, calc(0.5 * anchor(--bar bottom)))" should set the property value
Fail e.style['top'] = "min(100px, 10%, anchor(--foo top), anchor(--bar bottom))" should set the property value
Pass e.style['top'] = "calc((anchor(--foo top) + anchor(--bar bottom)) / 2)" should set the property value
Pass e.style['top'] = "calc(0.5 * (anchor(--foo top) + anchor(--bar bottom)))" should set the property value
Pass e.style['top'] = "anchor(--foo top, calc(0.5 * anchor(--bar bottom)))" should set the property value
Pass e.style['top'] = "min(100px, 10%, anchor(--foo top), anchor(--bar bottom))" should set the property value
Pass e.style['top'] = "anchor(--foo left, 0)" should set the property value
Fail e.style['top'] = "calc(anchor(--foo left, 0))" should set the property value
Pass e.style['top'] = "calc(anchor(--foo left, 0))" should set the property value

View file

@ -0,0 +1,55 @@
<!DOCTYPE html>
<title>Positioning pseudo-elements using anchor functions</title>
<link rel="help" href="https://drafts.csswg.org/css-anchor-position-1/#positioning">
<script src="../../resources/testharness.js"></script>
<script src="../../resources/testharnessreport.js"></script>
<style>
body { margin: 0 }
#anchor, #target::before, #target::after {
width: 100px;
height: 100px;
position: absolute;
}
#anchor.moved {
left: 200px;
top: 200px;
}
#anchor {
left: 50px;
top: 100px;
anchor-name: --a;
background: blue;
}
#target::before {
position-anchor: --a;
left: anchor(right);
top: anchor(top);
background: green;
content:'';
}
#target::after {
position-anchor: --a;
left: anchor(left);
top: anchor(bottom);
background: green;
content:'';
}
</style>
<div id=anchor></div>
<div id=target></div>
<script>
test(() => {
assert_equals(getComputedStyle(target, '::before').top, '100px', "#target::before top is positioned against anchor");
assert_equals(getComputedStyle(target, '::before').left, '150px', "#target::before left is positioned against anchor");
assert_equals(getComputedStyle(target, '::after').top, '200px', "#target::after top is positioned against anchor");
assert_equals(getComputedStyle(target, '::after').left, '50px', "#target::after left is positioned against anchor");
}, "Initial anchored position");
test(() => {
anchor.classList.add("moved");
assert_equals(getComputedStyle(target, '::before').top, '200px', "#target::before top is positioned against anchor");
assert_equals(getComputedStyle(target, '::before').left, '300px', "#target::before left is positioned against anchor");
assert_equals(getComputedStyle(target, '::after').top, '300px', "#target::after top is positioned against anchor");
assert_equals(getComputedStyle(target, '::after').left, '200px', "#target::after left is positioned against anchor");
}, "Anchored position after moving");
</script>