Skip to content
Merged
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
# 1.1.1

Released on Wednesday, September 9 2026

- Fixed unresolved converter re-entry (#243) @sebastienros
- Fixed issue with computation of `transform` functions
- Fixed computed `animation` longhands retaining unresolved `initial` values
- Fixed percentage `border-radius` components resolving against the wrong axis
- Fixed default list styling for unordered lists
- Added shorthand decomposition and `clip` support for `overflow`
- Added explicit pseudo class handling via `SetPseudoClass`
- Added support for parsing `filter` declarations

# 1.1.0

Released on Saturday, September 5 2026
Expand Down
27 changes: 27 additions & 0 deletions docs/general/04-Core-Interfaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,33 @@ var config = Configuration.Default

These are central for extensibility and custom property handling.

## Pseudo-Class Forcing

AngleSharp has no notion of pointer/keyboard interaction state, so pseudo-classes such as `:hover` and `:active` never match by default. `WithCss()` adds extension methods on `IElement` that let a caller force a pseudo-class to match (or not match) for a specific element, both in `Element.Matches(...)` and in computed style (`ComputeCurrentStyle()`/`ComputeDeclarations(...)`):

- `SetPseudoClass(pseudoClass, value = true)`
: Forces the given pseudo-class on (or off, with `value: false`).
- `GetPseudoClass(pseudoClass)`
: Returns the forced value, or `null` if nothing was forced for that element.
- `RemovePseudoClass(pseudoClass)`
: Clears a single forced pseudo-class, reverting to normal matching.
- `ClearPseudoClasses()`
: Clears every forced pseudo-class for the element.

```cs
using AngleSharp.Dom;

var target = document.QuerySelector("#target");
target.SetPseudoClass("hover");

Console.WriteLine(target.Matches(":hover")); // True
Console.WriteLine(target.ComputeCurrentStyle().GetPropertyValue("background-color"));

target.RemovePseudoClass("hover");
```

This applies to any pseudo-class the selector engine recognizes (`:hover`, `:active`, `:visited`, ...), works per element only (forcing a child does not propagate to its ancestors), and is not the mechanism for `:focus`: that pseudo-class already has a real, settable state in AngleSharp core, so `SetPseudoClass("focus", ...)` delegates to `IHtmlElement.DoFocus()`/`DoBlur()` instead of a separate forced flag.

## Rule Of Thumb

- Use parser and CSSOM interfaces for analysis/transforms.
Expand Down
1 change: 1 addition & 0 deletions docs/general/06-Provided-Services.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ From the default configuration extension:
- `ICssParser` -> parser instance using optional `CssParserOptions`,
- `IStylingService` -> `CssStylingService` for CSS MIME handling,
- CSS observer service (`Factory.Observer`) for style mutation integration.
- `IPseudoClassSelectorFactory` -> wrapped so any recognized pseudo-class can be forced per element via `SetPseudoClass(...)` (see [Core Interfaces](04-Core-Interfaces.md)).

## Quick Service Retrieval

Expand Down
28 changes: 27 additions & 1 deletion docs/tutorials/02-Examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,33 @@ Console.WriteLine(serialized);

This preserves comments, but not their exact original positions in every case. Depending on where a comment was placed, it may be moved when the stylesheet is serialized again.

## 11. Where To Go Next
## 11. Preview A `:hover` State Without Real Interaction

Useful for headless rendering/screenshot tools that need to show interactive states.

```cs
using AngleSharp;
using AngleSharp.Dom;

var html = @"<!doctype html>
<style>
#btn { background-color: rgb(0, 0, 255); }
#btn:hover { background-color: rgb(255, 0, 0); }
</style>
<button id='btn'>Hover me</button>";

var context = BrowsingContext.New(Configuration.Default.WithCss());
var document = await context.OpenAsync(req => req.Content(html));

var button = document.QuerySelector("#btn");
button.SetPseudoClass("hover");

Console.WriteLine(button.ComputeCurrentStyle().GetPropertyValue("background-color")); // rgba(255, 0, 0, 1)

button.RemovePseudoClass("hover");
```

## 12. Where To Go Next

- Read [API Documentation](01-API.md) for deeper CSSOM details.
- Read [Render Tree Examples](03-Render-Tree.md) for style-aware tree traversal and resource download workflows.
Expand Down
2 changes: 1 addition & 1 deletion src/AngleSharp.Css.Docs/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@anglesharp/css",
"version": "1.1.0",
"version": "1.1.1",
"preview": true,
"description": "The doclet for the AngleSharp.Css documentation.",
"keywords": [
Expand Down
6 changes: 5 additions & 1 deletion src/AngleSharp.Css.Tests/Extensions/Elements.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ public async Task DownloadResources()
};
var config = Configuration.Default
.WithDefaultLoader(loaderOptions)
.WithRenderDevice()
.WithRenderDevice(new DefaultRenderDevice
{
ViewPortWidth = 800,
ViewPortHeight = 600,
})
.WithCss();
var document = "<style>div { background: url('https://avatars1.githubusercontent.com/u/10828168?s=200&v=4'); }</style><div></div>".ToHtmlDocument(config);
var tree = document.DefaultView!.Render();
Expand Down
59 changes: 59 additions & 0 deletions src/AngleSharp.Css.Tests/Styling/AnimationComputedStyleTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#nullable disable
namespace AngleSharp.Css.Tests.Styling
{
using AngleSharp.Dom;
using NUnit.Framework;
using static CssConstructionFunctions;

/// <summary>
/// Per the CSS cascade, a computed style is supposed to fully resolve every longhand to a
/// concrete value - the CSS-wide keyword `initial` is itself resolved away during computation,
/// never present verbatim in the computed declaration. Confirmed empirically while building
/// CSS `animation` support in a downstream renderer (AngleSharp.Renderer): any `animation`
/// longhand the `animation` shorthand does not explicitly set reports the literal string
/// `"initial"` instead of that property's own real initial value (`normal` for
/// `animation-direction`, `none` for `animation-fill-mode`, `0s` for `animation-delay`,
/// `running` for `animation-play-state`) - unlike `transition`'s equivalent longhands, which
/// report an empty string instead in the same situation (see BasicStyling.cs's sibling
/// investigation for `transition`, not affected by this).
/// </summary>
[TestFixture]
public class AnimationComputedStyleTests
{
[Test]
public void UnsetAnimationDirectionResolvesToNormal()
{
var document = ParseDocument("<div id=target style=\"animation: spin 2s linear;\"></div>");
var target = document.GetElementById("target");

Assert.AreEqual("normal", target.ComputeCurrentStyle().GetPropertyValue("animation-direction"));
}

[Test]
public void UnsetAnimationFillModeResolvesToNone()
{
var document = ParseDocument("<div id=target style=\"animation: spin 2s linear;\"></div>");
var target = document.GetElementById("target");

Assert.AreEqual("none", target.ComputeCurrentStyle().GetPropertyValue("animation-fill-mode"));
}

[Test]
public void UnsetAnimationDelayResolvesToZeroSeconds()
{
var document = ParseDocument("<div id=target style=\"animation: spin 2s linear;\"></div>");
var target = document.GetElementById("target");

Assert.AreEqual("0s", target.ComputeCurrentStyle().GetPropertyValue("animation-delay"));
}

[Test]
public void UnsetAnimationPlayStateResolvesToRunning()
{
var document = ParseDocument("<div id=target style=\"animation: spin 2s linear;\"></div>");
var target = document.GetElementById("target");

Assert.AreEqual("running", target.ComputeCurrentStyle().GetPropertyValue("animation-play-state"));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#nullable disable
namespace AngleSharp.Css.Tests.Styling
{
using AngleSharp.Dom;
using AngleSharp.Html.Parser;
using NUnit.Framework;

/// <summary>
/// Per https://www.w3.org/TR/css-backgrounds-3/#corner-overlap, a percentage `border-radius`
/// resolves its horizontal component against the border box's own *width* and its vertical
/// component against the border box's own *height* - independently. Confirmed empirically
/// while building `border-radius` support in a downstream renderer (AngleSharp.Renderer) that
/// this does not happen: both components resolve against the containing block's/element's
/// *width* alone, so on a box whose width and height differ, the vertical radius comes out
/// wrong (tracking the wrong axis's dimension entirely, not merely imprecise).
/// </summary>
[TestFixture]
public class BorderRadiusPercentageResolutionTests
{
private static IDocument ParseWithRenderDevice(string html, int viewPortWidth = 1000)
{
var config = Configuration.Default
.WithCss()
.WithRenderDevice(new DefaultRenderDevice { ViewPortWidth = viewPortWidth });
var browsingContext = BrowsingContext.New(config);
var htmlParser = browsingContext.GetService<IHtmlParser>();
return htmlParser.ParseDocument(html);
}

[Test]
public void VerticalPercentageComponentResolvesAgainstTheElementsOwnHeight()
{
// 200x100 box, `border-radius: 10% / 30%` - the horizontal component (10%) should
// resolve against the 200px width (20px); the vertical component (30%) should resolve
// against the 100px height (30px), not against the 200px width (which would give the
// wrong value, 60px).
var document = ParseWithRenderDevice("<div id=target style=\"width:200px; height:100px; border-radius: 10% / 30%;\"></div>");
var target = document.GetElementById("target");
var style = target.ComputeCurrentStyle();

var horizontal = style.GetPropertyValue("border-top-left-radius");
Assert.IsTrue(horizontal.Contains("30px"), $"expected the vertical 30% component to resolve to 30px (30% of the 100px height); got '{horizontal}'");
}

[Test]
public void VerticalPercentageComponentTracksHeightAcrossDifferentElementHeights()
{
// The same vertical percentage against two different heights (but the same width)
// must resolve to two different pixel values if it is genuinely tracking height - if
// it were (incorrectly) tracking width instead, both would resolve identically despite
// the different heights.
var shortDocument = ParseWithRenderDevice("<div id=target style=\"width:200px; height:100px; border-radius: 0 / 30%;\"></div>");
var shortTarget = shortDocument.GetElementById("target");
var shortRadius = shortTarget.ComputeCurrentStyle().GetPropertyValue("border-top-left-radius");

var tallDocument = ParseWithRenderDevice("<div id=target style=\"width:200px; height:200px; border-radius: 0 / 30%;\"></div>");
var tallTarget = tallDocument.GetElementById("target");
var tallRadius = tallTarget.ComputeCurrentStyle().GetPropertyValue("border-top-left-radius");

Assert.AreNotEqual(shortRadius, tallRadius, "a 30% vertical radius against a 100px-tall box and a 200px-tall box must resolve to different pixel values.");
}
}
}
54 changes: 54 additions & 0 deletions src/AngleSharp.Css.Tests/Styling/FilterPropertyTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#nullable disable
namespace AngleSharp.Css.Tests.Styling
{
using AngleSharp.Css.Dom;
using AngleSharp.Dom;
using AngleSharp.Css.Values;
using AngleSharp.Html.Dom;
using NUnit.Framework;
using static CssConstructionFunctions;

/// <summary>
/// Verifies that filter function lists are parsed into structured values and preserved by
/// computed style, while the original inline style remains available through the DOM.
/// </summary>
[TestFixture]
public class FilterPropertyTests
{
[Test]
public void FilterComputedValuePreservesAuthoredFunctions()
{
var document = ParseDocument("<div id=target style=\"filter: grayscale(0.9) blur(2px);\"></div>");
var target = document.GetElementById("target");

Assert.AreEqual("grayscale(0.9) blur(2px)", target.ComputeCurrentStyle().GetPropertyValue("filter"));
}

[Test]
public void FilterFunctionsExposeNamesAndArguments()
{
var document = ParseDocument("<div id=target style=\"filter: grayscale(0.9) blur(2px);\"></div>");
var target = document.GetElementById("target");
var value = target.GetStyle().GetProperty("filter").RawValue as CssFilterValue;

Assert.IsNotNull(value);
Assert.AreEqual(2, value.Functions.Length);
Assert.AreEqual("grayscale", value.Functions[0].Name);
Assert.AreEqual("0.9", value.Functions[0].Arguments[0].CssText);
Assert.AreEqual("blur", value.Functions[1].Name);
Assert.AreEqual("2px", value.Functions[1].Arguments[0].CssText);
}

[Test]
public void RawFilterTextIsStillReadableFromTheInlineStyleAttributeItself()
{
// Confirms the gap is specifically in AngleSharp.Css's own computed-style/cascade
// pipeline, not in the HTML/attribute layer - the text is right there, just never
// parsed into a structured value or even echoed back through computed style.
var document = ParseDocument("<div id=target style=\"filter: grayscale(0.9) blur(2px);\"></div>");
var target = document.GetElementById("target");

Assert.AreEqual("filter: grayscale(0.9) blur(2px);", target.GetAttribute("style"));
}
}
}
57 changes: 57 additions & 0 deletions src/AngleSharp.Css.Tests/Styling/HoverPseudoClass.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#nullable disable
namespace AngleSharp.Css.Tests.Styling
{
using AngleSharp.Dom;
using NUnit.Framework;
using static CssConstructionFunctions;

/// <summary>
/// AngleSharp has no notion of pointer/interaction state, so `:hover` is unconditionally
/// non-matching unless explicitly forced via <see cref="ElementExtensions.SetPseudoClass"/>
/// (see PseudoClassForcing.cs for the general mechanism this relies on). These tests pin down
/// the specific `:hover` case that first surfaced the gap while building CSS `transition`
/// support in a downstream renderer (AngleSharp.Renderer).
/// </summary>
[TestFixture]
public class HoverPseudoClassTests
{
[Test]
public void HoverDoesNotMatchByDefault()
{
var document = ParseDocument("<div id=target></div>");
var target = document.GetElementById("target");

Assert.IsFalse(target.Matches(":hover"));
Assert.IsTrue(target.Matches(":not(:hover)"));
}

[Test]
public void ForcingHoverMakesItMatchInTheSelectorEngine()
{
var document = ParseDocument("<div id=target></div>");
var target = document.GetElementById("target");

target.SetPseudoClass("hover");

Assert.IsTrue(target.Matches(":hover"));
Assert.IsFalse(target.Matches(":not(:hover)"));
}

[Test]
public void ForcingHoverLetsTheMoreSpecificHoverRuleWinTheCascade()
{
// #target:hover is more specific than #target and declared after it, so once :hover
// is forced to match, the cascade should resolve to red instead of blue.
var document = ParseDocument(@"<html><head><style>
#target { background-color: rgb(0, 0, 255); }
#target:hover { background-color: rgb(255, 0, 0); }
</style></head><body><div id=target></div></body></html>");
var target = document.GetElementById("target");

target.SetPseudoClass("hover");
var backgroundColor = target.ComputeCurrentStyle().GetPropertyValue("background-color");

Assert.AreEqual("rgba(255, 0, 0, 1)", backgroundColor);
}
}
}
Loading
Loading