Skip to content

Mixing :is() (or equivalent) with pseudo-elements #9702

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
tabatkins opened this issue Dec 12, 2023 · 14 comments
Open

Mixing :is() (or equivalent) with pseudo-elements #9702

tabatkins opened this issue Dec 12, 2023 · 14 comments
Labels
css-nesting-1 Current Work selectors-4 Current Work

Comments

@tabatkins
Copy link
Member

tabatkins commented Dec 12, 2023

The original version of :is(), :-webkit-matches(), allowed pseudo-elements in its argument list. So you could write .foo:-webkit-matches(*, ::before, ::after), and it would be equivalent to .foo, .foo::before, .foo::after.

We ended up removing this, because it conflicts with the Selectors data model - simple selectors, like pseudo-classes, only ever filter the current set of matched elements; only combinators can change the set to new elements (and pseudo-elements). The pseudo-element "selector" is a legacy syntax mistake from the early days of CSS, and today would have been instead done as a type of combinator (as discussed in #7346, which still might happen).

But this means that you can't easily write useful selectors like the above. You can write them with Nesting, but you can't then nest further: .foo { *, &::before, &::after {...}} is fine, but .foo, .bar::before { &:hover {...}} won't match any hovered ::before pseudos, despite .bar::before:hover being a valid and meaningful selectors, because it's treated identically to :is(.foo, .bar::before):hover, which drops the .bar::before arg from the :is().

This isn't great! And it makes Nesting less useful for future cases of nested pseudo-elements, in addition to anything built on Nesting concepts, like Mixins probably will be.

I don't have an answer ready for this, I just needed to raise it for broader discussion and thought.


Some loosely-ordered thoughts:

  • The three syntactic elements of Selectors are simple selectors (filter the matched elements), combinators (change the matched elements), and lists (union the matched elements).
    • Possibly we recognize a fourth - the "complex selector unit" which filters and might change the subject to a pseudo-element.
  • :is() is trying to replicate the functionality of lists, but pulling it into the syntactic space of simple selectors, thus the conflict. You can combine :is() with other simple selectors, so the final subject of each selector inside of :is() needs to be the same element.
  • If we did allow pseudo-elements in there and kept the data model correct, it would be unintuitive; we'd effectively have some selector reordering. That is, :hover:is(*, ::before) would have to be equivalent to :hover, ::before:hover, not :hover, :hover::before.
  • But that sort of re-ordering is in conflict with things like .foo:is(::before, ::after), wanting to avoid repeating the preceding selector. And simple selector order can't matter; .foo:is() and :is().foo have to be identical. So that also prevents reasonable stuff like .foo:is(::before, ::after):hover from working.
  • I feel like we need to somehow invent a way to wedge "lists" into the syntax of a single selector, rather than being restricted to the top level of a selector as it is now. The problem isn't the branching that :is() allows, it's the fact that :is() is a simple selector, so we can't care about order/etc.
  • Spitballing: naked parens indicate a list. Lists can change the subject, and stuff can't get reordered across them. Like .foo(*, ::before, ::after):hover.
  • Nope, foo( is a function token. I need the parens to be separate from other bits of the compound selector, but I also need to still be able to glom a compound selector before or after it.
  • Second try: .foo = (*, ::before, ::after) = :hover.
    • = is a new combinator that doesn't change the set of matched elements at all; it's a no-op.
    • (...) is a new term in the <complex-selector-unit> grammar, that takes a selector list and matches any of them. Since it's part of a complex selector and guaranteed to be surrounded by combinators, we avoid the issues from earlier.
    • So in this example, we (1) select .foo elements, (2) without changing the set of matched elements, select *, ::before, ::after elements (aka the element, plus their before/after pseudos), (3) without changing the set of matched elements, select :hover elements.
    • This works with other combinators, too: .foo > (*, ::before) selects all children of a .foo element and their before pseudos.
    • The selectors inside the () can be full complex selectors, just like in :is(), so .foo = (.bar *, #baz > *) selects .foo elements that have a .bar ancestor or a #baz parent.
    • This is still slightly weaker than Nesting itself; you can't do the equivalent of .foo { &, & > .bar {...}} because the current match set (whatever the preceding combinator yielded) is always the subject of the selectors in the paren list. That's probably okay. We'd have to invent a new way to refer to the elements being matched if we wanted to expand that (not :scope or &, but a secret third way). I dunno, maybe we do need it.
    • Specificity would have to be the same as :is(), to avoid the same sort of "which way did it match" exponential explosion.
    • Nesting can then define itself on top of this - X { Y {...}} desugars to (X) = (Y) {...}, with the magic ability to use & to explicitly refer to the current match elements. If I'm thinking about this correctly, this is a no-op change except that it allows ::before, ::after { &:hover {...}} to work, where today it doesn't.
      • Ah, no, the fact that you'd take the highest specificity from the list means that (X) = (Y) is different - currently we do care about which way you matched the last bit. We'd probably just make that magic, then - I can't see a reasonable way to define () behavior to get that back in general. Nesting would just have the special behavior that it does care about the branch you actually matched in the final bit.
      • Also, we'd have to define that the relative ordering of & still doesn't matter in a compound selector, and it still "represents the elements matched by the parent selector", so ::before, ::after { &:hover {...}} and ::before, ::after { :hover& {...}} mean the exact same thing. (Both equivalent to ::before:hover, ::after:hover {...}.)
@tabatkins tabatkins added selectors-4 Current Work css-nesting-1 Current Work labels Dec 12, 2023
@tabatkins
Copy link
Member Author

tabatkins commented Dec 12, 2023

Yeah, I'll give it some more time to percolate, but some variant on my "Second Try" idea is probably what I'm aiming for. Pulling it out to make it easier to find/reference:

Problem statement: we'd like the following to be valid:

.foo {
	&, &::before, &::after {
		&:hover {
			color: blue;
}}}

...and have the obvious result, equivalent to:

.foo:hover, .foo::before:hover, .foo::after::hover {
	color: blue;
}

Solution: introduce a new combinator, =, and a new <complex-selector-unit>, the parenthesized list.

  1. = selector is a no-op combinator; it has no effect on the current set of matched elements. So .foo = .bar is the same as .foo.bar, .foo = ::before is the same as .foo::before, etc.
  2. <complex-selector-unit> currently expressed the compound selector + pseudo-element bit of the grammar. It both filters the current matched elements and possibly changes the set (just to pseudos, currently), and might do that repeatedly. We add (<selector-list>) to the grammar for it.
    • We just run the selector list on the current set of matched elements, filtering it and potentially changing it (just to pseudos, currently)
    • The problem statement above could be written as .foo = (*, ::before, ::after) = :hover {...}.
    • This works with other combinators, too: .foo > (*, ::before) selects all children of a .foo element and their before pseudos.
    • The selectors inside the () can be full complex selectors, just like in :is(), so .foo = (.bar *, #baz > *) selects .foo elements that have a .bar ancestor or a #baz parent.
    • This is still slightly weaker than Nesting itself; you can't do the equivalent of .foo { &, & > .bar {...}} because the current match set (whatever the preceding combinator yielded) is always the subject of the selectors in the paren list. That's probably okay. We'd have to invent a new way to refer to the elements being matched if we wanted to expand that (not :scope or &, but a secret third way). I dunno, maybe we do need it.
    • Specificity would have to be the same as :is(), to avoid the same sort of "which way did it match" exponential explosion.

Nesting can then define itself on top of this: X { Y {...}} desugars to (X) = (Y) {...}, with just a little extra magic:

  • & refers to the current matched elements (so in the (Y) bit, & refers to the (X) elements, etc). (This expands lists' ability to change the set from just changing to pseudo-elements, to changing to anything potentially.)
  • Remember that & represents the elements (and now, pseudo-elements) matched by the parent selector; it's not textual substitution. So ::before { :hover& {...}} is equivalent to ::before:hover, not :hover::before - the parent selector filters the match set to all the ::before pseudos, then :hover& represents elements that are (a) in the parent's match set, and (b) hovered.
  • The final list keeps "normal" specificity behavior, where it matters which selector actually matched. (This avoids changing the current behavior of .foo, { .bar, #baz {...}}.)

@Loirooriol
Copy link
Contributor

While this can be useful on its own, I don't see why nesting needs =? Wouldn't it suffice to change https://drafts.csswg.org/css-nesting/#nest-selector

the nesting selector represents the elements matched by the parent rule

into

the nesting selector represents the elements or pseudo-elements matched by the parent rule

@tabatkins
Copy link
Member Author

No, because you still need to distinguish ::before:hover from :hover::before, while &:hover and :hover& are meant to be identical in the Selectors data model.

@Loirooriol
Copy link
Contributor

Well yeah, &:hover and :hover& would both behave like ::before:hover

:hover::before could be done with #7346, like :hover :>> &.

= still doesn't seem required for nesting. Also, I think this character can be confusing because it seems to be aliasing selectors or something? I would prefer % or such.

@tabatkins
Copy link
Member Author

Also, I think this character can be confusing because it seems to be aliasing selectors or something?

I'm not sure what you mean by this. It's a combinator (just a no-op one).

@Loirooriol
Copy link
Contributor

The = symbol is too tied to equality/assignment in my mind, so at 1st glance .foo = .bar seems to mean that elements with a foo class will also match styles for a bar class or something like that, I don't know. Maybe it's just getting used to it, but I would prefer representing this combinator with another symbol.

@yisibl
Copy link
Contributor

yisibl commented Jan 10, 2024

Yes, we need this feature.

For example, if you want to set the input track for both browsers at the same time, you need to write the same rule set twice, and it won't work in :is().

input[type=range]:is(
    ::-moz-range-track,
    ::-webkit-slider-runnable-track
) {
    background: #ddd;
}

@Loirooriol
Copy link
Contributor

Loirooriol commented Jan 10, 2024

@yisibl That should already be doable as

input[type=range] {
  &::-moz-range-track, &::-webkit-slider-runnable-track {
    background: #ddd;
  }
}

Edit: Sorry, still half asleep. This actually this only works in Firefox because unrecognized ::-webkit- pseudo-elements are accepted, but other browsers will drop the entire selector due to not supporting ::-moz-range-track.

Or #7346 proposes

input[type=range] :> :is(-moz-range-track, -webkit-slider-runnable-track) {
    background: #ddd;
}

@yisibl
Copy link
Contributor

yisibl commented Jan 10, 2024

@Loirooriol O(∩_∩)O Haha, yes the core point here is the need for a pseudo-element selector that supports forgiving-selector-list.

@jpzwarte
Copy link

jpzwarte commented Oct 14, 2024

So i kind of got redirected here :) I ran into the following issue:

This works:

sl-month-view::part(finish) {
  background: var(--sl-color-success-plain);
  border-radius: 50%;
  color: var(--sl-color-text-inverted);
}

sl-month-view::part(finish):hover {
  background: var(--sl-color-success-bold);
}

But this doesn't:

sl-month-view::part(finish) {
  background: var(--sl-color-success-plain);
  border-radius: 50%;
  color: var(--sl-color-text-inverted);

  &:hover {
    background: var(--sl-color-success-bold);
  }
}

This behavior is not obvious to me and it feels like both should work just fine (from a web dev point of view).

@tabatkins can you confirm i'm in the right issue for this?

@Loirooriol
Copy link
Contributor

@jpzwarte I think & being able to match pseudo-elements doesn't require changing :is(), see #9492 (comment)

@andruud
Copy link
Member

andruud commented Mar 10, 2025

  • Remember that & represents the elements (and now, pseudo-elements) matched by the parent selector; it's not textual substitution. So ::before { :hover& {...}} is equivalent to ::before:hover, not :hover::before - the parent selector filters the match set to all the ::before pseudos, then :hover& represents elements that are (a) in the parent's match set, and (b) hovered.

Then ideally we would disallow & except at the start of the compound? It's not unreasonable to expect :hover::before here. I get that it's consistent with the "filtering" model, but this will surely be seen as your selector being silently re-ordered.

you can't do the equivalent of .foo { &, & > .bar {...}}

This would then desugar to: .foo = (&) { ... }, .foo = (& > .bar) { ... }?

.foo = (*, ::before, ::after) = :hover {...}

Are there any restrictions on where lists with pseudos can appear

  • Can anything follow it (that is not allowed parse time today)? .a = (.b, ::after) = .c {...}.

Since it's part of a complex selector and guaranteed to be surrounded by combinators

What does & refer to here? .a = (.b, .c = (&)) = .d

The pseudo-element "selector" is a legacy syntax mistake from the early days of CSS, and today would have been instead done as a type of combinator

Is there anything that could be improved in this proposal if we first fix that mistake (as an alternative way to access pseudo elements), and then make that form work well with nesting?

@Loirooriol
Copy link
Contributor

Is there anything that could be improved in this proposal if we first fix that mistake

In #7346 (comment) we already resolved on an (experimental) solution.

@tabatkins
Copy link
Member Author

Then ideally we would disallow & except at the start of the compound?

Well, we don't currently have that limitation, and Nesting has been out for long enough that we probably can't change that.

It's not unreasonable to expect :hover::before here. I get that it's consistent with the "filtering" model, but this will surely be seen as your selector being silently re-ordered.

I think it is somewhat unreasonable. For any normal element matched by the parent rule, :hover& in the child rule will mean "the same elements as the parent, but only when they're hovered", same as &:hover. So ::before { :hover& {...}} should match that - the same elements as the parent rule (some ::before pseudos), but only when they're hovered, aka ::before:hover. It's no more re-ordering than div { :hover& {...}} is when it desugars to div:hover.

If you wanted the other way, you'd need the pseudo combinator, and you'd write ::before { :hover :> & {...}}.

This would then desugar to: .foo = (&) { ... }, .foo = (& > .bar) { ... }?

No, since they're no longer nesting the & doesn't do the right thing. The (current) limitation in the proposal is that the (<sel>) can only change the subject via a pseudo-element selector; otherwise it's just filtering whatever the current unit would be otherwise. That's the issue with &, & > .bar - it's both retaining the parent's match set and moving to the match set's children element (and the filtering them with .bar). (But see the end of this comment for more exploration of this.)

Are there any restrictions on where lists with pseudos can appear

No. It's just another option for the <complex-selector-unit> production, so you can chain after it with another combinator/etc

Can anything follow it (that is not allowed parse time today)? .a = (.b, ::after) = .c {...}.

Yes, that's valid, and equivalent to .a.b.c, .a::after.c {...}.

Is there anything that could be improved in this proposal if we first fix that mistake (as an alternative way to access pseudo elements), and then make that form work well with nesting?

No, that's not the side that's problematic for Nesting. Nesting has issues due to our decision to keep it as purely a sugar as possible, without causing combinatorial or exponential blowup in the desugaring. That means we're limited to what :is() can do, and .foo:is(.bar, * :> before) (using the :> "pseudo combinator" proposal we resolved on) also doesn't do what you're wanting. (It only matches ::before pseudos that are also .foo elements, aka ::before.foo

The problem is that, unless we introduce a third way to refer to the "subject" coming from outside the () list, we're stuck with one of two options: either the subject is implicitly what the () selectors match (aka the last compound matches the subject) or it's implicitly what the () selectors descend from (aka the selectors are implicitly relative).

That is, we could move the selector into the parentheses, and then .foo { &, & > .bar {...}} could desugar to .foo (= *, > .bar) {...}, or we could leave the selector outside and then .foo { &, .bar > & {...}} desugars to .foo = (*, .bar > *) {...}. But we can't do both, and :is() already matches the second one, so staying consistent with that seems good.

The fact that ::foo is not actually using the combinator machinery rescues us slightly and makes the case I'm trying to solve work - it just leans on the fact that pseudo-elements can already change the subject of the selector between combinators.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
css-nesting-1 Current Work selectors-4 Current Work
Projects
None yet
Development

No branches or pull requests

6 participants
@jpzwarte @andruud @tabatkins @yisibl @Loirooriol and others