w3c / csswg-drafts

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

[extend-rule] #1855

Open jonathantneal opened 6 years ago

jonathantneal commented 6 years ago

As a CSS author, I want to re-use styles from other rules. I don’t want to (and often can’t) do this in HTML by stringing classnames together (the Atomic CSS strategy).

I caught a glimpse of a solution to this problem behind a flag in Chrome with the @apply feature. Its author, @tabatkins has rightly pointed out that this solution is problematic, and it has since been abandoned.

In its place, I propose we bring in a simplified version of the Sass @extend rule.

The @extend rule allows authors to declare that certain elements, such as everything matching .serious-error, must act as if they had the necessary features to match another selector, such as .error. So that:

.error {
  border: thick dotted red;
  color: red;
}

strong {
  font-weight: bolder;
}

.serious-error {
  @extend .error, strong;
}

Is equivalent to:

.error {
  border: thick dotted red;
  color: red;
}

strong {
  font-weight: bolder;
}

.serious-error {
  border: thick dotted red;
  color: red;
  font-weight: bolder;
}

A straight-forward thing about the Sass @extend rule is that styles pulled in from the strong selector are now weighted by the .serious-error selector. This means I don’t have to think about whether some other, higher-weighted selector is actually blocking the styles I am trying to pull in.

Unfortunately, the Sass version of @extend has sometimes been discouraged because it re-orders rules in order to group any extended selectors together and reduce the final output code. I recommend we drop that part of Sass extend, and instead treat the extend rule as plain simple substitution.

If possible, I would also like to see us bring in the Sass companion to the extend rule; the placeholder selector. These are simple selectors which do not match elements. So that:

%media-block {
  overflow: auto;
}

%media-block > img {
  float: left;
}

.image-post {
  @extend %media-block;
}

is equivalent to:

.image-post {
  overflow: auto;
}

.image-post > img {
  float: left;
}

Here is a link to a specification which covers this, originally by Tab Atkins: https://jonathantneal.github.io/specs/css-extend-rule/

chriseppstein commented 6 years ago

The sass version of extend has a simple definition:

Given an expression A { @extend B; } where A is a complex selector and B is compound selector, style all elements matching A as if they have the element attributes implied by B.

In essence, this is a way of assigning HTML attributes with specific values to elements that do not have those traits such that selectors will match against them anyway for the purposes of styling them.

The placeholder selector has the semantics of the class attribute but for an attribute that does not exist in HTML. Because this "style placeholder" attribute can never be assigned from the document, it gives a CSS-only domain within which styles can be re-used without ever touching the html or templates of an application -- this creates a strong separation of concerns that is very much in the spirit of CSS's original design goal.

A placeholder selector, like a class, can be used in any part of a complex selector, but those selectors never match an element unless they are extended, because there's no equivalent attribute in html for it.

The Sass implementation is problematic because it can only emulate this semantic through selector rewriting. In doing so, the specificity of the rewritten selector changes causing the selectors to match in a new overall cascade that is not the same as the original. But an in-browser implementation would not have this issue. As such, there's no reason to "instead treat the extend rule as plain simple substitution." It's is incorrect to even conceptualize @extend as a type of substitution or selector manipulation -- that is just how Sass attempts to implement it.

Of note: The legal syntax for the extended compound selector B referenced above would probably need to exclude certain attribute selector operators which do not resolve to a concrete attribute value -- The sass implementation does not do this because of the particulars of how it implements the concept of @extend does not force it to do so, but an in browser implementation would make sense to limit to just concrete html traits and not patterns of them.

jonathantneal commented 6 years ago

Thanks for sharing your thoughts and explaining things so well, @chriseppstein. There is one thing I would like to understand better.

the specificity of the rewritten selector changes causing the selectors to match in a new overall cascade that is not the same as the original. But an in-browser implementation would not have this issue.

Would you help me conceptualize this with an example? For instance, based on the following CSS, I would expect <element class="foo bar"> to be bolder.

strong {
  font-weight: bolder;
}

.foo {
  font-weight: lighter;
}

.bar {
  @extend strong;
}

I would expect <element class="foo bar"> to be bolder because I have placed .bar later in the cascade, and because I expect @extend to import other styles, but not import those styles’ original weight. Would you expect something different? If so, why?

I chose that example because it contrasts with what I expect Sass and Tab’s original spec to do. In Sass, it would be lighter because of how the cascade is re-ordered (citation: Sassmeister). In Tab’s original spec, it would also be lighter because the styles being inherited from strong have a lower specificity than .foo (citation: "Example #5").

Please accept my apologies if you already answered this and I misunderstood you.

chriseppstein commented 6 years ago

@jonathantneal This is a not uncommon misconception about @extend. Extend causes elements to "inherit" styles they wouldn't match otherwise. The behavior you describe in the example above is much more conceptually aligned with a kind of composition. Composition and Inheritance often degenerate into very similar concepts for trivial situations like the one you've constructed above but even there the differences of the two models are apparent.

But the behavior of extend that I (and @tabatkins) describe follows naturally from the syntactic choice to use selectors as the abstraction upon which the @extend directive is targeting. This is because selectors are not localized or unique, as soon as examples move away from simple selectors to complex selectors with specificity and document order, the behavior that you're describing becomes challenging, if not impossible, to define. Let's consider a more complex (albeit contrived) example:

.article strong {
  font-weight: bolder;
}

.article em {
  font-weight: lighter;
}

section .important {
  @extend strong;
}

section em {
  font-weight: 100;
}

strong {
  font-weight: bold;
}

em {
  font-weight: 200;
}

section strong {
  font-weight: normal;
}

.important {
  @extend em;
}

Now consider the following document:

<div id="id1" class="important">unscoped</div>
<div class="article">
   <span id="id2" class="important">important article text</span>
   <section>
     <span id="id3" class="important">important section text</span>
   </section>
</div>
<section>
   <p id="id4" class="important">important section paragraph</span>
</section>

Given those styles and this document what do you expect the font-weight to be for elements with ids 1-4?

If the specificity and document order of the selector A matters in determining the resolution of extended selectors it's unclear how that specificity and document order impacts the resolution. But if the extend directive is simply assigning additional html traits that could cause a selector to match, it becomes incredibly clear how the cascade resolves for .important.

If every document element can only be "extended" once, then the document order and specificity of the A selectors could be used to determine which one wins. But if that were the situation, the syntax should be changed to make extend a property, E.g. A { extends: B, C, D; } to match the semantic behavior of selector overrides. Personally, I don't think that design would be very useful.

Early in the design of Sass's @extend directive we considered an alternative syntax @extend A like B; or something along those lines. Such a syntax could be restricted to the start of a document before any ruleset -- we thought that design was less ergonomic than the syntax we chose, but I think it would have resulted in less confusion between inheritance and composition.

Ultimately, it seems like what you're asking for is the ability to assign a unique css-specific id to a single ruleset and then use it like a mixin at the call site. That's a fine feature, but it's not @extend and I think we shouldn't try to build such a concept on top of Sass's syntax with existing well-defined semantics.

chriseppstein commented 6 years ago

To clarify what i meant when I said:

The specificity of the rewritten selector changes causing the selectors to match in a new overall cascade that is not the same as the original. But an in-browser implementation would not have this issue.

Consider the following CSS with the hypothetical @extend implementation:

.article .important { font-weight: bold; color: black; }
.article .muted { color: gray; }
#a-particular-article h2 { @extend .important; }

Sass rewrites this to:

.article .important, .article #a-particular-article h2, #a-particular-article .article h2 {
  font-weight: bold;
  color: black; }

.article .muted {
  color: gray; }

(note: sass should also generate .article#a-particular-article heading for completeness, but we don't do this because it's less common and it causes a lot of bloat.)

If there is an element <div class="article"><h2 class="muted">My Article</h2></div>, then the cascade will resolve such that the h2 element will be color: black rather than color: gray as would be dictated under the definition I gave initially. You probably prefer this given your initial ask that the specificity/document order of the A selector matters, but it is an inconsistency dictated by the specific implementation that Sass is forced to use and was not what we ultimately wanted for an in-browser implementation of @extend.

Specifically, @extend was created to obviate the error-prone pattern of developers needing to apply multiple selectors in conjunction with each other in the document in order to implement "selector inheritance" as was commonly done in OOCSS and SMACSS approaches to css architecture.

Array23 commented 6 years ago

You don't have to extend to reuse, you just need class="error strong" if both ware classes... And in strong case, you are generating strong without HTML markup, which is something I would discourage.

chriseppstein commented 6 years ago

@Array23 that requires duplication at many existing markup locations to enforce a style concern that can be expressed in on place in the stylesheet (that two classes must always be used together - by design).

And the use of strong here is simply for exposition. For cases where you want something to look similar have have different markup semantics -- the same reason the font weight can be changed independently of the markup.

Array23 commented 6 years ago

That's mistake in templating. Duplication is always error if not meant for caching purposes.

chriseppstein commented 6 years ago

Right. Well, you do you. I think the use cases for this feature have been proven through 8+ years of it being widely used in the open source community.

carina-akaia commented 3 years ago

What's the status of the draft today?