Visitar URL original
[css-scoping] Inclusive vs exclusive lower boundary · Issue #6577 · w3c/csswg-drafts · GitHub
Skip to content

[css-scoping] Inclusive vs exclusive lower boundary #6577

Closed

Description

@Rich-Harris

https://drafts.csswg.org/css-scoping-2/ describes the possible use of CSS Scoping by component frameworks, with the following example:

@scope ([data-scope='main-component']) to ([data-scope]) {...}

Since the lower boundary is inclusive, this matches the behaviour of the scoped styles in Vue Single-File Components: CSS declared in an SFC's <style> element apply to all elements of the current component, plus the root element of child components.

But not all userland scoping systems work this way. Svelte has stricter encapsulation: by design, styles declared in one component can't affect a child component at all unless you opt-in to that behaviour with the :global(...) modifier (demo):

<div>
  <p>red on yellow</p>
  <Widget/>
</div>

<style>
  /* this will affect any <p> elements inside <Widget/> or its children
     in addition to this component's elements */
  div :global(p) {
    text-decoration: underline;
  }

  /* these styles only affect the <div> and the <p> above, regardless of
     whether the selectors match top-level elements in <Widget/> */
  div {
    background-color: yellow;
  }

  p {
    color: red;
  }
</style>

Svelte compiles this to the following CSS:

div.svelte-185puzw p {
  text-decoration: underline;
}

div.svelte-185puzw {
  background-color: yellow;
}

p.svelte-185puzw {
  color: red;
}

The above suggested approach would look like this...

@scope ([data-scope='main-component']) to ([data-scope]) {
  div p {
    text-decoration: underline;
  }

  div {
    background-color: yellow;
  }

  p {
    color: red;
  }
}

...which would incorrectly apply color: red to <p data-scope="sub-component">.

We on the Svelte team would love to be able to use CSS Scoping one day, but we think it's important that a component's styles don't leak into its children unless the author explicitly opts in. If the lower boundary is inclusive, then as far as I can see we would have to do something like this...

@scope ([data-scope='main-component']) to (:not([data-scope='main-component'])) {...}

...and also apply the data-scope="main-component" attribute to every element. It's not clear that this would be an improvement on the current situation.

Is there a way we might only apply styles until the lower boundary is reached? For example:

@scope ([data-scope='main-component']) until ([data-scope]) {...}
@scope ([data-scope='main-component']) to ([data-scope]) exclusive {...}

More controversially, perhaps the lower boundary should always be exclusive? It's worth noting that you can express the current semantics with an exclusive lower boundary...

@scope ([data-scope='main-component']) to ([data-scope] > *) {...}

...but the reverse isn't true, which to my mind is a strong argument in favour — it gives all authors more expressive power.

edited the final code snippet to remove :not(:scope) — on a closer reading of the explainer, the lower boundary selector only matches descendants of the upper boundary

Activity

  1. self-assigned this
    on Sep 3, 2021
  2. DarkWiiPlayer commented on Nov 8, 2021

    @DarkWiiPlayer

    It took me a while to even get what the point of this issue is because I just assumed that to <selector> would mean <selector> is not included in the scope anymore. I definitely think the lower boundary should be exclusive.

  3. mirisuzanne commented on Nov 12, 2021

    @mirisuzanne
    Contributor

    It doesn't seem to me that there is a universally right choice for this. In most of my CSS use-cases, I would want to define boundaries that are part of the scope (inclusive - also used by Vue), but it's also clear the exclusive approach makes more sense with some component-nesting cases. I hope we don't end up supporting one over the other. We need the choice available for authors to express both

    I like the fact that exclusive boundaries leave that choice open to an extent (via boundary > * selectors), but I think we might want some more explicit way of expressing that choice.

  4. DarkWiiPlayer commented on Nov 16, 2021

    @DarkWiiPlayer

    For every application I have for CSS scopes, it would make more sense to have an exclusive lower boundary. Usually one knows the first element down the tree that should be outside the scope, like a nested component.

    But I also think that both options should be available. While boundary > * would semantically be equivalent to an inclusive boundary, giving it its own keyword would both make the code look nicer and signal to the browsers that both options should be reasonably optimised (I can imagine the direct child selector being slower when not specifically optimised)

    However, the obvious question is whether mixing the two should be allowed; that is, having a scope end exclusively at one selector or inclusively at another.

    Say we wanted to exclude anything with an ID from the scope, and any child element from a nested component, but while keeping the component inside the scope; would this be valid: @scope (my-component) to (container-component) until ([id])?

    Of course, there'd still be the fallback of doing @scope (my-component) until ([id], container-component>*) in that case, but that has the same problems as not having an implicit boundary in general.

  5. mirisuzanne commented on Nov 16, 2021

    @mirisuzanne
    Contributor

    Another way to approach this that would allow for both-at-once is to say 'lower boundaries are exclusive by default unless explicitly marked with the :boundary pseudo-class' (actual name tbd). Then:

    @scope (.component) to (.lower) {
      div { /* does not match div.lower */ }
      div:boundary { /* does match div.lower */ }
    }
  6. DarkWiiPlayer commented on Nov 17, 2021

    @DarkWiiPlayer
    @scope (.component) to (.lower) {
      div { /* does not match div.lower */ }
      div:boundary { /* does match div.lower */ }
    }

    Would that div:boundary only select the div.lower then? If so, wouldn't that mean one would have to duplicate every selector, like outer-element > inner-element.some-class + p, outer-element > inner-element.some-class + p:boundary?

    But this also leads to another question: Should boundary inclusion be defined per boundary, or per selector?

  7. Rich-Harris commented on Nov 17, 2021

    @Rich-Harris
    Author

    Personally I would argue that the pseudo-class approach is a bit counterintuitive, since normally a pseudo-class narrows a selector rather than expanding it:

    div { /* matches all divs */ }
    div:last-child { /* narrows selection to divs that are the last child */ }

    I would find it much more natural if it was part of the scope itself — either with something like an exclusive keyword...

    @scope ([data-scope='main-component']) to ([data-scope]) exclusive {...}

    ...or, if exclusive were the default (which I would definitely advocate for, partly given my own use cases but mostly because inclusive semantics can be expressed with exclusive semantics but not vice versa):

    /* this... */
    @scope ([data-scope='main-component']) to ([data-scope]) inclusive {...}
    
    /* ...is sugar for this */
    @scope ([data-scope='main-component']) to ([data-scope] > *) {...}
  8. DarkWiiPlayer commented on Nov 17, 2021

    @DarkWiiPlayer

    I like the idea of having an inclusive keyword, but it seems very easy to miss at the end; maybe it would be better like this:

    @scope ([data-scope='main-component']) to inclusive ([data-scope]) {...}

    This is a bit easier to figure out than to vs. until which seem a bit harder to remember which is which. I also think making exclusive the default makes a lot of sense.

  9. mirisuzanne commented on Nov 18, 2021

    @mirisuzanne
    Contributor

    I agree that keywords are likely the clearest path forward here.

  10. DarkWiiPlayer commented on Jan 25, 2022

    @DarkWiiPlayer

    So the options we currently have are:

    1. @scope (.a) to (.b) vs. @scope (.a) until (.b)
    2. @scope (.a) to (.b) inclusive vs. @scope (.a) to (.b) exclusive
    3. @scope (.a) to inclusive (.b) vs. @scope (.a) to exclusive (.b)

    Leaving aside the question of which should be the default in 2. and 3., here's my thoughts on each of them:

    1. For myself I'd choose this one, as it's shortest and saves unnecessary typing, but "to" and "until" are often used interchangeably in casual language so this may also be the most confusing.
    2. This one is definitely easier to remember as the words have very clear definitions, but I can't find any specific advantage to putting these keywords at the end.
    3. I slightly lean towards this one because it makes the keyword a bit more visible when reading the rule.

    In the case of 2. and 3., the question of which should be the default when neither keyword is used could be decided at a later moment. That way the question could already be narrowed down a lot.

  11. mirisuzanne commented on Jan 25, 2022

    @mirisuzanne
    Contributor

    I also think option 3 makes the most sense. Agenda+ to see if we can resolve on that (or one of the others).

  12. 13 remaining items

  13. mirisuzanne commented on Sep 19, 2022

    @mirisuzanne
    Contributor

    For now:

    • lower boundaries are excluded from a scope
    • authors can use .lower-boundary > * to generate 'inclusive' lower boundaries
    • additional syntax can be added if there is developer need

    Closing this issue. We can open a new issue for the keywords (or reopen this) if we decide there is enough need.

  14. DarkWiiPlayer commented on Sep 21, 2022

    @DarkWiiPlayer

    Just throwing in another random thought:

    Should there be some explicit way of styling the lower boundary?

    Something along the lines of

    @scope ([data-scope="main-component"]) to ([data-scope]) {
       /* Generally, we do not want to style the lower boundary; probably the most common case */
       * { color: red; }
       /* occasionally we might want to explicitly include the lower boundary too */
       *+*, *+:lower-boundary { margin-top: 1em; }
    }
    

    Is there a better way around it with what we currently have? Is this worth opening its own issue for?

  15. mirisuzanne commented on Sep 21, 2022

    @mirisuzanne
    Contributor

    I think for now the solution is defining multiple scopes:

    /* Generally, we do not want to style the lower boundary; probably the most common case */
    @scope ([data-scope="main-component"]) to ([data-scope]) {
       * { color: red; }
       /* occasionally we might want to explicitly include the lower boundary too */
       *+*, *+:lower-boundary { margin-top: 1em; }
    }
    
    /* occasionally we might want to explicitly include the lower boundary too */
    @scope ([data-scope="main-component"]) to ([data-scope] > *) {
       *+* { margin-top: 1em; }
    }

    Similar to keywords for exclusive/inclusive, we could consider extra syntax sugar for that case once we have a better sense how the feature is being used.

  16. added 4 commits that reference this issue on Feb 21, 2023
  17. added a commit that references this issue on Feb 22, 2023
  18. added 2 commits that reference this issue on Feb 22, 2023
  19. added a commit that references this issue on Mar 7, 2023
  20. added a commit that references this issue on Mar 28, 2023
  21. added a commit that references this issue on May 18, 2023
  22. added a commit that references this issue on Sep 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions