Skip to content

Add :is(), retain argument selectors for :not()/:has()/:matches(), fix forgiving-list specificity - #201

Merged
TylerBrinks merged 1 commit into
TylerBrinks:masterfrom
jhaygood86:feature/is-selector-and-forgiving-list-specificity
Jul 23, 2026
Merged

Add :is(), retain argument selectors for :not()/:has()/:matches(), fix forgiving-list specificity#201
TylerBrinks merged 1 commit into
TylerBrinks:masterfrom
jhaygood86:feature/is-selector-and-forgiving-list-specificity

Conversation

@jhaygood86

Copy link
Copy Markdown
Contributor

Problem

:not(), :has() and :matches() throw away their parsed argument. Each function state formats the inner selector back to a string and wraps it in an opaque PseudoClassSelector:

var code = PseudoClassNames.Not.StylesheetFunction(sel.Text);
return PseudoClassSelector.Create(code);   // inner ISelector discarded

So after parsing a:not(.foo) there is no way to reach the .foo selector object — only its round-tripped text survives. And :is(), the current name for :matches() (CSS Selectors 4 §4.2), isn't recognized at all.

Changes

  • NotSelector / HasSelector / MatchesSelector — each retains the parsed argument ISelector (exposed as Inner) and round-trips correctly.

  • :is() as the modern alias of :matches(). Both share MatchesSelector; a Keyword field controls whether it serializes as :is(...) or :matches(...).

  • Forgiving-list specificity per CSS Selectors 4 §16.1:

    The specificity of an :is(), :not(), or :has() pseudo-class is replaced by the specificity of the most specific complex selector in its selector list argument.

    A selector list must therefore report the max of its alternatives, not their sum. Selectors.Specificity becomes virtual and ListSelector overrides it to the max. CompoundSelector keeps the sum — correct, since its members all constrain one element.

Verified against the spec's own examples

selector spec this PR
:is(em, #foo) (1,0,0)
:not(em, strong#foo) (1,0,1)

Tests

11 tests in SelectorObjectModelTests.cs: each function produces its typed selector and retains Inner; :is()/:matches() round-trip to their own keyword; the two spec examples plus a:not(.foo)/a:has(.foo)/a:is(.foo, #bar) specificities; and that a list's specificity is the max while a compound's stays the sum.

The full suite (1263 existing tests) stays green — the specificity change regresses nothing — and all seven target frameworks build with no new warnings. The new selector types are additive; the only behavioural change to an existing type is ListSelector.Specificity (sum → max), which is the spec fix above.

…x forgiving-list specificity

:not(), :has() and :matches() collapsed their parsed argument to a
formatted string and wrapped it in an opaque PseudoClassSelector, so the
inner selector object - and any way to inspect or re-match it - was lost.

Introduce NotSelector, HasSelector and MatchesSelector, each retaining
the parsed argument ISelector, and add :is() as the modern alias of
:matches() (they share MatchesSelector; a Keyword field controls which
form it serializes to).

Fix specificity to match CSS Selectors 4 16.1: the specificity of an
:is()/:not()/:has() is that of the most specific complex selector in its
argument. That requires a selector list to report the static max of its
alternatives rather than their sum, so Selectors.Specificity becomes
virtual and ListSelector overrides it. CompoundSelector keeps the sum,
which is correct - its members all constrain one element. Verified
against the spec's own examples: :is(em, #foo) is (1,0,0) and
:not(em, strong#foo) is (1,0,1).
@jhaygood86
jhaygood86 marked this pull request as ready for review July 22, 2026 22:29
@TylerBrinks
TylerBrinks merged commit 2e3ac6a into TylerBrinks:master Jul 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants