w3ctag / design-reviews

W3C specs and API reviews
Creative Commons Zero v1.0 Universal
332 stars 56 forks source link

Early design review of light-DOM CSS Scope proposal #593

Closed mirisuzanne closed 3 years ago

mirisuzanne commented 3 years ago

HIQaH! QaH! TAG!

I'm requesting early TAG review of my proposal for light-DOM CSS Scope.

Previous scope proposals have attempted to address highly-isolated use-cases along side the more light-touch issues of namespacing and selector-proximity. Those attempts were largely abandoned in favor of Shadow-DOM solutions -- which strongly prioritize strong isolation. In the meantime, third-party tools (eg CSS Modules) and conventions (eg BEM) are more often used by authors to provide low-isolation scoping for light-DOM components. I'm proposing a native CSS approach to those use-cases.

Further details:

We'd prefer the TAG provide feedback as 💬 leave review feedback as a comment in this issue and @-notify @mirisuzanne and @lilles

kenchris commented 3 years ago

Hi there,

Here are some of my initial thoughts.

I would really like to know how this ties into other CSS scoping methods like shadow DOM and the proposed https://github.com/WICG/webcomponents/issues/909 And would especially like to hear the feedback from @rniwa and @hober

I think that having multiple CSS scoping methods that aren't build on the same principles or underlying primitives is problematic, and we should attempt to avoid that.

There are real problems with shadow DOM scoping today, but solving those outside of shadow DOM would not be ideal. For instance, I have heard the need for scoped light DOM styling, ie. a more powerful ::slotted(), which seems related to this.

mirisuzanne commented 3 years ago

Thanks for the quick feedback @kenchris!

I agree that it's confusing to have both models use the language of "scope", but I don't think it means we can conflate them. While there are some similarities, these are two distinct problems – both with valid use-cases, but requiring distinct solutions. I've been happy to see that recent Cascade drafts refer to "encapsulation context" rather than "scope" for Shadow-DOM isolation. I'm also open to changing any of the language in this proposal - though I think this is the more common use of "scope" that authors are familiar with in the existing tools.

My proposal here is that getting shadow-DOM shouldn't solve both use-cases, but should instead free us up to solve the remaining presentation/selector-targeting use-case as a distinct problem-set.

I hope that's a helpful clarification. Thanks again for your thoughts!

LeaVerou commented 3 years ago

I'm very happy to see a proposal that aims to address the problems that cause patterns like BEM or CSS Modules to proliferate. Also, nice, thorough explainer. 💯

So, to decouple the different parts of this:

Some lower level comments below.

I think this should work inside and outside of the shadow DOM without any special concern for shadow boundaries.

This is a bit ambiguous. I can see two possible meanings:

  1. @scope should match on the whole tree ignoring shadow boundaries.
  2. It should be possible to use @scope both in CSS that is applied to light DOM, as well as CSS that is applied to Shadow DOM.

If you meant 2, that seems pretty uncontroversial. But in case you meant 1:

I think this would break author assumptions about shadow DOM. Also, it was decided early on that we don't want explicit access to styling shadow DOM structure that has not been explicitly exported. Furthermore, my understanding of implementations is that this kind of selector matching across Shadow DOM boundaries would be very difficult to implement.

Given a syntax of @scope (<selector>), are we placing any restrictions on the <selector>?

It is unclear whether the parentheses are optional or mandatory. In the examples that follow the part I quoted, there are no parentheses. Is that a typo or does it intentionally demonstrate that no parentheses are required in certain cases? Parentheses should definitely be required to disambiguate complex selectors, otherwise you don't know if @scope foo to bar is scoping foo until bar or just scoping under selector foo to bar (to could be a type selector targeting <to> elements).

Getting even more complex, is there reason to allow selector lists -- defining multiple roots for a single scope block?

I definitely see use cases for selector lists as lower boundaries, e.g. blocks with multiple different areas to exclude.

Can scoped selectors reference external context?

It is definitely useful to be able to vary a component's style based on its context. However, simply allowing a part of the selector to match outside the scope could lead to unintentional effects, when the author has not anticipated this. Consider this:

<div class="b">
    <div class="a">
        <div class="c"></div>
    </div>
</div>
@scope (.b) {
    .a .c { /* matches! */ }
}

Now consider this HTML, with the same CSS:

<div class="a">
    <div class="b">
        <div class="c"></div>
    </div>
</div>

The rule still matches, but the author may have not intended this, which breaks the isolation. To enable the use cases without unintended leaking I would suggest an explicit opt-in of some sort. Perhaps selectors containing :scope?

For normal declarations the inner scope's declarations override, but for ''!important'' rules outer scope's override.

Could you elaborate on this? I don't understand it as written.

Another idea that I considered was to combine the specificity of the scope selector to the specificity of nested rule-blocks:

Until this point, I was under the impression that part of the reason for having an at-rule instead of a "donut" selector was the different cascade mechanism. If it just resolves to the same specificity as nesting, then it could have scope leaks. Consider this:

#main a { color: red }

@scope (.my-component) {
    a { color: purple; }
}
<main id="main">
    <div class="my-component">
        <a>This is red?!</a>
    </div>
</main>

It may also be interesting to explore what a JS API for fetching "donut scope" elements could look like. I've often needed this, and ended up querying and filtering using element.matches(), which is rather expensive. On the other hand, if the lower boundary is expressed via a selector, and @scope merely enforces the different scoping, regular qSA would suffice.


@kenchris

I think that having multiple CSS scoping methods that aren't build on the same principles or underlying primitives is problematic, and we should attempt to avoid that.

This gives me pause as well. It would be great if these issues can be solved with existing primitives. Though if there are widespread problems the existing primitives cannot reasonably address, solving the problems is more important than keeping the set of primitives constant.

There are real problems with shadow DOM scoping today, but solving those outside of shadow DOM would not be ideal. For instance, I have heard the need for scoped light DOM styling, ie. a more powerful ::slotted(), which seems related to this.

My understanding is that this solves different problems than Shadow DOM. There are many use cases for partial style encapsulation where that is the only kind of encapsulation needed, and Shadow DOM would be too heavyweight a solution. Also, this being entirely a CSS side solution means it can address use cases where existing HTML is being styled.

hober commented 3 years ago

@mirisuzanne, while reading the (existing) :scope pseudo-class, it seemed to me that the idea is that there's an implicit :scope at the beginning of selectors which don't contain an explicit :scope. Is that the case?

mirisuzanne commented 3 years ago

Thank you all for the thoughtful notes! I'll work on some clarifications and updates to the proposal based on your feedback. In the meantime, here are some brief answers to the questions you raised:


there's an implicit :scope at the beginning of selectors which don't contain an explicit :scope. Is that the case?

@hober That's right. I'll add some clarity around it.

I've also had some conversations with @tabatkins about using aspects of the nesting syntax here, like the & nesting-selector. I'll add a section about that approach to the proposal.


@LeaVerou a few of the quick clarifications/answers:

I wonder if it would be better expressed as a selector extension.

I've done some work on this, but struggled to find a selector approach that makes sense to me. I'll try and document my thoughts on that a bit more, but if you have ideas, I'm interested.

2\. It should be possible to use `@scope` both in CSS that is applied to light DOM, as well as CSS that is applied to Shadow DOM.

Yes, this is what I meant. I agree that scope should respect existing shadow-boundary rules.

It is unclear whether the parentheses are optional or mandatory.

Yes, this was a typo. I agree that parentheses need to be mandatory.

I definitely see use cases for selector lists as lower boundaries

I agree for lower boundaries. The question in my mind is if we also need (or should support) selector-lists for the scope root.

Simply allowing a part of the selector to match outside the scope could lead to unintentional effects, when the author has not anticipated this.

That makes sense, thanks. I've updated the explainer to require :scope when matching contextual selectors.

For normal declarations the inner scope's declarations override, but for ''!important'' rules outer scope's override.

Could you elaborate on this? I don't understand it as written.

That's a quote from the 2014 scope proposal, in which scopes act much like origins – with the order of priority determined by proximity (inner/outer), but that order is reversed for important styles. For example:

@scope (.outer) {
  p {
    font-style: normal;
    color: green !important;
}

@scope (.inner) {
  p {
    font-style: italic;
    color: red !important;
}
<div class="outer">
  <div class="inner">
    <p>
       This paragraph would be
       italic (inner-scope has priority for normal declarations), and
       green (outer-scope has priority for important declarations)
  </div>
</div>

Shadow-DOM has similar importance-affected cascade rules, but in the reverse order.

It may also be interesting to explore what a JS API for fetching "donut scope" elements could look like.

Good question, I'll look into this more - depending where we land on selector-syntax…


The biggest question is about where scope belongs in the cascade. I recognize that my approach is a major departure from previous W3C approaches (including both the 2014 @scope and shadow-DOM "encapsulation"), and allows global overrides to easily flow through components, and interact with scoped style. That's intentional, and a big part of what makes this stand apart from existing Shadow-DOM encapsulation.

The primary use-case that I'm trying to address is one in which component-styles are "locked-in" to avoid cross-contamination, but global styles are used to "tie it all together" with consistent patterns like typography and branding. The desired behavior is to prevent scoped styles from leaking out, without getting in the way of global patterns that should flow through easily. If we give scope proximity more weight than specificity, authors are left with very few tools to manage that relationship. By putting proximity below specificity, authors can manage it in several ways:

This in-but-not-out approach also matches the existing JS tools & CSS naming conventions that authors already use. Those tools add lower-boundaries, and a single attribute-selector of increased specificity – very easy to override from the global scope. I think this low-weight approach to scope is also backed up by…

Anecdotally, I hear many CSS beginners surprised that the fallback for specificity is source-order rather than proximity. This proposal would allow authors to opt-into that expected proximity-over-source-order fallback behavior.

Meanwhile, Shadow-DOM already provides the alternative approach for more isolated "widgets" – where "encapsulation context" is weighted higher than specificity, and prevents style leaks in both directions. I think that can be expanded to make shadow-DOM declarative, and encapsulation available in the light DOM. This proposal would continue to be distinct, and cover a significantly different set of use-cases.

lilles commented 3 years ago

Shadow DOM

For selectors in @scope rules in shadow trees, we should figure out which restrictions apply wrt matching elements outside the shadow tree. @scope rules in shadow trees should not be able to target elements outside the shadow tree, but what about :host/:host-context?

Pseudo Elements

IIUC, you can target the scope-root itself in @scope rules. Can tree-abiding pseudo elements be @scope roots?

Is this allowed?

  @scope (div::before) {
    & { content: "xxx" }
  }
LeaVerou commented 3 years ago

I've also had some conversations with @tabatkins about using aspects of the nesting syntax here, like the & nesting-selector. I'll add a section about that approach to the proposal.

If you use & to refer to the scope root, authors in nested rules would not be able to refer to the nesting root and the scope root separately. I think :scope was perfectly suitable here.

I've done some work on this, but struggled to find a selector approach that makes sense to me. I'll try and document my thoughts on that a bit more, but if you have ideas, I'm interested.

A few raw thoughts on this:

Essentially the scope targeted by @scope (.a) to (.c) are elements that match .a or .a * except those that are also .c or contained in it. E.g. in the subtree below:

1 <div class="a">
2   <div class="b">
3           <div class="c">
4               <div class="a">
5                   <div class="b">
6                           <div class="c">

It would be the elements in lines 1, 2, 4, 5. If we could not have nesting of these donuts, this could have been expressed as :is(.a, .a *):not(.c, .c *), but given the nesting above, that would fail (it would select 1, 2 but not 4, 5). This makes me wonder if a pseudo-class would be the right approach, e.g. :donut(.a / .c) (name ridiculous on purpose to be bikeshedded). Why not .a:to(.c)? I think simple selectors need to be independent, and it's their intersection that produces the match of a compound selector (hence why :nth-match() needs to take its selector as an argument and not just follow it).

At first I thought a combinator makes more sense, but since the rightmost operand needs to match the selector target, that wouldn't work for specifying the lower boundary.

Aside: What does @scope (S) to (S) match, where S is any selector? Does it match nothing?

mirisuzanne commented 3 years ago

@LeaVerou yeah, I've been working on this today, and came to a similar conclusion.

I put together a codepen example as I was working. It's slightly different from your example, in that I do match the lower-boundary itself, but not descendants of the lower boundary. That's the proposed pattern, used by scoping tools today.

You can also see some JS there. Given the ability to reference the root element with :scope in JS, @scope (.a) to (.c) { * { ... } } can be represented by a.querySelectorAll(":not(:scope .c *)").

The @scope proposal would allow multiple lower-boundaries. I imagine that could be represented by chaining the pseudo-class?

:donut(.a / .c):donut(.a / .x) {
  /* establish both .c and .x as lower boundaries */
}

That could also describe nested @scope rules, if we wanted to allow them.

Aside: What does @scope (S) to (S) match, where S is any selector? Does it match nothing?

In my current proposal, lower boundary selectors would only match descendants of the scope, so:

<s>
  <p>This is matched as part of the outer scope</p>
  <s> <!-- lower-boundary of outer scope, and root of inner scope -->
    <p>This is not part of the outer scope, but it does match as part of the inner scope</p>
  </s>
</s>

(I suppose that means it would match the same as @scope (S) without a lower boundary…)

LeaVerou commented 3 years ago

@LeaVerou yeah, I've been working on this today, and came to a similar conclusion.

  • :to(.c) doesn't work alone because it doesn't match anything
  • But :donut(.a / .c) would have some potential

I put together a codepen example as I was working. It's slightly different from your example, in that I do match the lower-boundary itself, but not descendants of the lower boundary. That's the proposed pattern, used by scoping tools today.

I don't quite understand how to read the pen, but one of the questions in it is whether we can desugar to :not(.lower-boundary *). That doesn't work without :scope, as I explained in my previous comment.

You can also see some JS there. Given the ability to reference the root element with :scope in JS, @scope (.a) to (.c) { * { ... } } can be represented by a.querySelectorAll(":not(:scope .c *)").

That can only be applied to a single element, so in the common case where you have multiple scope roots, you still have to iterate over them.

The @scope proposal would allow multiple lower-boundaries. I imagine that could be represented by chaining the pseudo-class?

:donut(.a / .c):donut(.a / .x) {
  /* establish both .c and .x as lower boundaries */
}

Not a huge fan of the repetition of the scope root. No reason to disallow selector lists as the lower boundary (or even both), there is precedent of pseudo-classes accepting selector lists.

Aside: What does @scope (S) to (S) match, where S is any selector? Does it match nothing?

In my current proposal, lower boundary selectors would only match descendants of the scope, so:

<s>
  <p>This is matched as part of the outer scope</p>
  <s> <!-- lower-boundary of outer scope, and root of inner scope -->
    <p>This is not part of the outer scope, but it does match as part of the inner scope</p>
  </s>
</s>

(I suppose that means it would match the same as @scope (S) without a lower boundary…)

I think you perhaps misunderstood my question. S was a variable that could be any selector, not a type selector. E.g. what does @scope (.foo) to (.foo) match? It seems to me that it would match .foo and not its descendants, but I'm not sure.

mirisuzanne commented 3 years ago

I don't quite understand how to read the pen, but…

Your clarifications/comments (on most of these) are the same conclusions I came to, so we're on the same page here, even if I'm not communicating it clearly. :+1:

Not a huge fan of the repetition of the scope root. No reason to disallow selector lists as the lower boundary (or even both), there is precedent of pseudo-classes accepting selector lists.

Makes sense. :+1:

S was a variable that could be any selector, not a type selector. E.g. what does @scope (.foo) to (.foo) match? It seems to me that it would match .foo and not its descendants, but I'm not sure.

I understood that, but figured I could demonstrate it with a type as well as with a class or anything else. I had intended lower-boundaries to be descendants of the scope, never the scope itself. So I would expect @scope (.foo) to (.foo) would match .foo and all descendants until hitting a lower boundary of another element with class .foo. I think this behavior is more useful because it would allow authors to develop naming patterns like:

@scope (.scope-media) to ([class|='scope']) {
  /* automatically end this scope when we encounter another */
}

If we wanted to allow single-element scopes, I would rather we use something like @scope (.foo) to (:scope).

kenchris commented 3 years ago

Hi there,

Thanks for considering our feedback and the great discussion so far.

We briefly discussed it in a call today and it would be great if you could ping us when you have something new to share! Thanks!

LeaVerou commented 3 years ago

Hi @mirisuzanne,

@cynthia and I looked at this during a breakout in our Gethen VF2F today. We were wondering if you had any updates for us? Have you further explored integrating the new selection logic that @scope introduces with existing selectors? If the CSS WG decides this is something that cannot be done that's ok, but we do want to make sure using the existing element querying mechanisms in CSS have been explored for targeting "donut" scope before we add an entirely separate element querying method.

mirisuzanne commented 3 years ago

@LeaVerou I have not been able to find a reasonable way to handle this with existing selectors. Establishing the lower boundaries can be done very roughly with :scope but:

There is currently no way to establish a scoping element in CSS. One proposal was to use the nesting syntax for that - but after some investigation, and conversations with @tabatkins, I don't think that's possible. The nesting syntax was explicitly designed to de-sugar into individual :is() selectors. This would require adding an entirely new meaning to nesting in CSS, that does not mesh simply or clearly with the current goals or approach of that feature.

However, I do think your proposal for a new donut selector is interesting. I included that in my (still exploratory) Editor's Draft. With something like that, the selection logic of an @scope rule could also be de-sugared into those new selectors:

/* these would select the same elements, without lower bounds */
@scope (.scope) { .target { … } }
.target:in(.scope) { … }
.scope.target, .scope .target { … }

/* these would select the same elements, with lower bounds */
@scope (.scope) to (.lower, .bounds) { .target { … } }
.target:in(.scope / .lower, .bounds) { … }
/* not possible with existing selectors */

Both :in() and @scope syntaxes still to-be-bikeshed, of course. This is just the current state of the proposal.

LeaVerou commented 3 years ago

Hi @mirisuzanne,

As TAG, we are happy to see that making the selection logic a selector is an avenue that has been sufficiently explored, even if it doesn't resolve in actual edits to the specification. We will leave the details of designing this feature to the CSS WG and we are going to go ahead and close this.

Thank you for flying TAG!