w3c / csswg-drafts

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

[css-variables?] Higher level custom properties that control multiple declarations #5624

Open LeaVerou opened 3 years ago

LeaVerou commented 3 years ago

Currently, custom properties can be used to hold small pieces of data to be used as parts of larger values. Despite being called custom properties, they are mainly used as variables. High-level custom properties that control a number of other CSS properties cannot be implemented.

Besides limiting regular CSS authors, this makes it impossible for custom element authors to follow the TAG guideline to avoid presentational attributes and to use a custom property instead, except for very simple bits of data like fonts, colors, and lengths. For anything more complex, web component authors use attributes instead.

Examples from a variety of custom element libraries:

I can collect more if needed, examples abound in nearly all component libraries. Currently, these are impossible to implement as CSS custom properties, for a number of reasons:

Essentially, component authors need more high-level custom properties that encapsulate the corresponding declarations better instead of just containing fragments of values.

Some proposals to address this problem focus on a JS-based way to monitor property changes [w3c/webcomponents#856], but that appears to be hard to implement. So, I'm wondering if we can address this from CSS instead, especially since it would also address a number of other use cases too that are unrelated to components, a big one being mixins, without the problems that we had with @apply.

There are discussions in the group about inline conditionals [#4731, #5009]. If we were to have such conditionals, these would be possible to implement, but very painful (each declaration value would need to be one or more if()). I was wondering if we could simplify this.

A pseudo-class such as :if-var(<dashed-ident> <comparison-operator> <value>) would solve this ideally, but would likely not be implementable due to cycles. OTOH we already do cycle detection for variables, so perhaps it is? If so, I can flesh out a proposal.

Otherwise, perhaps a nested @rule?

my-input {
    @if-var(--pill = on) {
        border-radius: 999px;
    }
}

With nesting, that would even allow multiple rules, so it would cater for use cases such as e.g. the tabs placement without too much repetition.

One way to implement this would be as sugar for multiple if()s, but that would have the undesirable side effect of all containing declarations being set to initial when the conditional doesn't match and there's no @else, which is suboptimal.

Whatever solution we come up with, some things to consider:

Current status (updated April 5th, 2021)

This is a long discussion, this is the current status:

jonathantneal commented 3 years ago

I love the idea here because the conditions may be created and referenced from within CSS.

This shares some thematic similarity with the custom :state() pseudo-class. They both help authors style by custom conditions, and both without being directly reflected in serialized HTML. While :state() differs in by using selectors and not supporting assignment within CSS, I’m sharing it so we can glean from their similarities and from feedback on :state(), as that one is already shipping behind a flag in Chrome at the time of this writing.

Que-tin commented 3 years ago

I think we have to be careful with the naming of the expression, independent of how it's actually implemented. I like the approach of the @if-var but I really don't like the naming, as we don't know if we may expect constants or anything other than variables in CSS in the near or distant future.

Thinking about it I'd like to have a universally @if that could be used with all types of values not only with custom properties. That way we could use it with other already existing stuff like env().

e.g.

.my-input {
    @if(var(--pill) = on) {
        border-radius: 999px;
    }
}

.my-input {
    @if(env(safe-area-inset-left) = 20) {
        border-radius: 10px;
    }
}

Thought even further it would be possible to redefine already existing properties. Like background kinda did for background-color. Imagine exposing the screen width or anything to the user agent with like env(width).

That way it would be possible to combine conditions of each kind and even write media queries and media conditions as part of @if conditions.

e.g.

.my-input {
    @if(env(width) > 768) {
        border-radius: 10px;
    }
}

.my-input {
    @if(env(width) > 768) and (var(--pill) = on) {
        border-radius: 999px;
    }
}
LeaVerou commented 3 years ago

@Que-tin Naming is typically decided at the end, all features are proposed with an implied "name to be bikeshedded".

The problem with such a generic conditional is — as usual — cycles.

Consider this:

.my-input {
    @if (env(width) > 768px) {
        width: 700px;
    }
}

Also, with such generic expressions, there are ambiguities. E.g. what does @if (calc(env(width) + env(height)) > 100%) mean, since percentages mean different things in each property?

LeaVerou commented 3 years ago

A pseudo-class such as :if-var(<dashed-ident> <comparison-operator> <value>) would solve this ideally, but would likely not be implementable due to cycles. OTOH we already do cycle detection for variables, so perhaps it is? If so, I can flesh out a proposal.

It would be good to hear from implementors about this, and in general solicit feedback about a possible way forwards that is implementable and covers most use cases, so I'm gonna go ahead and Agenda+ this.

Note to chairs: I cannot attend the APAC call, so this would need to be next week.

Que-tin commented 3 years ago

Good point. There are actually a few exceptions to make at this point, hence percentages as well as integers, should have no meaning at all in this case (seen relative to the element the condition is used in). It would be hard to consider when values are relative and when absolute to the element they are used in inside of the if condition.

The advantages in my example above would actually be that you could use media conditions (if implemented as e.g. env(width) in the user agent) as part of the CSS properties as already know in e.g. SASS. It would also be possible to use things like attr() in the future.

andruud commented 3 years ago
my-input {
        border-radius: 0px;
    @if-var(--pill = on) {
        border-radius: 999px;
    }
}

#my-input {
       --pill: off;
}

@LeaVerou Just to make sure I understand the proposal, for <my-input id=my-input> you'd expect a border-radius of 0px, right?

andruud commented 3 years ago

(... interpreting that upvote as a "yes"):

Having selector matching (@if-var is not very different from a nested :if-var) depend on computed-value-time things adds far too much complexity, even if it's not impossible.

IMO if we want to if-else on custom properties, then that evaluation needs to happen computed value time, i.e. if-else needs to be part of the value. Or we need something which translates into effectively doing that internally. Or, we add a new kind of custom property which can be known selector matching time.

emilio commented 3 years ago

I agree with @andruud fwiw. I don't understand why something like this:

There are discussions in the group about inline conditionals [#4731, #5009]. If we were to have such conditionals, these would be possible to implement, but very painful (each declaration value would need to be one or more if()). I was wondering if we could simplify this.

Is really more painful / complex than what's being proposed here.

Basically, something like border-radius: if(var(--pill), 0px, 999px); seems simpler and much less action-at-a-distance to me.

In fact if you make --pill a boolean variable with values 0 or 1, you can already accomplish this particular usecase (border-radius: calc(var(--pill) * 999px)), though I understand there are more complex use cases that aren't probably covered by such a thing (and also it is a bit more obscure than some more explicit syntax).

LeaVerou commented 3 years ago

@emilio --pill might not be a good example, as it only needs to control one property, so indeed one if() serves that case just fine. However many of them need to control multiple, often across several rules. I listed a lot more in my original post. Also, once you have several of these custom properties controlling intersecting sets of properties, solving it with if() suffers from combinatorial explosion. Lastly, with if(), you need to provide an alternative (either explicitly or implicitly), whereas a lot of these are about additional "traits" that only set certain declarations when they are actually specified.

Idea: Would it be easier to implement if one was only able to set other custom properties in these conditionals?

css-meeting-bot commented 3 years ago

The CSS Working Group just discussed [css-variables?] Higher level custom properties that control multiple declarations.

The full IRC log of that discussion <dael> Topic: [css-variables?] Higher level custom properties that control multiple declarations
<dael> github: https://github.com/w3c/csswg-drafts/issues/5624
<dael> leaverou: There is a very reasonable tag guideline that custom elements shoudl use properties for presentational elements. With current state of custom properties this is impossible for non-trivial
<dael> leaverou: Current custom prop can only be literal fragments and you need to transform
<dael> leaverou: There are problems where we need to add inline conditionals.
<dael> leaverou: However, when you have lots of these properties intersecting then it can get really messy if only 2 you have is inline funcitons that need both condisions.
<dael> leaverou: Ideal is something to cascade but not sure feasible. Wanted impl feedback and then I can draft a more detailed proposal
<dael> leaverou: Examples I've looks at from component liberties are in the issue
<dael> leaverou: Some impl have weighed in in the issue [missed]
<dael> leaverou: Wondering if set of constraints could be introduced to make it more feasable. Wanted to bring to attention of group for more ideas or thoughts
<dael> astearns: Feedback on the shape of the feature?
<dael> fremy: I think it's a pretty good idea.
<dael> fremy: Really something that's a limitation of custom properties. Quite true when you use attributes you do more than reuse variable.
<dael> fremy: Pseudo class you can't use in theory but it would be really nice to have syntactic sugar. I know you can do it meta languages. Good to auto-prefix with an if condition. That would give us most of advenatages. Extend the css selector syntax to have an id for all properties
<TabAtkins> Assuming we're fine with simple conditionals in an if() function (which we've discussed before and this should be okay), doing an at-rule inside holding a block of props could have a reasonable desugaring to that.
<dael> leaverou: One thing to keep in mind is these often need to control multiple elements in the component. Alignment might need to control margins and padding. Ideally should work
<dael> leaverou: [missed]
<TabAtkins> It would have some side-effects - any properties in the block are effectively using a variable, so it would kick in IACVT behavior, etc.
<dael> leaverou: Often they need to control properties in mutli elements. Example alignment controls spaces, padding, etc in multi child elements. Good to keep in mind that it plays nicely with nesting module
<dael> TabAtkins: invalid at computed value time = iacvt
<fremy> @TabAtkins: I would think it solves many issues
<dael> leaverou: Some reasonable syntax to combine and falling back to invalid at computed value time would prob be acceptable
<leaverou> As long as we can combine conditions and nest them, having IACVT as the ultimate fallback is acceptable
<TabAtkins> so like `.foo { color: blue; @if (var(--state) = one) { color: red; } }`, it'd desugar to `.foo { color: cond(var(--state) = one, red; blue); }`
<dael> astearns: I think this is a really interesting proposal and I'd like to see further discussion on what we can do here. Any major concerns about spending time on this?
<dael> astearns: I think we should take this back to the issue and/or come up with a proposal which we can file issues on. IT's a really good idea. Anything else from group?
<dael> leaverou: I primarily wanted to draw impl attention. I can't design impl needs. Continue on issue is fine
FremyCompany commented 3 years ago

As mentioned above, I would like this to be pursued further. That sounds very useful (but even having if would be nice, I think).

Random thought: another possible syntax for the syntactic sugar:

selector {
    property-one: value;
    @transform-values if(--condition: true, $);
    property-two: value-x, value-y;
    property-three: value-u, value-v;
}

In the CSSOM, the @transform-values wouldn't be reflected, it would be a transform applied while parsing declarations.

selector {
    property-one: value;
    property-two: if(--condition: true, value-x, value-y);
    property-three: if(--condition: true, value-u, value-v);
}
tabatkins commented 3 years ago

So we'd previously discussed the cond() function as one of the "switch" variants, which would have effectively these exact semantics but grouped differently. That is, given Anders' example (lightly edited):

my-input {
    border-radius: 0px;
    @if (var(--pill) = on) {
        border-radius: 999px;
    }
}

it would be equivalent to the following using cond():

my-input {
    border-radius: cond((var(--pill) = on) 999px; 0px);
}

(I wrote cond() as being essentially a math function, but simple equivalence for keywords seems reasonable to mix into here. These might want to use additional non-math comparators, like "is the value in this set", so we'd have to give it some thought and care.)

The benefit of the at-rule syntax is that it inverts the grouping - when you have a bunch of variants of several properties, the at-rule groups them by variant, while cond() groups them by property. Which is more readable varies case-by-case, but when it matters it can have a large effect on the readability, particularly when the variants don't all affect the exact same set of properties. Noticing that sort of variation can be very hard when looking across several cond() functions.

If we treat the two as exactly equivalent, just sugar variations of each other, then this does end up implying some slightly non-obvious behavior in some cases. For example, in:

my-input {
    @if (var(--pill) = on) {
        color: green;
    }
}

(Note the lack of "default" color in the block.) Then if --pill is off or whatever, color wouldn't just be not set, it would be IACVT and end up setting itself to inherit, which has some minor cascading implications. (This is presumably the behavior we'd define for cond() if you don't provide a default clause.)

I don't think this is a big deal, it's just worth understanding the implications. I think this is much better than defining this as an almost identical feature that actually works completely differently under the covers.

FremyCompany commented 3 years ago

Regarding Tab's proposal, we can do slightly better, and copy the value preceding the @if as a fallback:

my-input {
        color: blue;
    @if (var(--pill) = on) {
        color: green;
    }
}

would be either blue or green, by filling the fallback part of the cond with the already-existing value in the declaration (and only in the declaration, not across another selector, so that would still be a limitation, just a more convenient one because it allows to specify a default.

tabatkins commented 3 years ago

Yes, that exactly what the first part of my post was implying - you'd collect a given property across all the if-blocks and "plain", and group them into a cond() (with the "plain" version being the final default branch of the cond()).

brandonferrua commented 3 years ago

I think this is a great idea and would find it very useful in our applications as we author them today.

We might author a web component that provides an interface to change the border radius of part=foo through var(--border-radius).

I'd assume through a conditional, we can expose an additional interface that would inherit a custom property from an ancestor.

@if(conditional: true) {
  --prop: var(--new-prop);
}

Based on the conditional, a component we author might take the shape of the following:

<your-element>
  #shadow-root
    <style>
      [part=foo] {
        border-radius: var(--border-radius, 0);
        @if(--theme: bubbles) {
          /* inherits --app-border-radius from ancestor such as :root */
          --border-radius: var(--app-border-radius, 15px);
        }
      }
    </style>
    <div part="foo">Text</div>
</your-element>

This would give our customers to control the behavior of --theme: bubbles outside of the component they don't own.

Since CSS custom properties inherit through shadow trees, my assumption is a customer can gain better control by changing CSS contextually in their component.

Their app might define 30px for all border-radius.

:root {
  --theme: bubbles;
  @if(--theme: bubbles) {
    --app-border-radius: 30px;
  }
}

But contextually, they want to target the prop from ::part(foo) and override the applications border-radius of 30px to be 4px for the instance of <my-element>.

<my-element>
  #shadow-root
    <style>
      ::part(foo) {
        @if(--theme: bubbles) {
          --border-radius: 4px;
        }
      }
    </style>
    <!-- customer does not own this component -->
    <your-element></your-element>
</my-element>

I highlight this example to capture how Salesforce would share styles between components but expose additional control for our customers.

Que-tin commented 3 years ago

@tabatkins How will nested conditions be written?

I saw this example of yours in the linked issue of course I think commas make more sense here.

margin-left: cond((50vw < 400px) 2em, (50vw < 800px) 1em, 0px);

But what if I turn the cond around? This makes it quite unreadable in my opinion especially the comma-separated list of possible values. Imagine having more than two conditions nested in each other

margin-left: cond((50vw < 400px) (50vw < 800px) 1em, 0px, 2em);

Or will it be possible to do smth like this to increase readability? Should be, correct?

margin-left: cond((50vw < 400px) ((50vw < 800px) 1em, 0px), 2em);

But how about the at-rule?

my-input {
  border-radius: 0;
  @if (var(--pill) = on) {
    @if (var(--half) = on) {
      border-radius: 10px;
    }
    border-radius: 999px
   }
}

Or will it be smth like this:

my-input {
  border-radius: 0;
  @if (var(--pill) = on) {
    border-radius: 999px
   }
  @if ((var(--pill) = on) (var(--half) = on) {
    border-radius: 10px;
  }
}

May it be possible to also implement some kind of logical operators for the conditions? AND, OR and NOT would be amazing to have in both solutions. I mean, of course, it would be possible to achieve this even without the operators, it just would increase the readability a lot.

my-input {
  border-radius: cond((var(--pill) = on) and (var(--round) = on) 999px; 0px);
}

my-input {
  border-radius: 0;
  @if (var(--pill) = on) and (var(--round) = on)) {
    border-radius: 999px
   }
}

I think this is one of the most complex proposals up to date as there are so many different approaches and solutions as well as things that have to be paid attention to.

LeaVerou commented 3 years ago

IACVT could work for components, since presumably all their styles are defined in the same place. Assuming these rules can be nested with predictable results, and do take all values defined in the rule into account before triggering IACVT (per @FremyCompany and @brandonferrua's suggestions), I don't think the downsides of IACVT will come into play too frequently. However, especially in that case, nesting support becomes really important.

I love the idea of using var() instead of <dashed-ident> so we can compare any two expressions, not just variables. E.g. this opens the door for conditionals like 1em < 16px, so it's a strictly more powerful feature. We should probably define these comparison expressions separately, in Values & Units, and reference them in any other spec that needs them.

Indeed, not, or, and and operators should certainly be allowed, most likely with mandatory parentheses like in @supports.

LeaVerou commented 3 years ago

A few issues with speccing generic comparison expressions: how are certain values interpreted outside of a declaration context? E.g. what are percentages relative to? If we disallow percentages, then I suppose we should define this as a union of specific types (and both sides of the comparison need to be of the same type), i.e. <dimension> | <color> | <image> .... There is currentcolor that works differently in color, and em that works differently in font-size. What do we do with them? Are there other values that are ambiguous outside of a declaration context? So far the only values we've allowed outside of declarations are those permitted in media features, which I think are limited to lengths. Also, I suppose anything that is not a <number> or <dimension> would only work with equality (and inequality, if we define one, we could also just depend on not for that). Is equality based on serialization of used values? E.g. is #f06 equal to #ff0066? What about to rgb(255 0 102)? They all serialize the same.

(this is mostly to @tabatkins but any input is welcome)

Edit: Oh, actually, if we define this as just sugar for inline if(), then we can use any value and it's interpreted in the context of each property inside the block. The equality question still stands.

mildred commented 3 years ago

Hi,

I'm new here but I would like to give feedback on this proposal that seems a powerful way to achieve many things in CSS. I would also like to stress to participants that CSS improvements can be used in other areas than custom components, and those use cases should be considered as well.

There is a number of features that I find interesting in the @if proposal:

For example, here is how I would use it for mixins:

* {
  @if(var(--mixin-danger) = on) {
    background-color: red;
  }
}

#my-error-message {
  --mixin-danger: on
}

As for naming, I would not use @if but rather @when because @if calls for a @else and @elseif which can complexify the syntax (elseif condition implicitly contains a negation of the conditions defined before, I'd prefer it to be explicit).

Also, would such condionals be allowed to be nested ?

Edit: Oh, actually, if we define this as just sugar for inline if(), then we can use any value and it's interpreted in the context of each property inside the block. The equality question still stands.

This would bring surprising behaviour where a property in your conditional could be applied but not the next one because the conditional applies differently to it.

I think the sanest approach would be to compare string equality and only allow comparison on types that are independent of the context. If you define --color1: #f00 and --color2: red, then @if var(--color1) = var(--color2) would be false. If we want to be able to compare colors, then perhaps we can have a function that would take a color expression of any form and return a standardized value that can then be compared.

If you allow colors to be compared, then you'll get a problem when a custom property contains something that can be interpreted as a color but is not meant to be a color. it could have an equality match with a value where this is not expected.

LeaVerou commented 3 years ago

@que-tin

@tabatkins How will nested conditions be written?

I saw this example of yours in the linked issue of course I think commas make more sense here.

margin-left: cond((50vw < 400px) 2em, (50vw < 800px) 1em, 0px);

But what if I turn the cond around? This makes it quite unreadable in my opinion especially the comma-separated list of possible values. Imagine having more than two conditions nested in each other

margin-left: cond((50vw < 400px) (50vw < 800px) 1em, 0px, 2em);

Or will it be possible to do smth like this to increase readability? Should be, correct?

margin-left: cond((50vw < 400px) ((50vw < 800px) 1em, 0px), 2em);

I'm not @tabatkins but hopefully your question is directed to the group and not specifically towards Tab?

Parenthesizing the conditional like that makes for a very hard to read syntax any way you order these. I would argue that a comma-separated three argument function is the way to go:

margin-left: if(50vw < 400px, 2em, if(50vw < 800px, 1em, 0px));

which I believe is more readable than any of the examples above, and more externally consistent (this is how inline conditionals work in CSS preprocessors (Sass Less), as well as in spreadsheets).

But how about the at-rule?

my-input {
  border-radius: 0;
  @if (var(--pill) = on) {
    @if (var(--half) = on) {
      border-radius: 10px;
    }
    border-radius: 999px
   }
}

Nested @if rules definitely need to be allowed and desugar via inline conditionals using and. However, your example raises an interesting point: border-radius: 999px comes after the @if (var(--half) = on), so it would be reasonable to override border-radius regardless of the value of var(--half) and desugar to:

my-input {
    border-radius: if(var(--pill) = on, 999px, 0);
}

I think what you meant to write was perhaps this:

my-input {
   border-radius: 0;
   @if (var(--pill) = on) {
     border-radius: 999px;
     @if (var(--half) = on) {
       border-radius: 10px;
     }
    }
}

which would desugar to:

my-input {
    border-radius: if(var(--pill) = on, if(var(--half) = on, 10px, 999px), 0);
}
LeaVerou commented 3 years ago

@mildred: Mixins was one of the use cases I mentioned in the original proposal, however do note that implementing these as sugar on the if() + the IACVT behavior both limit their utility for those use cases quite severely. Namely, in your example, background-color would be set on every element, either to red, or to unset. It would be essentially equivalent to this:

* {
  background-color: if (var(--mixin-danger) = on), red, unset);
}

#my-error-message {
  --mixin-danger: on;
}

This means that if you have something like this:

* {
  @if(var(--mixin-danger) = on) {
    background-color: red;
  }
}

div {
  background-color: yellow;
}

#my-error-message {
  --mixin-danger: on
}

and a <div id="my-error-message">, it wouldn't be red, but yellow, because background-color does not contain a conditional anymore. However, even if @if worked differently and actually cascaded, background-color: yellow would still override it. Basically, you'd need to stop using non-custom properties outside of @if rules to get these to behave as mixins.

Unfortunately, the feedback we got from implementers is that if we make the rule cascade, it is much harder to implement. I'm unclear on whether there are any constraints that would make cascading conditionals implementable (either limitations in what they contain, or in the condition itself). The question is, if they do not cascade (outside the rule they are defined in), do they still solve a large number of use cases?

LeaVerou commented 3 years ago

Trying to write up an Unofficial Draft on this, I've come across a few issues. @tabatkins and I had a good discussion yesterday about them. I'm going to try and summarize the current status here.

How to implement @if

Pseudo-classes are out of the question, since they match at a completely different point and would cause architectural issues otherwise. If we make @if cascade, we'd need to carry invisible extra context with each property, which is a significant increase in complexity across every declaration, whether it's inside a conditional or not.

It looks like the best tradeoff of implementation convenience and use case coverage is to implement @if based on my original idea of desugaring it into inline if() calls, but also taking into account any properties defined within the same rule as the @if block. E.g. this:

.button {
    border-radius: 2px;
    @if (var(--pill) = on) {
        border-radius: 999px;
        padding: 0 1em;
    }
}

desugars to:

.button {
    border-radius: if(var(--pill) = on, 999px, 2px);
    padding: if(var(--pill) = on, 0 1em, unset);
}

Avoiding partial application

There are certain values in CSS that evaluate differently depending on which property they are specified on. The obvious one is percentages, but also em, rem, lh, rlh, currentColor.

Currently, for a generic inline if() function, it makes sense to evaluate the condition in the context of the property it's specified on. However, this means that if we desugar @if by using if() on each value for each declaration it contains, any relative values used may make the condition true for some declarations and false for others. E.g. consider this:

.foo {
    @if (1em > 5%) {
        width: 400px;
        height: 300px;
    }
}

which desugars to:

.foo {
    width: if(1em > 5%, 400px);
    height: if(1em > 5%, 300px);
}

Now consider that an element that matches .foo is inside a 600px by 400px container and has a computed font-size of 25px; This makes 1em > 5% evaluate to false on the width property and true on the height property, which would make the @if partially applied. We most definitely don't want that.

One solution we came up with was to define two kinds of inline conditional functions: One that works as described above, and is not used for desugaring @if, and one whose main purpose is for desugaring @if, let's call it property-agnostic-if() (name obviously TBB) for the purposes of this discussion. That function will evaluate each type within its condition against a predefined property, e.g. color for <color> values, width for <length> or <percentage>, font-size for em and so on. This means it will evaluate the same on any property, preventing partial application for @if blocks that do not contain nested rules.

However, once CSS Nesting comes into play, partial application becomes a problem again. Many use cases require the @if to control multiple rules, which would become possible with nesting. However, since condition evaluation depends on the element context, this could mean the conditional matches for the rule it's defined on and doesn't match for nested rules or vice versa! For example:

.tabs {
    display: grid;

    @if (var(--alignment) = top) {
        grid-template-rows: auto 1fr;

        & > .tab-strip {
            display: flex;
        }
    }
}

What happens if .tabs has --alignment: top and .tab-strip has --alignment: left? So far we have not found a solution to this. Ideally, we'd want matching to happen at the root rule and everything inside the @if block either applies or doesn't, but there doesn't seem to be any reasonable way to desugar that into inline functions. Should we just live with this and caution authors against it? Do note that for the Web Components use cases, a lot of this matching will be in Shadow DOM, which is controlled by the component author. So maybe it's ok? However, once the feature is out, authors will use it in a more general way, and nothing can put that genie back in the bottle.

Cascading

Inline conditionals will have the IACVT (Invalid At Computed Value Time) behavior that we have come to know and love (?) from Custom Properties. Since @if will desugar to inline conditionals, it will also fall back to that, which may sometimes be surprising. This means that these two snippets are not equivalent:

.notice {
    background: palegoldenrod;
}

.notice {
    /* Desugars to background: if(var(--warning) = on, orange, unset); */
    @if (var(--warning) = on) {
        background: orange;
    }
}
.notice {
    /* Desugars to background: if(var(--warning) = on, orange, palegoldenrod); */
    background: palegoldenrod;

    @if (var(--warning) = on) {
        background: orange;
    }
}

This also affects how CSS optimizers combine rules, since combining rules with identical selectors can now produce different effects.

There is also the example in my comment above, where even though it makes sense if you think about it, for some reason the result feels very surprising.

css-meeting-bot commented 3 years ago

The CSS Working Group just discussed [css-variables?] Higher level custom properties that control multiple declarations.

The full IRC log of that discussion <dael> Topic: [css-variables?] Higher level custom properties that control multiple declarations
<jensimmons> Does that mean we get a CSS 2020 in 2020??
<astearns> just barely
<dael> github: https://github.com/w3c/csswg-drafts/issues/5624
<dael> leaverou: I didn't explicitly add this. WE discussed last time and didn't get resolution. Interesting discussion in issue and off GH
<leaverou> https://github.com/w3c/csswg-drafts/issues/5624#issuecomment-746339609
<dael> leaverou: I summerized current state in ^ comment
<dael> leaverou: Summary: It looks like best course of action for block conditionals. Can't use pseudo class, casuse issues. If if() cascades have to carry extra context and increases too much complexity
<dael> leaverou: Best is impl if based on idea of desugering to inline if calls and take into account properties in same rule. example in comment
<dael> leaverou: Rasises some issues b/c certain values eval differently depending on prop. Hasn't come up that much. Length in some MQs
<dael> leaverou: For example, ones we could come up with TabAtkins is %, em values, rem, lh, rlh, currentColor.
<chris> rrsagent, here
<RRSAgent> See https://www.w3.org/2020/12/16-css-irc#T17-34-13-1
<dael> leaverou: Problem. If it desugars to inline if calls nad conditional has relative values you may have cases where part of rule eval to true and a part of false. Example in comment.
<dael> leaverou: Agreed don't want partial applicaitons. How to solve?
<dael> leaverou: Came up with defining how these relative values would be evaluated. cureentColor is as if in color and so on. New inline conditional function to desugar iff
<dael> leaverou: Doesn't sound good, but couldn't come with better
<dael> leaverou: Addresses single conditional. Css nesting has same partial applicaiton problme. May have condition true for a rule but not decendnents.
<Rossen_> q?
<dael> leaverou: Might have var warning = on and a value for --warning on parent and different value on the child
<dael> leaverou: You again have @if block applied paritially
<dael> leaverou: Not sure if there's a way to address this. Couldn't come up with anything but just discussed yesterday. Don't know if there are ideas
<dael> fantasai: What do you do if content has if clause with a property that effect evaluation. if on a em and evaluate em against font size
<dael> leaverou: Can you put example in IRC?
<fantasai> @if (var(...) > 1em) { font-size: 35pt; }
<dael> leaverou: I see
<dael> leaverou: I'm not sure
<dael> leaverou: What would you suggest should happen?
<dael> leaverou: It's basically same as if you have inline if
<dael> Rossen_: In interest of time, are we ready to resolve or should we take it back to GH and continue there?
<dael> leaverou: I suppose we could go back to issue
<dael> Rossen_: Let's do that. Let's continue discussing there. I was hoping we were closer to resolution then we are. We'll come back
dead-claudia commented 3 years ago

@LeaVerou Chances of that if going anywhere in that particular form is slim to none due to reasons explained by @tabatkins in https://github.com/w3c/csswg-drafts/issues/3455 as he rejected that issue.

Edit: also read up on CSS mixins and the surrounding proposals related to that. The other half of this is basically that.

bramus commented 3 years ago

@isiahmeadows The one in #3455 allowed checks on "any value"

[W]hat's useful about if() is that […] you can use it for any value.

… which was the reason for closing it.

This is, unfortunately, what makes this so much harder, and probably not capable of happening […]

If it the proposed if() function were to be limited to only check a Custom Property against a certain value, then it'd be possible I guess?

/* Valid: Custom Properties */
width: if(var(--size) = big, 10em, 2em);
gap: if(var(--numchildren) > 10, 4em, 2em);

/* Invalid: Non-Custom Properties or other values */
padding: if(width > 10em, 2em, 1em);
width: if(1em < 5%, 400px, 600px);

That would immediately also solve the “Partial application” issue @LeaVerou mentions, as it simply wouldn't be allowed.

dead-claudia commented 3 years ago

@bramus Okay, now that I take a closer look, I think you're right. Edit: If units are allowed in the comparison, one could define font-size: if(var(--foo) > 2em, 2em, 1.5em) - would this produce a cycle?

bramus commented 3 years ago

@isiahmeadows font-size: if(var(--foo) > 2em, 2em, 1.5em) won't create a cycle between "regular" properties, as the font-size property is pointing to the custom property --foo.

It is possible that var(--foo) itself causes a cycle, but that's of no issue. Quoting @tabatkins in #3455 here again:

Custom properties can arbitrarily refer to each other […] and have a somewhat reasonable "just become invalid" behavior when we notice a cycle.

LeaVerou commented 3 years ago

@isiahmeadows @bramus

I was under the impression that these days there more or less is consensus for an inline if()/cond() function (see the issues I linked to in my first post), so this is exploring a block form. As @bramus pointed out, the closed proposal you point to could refer to other properties etc which is way harder to implement.

I did indeed initially propose limiting conditions to comparisons between var() references and values, but it seems that a more general form could perhaps be possible (see https://github.com/tabatkins by @tabatkins ).

Edit: also read up on CSS mixins and the surrounding proposals related to that. The other half of this is basically that.

I thought I was up to date on these, but it's entirely possible I've missed a few, were there any specific discussions you'd like me to focus on?

@bramus Okay, now that I take a closer look, I think you're right. Edit: If units are allowed in the comparison, one could define font-size: if(var(--foo) > 2em, 2em, 1.5em) - would this produce a cycle?

No because em in font-size points to the parent font-size. Otherwise even font-size: 2em would be a cycle 😄

That would immediately also solve the “Partial application” issue @LeaVerou mentions, as it simply wouldn't be allowed.

I don't see how. a) Custom properties can have different values in nested rules. b) Consider e.g. if(var(--foo) > 5%, ...) with var(--foo) being e.g. 12px. Percentages are resolved differently in different properties, so the condition could be true in one property and false in another.

bramus commented 3 years ago

@LeaVerou

I don't see how. a) Custom properties can have different values in nested rules.

They don't. Think we simply misunderstood each other here.

b) Consider e.g. if(var(--foo) > 5%, ...) with var(--foo) being e.g. 12px.

Ha, was a bit too focused there on referring to other "regular" properties vs. other custom properties. Didn't take the values they resolve to into account — which obviously can also be em/px values, as you mention here. Ignore my statement about it resolving the partial application issue, it is incorrect.

bramus commented 3 years ago

I've been giving this issue quite some thought the past few days, and think I've come to propose an alternative syntax. It's basically a reuse of the attribute selector, but then with a custom property.

To get a bit ahead of my self, here's the pill example from the OP with this proposed syntax:

[@var(--pill)="on"] {
   border-radius: 999px;
}

Motivation

The motivation behind this alternative syntax is two-fold:

Examples

(Note: Using nesting here to keep things tidy)

Pill button

Squares

.square {
    --width: 4em;
    width: var(--width);
    aspect-ratio: 1/1;

    &[@var(--size)="small"] {
        --width: 2em;
    }

    &[@var(--size)="big"] {
        --width: 8em;
    }
}

Benefits

Downsides


I don't know if this even possible (i.e. would this all require an extra run of the parser?), as I don't know how CSS parsers work. Feel free to point out how flawed this is in case is shouldn't be possible ;)

LeaVerou commented 3 years ago

@bramus There are several issues with this proposal.

Last, I'm not sure how @if is imperative? What makes it imperative?

bramus commented 3 years ago

Thanks for your responses @LeaVerou. Learning a lot here.

anything selector-based cannot depend on computed styles, as cascading needs to be processed earlier.

This is what I feared in that last paragraph .Makes total sense though.

we may as well ditch the @var() and just specify the <dashed-ident> to compare against.

Side note: I first thought of this syntax, but although uncommon — and not against spec rules apparently — I learned that you can create HTML attributes that start with -- and already target them from CSS using [--attribute] (#til). See this pen for a short demo.

I'm not sure how @if is imperative

True, technically it's a simple control structure. When mentioned within a CSS context I see people either get really excited or the exact opposite it as it reminds them a wee bit too much of (imperative) programming. It's a perception thing. (I personally would welcome it very much 😊)

LeaVerou commented 3 years ago

I was not suggesting we go with brackets + <dashed-ident>. I'm aware that attributes can start with --. I think it would be confusing to have a syntax similar to attribute selectors but for custom properties, even if selectors were fair game for this (which they are not). I was merely saying that if we go with a syntax that only allows comparison with custom property values, and not comparison between any CSS values, then requiring var() around it is pointless, unless we also want to allow for fallbacks, or to allow future extensibility.

There is nothing inherently imperative about conditionals, people are just pattern matching on superficial syntax. The distinction between imperative and declarative is deeper, and not actually that strictly defined if you look into it. The high level definition is that imperative tells the computer how to do something, whereas declarative what to do. Furthermore, CSS already has conditionals: @supports and @media, which can also be nested inside regular style rules, per [css-nesting]. There is nothing more inherently imperative in conditionals that depend on value comparison instead of feature support or user environment.

cdoublev commented 3 years ago

Hello,

I appreciate that CSS is declarative. I've been using PostCSS nested block/mixin/loops/... for many years, but I've also established conventions to prohibit more than two nesting levels, and I removed conditional statements that now only exist in mixins. I find that conditional statements make my CSS less readable and understandable. While reading @bramus post on this proposal and (almost all of) the above comments, I've been thinking many times that the provided cases can be resolved since it always has been, ie.:

/* Before this proposal */
.element {
  font-size: 2rem;
  color: red;
}
.element--modifier-1 {
  font-size: 3rem;
  color: blue;
}
.element--modifier-2 {
  font-size: 5rem;
  color: green;
}

/* Using this proposal */
.element {

  font-size: 2rem;
  color: red;

  @if-var(--modifier = 1) {
    font-size: 3rem;
    color: blue;
  }
  @if-var(--modifier = 2) {
    font-size: 5rem;
    color: green;
  }
}

I may have missed the initial motivation(s) for CSS conditional statements, either inline or as block. I'm sorry if I did and please consider this comment just as a humble feedback from a casual CSS author. Is it only about encapsulation (CSS vs CSS+JS, single vs multiple selectors)? Reducing CSS file weight? Web components?

LeaVerou commented 3 years ago

We had a breakout with @tabatkins and @fantasai on Friday, and we think we may have found a better implementable way forwards. I'm going to attempt to summarize below, @tabatkins @fantasai feel free to add/correct accordingly.

LeaVerou commented 3 years ago

@tabatkins @fantasai One issue I just realized: Since we can now divide with <dimension> in calc(), this means that any <number> could potentially depend on relative lengths. Consider e.g. calc(10vw / 5em). The result is a <number>, but it cannot be computed before lengths resolve. How can we limit it to prevent this?

tabatkins commented 3 years ago

Easiest solution is to just give them the same value they'd have in MQs. Not the most useful, but not useless, and solves the problem in a familiar way.


Some more detail on the proposal, as I understand it:

There's strong use-cases for setting const properties on sub-widgets based on const properties set on the widget itself. (Set a widget to "dark mode", and it sets its sub-widgets to "dark mode" as well, using whatever property name they expect; etc.) I think we can get away with leaning on Shadow DOM for this:

(Without shadow DOM, we'd have to define some notion of "iterate until stable" that I'm much more afraid of.)


I believe this sketch avoids any circularity issues while still addressing the majority of use-cases adequately.

Ultimately, this should put us at the same functionality as just using attributes, but with a substantially better user interface, as you can set const properties across your page with a bit of simple CSS, rather than writing JS and very carefully tracking state mutation so you can add/remove/change the attributes.

LeaVerou commented 3 years ago

@emilio @andruud Does our latest proposal address your concerns wrt implementability?

emilio commented 3 years ago

I believe such thing would be implementable, but doing two selector-matching / cascade passes is worrying, performance-wise. I guess it's what we do for links / :visited in some cases, but having to do that for all elements seems very unfortunate...

At a glance, there doesn't seem to be a particularly easy way of avoiding extra work if you don't use this feature (at least not without penalizing performance for the users of this feature). Though it's late and I might have overlooked something. Here's my reasoning:

It seems to me you need to do a first pass collecting const-properties basically unconditionally (because you don't know if they're used until you cascade)... You could optimistically assume they're not used, and do the regular cascade during this first pass. If they're indeed not used, you can just not do the second pass and carry on, so perf remains ~the same as it is now. But if they are, you've wasted a bunch of work that now you need to throw away because you're going to re-selector-match etc.

In general I'm a bit skeptic of adding features that are performance footguns... But again I might need to think a bit harder about potential ways of optimizing this because I might have overlooked some way of make them cheaper.

andruud commented 3 years ago

I was going to say basically what @emilio said. Not impossible, but perhaps not advisable.

FremyCompany commented 3 years ago

If we are about to constraints things that much, I am not sure why not using "inheritable" attributes like we already have for lang and :lang(...). The implementation cost seems way lower and it doesn't appear to produce less value.

As a straw man, let's pretend we add data-attr(xyz) that can get the value of the data-xyz attribute as a token stream if it exists, and looks at the parent element if it does not exist (similar to the lang attribute) eventually return an invalid value (or an empty token stream?) if we reach the root without finding the attribute.

<div class="toolbar" data-accent-color="red">
    ...
    <svg class="icon">...</svg>
    ...
</div>
svg[data-attr(accent-color)] {
  svg.icon { color: data-attr(accent-color); }
}

It's really easy to invalidate, any attribute that is every used in a data-attr(...) function invalidates the style of the descendants on mutation.

Then, we can add new attribute selectors comparison operators that interpret the value of the attribute as in a media query. These would be usable on existing attributes (we can finally do stuff like [number(size)<=3] for stuff like <font size="2">) and can use that also on the inheritable attributes.

FremyCompany commented 3 years ago

If we want to use it as a normal variable after that, you can easily write something like

@property --accent-color { syntax: '<color>'; inherits: true; }
[data-attr(accent-color)] { --accent-color: data-attr(accent-color); }

which might be useful for crossing shadow boundaries or things like that.

tabatkins commented 3 years ago

That doesn't let you style sub-objects with their own set of data-* attributes tho, right? You'd still have to listen for mutations and transfer them. That said, I suppose you have to do that anyway to properly handle attributes on the host object, so it might be okay.

tabatkins commented 3 years ago

Ah, but it also means we lose the entire "style things according to selectors" use-case that motivates a lot of this. If we give up on that, then yeah, a bit of enhancement to attribute selectors would suffice, but that's the core motivating use-case here - being able to style multiple properties as easily as you can use a single custom property.

LeaVerou commented 3 years ago

The whole point is separation of concerns, and being able to use CSS for styling. If we give up on that, components can already invent their own attributes (and this is what they've been doing, see first post), not sure what new thing there is to invent here. Is this just for inheritance? Inherited attributes would be useful in their own right, but a suboptimal solution here. One should not have to modify HTML attributes to change presentation.

FremyCompany commented 3 years ago

Two things:

  1. "Inheritance" of attributes in the tree (:lang-like)
  2. Ability to use the value of an attribute as a token stream (var(...)-like)

The combination of these two things means you can write any selector you would use :const() for using an attribute instead. In terms of expressive power, both have the same benefits.

I agree this is more about cementing the current use of attributes in web components and making it a workable solution than creating an entirely new css solution to the root problem. That said, the benefits of a css solution that is super limited in what it can do anyway and doubles the time required in cascade cost doesn't seem worthwhile to pursue to me, at first glance (as I doubt any major site would take the performance hit to be able to use this convenience, but I could be convinced otherwise).

Also, I am afraid we probably would not get that new cascade pass anytime soon, while a minor change to how attributes can be used in css sounds easier to land, and would help web components author directly today.

LeaVerou commented 3 years ago

@FremyCompany

I think attribute inheritance, range attribute selectors (gt, lt etc), and attr() as a token stream are things that are useful in their own right and should be pursued independently, regardless of where this goes. There have even been times when I mirrored attribute values with CSS properties, just so they can inherit!

However, I don't think attributes are a good solution here. We cannot be deprecating presentational HTML attributes, then directing Web Component authors to use attributes for presentation. Not to mention that this means it's hard or impossible for authors to use conditional rules and selectors to vary these values (e.g. think of a tabs component, where the author wants the tabs to be on top in narrow viewports and to the left in larger viewports).

You say that these new properties are way too constrained, however we carefully reviewed how web component libraries are currently specifying these presentational attributes and it seems that these would cover nearly all use cases. Did you have any use cases in mind, from popular web components, which would not be addressed by something like this?

I agree the double cascade pass is not ideal, but:

  1. It's only done when such properties are both specified and used, so only when two cascade passes are actually needed
  2. It's already done today for certain things as @emilio pointed out
  3. Let's not forget that O(2 * ƒ(N)) is still O(ƒ(N)). The cascade today is extremely fast, so twice that is still extremely fast, and definitely much faster than a lot of JS solutions that authors are employing to address these gaps.

@tabatkins I just realized one more reason to make this an @property option instead of new syntax: It promotes better encapsulation. How components use these properties is an implementation detail, and could change between different versions of the component. Authors should not have to care, or even know how these properties are used within the component, and should not need to change their code that styles the component if the component starts using properties in a different way. (I suppose components can always internally define regular custom properties that take their values from constants, but not the other way around, so it could become a pattern for components to accept all their styling inputs via constants wherever the syntax permits this. Not sure that's a good thing.)

tabatkins commented 3 years ago

The combination of these two things means you can write any selector you would use :const() for using an attribute instead. In terms of expressive power, both have the same benefits.

Yes, when you scope the benefits to "can I write a selector that responds to this quality", you're right. But as I said, one of the big benefits is being able to apply the quality via CSS; using attributes means you instead have to do it with static markup or with JS; in the limit, where you're adding/removing/mutating things that would affect the selector you're applying it with, reproducing this in JS requires some pretty subtle and non-trivial MutationObserver work. That's the benefit of Selectors, tho. So, losing that is a significant blow.

(Reading Lea's reply now, they hit on the same argument.)

I just realized one more reason to make this an @property option instead of new syntax: It promotes better encapsulation.

Oh I agree that this is a benefit, yeah. I just, currently at least, think the benefits of having these completely behaviorally different properties have a syntactic difference as well outweighs the downsides of authors having to decide which of the two styling methods they want to use. In particular, the fact that you can't set a const property according to a const selector is important, and much harder to see what's going wrong if the property looks the same as all your other custom properties.

FremyCompany commented 3 years ago

Ok, I see the value proposition better thanks to your explanation. You're saying that by doing this in css you can also support things like html.dark-theme svg.icon {--const-accent-color: royalblue} and, unlike with my proposal, not worry about having a Javascript keep the data-accent-color attribute in sync when the dark-theme class on <html> is added or removed (or more complex situations, selectors being powerful).

This is a valid point, thanks for bringing this up. There is a hefty price for that value proposition though. I still feel the attributes improvements are more likely to land and are in many cases sufficient while not incurring performance costs, but I agree this is something that can be done independently and doesn't remove all the value from this proposal.

Digging deeper, you seem to be proposing to add something like Tab's CAS proposal to CSS but restrict it to a subset of custom properties instead of all attributes. At this point if we are about to add a second cascade pass I wonder if we can't try to make it at least more useful by making it affect more things, like actually update data attributes? (but that might be more complex to implement too, I'm just throwing that out there to get us all thinking about other use cases for a second css cascade; don't want to bandwagon just try to see if we can get other benefits eventually or if it's a one-off).

I'm saying that because often the attributes in the web components do not only affect style: they also affect the structure of the component itself, in which case a css only solution isn't viable anyway because you can't react to it to change your tree.

Btw I'm not sure I like const as a prefix. These things are not constants they are just evaluated eagerly. I'd rather find a better name of we go that route.

mildred commented 3 years ago

Excuse me if I'm completely off, but regarding implementation in @emilio comment https://github.com/w3c/csswg-drafts/issues/5624#issuecomment-770470408 there seems to be an easy way to speed up CSS not using the const properties: During initial CSS parsing, construct a list of const properties used in selectors and a list of rules defining const properties.

If either no const properties are used in selectors, or no rules is defining them, we can avoid the first pass entirely. Else, we can perhaps only do the first cascade pass only on the rules that are actually defining the const properties.

Also, perhaps we might not need to create a new CSS thing for that and just use custom properties as they are today. With just the constraint that when used in selectors, no function interpolation is done. it puts the burden on CSS authors to use it right though and can perhaps cause bugs. Or else, the const property could be just a specific flag set on the custom property such as html.dark-theme svg.icon { @const --accent-color: royalblue}.