w3c / csswg-drafts

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

[css-cascade] Evaluate cascade order of ::slotted and global styles in the same conditions #6466

Open jorgecasar opened 3 years ago

jorgecasar commented 3 years ago

Description

According to the spec 3.2.4. Selecting Slot-Assigned Content: the ::slotted() pseudo-element:

The specificity of ::slotted() is that of a pseudo-element, plus the specificity of its argument.

In a previous conversation (https://github.com/w3c/csswg-drafts/issues/1915#issuecomment-535381522), it was clear and the solution proposed is to use !important but I think that the current solution is a bad Developer Experience when you apply it to real life.

Following the definition,::slotted(h1) should have a 0-0-2 specificity and h1 just 0-0-1. Then ::slotted(h1) should win without !important. But it seems they don't fight in the same arena and !important is required.

Example

Here is an that explains better the use case. Taking this HTML as a base. We want to use slots to take the advantage of HTML declaration, for example for SEO reasons.

<fancy-hero>
  <"shado-dom">
    <style>
      :host {
        background: #333;
      }
      :slotted(h1) {
        color: #CCC;
      }
    </style>
  </"shado-dom">
  <h1>My site is awesome</h1>
</fancy-hero>

We would like to have some generic styles for headings and paragraphs but the web component would like to restyle them using the ::slotted() pseudo-element. As there are other h1 in other pages without fancy-hero wrapper, we have some generic styles like this:

h1 {
  color: #333;
}

Then the color of my fancy-hero h1 changed to #333. The only way to preserve the fancy-hero h1 style from inside the fancy-hero is by applying !important

:host {
  background: #333;
}
::slotted(h1) {
  color: #CCC !important;
}

But once you do that, it's impossible to change from outside, to make my component customizable. And the only way is to use custom properties like this:

:host {
  background: #333;
}
::slotted(h1) {
  color: var(--hero-title-color, #CCC) !important;
}

Then I can customize my fancy-hero adding this styles into the global styles:

fancy-hero{
  background: red;
  --hero-title-color: white;
}

Extrapolating this use case to a complex component with multiple slots and much more properties, it's not viable. At this point we have two options:

Proposal

I would like to propose that shadow DOM styles fight with the global styles in the same conditions. The cascade should be applied independently where the styles are defined.

Coming back to the specificity definition::slotted(h1) should have a 0-1-1 and h1 0-1-0. Then ::slotted(h1) should win without !important. This allows developers to create custom elements more reusable and easy to customize without dealing with thousand of custom properties and fill the code with !important in all properties.

Demo

https://webcomponents.dev/edit/eQFJPlOvQMIuUZj71ohV/README.md

jorgecasar commented 3 years ago

I also found in this explanation that don't talk about specify.

https://www.w3.org/TR/css-cascade-5/#encapsulation-contexts

When comparing two declarations that are sourced from different encapsulation contexts, then for normal rules the declaration from the outer context wins, and for important rules the declaration from the inner context wins

To compare 2 declaration you don't take only the source of the encapsulation context. Specificity of the selector should be evaluated too.

castastrophe commented 3 years ago

I can definitely see the issue here but I'm not sure I agree with the solution, in part, because it results in potential breaking changes for existing code that has already worked around it. As I understood it, the slotted content was meant really to be a pointer to light DOM and not necessarily meant to change its styling.

That we can force a win by adding !important seems to be the workaround while still allowing !important from the light DOM to override even that. I'm not sure it's the best experience for those consuming the components rather than building them. πŸ€”

Perhaps there's a middle ground. An opt-into level specificity by the shadow DOM styles?

jorgecasar commented 3 years ago

I know it could break the backward compatibility, and I don't suggest it as solution. I opened the issue to find a way to improve the DX and be able to use the light DOM comfortably.

The problem with important is that once you use important in your component styles they can't be override externally. Then you have to choose between weak components styles (without important) or fixed styles (with important). In the weak case, just with a * selector sternal styles can break your component visualization.

From my point of view, it's impossible to build a design system using light DOM because you have to put important in all your properties and if you want to open the possibility to override you have to declare one custom property per property. This make unusable the light DOM and it much easier solve it with Shadow DOM and part attribute, but you can't with form components.

We have to find a solution to make light DOM "stylable" easily.

jorenbroekema commented 2 years ago

One of the main reasons why I dislike !important so much here "as a solution" is that it forces the reliance on cascading order.

Imagine a web component and an extension of it:

class FooExt extends FooEl {
  styles() {
    return `
      ${super.styles}

      ::slotted(#foo) {
        border-color: green !important;
      }
    `;
  }
}

The border color will only be green if this CSS part is later in the cascade than super.styles, because they both have !important. However, it's much cleaner if I can use CSS specificity e.g. by using an ID selector (#foo). This makes things less fragile and gives developers more control in my opinion, because it's a lot easier in practice to fight specificity wars than to fight cascade wars.

pmoleri commented 2 years ago

@castastrophe

I'm not sure I agree with the solution, in part, because it results in potential breaking changes for existing code that has already worked around it

Currently, the only way of working around this is with !important so I don't think that the proposed solution would break that.

IMO current behavior is pretty broken. ::slotted(foo) is targeting foo children of this component, a global foo is just that, a global style, should be considered less specific. With the proposed solution, if you need to override it from light DOM, you can always do so by adding more specificity to your selector or with !important if you like that.

However, I wouldn't mind opt-in, if it's something simple like :host ::slotted(foo).

castastrophe commented 2 years ago

The problem with important is that once you use important in your component styles they can't be override externally.

You can override them externally with the use of !important again. Slotted styles are on par with other pseudo elements for specificity.

jorgecasar commented 2 years ago

@castastrophe you can't. Take a look the Demo provided, as you can see "Slotted paragraph with !important in the internal and external styles πŸ™ˆπŸ™ˆ" is in green instead of pink trying to override styles with important in the external stylesheet.

https://studio.webcomponents.dev/edit/eQFJPlOvQMIuUZj71ohV/README.md?p=README.md

pmoleri commented 2 years ago

Nice example. Funnily enough even the dev tools get confused: dev tools

This happens in both Chrome 103 and Firefox 103

castastrophe commented 2 years ago

@jorgecasar Let's simplify your example: https://codepen.io/castastrophe/pen/KKoXGQo

There seems to be custom styles applied to READMEs in the environment you shared and that interferes with the discussion of specificity and scope.

Typography styles are complex because they already penetrate the shadow DOM; they're some of the few properties that do.

I want to focus the example on the use of !important when it comes to styling the background color (which does not penetrate shadow DOM boundaries).

I've reduced the component's template to:

<style>
  ::slotted(*) {
    background-color: yellow;
  }
</style>
<slot></slot>

Now to override, we use:

<style>
my-content .important {
  background-color: pink !important;
}
</style>
<my-content>
  <p>Slotted paragraph</p>
  <p class="important">Slotted paragraph with <code>!important</code></p>
</my-content>

I've used a simple selector here on my page so you can see that the specificity of the selector isn't as important either: just the component and the class we're targeting. You can see in my provided link that this works as I would expect.

castastrophe commented 2 years ago

I want to note, I would not expect a !important from within a slot to be able to be overwritten from the outside of the component.

castastrophe commented 2 years ago

I find a fairly major flaw in your original proposal though to be honest which is this statement I would like to propose that shadow DOM styles fight with the global styles in the same conditions. which I feel contradicts the exact goal of web components which is to separate Shadow DOM elements from the global cascade entirely.

pmoleri commented 2 years ago

@castastrophe

I want to note, I would not expect a !important from within a slot to be able to be overwritten from the outside of the component.

Then, we're pretty locked: a. ::slotted() can specify styles which will only work if there no other applicable style set anywhere outside (no concept of specificity), something like a fallback in the absence of any style. b. ::slotted() with !important will overwrite permanently, no way for outer styles to overwrite it back.

I find a fairly major flaw in your original proposal though to be honest which is this statement I would like to propose that shadow DOM styles fight with the global styles in the same conditions. which I feel contradicts the exact goal of web components which is to separate Shadow DOM elements from the global cascade entirely.

Then ::slotted() shouldn't exist and !important shouldn't be considered, right?

I think the point here is to have a useful ::slotted(), the current one is very limited because it forces you to never ever write global styles if you want slotted() styles to work.

jorenbroekema commented 2 years ago

Hmm slots and content projection are a very common use case and the exact contract between a slot and its slottable (that which is slotted in) is ambiguously defined imo, it's not clear to what extent the slottable should be influenced by the component. In my personal experience authoring and consuming components, the answer is "a fair amount". If you have some widget in which you can slot DOM, it does happen from time to time that the widget relies on needing to functionally style what's projected into it, in theory ::slotted is for this purpose but currently it just doesn't do that job well at all.

It's not about a global stylesheet vs encapsulated component distinction

gipoezcan commented 3 months ago

I humbly suggest that there should be some sort of new mechanism to enable specific styles to slotted components without breaking the current behavior. Having to add "!important" on all ::slotted rules is a very ugly work-around. Web components are a breath of fresh air for improving old code-bases with cool new functionality, but it's also the old code-bases which tend to have a lot of weird global CSS rules.

zhylmzr commented 2 months ago

When my boss asked me to override an input with border-width: 2px !important in a third-party web component library, I realized how tricky this kind of thing is in the case of ::slotted !important. I had to spend a day cloning this repo and then fixing its build issues on Windows just to change the border of the input from 2px to 1px. πŸ˜‚

emilio commented 1 month ago

Retitling because it was clear in https://github.com/w3c/csswg-drafts/issues/6867 that the problem with slotted is not the specificity but the cascade order rules. It seems the reason people don't hit this with :host is because those are not builtins.

We discussed a bit "what would the ideal cascade order of these be". It seems there was a desire of just letting specificity fight as usual, and only then sort by tree (that is, make the sort key, ignoring cascade layers, something like (specificity, tree, source order).

Another less breaking alternative would be something like an "important layer" kind of concept, where you'd sort by layer importance before sorting by tree. Cc @LeaVerou @keithamus @rniwa @tabatkins

LeaVerou commented 1 month ago

We discussed this a fair bit during breakouts with @emilio @rniwa during TPAC, and we'd love to hear from @tabatkins and @fantasai.

Currently, ::slotted() is practically near-useless, as even CSS resets from the host page override component styles. I thought #7922 would fix this, but on its own it’s not enough: the core problem is this cascade order rule that defines that all shadow CSS has lower precedence than all light DOM CSS unless !important is used.

In my experience, you typically want more granularity than the all-or-nothing of the current situation: you don’t want component styles to override author styles specifically targeting those elements, but also you don't want generic catch-all author rules like ul to override component rules applying styles to e.g. lists within that specific component. Also, specificity can be more granularly tweaked either up or down, whereas there is no recourse when a cascade order rule that sits above specificity doesn't do what you want.

Intuitively, it seems that the kind of cascade order that would make sense here is to treat encapsulation context at the same precedence level as source order. @emilio's proposed "important layer" concept seems useful in its own right, but would have similar problems as !important if it were the only solution to this problem.

Fixing this may require an opt-in of some sort (possibly as a ShadowRootInit option), since at this point these selectors are used widely enough that fixing this could be a breaking change. Alternatively, if we see that ::slotted() is used sufficiently infrequently, we could simply define ::part() as having very high (or even infinite) specificity. I suspect that will be a no-go however.

mayank99 commented 2 weeks ago

Currently, ::slotted() is practically near-useless, as even CSS resets from the host page override component styles.

I want to focus on this particular point. In #10094, I've suggested introducing (something like) @layer !defaults as a way to indicate low-priority styles that should lose to any styles defined within a shadow context.

Even if that doesn't solve all use-cases, I think it would be helpful. I've been using this simple example to demonstrate the problem: * { margin:0 } in the document currently overrides the margin declaration in all :host/::slotted rules.

mirisuzanne commented 2 weeks ago

It seems a little strange to me that cascade layers (currently sorted after context) would have an internal mechanism for jumping ahead of context in the sort order. But I agree that something layer-like would be useful here.

And in this case, I think specifically named options (like the mentioned !important and/or !default layers) since custom names would introduce some larger coordination issues?

LeaVerou commented 1 week ago

Any solution that expects the host page to just "behave" is not workable when you're building components that need to work in any page. :/

mayank99 commented 1 week ago

It can be both, right? A way to deprioritize "outer" styles, and a way to prioritize "inner" styles.

In https://github.com/w3c/csswg-drafts/issues/10094#issuecomment-2179369415, I've suggested a new @context at-rule for that.

Hypothetical example ```html
Slotted
```

The exact syntax and names are debatable of course. The important thing I want to highlight is that this is a concern that lies above layers, specificity, source order, etc. It doesn't make sense to solve it at the level of specificity. It would be more appropriate to solve it using context or a new concept that sits above context-scoped layers.