Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[css-selectors] Reference selectors #3714

Open
justinfagnani opened this Issue Mar 7, 2019 · 25 comments

Comments

Projects
None yet
7 participants
@justinfagnani
Copy link

justinfagnani commented Mar 7, 2019

This proposes a new type of selector*, tentatively called a "reference", that serves as a way to uniquely identify a declarations block.

References are intended to enable a number of features and use cases addressed by CSS-in-JS type libraries, in a way that's incremental to existing CSS patterns and coherent with the CSSOM, Constructible Stylesheets, and the CSS Modules proposal.

This is largely based on ideas from @threepointone, the author of Glamour.

Disclaimers:

The description of this idea may seem fairly concrete, but it's largely speculative. The important parts are the concept of references and the use cases they enable. There may be ways to achieve the similar results with new constructs that use classes, for instance.

*"selector" may be a bad categorization, as references do not actually select any elements, but reference a declarations block instead. They appear in the selector list of a ruleset though, and may be used to associate an element with one or more declaration blocks.

Quick Example

styles.css

$foo {color: red;}

app.js

import {foo} from './styles.css';
document.querySelector('#app').cssReferences=[foo];

The app's element is now styled with red text.

CSS Syntax

A reference appears in a selector list, and is denoted by a sigil prefix, tentatively $:

$foo {
  color: red;
}

References may not be combined with other selectors (but they may need to support pseudo-classes, ala $foo:hover), but they can appear in a selector list:

$foo, .warning {
  color: red;
}

The important distinctions from other selectors are that:

  • References do not match elements
  • References are exposed on Stylesheet objects

Another important feature is that references are lexically scoped to a CSS file. $foo in a.css is a different reference from $foo in b.css.

Getting References

Once a reference is defined in a CSS file is must be imported into another context to be usable. The web has three main types of contexts: HTML, CSS, and JavaScript;

JavaScript

CSS references are exposed on CSSStyleSheet, which allows us to get them from styles defined in <style> tags, loaded with <link>, etc. References are instances of CSSReference. (TBD: are they opaque, or do they contain their declarations? Can they be dereferenced via the StyleSheet?)

// define a stylesheet
let stylesheet = new CSSStyleSheet();
stylesheet.replaceSync(`$foo {color: red; }`);

// get the reference
let fooRef = stylesheet.references.foo;

References are also exported from CSS Modules:

import styles, {foo} from './styles.css';

styles instanceof CSSStyleSheet; // true
foo instanceof CSSReference; // true
styles.references.foo === foo; // true

CSS

References are in scope for the file they're defined in.

They can also be imported with @import:

@import {foo} from './styles.css';

Here we're borrowing JS-like syntax and putting named imports before the URL, since media queries come after the URL. I'm not sure if this syntax works.

HTML

A key use-case for references is to enable tools to statically analyze and optimize CSS. For this reason they are intended to be relatively opaque, easily renamable, and not accidentally meta-programmed over. This may be in tension with a string representation usable in HTML. But with HTML Modules being proposed, and that bringing some kind of scoping to HTML, and HTML into the module graph, it may be useful:

<style>
$foo {color: red;}
</style>
<div css-refs="$foo"></div>

An open question is: is this any different from classes, especially if we generate unique classes with a library? The answer may be no, and that in general this isn't a good idea, but that we need some HTML representation for server-side rendering. TBD.

Using References

Styling Elements in JS

Since references do not match elements, declarations identified by references must be associated with elements directly. via a new cssReferences property on Element:

import {foo} from './styles.css';

const div = document.createElement('div');
div.cssReferences = [foo];

Cascade

Q: Would references introduce a new cascade order?

Composing Declaration Blocks in CSS

This may or may not be a good idea, but is an example use case.

CSS developers often want to compose styles. SASS, etc., allow this, as do many CSS-in-JS libraries and the now defunct @apply proposal.

One problem with @apply was its dynamically scoped nature. While useful in some cases for subtree-scoped theming, most programmers are use to lexical scoping and simply wanted to import a "mixin" and apply it directly, and passing the custom variables down the tree had too much overhead.

References allow us to have a more lexically-scoped version of @apply, let's call it @include as a nod to SASS:

import {foo} from './styles.css';

.warning {
  @include $foo;
}

This functions more like SASS @include, but not quite because $foo isn't a mixin with parameters. This idea may also suffer from the problems highlighted in @tabatkins @apply post (especially, are var()s in references resolved early or late?). This whole area deserves its own proposal(s).

Rationale

When looking userland CSS frameworks, especially CSS-in-JS libraries, to see what features they add that aren't present natively, I think we see a few major common themes:

  1. Participation in the module graph
  2. Exporting classes from stylesheets
  3. Scope emulation & simplifying the cascade
  4. Static analysis

(1) is addressed by the CSS Modules proposal.

(2) is important for a few reasons, like ensuring class names are correct, enabling refactoring of class names, dead CSS elimination, etc. It's addressed by references being exported in CSS Modules, and available via the CSSOM.

(3) generally works by modifying or generating class names in CSS to be unique, and sometimes discouraging the use of selectors. With purely generated class names and no other selectors or combinators, you kind of get scoping because styles are directly applied as if using style attributes, and nothing matches across component boundaries.

Shadow DOM generally solves the need for userland scoping, but many developers will prefer the mental model of directly applying styles to an element via a reference, than considering the cascade. References allow these mental models to be intermixed, even within the same stylesheets and DOM trees.

(4) By effectively adding named exports and imports, and exposing references on a CSSStyleSheet.references object, and not via a map-like interface, references should make it easier for tools to associate CSS references across file and language boundaries. Developers could use long descriptive reference names, and have tools minify them, enable jump-to-definition, etc. like they do with pure JavaScript references.

Lexical vs Dynamic Scoping

JavaScript programmers are use to lexical scoping, but HTML + CSS implement a kind of dynamic scoping: CSS properties and variables inherit down a tree and their value depends on the location of the matched element in the tree, similar to dynamic scoping depending on the call stack. This dynamic scoping is extremely powerful and necessary for many styling techniques (it's even useful in JS, see React's context feature), but sometimes a developer just wants to say "put these styles on this element" and not worry about tree structures or conflicts from other selectors.

@developit

This comment has been minimized.

Copy link

developit commented Mar 7, 2019

This is super interesting! As I read I might just be missing it - what's the rationale for having an opaque type for CSSReference rather than a String / id?

@justinfagnani

This comment has been minimized.

Copy link
Author

justinfagnani commented Mar 7, 2019

@developit mainly that references are supposed to be real references to CSS objects. I suppose they could be values of type CSSStyleDeclaration.

@EisenbergEffect

This comment has been minimized.

Copy link

EisenbergEffect commented Mar 7, 2019

Is there any real use for this outside of React-based CSS in JS frameworks?

@giuseppeg

This comment has been minimized.

Copy link

giuseppeg commented Mar 7, 2019

@EisenbergEffect AFAIK CSS Modules are language agnostic i.e. you can use them with other languages (eg. PHP).

@justinfagnani I ❤️ this. FWIW a while ago I had a similar idea but thought of another syntax https://twitter.com/giuseppegurgone/status/1089633480458358785 just posting it here for the record.

@EisenbergEffect

This comment has been minimized.

Copy link

EisenbergEffect commented Mar 8, 2019

But with CSS Modules, does not the module export an object with properties for each class? Why not define that as the standard behavior? Why add an additional construct? It seems that the main feature is the lexical scoping of the references, but why not define that that's the way classes work when imported through the module system?

@EisenbergEffect

This comment has been minimized.

Copy link

EisenbergEffect commented Mar 8, 2019

To clarify, I'm connecting this to the CSS Modules community spec that people use today, not the W3C spec linked above. But my question is...why not align them? Why don't we make the w3c CSS modules work like the CSS Modules that people use through transpilers?

@justinfagnani

This comment has been minimized.

Copy link
Author

justinfagnani commented Mar 8, 2019

@EisenbergEffect The CSS Modules proposal is the most minimal and obvious semantics, immediately usable with adoptedStylesheets. It's intended to be useful on its own now that we have Constructible StyleSheets, and the starting point for other features that are common in various userland CSS modules-like tools. The exact semantics of the css-modules libraries, like exporting only an object with properties associated with class names, aren't sufficient when we need to get an actual StyleSheet object from a module. That's an important difference, but the major intent of the idea - importing a .css file from a JS module - remains. Discussing that is probably best on the CSS Modules issue.

Regarding references vs classes, the situation is similar. The usage of class names in the css-modules library is sometimes very much like references. See the discussion of composition in the README. There class names have restrictions (you can only compose a class name if it was used as a simple selector with only that class name) that make them function very closely to references as proposed here.

It's important to note that the css-modules library is only one of many similar CSS tools. Some are meant to be used with inline styles. Libraries like Glamour abstract over classes entirely. styled-components goes even further and abstracts away the component/style separation. No proposal is going to look exactly like all of the existing userland solutions, and might have different mechanisms, but we can find and solve for the common use cases.

For this proposal I see the use cases having the common thread of directly referencing a declaration, whether that's for SASS-like includes (or css-modules-like composes), or directly styling an element. I think modeling this as explicit references, rather than trying to repurpose classes to that effect, has major benefits in terms of understandability, and likely performance: if you only use a class as a unique identifier for a declaration, and the developer can directly associate elements and/or other declarations, then don't bother trying to match anything else against it.

@EisenbergEffect

This comment has been minimized.

Copy link

EisenbergEffect commented Mar 8, 2019

That all makes sense. Thanks for expounding a bit.

I think this particular notion of references might be more useful when trying to accomplish scoped styles without shadow dom. It seems a bit less useful with shadow dom. With that in mind, it would be cool to see a PoC of css modules (the library) built on top of CSS Modules (the spec) using the reference idea proposed here. If that worked out nicely and this worked for things like Glamour too, then I could get behind it.

To be honest, I was always frustrated that shadow dom seemed to mix so many concerns together: slotted composition, scoped styles, dom encapsulation, event retargeting. If this provides a way to extract out the ability to scope styles without using shadow dom, that's a win I think.

@giuseppeg

This comment has been minimized.

Copy link

giuseppeg commented Mar 8, 2019

@EisenbergEffect I think that one thing that is quite different from classical ShadowDOM encapsulation is that references don't obey to cascade or specificity, you can mix them together and get a final predictable result.

If you think of a reference as a JavaScript Object and if you are familiar with JavaScript's Object.assign I imagine that, when applied, a bunch of @include $ref would be similar to Object.assign(ref1, ref2, ref3).

If you want to see a PoC, I built an experimental library last year. You can read about it in this blog post.

@bkardell

This comment has been minimized.

Copy link

bkardell commented Mar 8, 2019

Really, being able to discuss some of this seems to, in my mind at least, hinge on what it would actually mean to associate an element with a reference and why/how you might do that in practice, but almost all of these have inline questions in @justinfagnani's original post.

Would it be productive to talk details on some of those things? I feel like it is sort of impossible to even ask good questions with only vague ideas on what seem like kind of ultimately maybe the most critical points. It's also possible I am missing something important here and over-stating, but... for example..

If I had

/* styles.css */
$foo { color: yellow; }
$foo { background-color: blue; }
@media only screen and (max-width: 480px) {
  $foo {
    background-color: black;
  }
}

and

/* app.js */
import {foo} from './styles.css';
document.querySelector('#app').cssReferences=[foo];

and

<style>main { background-color: purple; }</style>
<script type="module" src="app.js"></script>
<main id="app">
   Hello
</main>

Can someone explain what color do we expect the foreground and background to be, and why? If there were agreement on that that I could wrap my head around, I feel like this would be a little easier to discuss.

Also, at the end of this

  • It seems like you need to be able to indicate in HTML itself that this thing applies over here. Is that not correct?
    • It seems like the value of that would have to be DOMTokenList?
    • Then we create another way to assign 'references' that interact with this via a diff API somehow? Do they reflect or ...?
    • At the end of all of this, it seems CSS is/can still act on those and the cascade and all kind of still plays into it? It seems you could totally write [css-refs="$foo"] in your stylesheet or qSA?

Is it possible that classes + some things in other proposals could be 'enough'? Like maybe stuff in https://tabatkins.github.io/specs/css-aliases/ provides interesting ideas? If you can alias classes with pseudo-names, we could maybe both export those and have consistent specificity, and use that to also chase the 'how to match' end of this as well, all in one?

@giuseppeg

This comment has been minimized.

Copy link

giuseppeg commented Mar 8, 2019

@bkardell the moment you introduce classes (selectors) and the cascade you can't resolve references in application order anymore. I think this proposal is about providing that feature instead. Somewhat similar to this and what I explained in this tweet.

@bkardell

This comment has been minimized.

Copy link

bkardell commented Mar 10, 2019

@giuseppeg As I said tho, it seems to me that selectors and the cascade inevitably are still there and that means we'd have to figure out where all this fits. To me, it feels very hard to discuss without sorting some of the things I asked above - these and more are kind of listed as '..?" unknowns in the opening post.

(note: you said tweet, but linked to codesandbox - can you update that for posterity/clarity here?)

@giuseppeg

This comment has been minimized.

Copy link

giuseppeg commented Mar 10, 2019

@bkardell oh yes sorry, I fixed the links. I meant to comment on using css aliases (selectors) to define references. I am not sure how one would enforce "consistent specificity" and how we would distinguish between regular class aliases and references. Unless you meant a new extension eg. @reference :--foo or a custom at-rule. Either ways it seems that JS would be a requirement for those and they aren't SSR-friendly. One of the reason why people still prefers CSS in JS over Shadow DOM is that the former can be pre-rendered on the server.

To me, it feels very hard to discuss without sorting some of the things I asked above - these and more are kind of listed as '..?" unknowns in the opening post.

I agree and would like to know the answers to those questions too :)

@matthewp

This comment has been minimized.

Copy link

matthewp commented Mar 18, 2019

References may not be combined with other selectors

Why not? I would think $foo > .thing would be a useful thing to do. It seems the only alternative is to create another reference. Does $foo > $bar work? It sounds like it would not. So how do you mimic the child combinator (or sibling combinator or many other such useful CSS selector types)? I can only imagine that you need to imperatively add the $bar reference in JavaScript (only on immediate children of $foo).

I don't think CSS should adopt features that render other useful CSS features to be disabled. If selectors are a problem then we should improve selectors while still keeping them around. I agree with some of the others that import might be a better way of achieving a similar thing. It would allow you to use another stylesheet and mix it in without having its selectors applied globally.

@justinfagnani

This comment has been minimized.

Copy link
Author

justinfagnani commented Mar 18, 2019

Why not? I would think $foo > .thing would be a useful thing to do.

Think of references as like inline styles, but parsed once even if the same rules are used on multiple elements. Inline styles can't style descendants.

@matthewp

This comment has been minimized.

Copy link

matthewp commented Mar 18, 2019

I understand how it works but not the why. What is the purpose of this restriction?

@bkardell

This comment has been minimized.

Copy link

bkardell commented Mar 18, 2019

Think of references as like inline styles, but parsed once even if the same rules are used on multiple elements. Inline styles can't style descendants.

Hmm, that's an interesting point of view and seems like a good analogy. If we had done mixins would they have just been valid here automatically and we'd be done?

@giuseppeg

This comment has been minimized.

Copy link

giuseppeg commented Mar 19, 2019

What is the purpose of this restriction?

@matthewp the purpose is deterministic styles resolution based on application order - not cascade or specificity*

* it is possible to achieve deterministic styles resolution when specificity is of a particular kind. I implemented a solution that resembles references (a CSS Modules/Blocks hybrid) as a PoF, it is called DSS and you can read about it here. From my experience here are the CSS features that can work in such a system.

If we had done mixins would they have just been valid here automatically and we'd be done?

Indeed references are mixins that can be also consumed in JS

@matthewp

This comment has been minimized.

Copy link

matthewp commented Mar 19, 2019

@giuseppeg I think some amount of specificity is unavoidable. This is specificity:

$foo { color: blue; }
$foo { color: red; }

And what of @bkardell's example with a media query? Are references not allowed to be used inside media query blocks?

It seems like you can use references in a way that avoid specificity just by not using complex selectors, not using media queries, etc. But this is an orthogonal problem, some sort of reference idea has value in normal CSS where you do use selectors.

@threepointone

This comment has been minimized.

Copy link

threepointone commented Mar 19, 2019

 $foo { color: blue; }
 $foo { color: red; }

this would throw an error. you can't define multiple references with the same name in a module.

@bkardell's example would be rewritten as

$foo { 
  color: yellow; 
  @media only screen and (max-width: 480px) {
    & {
      background-color: black;
    }
  }
}
@threepointone

This comment has been minimized.

Copy link

threepointone commented Mar 19, 2019

sorry I haven't piped in here yet, been swamped. I'll try to get some time next week and try to answer some questions + thoughts. cheers all.

@matthewp

This comment has been minimized.

Copy link

matthewp commented Mar 19, 2019

@threepointone

this would throw an error. you can't define multiple references with the same name in a module.

This is very un-CSS like, I'm not sure I've ever seen CSS throw, even with bad syntax mistakes. This would mean rules defined after this error would not be applied, which again is not CSS like (usually it continues to apply rules further down the stylesheet when encountering a syntax error).

I still don't have a clear answer as to why this specificity requirement is tied to the reference idea. Am I wrong that you cannot achieve what you want simply by not using complex selectors?

@giuseppeg

This comment has been minimized.

Copy link

giuseppeg commented Mar 19, 2019

@matthewp, @threepointone I am not sure about that. There is definitely smarter people than me in this thread to make the final call but I'd probably expect that to be doable but scoped to the current module only - after all that's possible in JS

function foo() {
  return `color: red`
}

function foo() {
  return `color: green`
}

console.log(`
.foo {
  ${foo()}
}
`)

or with Sass mixins

@mixin foo() {
  color: red;
}

@mixin foo() {
  color: green;
}

.foo {
  @include foo()
}

The only difference from classic cascade is that the last one overrides the previous completely.

edit it would be nice to avoid this and throw an error as @threepointone suggested though. Removing the ordering factor is one of the main goal of this proposal after all. References are more like variables declared with const than functions (mixins)

@justinfagnani

This comment has been minimized.

Copy link
Author

justinfagnani commented Mar 19, 2019

@matthewp

I understand how it works but not the why. What is the purpose of this restriction?

I think the question for me is: what is a reference a reference to?

In talking to @developit in this thread and @tabatkins offline, I think to start with in this proposal these are references to CSSStyleDeclarations (so I should remove the CSSReference opaque type from the issue).

This means that they aren't strings or selectors, and can't be combined with such.

Nesting will allow for these objects to still be CSSStyleDeclarations and contain selectors, critically pseudo-class selectors.

@EisenbergEffect

This comment has been minimized.

Copy link

EisenbergEffect commented Mar 19, 2019

@justinfagnani CSSStyleDeclaration makes a lot of sense to me.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.