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:

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> Rossen_: Before we go into details, leaverou_ is this ready?
<dael> leaverou_: Is TabAtkins on?
<dael> leaverou_: To remond people use case is web components forced to use presentational attributes, even thought ag guidline. custom properties can only contain frag and often need higher level keywords
<dael> leaverou_: Been exploring how would be possible. First idea was to have an @if block. Had a breakout with fantasai and TabAtkins and realized what if block was trying to do is replicate selector logic b/c can't use selectors for omputed style. Had an idea which might make more impl-able
<Rossen_> q?
<dael> leaverou_: TabAtkins suggested new type of custom property resolved earlier in cascade which only has keywords or numbers. TabAtkins do you want to desc?
<dael> TabAtkins: Preface this that it's a possibility here. I think it's a useful use case.
<dael> Rossen_: Let me pause you. Is this issue ready for resolution? Or to bring WG back to point of current thinking?
<dael> TabAtkins: Get people informed about issue to get more eyes. Not seeking resolution
<dael> Rossen_: Thanks
<dael> TabAtkins: Idea I have is, new custom property. compst property
<dael> TabAtkins: Can be set like custom property, but can be selected on. Can do < or >.
<dael> TabAtkins: Way you avoid problems is do sep cascade pass to resolve compst properties and treat them as not matching. You set compst and then do everything else. Avoids loops, but lets you pass properties through the tree
<dael> TabAtkins: Can set them on the light dom, will fall into shadows. Can match based on them.
<dael> TabAtkins: This is a possiblity. Would like eyes. Fear is 2nd pass like this is a lot of expense.
<dael> TabAtkins: Possible other ways to solve. Lots of space there, want help exploring and finding how ot make this better for users of custom elements
<dael> leaverou_: Hope we can solve w/o resorting to attributes since we're trying to move away from those. Making attribute selectors more powerful doesn't solve issue
<dael> TabAtkins: Open to a lot of possibilities. WOuld like attention paid so we can progress
<dael> Rossen_: Anything else we can do to attract attention?
<dael> Rossen_: Please take a look. Important issue and worth solving
Crissov commented 3 years ago

Regarding (nearly) constant third-party property values, in #6099 I proposed to use (something like) @namespace to specify the prefix between the two dashes used within var(), but to never use the prefix in the property on the left side of a colon.

@prefix "foo" {
  --bar: green;
  -foo-bar: magenta; /* unknown private property */
}
baz {
  -foo-bar: maroon; /* unknown private property */
  color: red;
  color: var(-foo-bar); /* green */
  background: green;
  background: var(--bar); /* undefined custom property value */
}
baz:const(-foo-bar = "green") { /* property value is not a string but a color */
  border: red thick solid;
}
baz:const(-foo-bar = green) {
  border: green thick dashed;
}
baz:const(-foo-bar = #0F0) { /* color notation should be irrelevant */
  border: lime thick dashed;
}
dead-claudia commented 3 years ago

@Crissov I still feel that might get confused with vendor prefixes at least visually and for some intuitively.

andruud commented 3 years ago

@LeaVerou I'm not super thrilled about having two selector+cascade passes. Trying to come up with alternatives. Can we drop the two passes, let :const() resolve using the regular custom properties, but make it invalid for the subject compound? E.g. you would use it like this: :const(--x:1) .thing { /* ... */ }.

LeaVerou commented 3 years ago

@andruud That won't cover all use cases as-is, but might cover them if modified (eg to render the entire component in descendants). Does it allow us to relax any of the other restrictions or is it just an additional restriction?

andruud commented 3 years ago

We'd relax all restrictions related to the proposed const-variables, since we wouldn't need to introduce them: :const() would refer to the existing custom properties. When matching selectors for a given element, the computed values of the parent/ancestor/preceding-sibling is already known*, so it should be fine-ish to depend on those computed values during selector matching of a subsequent element.

Might be too annoying for authors if they have to wrap their component in a "control element" just to set high-level variables though. Perhaps combine with an inline if()/switch() that can be used on the element itself? (Just thinking out loud).

* In Chrome that is. Hopefully also in other browsers.

LeaVerou commented 3 years ago

We'd relax all restrictions related to the proposed const-variables, since we wouldn't need to introduce them: :const() would refer to the existing custom properties. When matching selectors for a given element, the computed values of the parent/ancestor/preceding-sibling is already known*, so it should be fine-ish to depend on those computed values during selector matching of a subsequent element.

In that case, this sounds like a much better solution over everything else that has been proposed so far.

Does that mean that we could even have full-blown conditionals, e.g. 100vw > 500px? That would be a nice to have, though not required for the primary WC use cases.

Might be too annoying for authors if they have to wrap their component in a "control element" just to set high-level variables though. Perhaps combine with an inline if()/switch() that can be used on the element itself? (Just thinking out loud).

I was hoping they'd be very useful in Shadow DOM, branching off conditionals on the main element. Yes, it would be annoying to force authors to introduce extra elements.

However, I believe there's more or less consensus on a bunch of different inline conditional functions in #5009. I've even been playing around with an (unofficial) draft for an if() with IACVT behavior here, feedback very welcome.

If computed style of parents is known when matching descendants, I wonder if we can make it more powerful, and use the same general value comparisons instead of something specific to custom properties.

Although do note that a potential problem with this strategy is that the previous compound selectors are not always ancestors, they may be previous siblings, and in the future, as new combinators get introduced, potentially anywhere in the tree. See my comment on #5979 that proposed as similarly subject-excluding pseudo-class.

* In Chrome that is. Hopefully also in other browsers.

šŸ¤žšŸ¼

tabatkins commented 3 years ago

That is a pretty interesting proposal, indeed. We'd want to be strict about the ordering that you were allowed to query, to prevent loops with, say, :nth-last-child(1 of :const(...)) being able to reference subsequent siblings; it would be restricted solely to caring about the values on elements preceding you in tree order.

emilio commented 3 years ago

We'd relax all restrictions related to the proposed const-variables, since we wouldn't need to introduce them: :const() would refer to the existing custom properties. When matching selectors for a given element, the computed values of the parent/ancestor/preceding-sibling is already known*, so it should be fine-ish to depend on those computed values during selector matching of a subsequent element.

The main issue with this is that this would make DOM APIs like .matches() etc need to update style of all ancestors, etc, which would be a bit unfortunate, imo.

andruud commented 3 years ago

Right, yeah. I always forget about that. The same (approximately) goes for the "const var" approach though. I guess if we want .matches() to not require a clean style, any kind of conditional pseudo involving custom-props (or computed value time stuff) is out of the question.

andruud commented 3 years ago

What about having the computed variables of a container being one of the things that can be queried by container queries?

#component {
  /* Approximately the current state of bikeshed on how to mark a container,
     if I understand Issue 6174 correctly. */
  query: custom-properties;
}

@container (--pill=on) {
   .box { ... }
}

This doesn't really add any significant complexity*, since knowing the computed style (and even layout) of the container before evaluating the container query is already needed for normal (size) queries.

We'd then avoid Emilio's concern with Element.matches.

cc @mirisuzanne

* I think. Famous last words.

LeaVerou commented 3 years ago

Interesting. Would we be able to style #component itself based on --pill or only descendants?

andruud commented 3 years ago

Only descendants unfortunately, otherwise we get circularity problems.

LeaVerou commented 3 years ago

It's not ideal, but combined with an inline if() (that triggers IACVT) it could work. Shadow DOM elements would be able to style themselves based on properties on :host, right?

andruud commented 3 years ago

I don't think container-queries+ShadowDOM have been thought about thoroughly yet, but yes that sounds like what we'd want.

LeaVerou commented 3 years ago

That's the major use case, so whatever solution we pick needs to be able to work with that.

LeaVerou commented 3 years ago

Is the @container solution able to do other kinds of conditionals too? E.g. 1vw > 1em? Or only one custom property compared with one value? It would be nice if people can compare values of entire expressions instead of having to basically solve for x to find what the bounds are for the custom property, but that's a nice-to-have, not a dealbreaker. That would also allow them to have conditionals that depend on multiple custom properties.

andruud commented 3 years ago

That should be possible, provided that we can define what the operators mean and how things should evaluate. For example, in 1vw > 1em both sides might resolve to pixels, but for --x > 1em the left hand side resolves to a token sequence, the right hand side to pixels, so we'd need rules for how all of that should work. (In this case maybe @property is needed to make --x > 1em allowed)?

It would be nice if people can compare values of entire expressions

Mostly a matter of specifying the grammar + rules I think. Totally possible.

LeaVerou commented 3 years ago

Sure, but a lot of these rules are needed for if() anyway. The extra complexity of the @-rule is that they need to resolve in a way that is property-independent.

muratcorlu commented 2 years ago

I just noticed this discussion after @johannesodland mentioned here in my proposal (#7273) for addressing the same issues in a different way. I completely agree about the needs we have here as @LeaVerou explained.

Do you find useful, a solution like the below by having map-get function (as in older versions of SASS) to address the issues mentioned here?

<my-button>Save</my-button>
my-button {
   --size: small;
}

@media screen and (max-width: 900px) {
  my-button {
    --size: large;
  }
}
/* inside my-button component style */
:host {
   padding: map-get((small: 4px  8px, regular: 8px 16px, large: 12px 24px), var(--size, regular));
}

More details are in #7273.

Crissov commented 1 year ago

With the proposal in #3714 slightly extended for conditionals, a solution could look something like this:

$pill {
  border-radius: 999px;
}

my-input {
  @include pill if (var(--pill) = "on");
}
trusktr commented 2 months ago

This can be implemented in CSS today using cyclic space toggles (I like to think of them like enums and switch cases).

.foo {
  --size: var(--small);
  --theme: var(--dark);
  --placement: var(--top);
  --orientation: var(--vertical);
  --significance: var(--warning);
}

Although it isn't the most convenient, here's how something like --size can be implemented as a library author:

/**
 * ## CSS library author code: ################################
 */
button {
    /* define the configurable option for the end user, with its default value */
    --size: var(--size-medium);

    /* define all the possible enum values for the option (commas are required, they enable the magic) */
    --size-small: var(--size,);
    --size-medium: var(--size,);
    --size-large: var(--size,);

    /* finally apply output values using a switch case inside of each output property */

    border:
        solid deeppink
        var(--size-small, 1px)
        var(--size-medium, 2px)
        var(--size-large, 3px);

    border-radius:
        var(--size-small, 2px)
        var(--size-medium, 4px)
        var(--size-large, 6px);

    padding:
        var(--size-small, 2px)
        var(--size-medium, 4px)
        var(--size-large, 6px);
}

Here's how an end user would use it:

<button id="one">button</button>
<button id="two">button</button>
<button id="three">button</button>
/**
 * ## End user code: ##########################################
 */
#one {
    /* end user picks the enum value they want */
    --size: var(--size-small);
}

#two {
    --size: var(--size-medium);
}

#three {
    --size: var(--size-large);
}

where, depending on the picked --size, the component library will set a bunch of properties such as border-width, padding, margin, height, to various different values depending on the single value of --size, where --size is not just a length value.

codepen example: CSS enums and switch-case logic

Roman Komarov has some nice articles on it here: https://kizu.dev/layered-toggles/

TLDR: yeah, maybe not entirely intuitive. But! Once the pattern is learned, it is easy to implement in libraries, and most importantly super easy for the end user to use.


Using one of the syntax ideas above, here's the same thing in would-be new format:

button {
    --size: medium; /* possible values: small, medium, large */

    border:
        solid deeppink
        if(var(--size) = small, 1px)
        if(var(--size) = medium, 2px)
        if(var(--size) = large, 3px);

    border-radius:
        if(var(--size) = small, 2px)
        if(var(--size) = medium, 4px)
        if(var(--size) = large, 6px);

    padding:
        if(var(--size) = small, 2px)
        if(var(--size) = medium, 4px)
        if(var(--size) = large, 6px);
}

#one {
    --size: small;
}

It is not a ton more terse, but the main advantage is that there is no cognitive overhead needed as with the space toggles. Someone who sees this code will know how it works without having to learn how space toggles and property cycles work.

Paired with @property, IDEs could support type checking:

@property --size {
    syntax: "small | medium | large";
    inherits: true;
    initial-value: medium;
}

(namespaced property names highly recommended)

LeaVerou commented 2 months ago

Iā€™m well aware of cyclic toggles, but they are a hack/workaround, not something we can be content about as a solution and have quite a lot of limitations, not to mention the awkwardness of actual values having to be hidden behind variables. @kizu who came up with them is actually an Invited Expert in the group and has exactly the same opinion ā€” in fact we used the ugliness of this and other hacks as an argument to convince the WG to resolve for if() in #10064