w3c / csswg-drafts

CSS Working Group Editor Drafts
https://drafts.csswg.org/
Other
4.38k stars 643 forks source link

[css-nesting-1] Clarify when nested rules are equivalents to `:is()` #10523

Open paceaux opened 3 weeks ago

paceaux commented 3 weeks ago

Could you provide clarity for when :is() is used for selectors

In the examples section, I spotted some examples that seem to suggest contradictory situations where :is() is an equivalent to the nested code.

This example suggests that :is() is an equivalent when there's a list of multiple selectors:

/* multiple selectors in the list are all
   relative to the parent */
.foo, .bar {
  color: blue;
  + .baz, &.qux { color: red; }
}
/* equivalent to
  .foo, .bar { color: blue; }
  :is(.foo, .bar) + .baz,
  :is(.foo, .bar).qux { color: red; }
*/

And this seems consistent with this example, where it is not considered to be an equivalent to an :is()


/* & doesn’t have to be at the beginning of the selector */

.foo {
  color: red;
  .parent & {
    color: blue;
  }
}
/* equivalent to
  .foo { color: red; }
  .parent .foo { color: blue; }
*/

However, I then see this example where there is not a list of selectors, but it's called an "equivalent" of an :is()

.ancestor .el {
  .other-ancestor & { color: red; }
}
/* equivalent to
  .other-ancestor :is(.ancestor .el) { color: red; }

The difference between the first and third examples is that in the first, the parent is a single selector while in the third there is a general-descendant selector. I wouldn't consider .ancestor .el to be a selector list because there's no comma indicating more selectors.

Request: Could you please explicitly describe when nested CSS produces an equivalent to :is(). i.e.:

Could you provide clarity on the equivalency of & and :is()

In the draft, in the section about mixing nesting rules and declarations, There is this example

article {
  color: green;
  & { color: blue; }
  color: red;
}

/* equivalent to */
article { color: green; }
:is(article) { color: blue; }
article { color: red; }

/* NOT equivalent to */
article { color: green; }
article { color: red; }
:is(article) { color: blue; }

I understand the point about order. However, I also observe an implication regarding what & does. This suggests that & will produce an :is() if it's neither preceded or seceded by another selector.

However, this contradicts an earlier example:

/* Somewhat silly, but & can be used all on its own, as well. */
.foo {
  color: blue;
  & { padding: 2ch; }
}
/* equivalent to
  .foo { color: blue; }
  .foo { padding: 2ch; }

Request Could the incorrect example be removed? Or if they're somehow both correct, could you explain how?

Could you explicitly call out the conditions under which && would work.

The specifications give this example

/* Again, silly, but can even be doubled up. */
.foo {
  color: blue;
  && { padding: 2ch; }
}
/* equivalent to
  .foo { color: blue; }
  .foo.foo { padding: 2ch; }
*/

But it has also been made very clear that the & is not capable of sass-like concatenation.

So then what is the output of these examples?

  article {
    && {
      padding: 2ch;
  }
}

  .article {
    && {
     padding: 2ch;
  }
 }

#article {
  && {
    padding: 2ch;
  }
}

The examples, as they're given, suggest one of these produces an error:


articlearticle {
  padding: 2ch;
}

.article.article {
  padding: 2ch;
}

#article#article {
  padding: 2ch;
}

unless the & is actually always an equivalent to :is(). In which case each condition produces valid selectors:


article:is(article) {}
.article:is(.article) {}
#article:is(#article){}

But if this is the case, that contradicts this example where the & seems to be "consumed".

Request Could you expand the "Nesting Selector" section and explain when & is an equivalent to :is(). Could you add a section that explicitly calls out how behavior may differ with a type selector? Maybe add an example of && to the slightly rephrased example with type selectors

mirisuzanne commented 2 weeks ago

The & selector is always equivalent to using :is() - they use the same mechanism. In some cases, both & and :is() are also equivalent to a bare selector. Generally, for example, :is(article) and article are equivalent selectors. They are not the same selector, but they select the same thing at the same specificity. They are all three equivalent in this case:

article { & { color: red; } }
:is(article) { color: red; }
article { color: red; }

In Sass, the & is literally replaced by another selector, which can result in concatenation. But in CSS, & is the selector – just like article is a selector. So && doesn't become something else, it remains && (a valid selector). But the result of that valid selector (in your example) is equivalent to :is(article):is(article), and not articlearticle.

The spec examples are not showing literal transformations that happen to your selector, they are showing alternative selectors that have equivalent results.

Does that make sense?

paceaux commented 2 weeks ago

@mirisuzanne Yes that does make sense and it is incredibly helpful. I want to make sure that I understand this behavior properly:

  1. In all cases of nested selectors, the nearest "true equivalent" will be a selector that uses the :is() pseudo-selector

  2. Both the parent and the nested selector(s) would be considered to be wrapped in an:is() (e.g. article { h1 {display: block}} => :is(article) :is(h1)

  3. the unnested & ends up representing * (e.g. & {display: block;} => :is(*) {display: block})

  4. The nested & that doesn't have any inline selectors is a single application of :is() where it's the parent that's wrapped in an :is(). e.g. ( section > article { & {display: block}} => :is(section > article)

  5. The nested & that does have inline selectors is a multi-application of :is() because it comes from both parent and nested child. e.g. section > article {main & {display: block}} => :is(main) :is(section > article) {display: block}

  6. double-& is a double-chained :is(), but the reality is that it has nothing to do with what's nested. It's because nesting triggers children and parents to be wrapped in :is(), and in this case we've just chained our parent :is() together.

article { /*:is(article):is(article) */
 && {
  display: block;
 }
}

.article {
 && { /* :is(.article):is(.article) */
   display: block;
 }
}

I know that the nesting behavior superficially looks / acts like Sass. But what I'm really trying to understand is all the ways it isn't like Sass.

Sass of course errors on a single nested &. It also wouldn't allow an unnested &, nor would it allow &&, all of which aren't problematic in CSS.

Do you think it might be beneficial to explain explicitly in the documentation how nesting invokes :is() on both parent and child, and to possibly even give an example of the unnested & so as to illustrate how it also uses :is() ?

mirisuzanne commented 2 weeks ago

I want to make sure that I understand this behavior properly:

No problem, glad that helped. In the meantime, I'm going to close this as 'question answered' if you don't mind.

  1. In all cases of nested selectors, the nearest "true equivalent" will be a selector that uses the :is() pseudo-selector

right

  1. Both the parent and the nested selector(s) would be considered to be wrapped in an:is() (e.g. article { h1 {display: block}} => :is(article) :is(h1)

no, only the & should be thought of as similar to :is(<parent>)

  1. the unnested & ends up representing * (e.g. & {display: block;} => :is(*) {display: block})

No, see the Editor's Draft (emphasis added):

When used in the selector of a nested style rule, the nesting selector represents the elements matched by the parent rule. When used in any other context, it represents the same elements as :scope in that context (unless otherwise defined).

This is also often equivalent to :root, when not in scoped situations. According to this previous resolution it has a specificity of 0 in those situations.

  1. The nested & that doesn't have any inline selectors is a single application of :is() where it's the parent that's wrapped in an :is(). e.g. ( section > article { & {display: block}} => :is(section > article)
  2. The nested & that does have inline selectors is a multi-application of :is() because it comes from both parent and nested child. e.g. section > article {main & {display: block}} => :is(main) :is(section > article) {display: block}

As far as I can tell, only the & should ever be treated 'like' :is(<parent-selector>). There is never any need to think of the nested selector as though it is wrapped. I've confirmed that with a quick test of the resulting specificity on codepen.

  1. double-& is a double-chained :is(), but the reality is that it has nothing to do with what's nested. It's because nesting triggers children and parents to be wrapped in :is(), and in this case we've just chained our parent :is() together.
article { /*:is(article):is(article) */
 && {
  display: block;
 }
}

.article {
 && { /* :is(.article):is(.article) */
   display: block;
 }
}

The replacement is a metaphor, but a fairly reliable one. The & is a selector that matches the same elements as the parent selector, with :is()-like implications. And it can be chained as desired.

The big difference here is that Sass does literal string replacement, which can change the selector meaning based on context – .foo { &bar {}} becomes .foobar. But CSS does not do any replacement, so the & selector has the same meaning no matter how you combine it with other things. It means: the same elements as above. In that case the direct :is() replacement doesn't work (:is(.foo)bar) – but the same meaning still holds. We just have to turn it around to get the equivalent bar:is(.foo).

But yes, in your case, the chained-is() metaphor continues to be reliable.

I know that the nesting behavior superficially looks / acts like Sass. But what I'm really trying to understand is all the ways it isn't like Sass.

Sass of course errors on a single nested &. It also wouldn't allow an unnested &, nor would it allow &&, all of which aren't problematic in CSS.

Besides the impact on specificity, and the inability to do string-concatenation, this thread covers the biggest difference that I would note. Here's a codepen demo of the difference.

Do you think it might be beneficial to explain explicitly in the documentation how nesting invokes :is() on both parent and child and to possibly even give an example of the unnested & so as to illustrate how it also uses :is() ?

I don't believe those assertions are correct. :)

But it's possible @tabatkins will want to clarify some of the examples based on this conversation, I'm not sure.

paceaux commented 2 weeks ago

@mirisuzanne Thank you again for clarifying. I really appreciate you using the term, "chained is() metaphor," because that really helps solidify how one should understand what is happening.

I would strongly encourage the specifications to use this terminology to help CSS users understand the mechanics.

As far as I can tell, only the & should ever be treated 'like' :is(). There is never any need to think of the nested selector as though it is wrapped

The reason I made the conclusion, "oh, the parent should also be wrapped in :is() is because one of the examples suggests this:

/* multiple selectors in the list are all
   relative to the parent */
.foo, .bar {
  color: blue;
  + .baz, &.qux { color: red; }
}
/* equivalent to
  .foo, .bar { color: blue; }
  :is(.foo, .bar) + .baz,
  :is(.foo, .bar).qux { color: red; }
*/

I'll weed out the parts that aren't related, and it looks like this:

.foo, .bar {
   + .baz {
    color: red;
  }
}

/* equivalent to
  :is(.foo, .bar) + .baz { color: red; }
*/

I ran some tests on specificity, and it appears that any time the parent selectors are a list, they get wrapped in :is().

Chrome's dev tools reports specificity and I experimented with my final ruleset and looked at the specificity.


.oof {
  + .baz { /*specificity 0,2,0*/
   color: orange
  }
}
.oof,
div.oof {
 + .baz { /* specificity 0,2,1 */
  color: orange;
 }
}

Is it more accurate to say, "the & should be thought of as similar to :is(), and any instance where the parent is a list should also be thought of as :is(<parent>,<parent>?

If that's the case, do you think it might be easier to simplify that "metaphor" as, "in cases of nesting, always think of the nested parent as wrapped in :is().

Your explanations have been great and I've found them very insightful; I really appreciate you correcting my assertions.

Do you think that Section 3.2's examples could be rewritten and organized so as to only make one clear assertion at a time? The examples written in the spec are very long and often make several assertions in a single example. Complex (using & or not with multiple lines) come first, and simple examples (not using &, only being type selectors) are in the middle, while examples for nesting at-rules are at the end.

Sass' own documentation for nesting does a great job of providing increasingly-complex examples. Do you think the CSS spec could possibly consider even using Sass' exact examples?

I know that the goal here is to not conflate or confuse CSS Nesting or Sass Nesting because the mechanisms are different. But given that Sass got there first (¿probably?), leaning on how Sass has documented behaviors like & with first simple examples, and then gradually increasing complexity, could help folks more clearly understand the differences.

I'd love to see section 3.2 arranged so that the first examples had nothing to do with & at all, then went to lists in the parent, lists in the child, and then the special behavior of & all alone, then &&, and then the output preceding and following selectors. That would make it so much easier to track when :is() is relevant and when expectations should change.

mirisuzanne commented 2 weeks ago

Wait. When I say "only the & should ever be treated 'like' :is()" I mean exactly what you show in your demo – and what the spec says (in section 4). There's only the one 'desugaring' metaphor that you need in order to understand all the examples, and it comes later in the spec:

The nesting selector can be desugared by replacing it with the parent style rule’s selector, wrapped in an :is() selector. For example,

a, b {
  & c { color: blue; }
}

is equivalent to

:is(a, b) c { color: blue; }

The & being replaced by :is(<parent-selector>) is the same thing as the parent selector being wrapped by :is(). Because in this metaphor, the & is acting as a placeholder for the parent selector.

It seems like you are reading complexity in the examples that doesn't actually exist? In part because they don't distinguish between e.g. article vs :is(article) unless the distinction matters to the outcome. The extra :is() wrapper has been left off where it doesn't impact anything. So that looks like extra magic to learn, but it's not.

I don't mean that all the examples are simple, but they aren't intending to express anything additional with the presence or absence of the :is() wrapper. The comments do actually communicate what each example is meant to demonstrate.

But there's something else going on with your read here. Because the & is always in play, either explicitly or implicitly. And it doesn't have special behavior alone, or chained. It always does the same thing, and we can always think of that thing as being the :is(<parent>) thing. Every time, in every example. So I'm not clear how to read your final paragraph. We seem to be talking in circles.

I'm always in favor of specs & examples being more clear where possible. But there are a lot of specs, and it can be a lot of work – especially since different people will understand different phrasings. So it's hard to get perfect, and comes down to time & focus & priorities.

The good news is, if you have ideas for improvements, anyone can submit a PR. :)