[go: nahoru, domu]

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-properties-values-api] add support for scope-global variables #7866

Open
brandonmcconnell opened this issue Oct 11, 2022 · 4 comments
Open

Comments

@brandonmcconnell
Copy link
brandonmcconnell commented Oct 11, 2022

The problem

In many cases, a developer may want to set a variable value that can be accessed and re-used later in their application. CSS custom properties (variables) already support inheritance with or without using @property, so if I set a variable value, I can—in the same or a descendant element—reference that variable to use its value. This is helpful, but it also requires that all global variables be set in a high enough scope to be exposed to the entire document, such as one of these:

:root { /* truly global variables */ }
 html { /*   global-ish variables */ }
 body { /*  body-global variables */ }

The issue here is that sometimes, we encounter situations where we want to dynamically set one of those global values based on a set of c conditions related to the state of the DOM. If we combine our needs for DOM-aware variables and global-ish variables, we only have two options—

  • Utilizing JS

    The most flexible solution to this problem is to fall back to JS and set up whatever observers or event listeners we need to continually write and rewrite the value of our variables when relevant DOM changes occur. This option is workable but blurs the line between JS and CSS in an area that I think CSS should be able to handle without using JS as a crutch

  • CSS-only

    A way to accomplish DOM-aware global-ish variables purely in CSS would be to set up options or toggles high up in the DOM/cascade and then let those values trickle down. This is efficient from a CSS-only perspective, but it strictly enforces how we add some global values, and therefore, our pattern of development when working with CSS, employing a non-negotiable and inflexible method for setting up our global values that does not leave much room for designing UX-first or including such toggles in a way that naturally flows with the order of the page.

    A simple example of this might be a checkbox at the top of the DOM adjacent to a container that toggles between light and dark mode styles based on the :checked state of the checkbox (assuming the checkbox is checked):

    It might look like this:

    html ━┓
          ┣━ head
          ┗━ body ━┓
                   ┣━ input[type="checkbox"]#darkmode
                   ┗━ main#page
    
    #page {
      --color-bg: white; /* ⬜️ */
      --color-text: black; /* ⬛️ */
    }
    #darkmode:checked + #page {
      --color-bg: black; /* ⬛️ */
      --color-text: white; /* ⬜️ */
    }
    #page {
      background-color: var(--color-bg); /* ⬛️ */
      color: var(--color-text); /* ⬜️ */
    }

    This may be doable for one or two toggles, though hardly ideal. As more options/toggles are added, such an implementation becomes more difficult to maintain, not to mention that the actual problem and use cases extend far beyond simple toggles. Pretty soon, you're forced to organize a handful of toggles either at the top of your document or in an absolute or fixed space so that you can manage them> Even then, without using JS, you can't even collapse them to save space, as containing them would break your ability to reference them using the sibling selectors (+ or ~), so in most even remotely sophisticated examples, developers are forced to employ JS to manage styles like these.

Description of the proposal

TL;DR: Extend @property at-rule to support a global property that allows variables' values to be globally settable, so they only ever contain one value that can be referenced from anywhere, that with the greatest specificity

I propose adding a single boolean global property to the @property at-rule, which would expose those variables in a different way, where instead of the variables taking on different values in various places and those values flowing down the cascade, the value of the variable would always match the most specific value set anywhere within the CSS.

With this in place, such a variable's value could be set anywhere in the cascade/DOM, and only the value with the greatest specificity would be stored, which would be the value reflected everywhere that variable is referenced within a property value.

For added context, before settling on this proposal spec, I ran through several alternative ideas—even one that added functions that would reference elements inline by selector/id and could be used anywhere—but this is the first which felt as though it genuinely worked with the existing nature of CSS and added value rather than fighting against the "CSS way" simply to meet a need.

Examples

Using the same example from above, something like this would now be possible (assuming the checkbox is checked):

html ━┓
      ┣━ head
      ┗━ body ━┓
               ┣━ header ━┓
               ┃          ┣━ img#logo
               ┃          ┣━ nav ━┓
               ┃          ┃       ┗━ ul ━┓
               ┃          ┃              ┣━ li.menu-item
               ┃          ┃              ┗━ li.menu-item
               ┃          ┗━ input[type="checkbox"]#darkmode
               ┗━ main#page
@property --color-bg {
  syntax: "<color>";
  inherits: true;
  initial-value: white; /* ⬜️ */
  global: true;
}
@property --color-text {
  syntax: "<color>";
  inherits: true;
  initial-value: black; /* ⬛️ */
  global: true;
}
#darkmode:checked {
  --color-bg: black; /* ⬛️ */
  --color-text: white; /* ⬜️ */
}
#page {
  background-color: var(--color-bg); /* ⬛️ */
  color: var(--color-text); /* ⬜️ */
}

The beauty of this solution, in my opinion, is that it still utilizes the same specificity rules as CSS, except that instead of specificity being evaluated per element/selector when a style is applied, that value with the greatest specificity is applied to the variable itself at the global scope.

For example, if a value of greater specificity were declared further down in the scope, that would be the value used constantly, so long as it remains the greatest specificity, as seen in this example (using the same DOM structure as above, assuming the checkbox is checked):

@property --color-bg {
  syntax: "<color>";
  inherits: true;
  initial-value: white; /* ⬜️ */
  global: true;
}
@property --color-text {
  syntax: "<color>";
  inherits: true;
  initial-value: black; /* ⬛️ */
  global: true;
}
/* using :where() for 0 specificity */
:where(#darkmode:checked) {
  --color-bg: black; /* ⬛️ */
  --color-text: white; /* ⬜️ */
  background-color: var(--color-bg); /* <-- 🟥 red, set via the style below */
}
#page {
  background-color: var(--color-bg); /* 🟥 */
  color: var(--color-text); /* ⬜️ */
  --color-bg: red; /* <-- 🟥 this would be used (greater specificity) */
}

In the above example, even under :where(#darkmode:checked), where the value for --color-bg is explicitly set to black, it would still evaluate to red since that is the value with the greatest specificity, as declared in the style below it (under #page).

Syntax

@property --property-name {
  /* other @property properties */
  [global?: boolean];
}

The global property would be optional, false by default.

@romainmenke
Copy link
Member

I think you can do this today with :has() but the ergonomics might not be ideal.
At least it is possible to polyfill/downgrade

/* using :where() for 0 specificity */
:root:has(:where(#darkmode:checked)) {
  --color-bg: black; /* ⬛️ */
  --color-text: white; /* ⬜️ */
}

/* using :where() for 0 specificity */
:where(#darkmode:checked) {
  background-color: var(--color-bg); /* <-- 🟥 red, set via the style below */
}

:root:has(#page) {
  --color-bg: red; /* <-- 🟥 this would be used (greater specificity) */
}

#page {
  background-color: var(--color-bg); /* 🟥 */
  color: var(--color-text); /* ⬜️ */
}

I am unsure if custom key/values with env() had a CSS only part.
But env() might be interesting to avoid author confusion.

Keeping track of which custom props are global or not will be tricky.
env() has the benefit of being understood as global.

@brandonmcconnell
Copy link
Author
brandonmcconnell commented Oct 11, 2022

I think you can do this today with :has() but the ergonomics might not be ideal. At least it is possible to polyfill/downgrade

@romainmenke Thanks for your feedback! I appreciate it!

:has() can achieve something similar to the examples I laid out in my brief, and it's probably my own fault for not providing more sophisticated examples to express the unique value global variables like this would add. For a simpler checkbox example like the one I used, :has() would suffice, yes.

It does, however, get a bit trickier once you start storing complex or dynamic values.

Consider the below example:

html ━┓
      ┣━ head
      ┗━ body ━┓
               ┣━ span
               ┗━ form ━┓
                        ┗━ input[type="range"]#range
@property --range-min {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
  global: true;
}
@property --range-max {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
  global: true;
}
span::before {
  content: "In the form below, select a value between " var(--range-min) " and " var(--range-max);
}
form > input[type="range"]#range {
  --range-min: attr(min number);
  --range-max: attr(max number);
}

Something more dynamic like this would not be dynamically possible using only :has().

This is just one more example, though I think we could probably come up with many more where this would enable developers to perform more powerful operations using CSS.

Good thinking on env(). After having read up a bit more on it, I'm not sure either is a perfect fit for this, at least in their current implementations, since by definition, var() is meant for values defined per element and env() values are defined by the user-agent.

Personally, I think I still lean toward var() as it's most similar to how these would be used— almost identically. Even if we were to introduce a separate namespace for these such as global(), I think we would be doing a disservice to these variables if they lacked any of the helpful features baked into var() including @property.

@brandonmcconnell
Copy link
Author
brandonmcconnell commented Oct 12, 2022

Another idea to help differentiate globally set variables from locally set variables would be to implement a unique syntax unique to when you want to set a global/scoped variable.

Even more powerful, I think, would be to enable variables to be used both ways, locally and globally (within the current scope), by using or not using this syntax. There's already quite a bit of discussion going on surrounding the meaning and implications of &, and it's quite powerful. Per some recent discussion, & will likely reference the current scope whether that's a nested context, a scoped region, or even the global scope.

One caveat I can foresee to my spec here already is that if a third party uses the same variable names you do with a global scope, there may be a conflict where their values override yours and vice-versa. Alternatively and more safely, I propose making these "global" variables scoped variables instead of making them truly scoped, which feels a lot less dangerous to me already.

They could be used the same way variables are already used, with or without @property. This could exist without extending, though it would require extending the syntax surrounding variable definitions.

--some-prop: 123; would set the value of --some-prop to 123 locally only, while
&--some-prop: 123; would set the value of --some-prop to 123 locally AND globally within the current and descendant scopes, though any scoped variables of the same name used in descendant scopes would override ancestor scoped contexts to avoid variable name conflicts

I'm considering this new syntax for a couple of reasons—

  • & is relevant to the scope, and with the redefinition of this spec to be scoped variables rather than truly global variables, this may be the clearest way to set these definitions apart
  • I'm not an expert with compilers and how they operate, but from what I understand of CSS and how we're already setting up & to be the first character of a selector to boost performance of the compiler, I'm thinking the same idea may apply to this use case, so &-- could uniquely and quickly identify variable values that are setting a value for that named property within the current scope.
    • I would also recommend the same for referencing scoped variables (e.g. var(&--some-prop)).

That said, I think &--some-prop: 123 is a little ugly-looking, and *--some-prop: 123 or even scoped(--some-prop): 123 would look nicer, but I value clarity and intuitive adoption over how nice something looks, so I'd probably opt for &-- over *-- or scoped(--).

Here's an example of how this change could look in practice:

main {
  span {
    --some-prop-1: var(--title); /* this will NOT work, as no value for `var(--title)` exists to be naturally/locally inherited within the current cascade */
    --some-prop-2: var(&--title); /* this WILL work, uses value from current scope, declared LATER in `main + div[title]`  */
  }
  & + div[title] {
    &--title: attr(title);
    --addtl-title-1: var(--title); /* same as `var(&--title)`, defining globally also defines locally */
    --addtl-title-2: var(&--title); /* global works in the same selector here as well */
    div {
      --content-1: var(--title); /* same value as parent's `title` attribute, naturally/locally inherited */
      --content-2: var(&--title); /* parent's `title` attribute, but references scoped variable, not inherited */
      --title: "Test";
      &::before {
        --content-1: var(--title); /* "Test", inherited from parent, locally */
        --content-2: var(&--title); /* grandparent's `title` attribute, references scoped variable, not inherited */
        /* var(--content-1) ≠ var(--content-2) */
      }
    }
    & + span {
      --some-prop-1: var(--title); /* this will NOT work, as no value for `var(--title)` exists to be naturally/locally inherited within the current cascade, even though the previous sibling holds a value */
      --some-prop-2: var(&--title); /* this WILL work, uses value from current scope, declared LATER in `main + div[title]`  */
    }
  }
}

If, however, it would in fact be more confusing to the compiler to use & in this context as it may not be able to tell between a nested context and a scoped variable, the asterisk * has my vote 😉

element {
  *--some-value: 5;
  --other-value: var(*--some-value);
}

@brandonmcconnell brandonmcconnell changed the title [css-properties-values-api] Extend @property at-rule to support globally settable values [css-properties-values-api] add support for globally-(within scope)-settable variable values Oct 12, 2022
@brandonmcconnell brandonmcconnell changed the title [css-properties-values-api] add support for globally-(within scope)-settable variable values [css-properties-values-api] add support for scope-global variables Oct 12, 2022
@brandonmcconnell
Copy link
Author

Circling back to this as it's been a while since the last activity. Are there any steps I can take to push this proposal forward, along with its related dependent #7869?

attn @romainmenke cc @tabatkins

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants