Skip to content
Merged
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
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,37 @@ var config = Configuration.Default

If no specific `IRenderDevice` (e.g., via creating an `DefaultRenderDevice` object) instance is created a default implementation will be set.

The render device also carries the *user preferences* of the Media Queries Level 5 (and Level 4) user-preference features. `DefaultRenderDevice` implements `IRenderDevicePreferences` for that, and any custom `IRenderDevice` can implement it as well. The dictionary is keyed by the media feature name and holds the keyword that the feature should answer with:

```cs
var config = Configuration.Default
.WithCss()
.WithRenderDevice(new DefaultRenderDevice
{
Preferences = new Dictionary<String, String>
{
{ "prefers-color-scheme", "dark" },
{ "prefers-reduced-motion", "reduce" },
},
});
```

With this device `@media (prefers-color-scheme: dark)` applies in the cascade and `window.MatchMedia("(prefers-color-scheme: dark)").IsMatched` is `true`. A key that is not set leaves its media feature unknown, i.e., a query using it never matches. The keys a browser would set are:

| Key | Keywords |
| --- | --- |
| `prefers-color-scheme` | `light`, `dark` |
| `prefers-reduced-motion` | `no-preference`, `reduce` |
| `prefers-reduced-transparency` | `no-preference`, `reduce` |
| `prefers-contrast` | `no-preference`, `more`, `less`, `custom` |
| `prefers-reduced-data` | `no-preference`, `reduce` |
| `forced-colors` | `none`, `active` |
| `hover`, `any-hover` | `none`, `hover` |
| `pointer`, `any-pointer` | `none`, `coarse`, `fine` |
| `display-mode` | `fullscreen`, `standalone`, `minimal-ui`, `browser` |

The value is compared to the queried keyword case insensitively, so a keyword that is newer than this library works as well. Used without a value, e.g., `@media (prefers-reduced-motion)`, the feature evaluates in a boolean context, where `no-preference` (and `none` for `forced-colors`, `hover`, `any-hover`, `pointer` and `any-pointer`) is `false`. Without a preference `hover` and `pointer` keep answering as they did before, i.e., as a device with no input mechanism.

Going a bit further it is possible to `Render` the current document. This render tree information can then be used to retrieve or other information, e.g.,

```cs
Expand Down
33 changes: 33 additions & 0 deletions docs/general/05-Extensibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ AngleSharp.Css is designed to be composed through services in the AngleSharp con
: Add pseudo-element behavior.
- `IRenderDevice`
: Provide device characteristics for style computation.
- `IRenderDevicePreferences`
: Provide the user preferences answering the user-preference media features.

## Override The Default Stylesheet

Expand Down Expand Up @@ -56,6 +58,37 @@ var config = Configuration.Default
.WithRenderDevice(renderDevice);
```

## Provide The User Preferences

Beside the dimensions a render device carries the user preferences, which answer the user-preference media features. `DefaultRenderDevice` implements `IRenderDevicePreferences` for that; a custom `IRenderDevice` can implement it as well and is picked up the same way.

```cs
var renderDevice = new DefaultRenderDevice
{
Preferences = new Dictionary<String, String>
{
{ "prefers-color-scheme", "dark" },
{ "prefers-reduced-motion", "reduce" },
},
};
```

The dictionary is keyed by the media feature name and holds the keyword the feature answers with. A key that is not set leaves its media feature unknown, i.e., a query using it never matches.

| Key | Keywords |
| --- | --- |
| `prefers-color-scheme` | `light`, `dark` |
| `prefers-reduced-motion` | `no-preference`, `reduce` |
| `prefers-reduced-transparency` | `no-preference`, `reduce` |
| `prefers-contrast` | `no-preference`, `more`, `less`, `custom` |
| `prefers-reduced-data` | `no-preference`, `reduce` |
| `forced-colors` | `none`, `active` |
| `hover`, `any-hover` | `none`, `hover` |
| `pointer`, `any-pointer` | `none`, `coarse`, `fine` |
| `display-mode` | `fullscreen`, `standalone`, `minimal-ui`, `browser` |

The value is compared to the queried keyword case insensitively, so a keyword that is newer than this library works as well. Used without a value, e.g., `@media (prefers-reduced-motion)`, the feature evaluates in a boolean context, where `no-preference` (and `none` for `forced-colors`, `hover`, `any-hover`, `pointer` and `any-pointer`) is `false`. Without a preference `hover` and `pointer` keep answering as they did before, i.e., as a device with no input mechanism.

## Composition Pattern

Start from the default registrations and replace only what you need:
Expand Down
7 changes: 7 additions & 0 deletions src/AngleSharp.Css.Tests/CssConstructionFunctions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,13 @@ internal static Predicate<IRenderDevice> CreateValidator(String name, String val
return device => validator.Validate(feature, device);
}

internal static Predicate<IRenderDevice> CreateBooleanValidator(String name)
{
var validator = CreateMediaFeatureValidator(name);
var feature = new MediaFeature(name);
return device => validator.Validate(feature, device);
}

internal static CssFontFeatureValuesRule ParseFontFeatureValuesRule(String source)
{
ICssParser parser = new CssParser();
Expand Down
163 changes: 163 additions & 0 deletions src/AngleSharp.Css.Tests/Extensions/MediaPreferences.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
#nullable disable
namespace AngleSharp.Css.Tests.Extensions
{
using AngleSharp.Css.Dom;
using AngleSharp.Css.Tests.Mocks;
using AngleSharp.Dom;
using AngleSharp.Html.Parser;
using NUnit.Framework;
using System;
using System.Collections.Generic;

[TestFixture]
public class MediaPreferencesTests
{
[Test]
public void MatchMediaPrefersColorSchemeDarkIsMatchedWhenDarkIsPreferred()
{
var window = CreateWindow(DeviceWith(FeatureNames.PrefersColorScheme, CssKeywords.Dark));
Assert.IsTrue(window.MatchMedia("(prefers-color-scheme: dark)").IsMatched);
}

[Test]
public void MatchMediaPrefersColorSchemeDarkIsNotMatchedWhenLightIsPreferred()
{
var window = CreateWindow(DeviceWith(FeatureNames.PrefersColorScheme, CssKeywords.Light));
Assert.IsFalse(window.MatchMedia("(prefers-color-scheme: dark)").IsMatched);
}

[Test]
public void MatchMediaPrefersColorSchemeDarkIsNotMatchedWithoutAnyPreference()
{
var window = CreateWindow(new DefaultRenderDevice());
Assert.IsFalse(window.MatchMedia("(prefers-color-scheme: dark)").IsMatched);
}

[Test]
public void MatchMediaNotPrefersColorSchemeDarkIsMatchedWhenLightIsPreferred()
{
var window = CreateWindow(DeviceWith(FeatureNames.PrefersColorScheme, CssKeywords.Light));
Assert.IsTrue(window.MatchMedia("not (prefers-color-scheme: dark)").IsMatched);
}

[Test]
public void MatchMediaPrefersColorSchemeDarkIsMatchedForAThirdPartyDevice()
{
var window = CreateWindow(new PreferringRenderDevice(FeatureNames.PrefersColorScheme, CssKeywords.Dark));
Assert.IsTrue(window.MatchMedia("(prefers-color-scheme: dark)").IsMatched);
}

[Test]
public void MatchMediaPrefersColorSchemeDarkIsNotMatchedForADeviceWithoutPreferences()
{
var window = CreateWindow(new PlainRenderDevice());
Assert.IsFalse(window.MatchMedia("(prefers-color-scheme: dark)").IsMatched);
}

[Test]
public void MatchMediaScreenAndPrefersColorSchemeDarkIsMatchedWhenDarkIsPreferred()
{
var window = CreateWindow(DeviceWith(FeatureNames.PrefersColorScheme, CssKeywords.Dark));
Assert.IsTrue(window.MatchMedia("screen and (prefers-color-scheme: dark)").IsMatched);
}

[Test]
public void MatchMediaPrefersColorSchemeInBooleanContextIsMatchedWhenSet()
{
var window = CreateWindow(DeviceWith(FeatureNames.PrefersColorScheme, CssKeywords.Dark));
Assert.IsTrue(window.MatchMedia("(prefers-color-scheme)").IsMatched);
}

[Test]
public void MatchMediaPrefersColorSchemeInBooleanContextIsNotMatchedWithoutAnyPreference()
{
var window = CreateWindow(new DefaultRenderDevice());
Assert.IsFalse(window.MatchMedia("(prefers-color-scheme)").IsMatched);
}

[Test]
public void MatchMediaPrefersReducedMotionIsMatchedWhenReduceIsPreferred()
{
var window = CreateWindow(DeviceWith(FeatureNames.PrefersReducedMotion, CssKeywords.Reduce));
Assert.IsTrue(window.MatchMedia("(prefers-reduced-motion: reduce)").IsMatched);
Assert.IsTrue(window.MatchMedia("(prefers-reduced-motion)").IsMatched);
}

[Test]
public void MatchMediaPrefersReducedMotionIsNotMatchedWhenNoPreferenceIsSet()
{
var window = CreateWindow(DeviceWith(FeatureNames.PrefersReducedMotion, CssKeywords.NoPreference));
Assert.IsFalse(window.MatchMedia("(prefers-reduced-motion: reduce)").IsMatched);
Assert.IsFalse(window.MatchMedia("(prefers-reduced-motion)").IsMatched);
}

[Test]
public void MatchMediaForcedColorsIsMatchedWhenActive()
{
var window = CreateWindow(DeviceWith(FeatureNames.ForcedColors, CssKeywords.Active));
Assert.IsTrue(window.MatchMedia("(forced-colors: active)").IsMatched);
Assert.IsTrue(window.MatchMedia("(forced-colors)").IsMatched);
}

[Test]
public void MatchMediaHoverIsMatchedFromThePreference()
{
var window = CreateWindow(DeviceWith(FeatureNames.Hover, CssKeywords.Hover));
Assert.IsTrue(window.MatchMedia("(hover: hover)").IsMatched);
Assert.IsFalse(window.MatchMedia("(hover: none)").IsMatched);
}

[Test]
public void PrefersReducedMotionMediaRuleIsAppliedInTheCascade()
{
var document = CreateDocument(DeviceWith(FeatureNames.PrefersReducedMotion, CssKeywords.Reduce));
var style = document.QuerySelector("div").ComputeCurrentStyle();
Assert.AreEqual("rgba(0, 128, 0, 1)", style.GetColor());
}

[Test]
public void PrefersReducedMotionMediaRuleIsSkippedWithoutThePreference()
{
var document = CreateDocument(new DefaultRenderDevice());
var style = document.QuerySelector("div").ComputeCurrentStyle();
Assert.AreEqual("rgba(255, 0, 0, 1)", style.GetColor());
}

[Test]
public void PrefersReducedMotionMediaRuleIsSkippedForADeviceWithoutPreferences()
{
var document = CreateDocument(new PlainRenderDevice());
var style = document.QuerySelector("div").ComputeCurrentStyle();
Assert.AreEqual("rgba(255, 0, 0, 1)", style.GetColor());
}

private static DefaultRenderDevice DeviceWith(String name, String value) => new DefaultRenderDevice
{
Preferences = new Dictionary<String, String>(StringComparer.OrdinalIgnoreCase)
{
{ name, value },
},
};

private static IDocument CreateDocument(IRenderDevice device)
{
var source = @"<!doctype html><style>
div { color: red }
@media (prefers-reduced-motion: reduce) { div { color: green } }
</style><div></div>";
var config = Configuration.Default.WithCss().WithRenderDevice(device);
var context = BrowsingContext.New(config);
var parser = context.GetService<IHtmlParser>();
return parser.ParseDocument(source);
}

private static IWindow CreateWindow(IRenderDevice device)
{
var config = Configuration.Default.WithCss().WithRenderDevice(device);
var context = BrowsingContext.New(config);
var parser = context.GetService<IHtmlParser>();
var document = parser.ParseDocument("<!doctype html><title>Example</title>");
return document.DefaultView;
}
}
}
43 changes: 43 additions & 0 deletions src/AngleSharp.Css.Tests/Mocks/PlainRenderDevice.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
namespace AngleSharp.Css.Tests.Mocks
{
using AngleSharp.Css;
using System;

/// <summary>
/// A render device that deliberately does not implement
/// <see cref="IRenderDevicePreferences"/>, i.e., what an existing
/// third-party implementation of <see cref="IRenderDevice"/> looks like.
/// </summary>
sealed class PlainRenderDevice : IRenderDevice
{
public DeviceCategory Category => DeviceCategory.Screen;

public Int32 ColorBits => 32;

public Int32 DeviceHeight => 800;

public Int32 DeviceWidth => 1000;

public Int32 Frequency => 60;

public Boolean IsGrid => false;

public Boolean IsInterlaced => false;

public Boolean IsScripting => true;

public Int32 MonochromeBits => 16;

public Int32 Resolution => 96;

public Int32 ViewPortHeight => 800;

public Int32 ViewPortWidth => 1000;

public Double RenderWidth => ViewPortWidth;

public Double RenderHeight => ViewPortHeight;

public Double FontSize => 16;
}
}
55 changes: 55 additions & 0 deletions src/AngleSharp.Css.Tests/Mocks/PreferringRenderDevice.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
namespace AngleSharp.Css.Tests.Mocks
{
using AngleSharp.Css;
using System;
using System.Collections.Generic;

/// <summary>
/// A third-party render device that opts into the user preferences
/// without deriving from <see cref="DefaultRenderDevice"/>.
/// </summary>
sealed class PreferringRenderDevice : IRenderDevice, IRenderDevicePreferences
{
private readonly Dictionary<String, String> _preferences;

public PreferringRenderDevice(String name, String value)
{
_preferences = new Dictionary<String, String>(StringComparer.OrdinalIgnoreCase)
{
{ name, value },
};
}

public IReadOnlyDictionary<String, String> Preferences => _preferences;

public DeviceCategory Category => DeviceCategory.Screen;

public Int32 ColorBits => 32;

public Int32 DeviceHeight => 800;

public Int32 DeviceWidth => 1000;

public Int32 Frequency => 60;

public Boolean IsGrid => false;

public Boolean IsInterlaced => false;

public Boolean IsScripting => true;

public Int32 MonochromeBits => 16;

public Int32 Resolution => 96;

public Int32 ViewPortHeight => 800;

public Int32 ViewPortWidth => 1000;

public Double RenderWidth => ViewPortWidth;

public Double RenderHeight => ViewPortHeight;

public Double FontSize => 16;
}
}
Loading
Loading