w3c / csswg-drafts

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

[css-selectors][css-namespaces] Lexical Scoping #4061

Open chriseppstein opened 5 years ago

chriseppstein commented 5 years ago

Relevant Specs:

A common complaint about CSS is that everything is global.

I know of at least a couple community proposals that attempt to work around this issue, but they seem to introduce javascript as an intermediary.

I understand that web components have their own notion of a scope local to the component. But this doesn't address the notion of global scope, it just reduces the document surface and the number of selectors in the global scope. There's still no mechanism to address a naming collision.

So why not allow stylesheets and web pages to opt into lexical scoping for name references?

Example of global css consumed into a namespace:

/* navbar.css */
#navbar {
  /* main container */
}
.element {
  /* stuff contained by the navbar */
}
<!-- unscoped.html -->
<link rel="stylesheet" href="navbar.css">
<aside>
   <ol id="navbar"> <!-- matches #navbar in the global namespace -->
      <li class="element">Home</li>  <!-- matches .element in the global namespace -->
  </ol>
</aside>
<!-- scoped.html -->
<link rel="stylesheet" href="navbar.css" ident-namespace="nav">
<aside>
  <!-- using the html namespace sigil here... is that a bad idea? -->
   <ol id="nav:navbar">  <!-- matches, doesn't match #navbar in the global namespace defined by other css files -->
      <li class="nav:element">Home</li>  <!-- matches, doesn't match other .element in the global namespace defined by other css files -->
  </ol>
</aside>

Example of lexically scoped, namespaced css

/* navbar.css */
@ident-namespace local;
#navbar {
  /* main container */
}
.element {
  /* stuff contained by the navbar */
}
<!-- unscoped.html -->
<link rel="stylesheet" href="navbar.css">
<aside>
   <ol id="navbar"> <!-- matches #navbar in the global namespace so the idents in navbar.css don't match here.  -->
      <li class="element">Home</li>  <!-- matches .element in the global namespace so the idents in navbar.css don't match here. -->
  </ol>
</aside>
<!-- scoped.html -->
<link rel="stylesheet" href="navbar.css" ident-namespace="nav">
<aside>
   <ol id="nav:navbar">  <!-- matches #navbar in the nav namespace -->
      <li class="nav:element">Home</li>  <!-- matches .element in the nav namespace -->
  </ol>
</aside>

Example of css involving multiple namespaces

/* buttons.css */
@ident-namespace local;
.button { }
/* links.css */
@ident-namespace local;
.link {}
/* call-to-action.css */
@import url(button.css) as buttons; <!-- should this namespace get exported? -->
@import url(link.css) as links; <!-- should this namespace get exported? -->
@ident-namespace local;
/* using the css namespace sigil here... is that a bad idea? */
.cta .buttons|button.links|link {
  /* styles for elements having both of these classes */
}
<!-- scoped.html -->
<link rel="stylesheet" href="buttons.css" ident-namespace="buttons">
<link rel="stylesheet" href="links.css" ident-namespace="links">
<link rel="stylesheet" href="call-to-action.css" ident-namespace="marketing">
<aside class="marketing:cta">
   <a href="signup.html" class="buttons:button links:link"> Sign Up! </a> <!-- matches the selector defined in call-to-action.css -->
</aside>

Example of multiple namespaces in a single file (concatenation)

/* all.css */
@ident-namespace buttons {
  .button { }
}

@ident-namespace links  {
  .link { }
}

@ident-namespace call-to-action {
  .cta .buttons|button.links|link {
    /* styles for elements having both of these classes */
  }
}
<!-- scoped.html -->
<link rel="stylesheet" href="all.css" ident-namespace="buttons:buttons links:links call-to-action:marketing">
<aside class="marketing:cta">
   <a href="signup.html" class="buttons:button links:link"> Sign Up! </a> <!-- matches the selector defined in call-to-action.css -->
</aside>

Open Questions:

AmeliaBR commented 5 years ago

To summarize the net effect of this proposal, as I understand it:

When you import a stylesheet into your project (via <link> or `@import), you declare a prefix that is implicitly appended to all class or ID selectors in that stylesheet, and maybe other stylesheet identifiers.

This way, you can safely use third-party CSS (or styled components created by different devs within the same large organization) without conflicts, because as the author of the final, composed document, you chooses the prefix and uses it for classes and IDs. You can also override or extend rules from the imported stylesheets by using the prefix in you own selectors.

Pros:

Cons:

As I mentioned on Twitter, if this scoping mechanism is going to apply to CSS identifiers, it should be integrated with a scoping mechanism for referencing identifiers from shadow trees, as discussed in #1995.

chriseppstein commented 5 years ago

@AmeliaBR Thanks for writing this up. I think it's a good summary and you've called out some important considerations and back-compat issues.

It could be very confusing to have two ways of declaring namespace prefixes which affect different selector parts (this proposal for classes & IDs, and XML namespace prefixes for tags and attributes).

This is a good point and having two types of namespaces bothers me too.

This proposal so far has taken the perspective that there can be classnames and ids that are defined by the stylesheet and referenced from html. Given that stylesheets tend to be shared across multiple documents, I think this is not an uncommon mental model for developers to use, but it is contrary to the current model that css is just selecting values from html documents.

It occured to me that we could take a slightly different angle. What if a document could easily define an attribute namespace based on a stylesheet's url. In this way we keep the idea that the namespace belongs to the document's attributes. The new semantic then introduced would be that a stylesheet can have its class and id selectors placed either explicitly (from the css file) or implicitly (via import/link) into the namespace of the stylesheet.

Updating my first example, everything remains the same, but the markup for a scoped stylesheet link would become:

<!-- scoped.html -->
<link rel="stylesheet" href="navbar.css" namespace="nav">
<aside>
   <ol nav:id="navbar">  <!-- matches, doesn't match #navbar in the global namespace defined by other css files -->
      <li nav:class="element">Home</li>  <!-- matches, doesn't match other .element in the global namespace defined by other css files -->
  </ol>
</aside>

Updating the second example, we can now use @namespace but a new value of local() would have the behavior of changing the meaning of the id and class selectors to be selecting the id and class attributes from the namespace of the stylesheet, it can also provide a scope for the identifiers used in css-only constructs like @keyframes.

/* navbar.css */
@namespace local();
#navbar {
  /* main container */
}
.element {
  /* stuff contained by the navbar */
}
<!-- unscoped.html -->
<link rel="stylesheet" href="navbar.css">
<aside>
   <ol id="navbar"> <!-- matches #navbar in the global namespace so the idents in navbar.css don't match here.  -->
      <li class="element">Home</li>  <!-- matches .element in the global namespace so the idents in navbar.css don't match here. -->
  </ol>
</aside>
<!-- scoped.html -->
<link rel="stylesheet" href="navbar.css" namespace="nav">
<aside>
   <ol nav:id="navbar">  <!-- matches #navbar in the nav namespace -->
      <li nav:class="element">Home</li>  <!-- matches .element in the nav namespace -->
  </ol>
</aside>

Updating the multi-namespace example we can now use @namespace as a ruleset parent (previously illegal). These would define a namespace against the url of current stylesheet with a hash identifier added on. That is, @namespace local(foo) is the same as @namespace foo url(#foo) but with the extra semantics of lexical identifier scoping.

/* all.css */
@namespace local(buttons) {
  .button { }
}

@namespace local(links)  {
  .link { }
}

@namespace local(call-to-action) {
  .cta .buttons|button.links|link {
    /* styles for elements having both of these classes */
  }
}
<!-- scoped.html -->
<link rel="stylesheet" href="all.css" namespace="#buttons:buttons #links:links #call-to-action:marketing">
<aside marketing:class="cta">
   <a href="signup.html" buttons:class="button" links:class="link"> Sign Up! </a> <!-- matches the selector defined in call-to-action.css -->
</aside>

I feel like this provides all the same benefits while at the same time relying on existing browser primitives better.

cherscarlett commented 5 years ago

Do you think it would be possible to extend this scoping to all selectors within a namespace?

chriseppstein commented 5 years ago

@cherscarlett Which selectors do you have in mind?

matthew-dean commented 5 years ago

I feel like this is extremely clunky:

<link rel="stylesheet" href="all.css" namespace="#buttons:buttons #links:links #call-to-action:marketing">
<aside marketing:class="cta">
   <a href="signup.html" buttons:class="button" links:class="link"> Sign Up! </a> <!-- matches the selector defined in call-to-action.css -->
</aside>

I mean, not only does the namespace prop get hairy, but the xml-style namespacing is extremely limited, and this form doesn't allow multiple inheritance (or exclusions). It also clashes with xml namespacing, does it not? Regardless, I think something like Rich Harris's suggestion is much more palatable.

e.g.

<style>
@scope foo {
  p {
    font-family: 'Comic Sans MS';
  }
}
</style>
<div css="foo">
  <p>Paragraph with scoped styles</p>
</div>

The reason why this is hugely powerful is because you could extend it in a media-query-like fashion, which namespacing doesn't provide any flexibility for. I might also change the HTML attribute. As in:

<style>
@scope foo {
  p {
    font-family: 'Comic Sans MS';
  }
}
@scope bar {
  p {
    color: red;
  }
}
</style>
<div scope="only (foo, bar)">
  <!-- or this would just be the default if scope is defined -->
  <p>Paragraph with foo and bar styles only</p>
</div>
<div scope="all and (foo, bar)">
  <!-- Allows opt in to global styles plus specific scope styles -->
  <p>Paragraph including global p and foo and bar styles</p>
</div>

Just like media queries being either an at rule or a HTML prop, you should also be able to wrap a stylesheet in a scope to make it not apply globally. As in:

<link rel="stylesheet" href="all.css" scope="foo">

Scoped styles could be inheritable in Web Components (since they are opt in by default, and do not apply globally), which would make it orders of magnitude easier to implement / manage than the proposed adoptedStyleSheets JS interface (not that that shouldn't happen, it's just an unruly interface for something that can be relatively simple declaratively).

FremyCompany commented 5 years ago

I like @matthew-dean's proposal and I would like to show my support for this thread in general

myakura commented 5 years ago

Just like media queries being either an at rule or a HTML prop, you should also be able to wrap a stylesheet in a scope to make it not apply globally. As in:

<link rel="stylesheet" href="all.css" scope="foo">

this will load all.css in all current and legacy browsers. if we want certain css to load conditionally we should reuse either rel, media or type. the latter two don't make much sense in case of scoped css, so we might need a new linktype for scoped css...

matthew-dean commented 5 years ago

this will load all.css in all current and legacy browsers.

That's a good point.

You could always prevent a media match with:

<link rel="stylesheet" href="all.css" media="scope" scope="foo">

AFAIK, any non-matching media-query is supposed to be converted to not all according to the spec

I think by the same token, you could extend those queries in scope-supporting engines like:

<link rel="stylesheet" href="all.css" media="(min-width: 600px) and scope" scope="foo">

... which would be the equivalent of:

@scope foo {
  @media (min-width: 600px) {
    // all of all.css
  }
}

(Note that the word scope in the <link> media query has no special meaning; it's simply an unrecognizable query to a legacy browser which would make it ignore application of the stylesheet.)

AmeliaBR commented 5 years ago

Note that the word scope in the media query has no special meaning;

I'm not sure that's the best way to describe it. Better to define it as an official media query that evaluates as a Boolean representing whether the user agent supports scoped syntax. Which is a bit of a stretch of how media queries generally work (a user agent that recognized the query would presumably never evaluate to false), but is reasonable considering the benefits.

valtlai commented 5 years ago

Could we use type="scoped" instead? (Similar to <script type="module">.)

matthew-dean commented 5 years ago

I think the local keyword mentioned earlier is fairly useful, and I would extend that in this syntax to be the essentially the boundary root i.e. normally the document or the shadow root if within there.

In other words, every HTML component, by default, could have a scope="local" value. But in a shadow root, I could then do this:

<div scope="local, document(foo)"></div>

Meaning, the local (unscoped) styles apply, as does the global scoped foo (since this is an "or" join, so either query can match). A vendor applying styles would just need to know which scoped stylesheets / stylesheet blocks to apply for the cascade.

There may be a little syntactic massaging needed there, but a query-style syntax seems less verbose and yet more powerful than the namespaced-style syntax.

matthew-dean commented 5 years ago

@valtlai

Could we use type="scoped" instead? (Similar to Githubissues.

  • Githubissues is a development platform for aggregating issues.