w3c / csswg-drafts

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

[css-values-5] What is the MVP for inline conditionals on custom properties? #10064

Open LeaVerou opened 5 months ago

LeaVerou commented 5 months ago

Edit: Resolved to pursue this! 🎉


There are several issues across this repo proposing some kind of inline conditional function. Here is a sample:

Yet, this is another case where progress has stalled because we’re trying to flesh out a much more general and powerful feature, which involves a significant amount of design & implementation effort. Meanwhile, this major author pain point remains unsolved, and authors have to resort to HTML attributes instead (as explained in #5624).

The current workarounds are:

However, there is no workaround for transforming arbitrary keywords to arbitrary values, even simple values. E.g. custom properties like these are impossible to implement (examples inspired from the presentational attributes found in Shoelace, one of the most popular web component libraries, but similar use cases can be found in almost any design system and/or WC library):

What if we could come up with an MVP that could be implemented fast and extended later?

We could scope it down quite a lot and still have something that addresses the most pressing author pain points. Some example restrictions we could start with:


Edit (June 10th, 2024):

I had a chat with @andruud a few days ago, and he said that this proposed design would be fairly easy to implement.

Grammar:

<if()> = if( <container-query>, [<declaration-value>]{1, 2} )

I did not discuss this but a nice DX improvement could be this grammar instead:

<if()> = if( 
    [ <container-query>, [<declaration-value>]{2}  ]#{0, },
    <container-query>, [<declaration-value>]{1, 2} 
)

which would allow for multiple conditions more easily. Compare:

Grammar 1:

background-color: if(
    style(--variant: success), var(--color-success-60), 
    if(style(--variant: warning), var(--color-warning-60), 
        if(style(--variant: danger), var(--color-danger-60), 
            if(style(--variant: primary), var(--color-primary))
        ),
    )
);

Grammar 2:

background-color: if(
    style(--variant: success), var(--color-success-60), 
    style(--variant: warning), var(--color-warning-60),
    style(--variant: danger), var(--color-danger-60), 
    style(--variant: primary), var(--color-primary)
);

Behavior: Any invalid value would make the property IACVT.

Does it cover enough use cases? I think so. Ideally, you'd want a mechanism to fallback to whatever value the declaration would have normally if nothing matches, instead of IACVT, but that’s much harder to implement.

Implementors, would this make it tractable? If not, what would?

benface commented 2 months ago

And in #9539 we already agreed on a way to separate such things: a semicolon, which can be replaced with an "implicitly upgradeable" comma in specific cases.

I was not familiar with that issue, or its resolution. Now that I've read it, I think we should go for property: if(<condition>; <value-if-true>; <value-if-false>). Or, if the resolution of #9539 is open to dispute, I really like the idea of plain parentheses to distinguish values (regardless of any commas they contain), so it could be property: if(<condition>, <value-without-commas-if-true>, <value-without-commas-if-false>) or property: if(<condition>, (<value-with-commas-if-true>), (<value-with-commas-if-false>)). Or maybe plain parentheses can include ternaries as well, in which case it could be property: (<condition> ? <value-if-true> : <value-if-false>), where the values can contain commas.


I don't want that either. But we can use : here, which is consistent with how we relate values to properties in declarations.

if(cond1: val1, cond2: val2, fallback) /* only if no value contains commas */
if(cond1: val1; cond2: val2; fallback) /* general syntax */

I feel like it would be more readable to just have one condition in if():

if(cond1; val1; if(cond2; val2; fallback))
LeaVerou commented 2 months ago

Ah, we are now in full-blown bikeshedding mode 😅

: is valid in custom properties, so it seems unnecessary limiting to disallow it in conditionals that may be used in said custom properties.

We could also disallow it, like we did with braces when we needed to for Nesting (I think? Or did we disallow --tagname instead?).


How about both? If we can have commas that become semicolons when needed, we could also have colons that become semicolons when needed.

I think it’s a big antipattern to use , or ; to separate some arguments and another character to separate others. Either we use two distinct characters, or we use ;/, for both.

@benface Even if if() does accept multiple conditions, authors can still choose to nest it instead. So if you find that more readable, you can do that! Personally, I don’t, and nested parens are easy to get wrong.

Loirooriol commented 2 months ago

@benface Yes, if we require additional conditions to be nested then we can make do with just , or ;. But note when there are lots of possibilities it can get cumbersome with so much nesting. Though I still prefer that over if (cond ? iftrue : iffalse)

benface commented 2 months ago

@benface Even if if() does accept multiple conditions, authors can still choose to nest it instead. So if you find that more readable, you can do that! Personally, I don’t, and nested parens are easy to get wrong.

How would if() accept multiple conditions without using : after the condition?

EDIT: Ah, I guess what someone wrote above:

* {
  --prop: if(style(--x: 'one') ? 1 : style(--x: 'two') ? 2 : 3);
}

The more I think about it, the more I feel like if(cond ? iftrue : iffalse) is wrong. I really like the ternary syntax, but I don't like that it's a special syntax that only works in a specific function. So if we can't make it work everywhere (i.e. with plain parens), I prefer the ,-or-; approach (and @Loirooriol's proposal for multiple conditions is also growing on me).

kizu commented 2 months ago

For me, the ? and : in if (a ? b : c) are preferable, as this will allow us to later use them in calc() and other similar cases without the wrapping if().

Yes, we could have the if() with a different syntax while still adding the ternary to calc() later, but why if we can reuse the same syntax inside it?

tabatkins commented 2 months ago

I think we should go with if ([cond ? iftrue : iffalse ]+)

I feel strongly that we should not mint a new separator that indicates the end of a <decl-value>. Doing so means there's one more character we can't use for properties in the future, or that we then have to invent another convention for allowing values with such a character to be given in if() (and anything else using this syntax convention).

We should stick with comma and upgradeable semicolon. Comma is ubiquitous, and semicolon is already excluded from <decl-value> so it's safe forever. This then means that the function is a comma (/semicolon)-separated list of condition/value pairs, which is understandable and easy to understand in the existing mental model of CSS syntax.

(This objection does not apply to places where the grammar already distinguishes the character from the surrounding values, like whatever we use between the condition and the value. So using ? is acceptable there from a syntax-design standpoint, tho I'd prefer : as it more closely matches several vaguely-related syntax conventions, like CSS properties, JSON properties, Python if statements, the upcoming JS match() expression, etc. The only syntax convention already using ? is the JS/etc inline conditional, which pushes us to use : to separate the "true" value from the rest of the condition.)


Separately, while I like inline conditionals in JS, I think it's fairly common wisdom that stacked inline conditions (a ? b : c ? d : e ...) is moderately unreadable and often linted against. Unless you carefully format it, it's very hard to follow the logic, and the operator precedence isn't immediately clear either. I don't think we want to lean on that convention for multi-condition if().

tabatkins commented 2 months ago

To be more productive, then, my suggestion for the syntax is:

if( [<<if-cond>> ':' <<decl-value>> ]# )
<if-cond> = <<boolean>> | else

With the condition that else is only valid in the final pair. (And if there's no else and all the conditions fail, the value is IACVT.)

benface commented 2 months ago

To be more productive, then, my suggestion for the syntax is:

if( [<<if-cond>> ':' <<decl-value>> ]# )
<if-cond> = <<boolean>> | else

With the condition that else is only valid in the final pair. (And if there's no else and all the conditions fail, the value is IACVT.)

So something like this?

property: if(style(--condition: true): var(--value-if-true), else: var(--value-if-false));

Makes a lot of sense to me!

LeaVerou commented 2 months ago

With the condition that else is only valid in the final pair. (And if there's no else and all the conditions fail, the value is IACVT.)

Syntax aside, we need to decide separately if a lack of else means IACVT or an empty token stream. I think the latter may be more composable.

benface commented 2 months ago

With the condition that else is only valid in the final pair. (And if there's no else and all the conditions fail, the value is IACVT.)

Syntax aside, we need to decide separately if a lack of else means IACVT or an empty token stream. I think the latter may be more composable.

My vote would be for IACVT + a separate mechanism for reverting IACVT declarations, like the one you proposed in #10443.

LeaVerou commented 2 months ago

That is orthogonal, since even if it returns an empty token stream it can make the resulting property IACVT. E.g. this would work fine with the empty token stream behavior:

border-radius: if (style(--pill) ? calc(infinity) ) !revertable;

But it means something like this is possible too, without having to explicitly specify an empty fallback:

background: if (style(--skeuomorphic) ? var(--texture)) var(--color) ;
jimmyfrasche commented 2 months ago

@tabatkins

With the condition that else is only valid in the final pair.

If the semantics are first true condition wins (which is the easiest to implement and understand, especially) and else is defined to always match then it's valid everywhere but probably only means what you want when it's at the end. This allows some printf debugging by sticking an else in the middle, and it's easy enough for linters to detect and warn about a medial else.

Alternately you could define it as matching whenever nothing else matches so that it's valid anywhere (but only once). I don't think this buys much over the simpler rule.

@kizu I don't understand why it would be good to allow if(x?y:z) and calc(x?y:z). That would make if by itself fairly useless once the latter lands. Why not skip straight to calc(x?y:z)? [I much prefer the switch style to the ternary but note that both could exist if the ternary is just a regular calc expression]

Loirooriol commented 2 months ago

Why not skip straight to calc(x?y:z)?

Because people desire to use conditionals outside of calculations.

tabatkins commented 2 months ago

Syntax aside, we need to decide separately if a lack of else means IACVT or an empty token stream.

Empty token stream can be accomplished by just omitting the value part, so I don't think it needs to be specially addressed.

If the semantics are first true condition wins (which is the easiest to implement and understand, especially) and else is defined to always match then it's valid everywhere but probably only means what you want when it's at the end. This allows some printf debugging by sticking an else in the middle, and it's easy enough for linters to detect and warn about a medial else.

Ah, true, I do find that valuable (and end up making this same mistake every time I design something like this, lol).

And yes, it would be "first true condition wins" semantics, absolutely. Nothing else is reasonable, imo.

martinthomson commented 2 months ago

Good to see progress. 🚲🏚️ Given the extended syntax, is this an "if()" or a "match()"?

kizu commented 2 months ago

A random thought after reading https://github.com/w3c/csswg-drafts/issues/10489, which proposes to align mix() to have of and to keywords instead of commas with what is defined for progress() which has from and to.

I guess, another option for if() could be something like if(A then B else C), although it might be a bit weird when the commas are involved, as visually you'd think the keywords belong to different parts: if(A then B, C else D).

(not sure how I feel about this option compared to others, but I felt it might be useful to bring it up here as it was not mentioned yet)

romainmenke commented 2 months ago

then and/or else could be valid parts of A, B or C. Maybe a grid template area named then :)


AFAIK there is no way to have a new separator for this case that isn't prone to the exact issue that ; is designed to resolve.

Loirooriol commented 2 months ago

Regardless of syntax ambiguities, conditionals need to be general purpose. In particular, people should be able to use keywords inside them. So special-casing some keywords as separators seems a very bad idea. We already made this mistake in grid (see #1137), it shouldn't happen again.

LeaVerou commented 2 months ago

@jimmyfrasche

If the semantics are first true condition wins (which is the easiest to implement and understand, especially) and else is defined to always match then it's valid everywhere but probably only means what you want when it's at the end. This allows some printf debugging by sticking an else in the middle, and it's easy enough for linters to detect and warn about a medial else.

This has the same issue as many other proposed separators, now else: is invalid in other properties (including custom properties).

But yes, +1 for the "first true wins".


@tabatkins

Empty token stream can be accomplished by just omitting the value part, so I don't think it needs to be specially addressed.

I don't understand what you mean. Omit what, how? Can you write an example?


@martinthomson

Good to see progress. 🚲🏚️ Given the extended syntax, is this an "if()" or a "match()"?

It’s still a conditional, so still if(). match() would be incredibly confusing.


Agreed with @Loirooriol wrt keywords, but also this is one of these few cases where conciseness is extremely important because this will be used all over the place. So can we please stop suggesting lengthy function names and separators?

Link2Twenty commented 1 month ago

The ternary syntax makes the most sense to me, I'd imagine in most places a class would be used rather than an if, though one place where if does excite me is with functions and mixins.

/* Syntax not finalised */
@function --lighten-darken(--base, --percent, --ld: 'lighten') {
  @return color-mix(in srgb, var(--base), if(style(--ld: 'lighten') ? #fff : #000) var(--percent));
}

.some-class {
  color: --lighten-darken(var(--palette-primary), 30%, 'darken');
}

.some-other-class {
  background-color: --lighten-darken(var(--palette-secondary), 60%, 'lighten');
}

I love the idea of having multiple statements and the first true being applied though it really feels more like a switch than an if.

background-color: switch(
  matches(:disabled): var(--color-disabled), 
  style(--variant: success): var(--color-success-60), 
  style(--variant: warning): var(--color-warning-60),
  style(--variant: danger): var(--color-danger-60), 
  style(--variant: primary): var(--color-primary),
  default: var(--color-fallback)
);

EDIT: add matches selector and default to switch.


Is there a mechanism for greater than and less than?

  border-radius: if(compare-style(--some-enum >= 2) ? 4px : 0px);
mindplay-dk commented 1 month ago

I see a lot of people bringing up the question of whether this is more like an if, switch or match.

Just my two cents, but these days, every language that's ever had an if-statement eventually realizes they want is pattern matching - an if statement generally implies branching, but CSS is declarative, so a match might be a better choice.

Something like @Link2Twenty 's switch statement (above) but allowing expressions with comparison operators.

This would be much more readable than the ternary style syntax inside the if(...) - especially given that nesting ternaries is already frowned upon in other languages.

It also solves the ambiguity problems and the case where you don't want a default - which, in addition, you make more readable, safer, and easier to parse by actually requiring a default: in the match/switch to indicate a fallback value when no condition is matched.

brandonmcconnell commented 1 month ago

@LeaVerou I opened #10593 and #10594 per my earlier comment re a matches() function. If they were to be accepted, a math-based switch example like the one below could be rewritten inline using reusable and type-safe boolean logic.

BEFORE AFTER
```postcss label { --selected: 0; &:has(:checked) { --selected: 1; } grid-template-columns: auto calc(var(--selected) * 20px); svg { opacity: var(--selected); } } ``` ```postcss label { --selected: match(:has(:checked)); grid-template-columns: auto if(var(--selected) ? 20px : 0); svg { opacity: if(var(--selected) ? 1 : 0); } } ```

** This is a simpler example, and personally, I might still opt for a math-based switch like this in a similar situation, but in many situations, boolean logic might be easier for a developer to grok at first glance.

tabatkins commented 1 month ago

This has the same issue as many other proposed separators, now else: is invalid in other properties (including custom properties).

No, it's fine as long as it sits in the condition position of the grammar. It's just a special condition, spelled else, that always matches. It doesn't interact with the grammar of the value part at all. if(foo(): first value, else: second value)

I don't understand what you mean. Omit what, how? Can you write an example?

If you don't want a value, you can just not write a value, like if(foo(): , else: a value). That's first value part is perfectly reasonable, it's just empty.

So can we please stop suggesting lengthy function names and separators?

Right, thus if( [ <condition> ':' <decl-value>? ]# ). Don't think it's possible to get any shorter/simpler.

brandonmcconnell commented 1 month ago

To be more productive, then, my suggestion for the syntax is:

if( [<<if-cond>> ':' <<decl-value>> ]# )
<if-cond> = <<boolean>> | else

With the condition that else is only valid in the final pair. (And if there's no else and all the conditions fail, the value is IACVT.)

@tabatkins If we settle on the if( [<<if-cond>> ':' <<decl-value>> ]# ) grammar, how would values that contain commas be handled? Would the parser not consider a comma , the statement terminator unless preceding a value followed by a colon :?

Consider this example:

* {
  --val: if(media(width > 200px): media(width > 100px), media(width > 50px), media(width > 10px): 10px),
}

While it may be a bit convoluted, this would be the expected condition-result map, as I see it:

Condition Result Result type
media(width > 200px) media(width > 100px) "<boolean>" (re #10593)
media(width > 50px) 10px "<length>"
(omitted else) IACVT

This is part of why I found the ternary syntax to be a bit clearer, where neither ? or : are currently used in CSS to delimit any values iirc.

tabatkins commented 1 month ago

how would values that contain commas be handled?

We would rely on the semicolon-upgrading text.

So your example would be grammatically invalid as written, but you could upgrade the argument-separating commas to semicolons, like:

if(media(width > 200px): media(width > 100px), media(width > 50px); media(width > 10px): 10px)

So the first value is media(width > 100px), media(width > 50px) and the second value is 10px.

(I presume that was your intended reading; if not, I'm not sure what you meant it to parse as.)

LeaVerou commented 1 month ago

Nice brainstorming so far everyone, but it's time to narrow down the design space so we can make progress. I think we should break down the resolution in two steps:

  1. Get resolution that the syntax should be if( [ <boolean> <c1> <decl-value> <c2>]+ <decl-value> ) where <c1> and <c2> are some single character tokens. This is something that will be used all over CSS so conciseness is important.
  2. Within that far more limited design space, decide what the characters should be. I think the options currently are:
    • For <c1>, ?, :, or ,/;
    • For <c2>, :, or ,/;
chriskirknielsen commented 1 month ago

I feel like using , introduces (potential) confusion for any kind of list, like font-family/box-shadow/background/etc. unless you tuck it away in a variable, while ; and : are already significant symbols and might trip up parsing (emphasis on "I feel" and "might" because I don't work on browser engines!).

The question mark not only feels semantically correct (with the sentence-like structure of is cond true ? if so, here's A : if not, here's B), but if paired with :, we get the familiarity of ternaries many already know from in other languages like JS and PHP (see example one). That said, if the question mark should be reserved for a higher purpose, it might be possible to go all in with : (see example two).

Would the below be viable examples?

one {
    font-size: if(media(width < 800px) ? 1rem : 1.5rem);
    font-family: if(media(print) ? Times New Roman, serif : Helvetica, sans-serif);
    color: if(
        style(--accent: blue) ? cyan :
        style(--accent: red) ? pink :
        white
    );
}

two {
    font-size: if(media(width < 800px) : 1rem : 1.5rem);
    font-family: if(media(print) : Times New Roman, serif : Helvetica, sans-serif);
    color: if(
        style(--accent: blue) : cyan :
        style(--accent: red) : pink :
        white
    );
}

I would learn towards the ?: combo due to ternary familiarity, and how it's easier to pick up where the conditions are, at a glance. It's terse, but readable, whereas the abundance of : in the other example can be confusing unless formatted like above. If you gave me the following (contrived) example, I'd be confused as to where one thing ends and the other begins:

color: if(style(--accent: blue) : var(--color-main) : style(--accent: red) : oklch(from var(--color-secondary) 0.85 50% calc(h/2)) : var(--color-base))

Anyway, just my two cents, and I wanted to make sure I understood what is potentially feasible. Very excited for this to land either way!

mindplay-dk commented 1 month ago

The syntaxes proposed and discussed here all echo the most criticized "bad practice" patterns in other languages - in my opinion, we need to think harder about the syntax and come up with something that doesn't do that.

If we push for something like this out of impatience, there's a good chance we're making the situation irreparably worse, in the sense that any future syntax attempting to address the problems and shortcomings of an early syntax will need to be backwards compatible, which really narrows our options - which are already narrow.

That said, why are we trying to force this into property-level conditionals?

Wouldn't it make more sense to take cues from established and popular languages such as SASS, where conditionals are structural concepts? CSS is really all about thinking and designing in terms of structure.

Something in this direction would be immediately easy for almost anyone to understand:

.element {
  /* Base styles */
  font-family: sans-serif;
  color: black;

  /* Conditional block */
  @if (--theme == 'dark') {
    color: white;
    background-color: #333;
  } @else if (--theme == 'light') {
    color: #333;
    background-color: #f0f0f0;
  } @else {
    /* Default theme */
    color: #444;
    background-color: #e0e0e0;
  }

  /* Media query integration */
  @if (media(min-width: 768px)) {
    width: 750px;
  } @else {
    width: 100%;
  }

  /* Nested conditions */
  @if (--layout == 'grid') {
    display: grid;
    @if (media(min-width: 992px)) {
      grid-template-columns: repeat(3, 1fr);
    } @else if (media(min-width: 768px)) {
      grid-template-columns: repeat(2, 1fr);
    } @else {
      grid-template-columns: 1fr;
    }
  }
}

It's a bit more verbose, but a lot more readable and less cramped than trying to force complex expressions to fit into property values, I think? It also accounts better for situations where you want the same condition to control two alternative properties - that's already common with things like media queries, and I'm betting you would find, in practice, the same requirement would exist here.

I understand there is a potential for creating cycles with a feature like this, but... developers should know not to do that. 😅

And it's not too difficult to think of a relatively simple approach to mitigate that problem, without resorting to complex static analysis or ugly runtime checks like cycle prevention - for example, maybe we specify a simple two-phase evaluation, such that:

  1. CSS properties are updated in the first phase, and
  2. custom properties are updated in a second phase.

So to illustrate, consider an example like this:

:root {
  --theme: 'light';
}

.element {
  @if (--theme == 'light') {
    background-color: #333;
    --theme: 'dark';
  } @else {
    background-color: #fff;
    --theme: 'light';
  }
}

In terms of execution, it would work something like this:

.element {
  /* PHASE 1: Condition evaluation */
  @if (--theme == 'light') {
    background-color: #333;
  } @else {
    background-color: #fff;
  }

  /* PHASE 2: Property modifications */
  @if (--theme == 'light') {
    --theme: 'dark';
  } @else {
    --theme: 'light';
  }
}

In other words, you can update custom properties, but only for the purposes of using those values in CSS properties - it's not going to cascade or have any impact on other conditionals.

If users do attempt to write CSS with cascading conditional effects, in addition, perhaps the browser would issue a warning, letting them know they're getting a previous custom property value in their conditionals, rather than the updated value.

Full disclosure: I'm not at all certain about this idea, just putting it out there. 😅

Link2Twenty commented 1 month ago

It feels like the @if you're describing is a way to replace all conditional rules with a new syntax that can mix and match them.

.element {
  @if media(width >= 993px) and supports(display: grid) and container(orientation: landscape) and style(--accent-color: blue) {
    content: 'You match our exact use case';
  } @else {
    content: 'Oh no!'
  }
}

I have to admit @if does feel to fit the current CSS syntax better than inline but ?, : would get my vote for inline syntax.

LeaVerou commented 1 month ago

The downside of the publicity this issue got is many people are parachuting into it and confidently asserting they can solve all the problems without being aware of the numerous constraints we need to navigate for something like this to even be possible. 😕

@mindplay-dk @Link2Twenty Believe me, we’d all LOVE to do @if and do it properly, and me more than anyone. However, there are currently technical constraints that prevent us from doing so, namely that we don’t want a rule that would have different behavior on descendants (like style queries) and different on the element itself (IACVT). Perhaps in the future it may become possible, if we implement something like #10443 to make IACVT behave as similarly to the regular cascade as possible.

for example, maybe we specify a simple two-phase evaluation, such that:

  1. CSS properties are updated in the first phase, and
  2. custom properties are updated in a second phase.

These are not independent, custom properties can depend on certain CSS properties, and CSS properties often depend on custom properties. Also, you’re proposing a very major change to how CSS is evaluated, and major changes require major benefit to be justifiable, otherwise they stand no chance of implementation.

I understand there is a potential for creating cycles with a feature like this, but... developers should know not to do that. 😅

Yeah, "users should just not do that" is not how you design a language (or any UI really).

Wouldn't it make more sense to take cues from established and popular languages such as SASS, where conditionals are structural concepts?

Even when languages have block conditionals, they ALL also have some kind of inline conditional, because there are just so many use cases where only a small part of a value actually varies, and a block conditional would actually make code less readable. Yes, even Sass. 🙂

CSS is really all about thinking and designing in terms of structure.

Is it now? 😅

mindplay-dk commented 1 month ago

The downside of the publicity this issue got is many people are parachuting into it and confidently asserting they can solve all the problems

I'm sure you're not talking about me, because I wrote in italics "I'm not sure about this idea".

I've been watching this thread for about a month, so I don't think I'm parachuting in either.

I don't know how CSS engines are implemented internally, and my idea might be totally unrealistic. I'm not a browser or language engineer, I'm only commenting as someone who has been writing CSS since the 90s.

I'm just an end user, but you seem to agree that my opinion probably echoes that of most end users:

Believe me, we’d all LOVE to do @if and do it properly

That's why, as said, I hope we don't push for this out of impatience - any web feature effectively becomes a permanent fixture of the platform for eternity. If we add a half feature with already well established drawbacks, the syntax and complexity better not get in the way of doing something different, in the future, that "we'd all LOVE", right? 🙂

dead-claudia commented 1 month ago

As someone who's off-and-on followed a few of the parent issues for years (I didn't even know this aggregate issue existed until today), I would like to point out that @if could be desugared into a series of if(cond, then, else) if needed.

Personally, I'd like to see both an at-rule block and a ternary expression happen, not just one or the other. Both are useful in their own right, and most major languages nowadays that have separate statement and expression contexts have both. It doesn't have to be either-or.

mindplay-dk commented 1 month ago

@dead-claudia I completely agree - but if I had to pick one, I wouldn't pick ternaries, because nesting them gets really confusing, really fast. There are good reasons why nested ternaries are frowned upon in other languages. If that's the only choice, confusing code and frowny faces is what we'll get.

I'd rather have high verbosity readable code than low verbosity unreadable code.

Unreadable code leads to bugs - verbosity only hurts developer sensitivities. 🙂

dead-claudia commented 1 month ago

Quick question about the original comment: shouldn't the BNF for the multi-condition <if()> be this?

<if()> = if(
    [ <container-query>, <declaration-value>? ]#{1, }
    [ , <declaration-value>? ]?
)

That 1. fits your intent and 2. brings consistency with var(...).

andruud commented 4 days ago

We are planning a prototype of this in Blink, and it would be great to see some progress here.

As far as I can tell from reading the comments, there are two main syntax proposals:

A) Comma/else, from @tabatkins / @Loirooriol:

background: if(
    style(--status:error): red,
    style(--status:warning): yellow,
    else: green
);
background: if(
    style(--status:error): url(error.png), red;
    style(--status:warning): yellow;
    else: green
);

I see earlier proposals from Oriol have a plain <declaration-value> as the fallback (no else:), but I assume this isn't an option, since we couldn't distinguish a single fallback <declaration-value> from a <cond> ':' <declaration-value>. (See also #10818).

B) ?:, from @LeaVerou:

background: if(
    style(--status:error) ? red:
    style(--status:warning) ? yellow:
    green
);
background: if(
    style(--status:error) ? url(error.png), red:
    style(--status:warning) ? yellow:
    green
);

I can live with either, but the fact that <declaration-value> allows : does weigh in favor of (A) in my opinion.

LeaVerou commented 4 days ago

@andruud Exciting that a prototype is in the works! I think we're at the point where we need separate issues to discuss the individual design decisions. I'll try to do that in the next few days.

FWIW I think my biggest issue with (A) is the verbosity of an explicit else: clause — this is a feature that will be used all over the place, and thus is one of the few cases where terseness should be a priority.

Btw IACVT handling is identical across the two — unless I’ve misunderstood a part of Tab's proposal, their differences are purely syntactic.

Loirooriol commented 4 days ago

we couldn't distinguish a single fallback <declaration-value> from a <cond> ':' <declaration-value>

I think we could distinguish them because only the last item of the comma/semicolon-separated list would use the former. All the other items in the list would need to parse <cond> ':' before the <declaration-value>. But I don't really mind having the else: or not.

SebastianZ commented 4 days ago

@mindplay-dk @Link2Twenty Believe me, we’d all LOVE to do @if and do it properly, and me more than anyone. However, there are currently technical constraints that prevent us from doing so, ...

I wonder why you (or anyone else in this thread) didn't mention it so far, though there are already general conditional at-rules named @when and @else in CSS Conditional 5. So far, they only cover @supports and @media use cases, though. I.e. they do not cover (custom) properties at the moment. This is due to the technical constraints mentioned.

And yes, an inline solution and a structural solution don't exclude each other, though this proposal explicitly discusses an inline conditional solution. Any structural solution should be discussed separately. I'd just note that both should be aligned with each other, i.e. use the same keyword (so probably when()) and functions (e.g. style() as initially suggested plus media() and supports() and possibly others).

Sebastian

andruud commented 4 days ago

Btw IACVT handling is identical across the two — unless I’ve misunderstood a part of Tab's proposal, their differences are purely syntactic.

@LeaVerou Oh, does that mean the : would actually be optional in the ?: approach? E.g.:

background: if(
    style(--status:error) ? red:
    style(--status:warning) ? yellow
);

(IACVT for --status:ok, for example.)

FWIW I think my biggest issue with (A) is the verbosity of an explicit else: clause — this is a feature that will be used all over the place, and thus is one of the few cases where terseness should be a priority.

I do see that for short one-liners, e.g.:

width: if(style(--wide): 200px, else: 100px);
/* vs */
width: if(style(--wide) ? 200px : 100px);

For a multi-line thing with multiple clauses, though, the else: seems to fit in a little better without feeling verbose. Could we somehow drop the else:, and make it just: if(style(--wide): 200px, 100px)? (Oriol seems to think "yes").

we couldn't distinguish a single fallback <declaration-value> from a <cond> ':' <declaration-value>

I think we could distinguish them because only the last item of the comma/semicolon-separated list would use the former. All the other items in the list would need to parse <cond> ':' before the <declaration-value>. But I don't really mind having the else: or not.

@Loirooriol This would have to exclude : from the fallback's <declaration-value>, then? Otherwise, all of style(--status:warning): yellow (a valid <declaration-value>) would actually be treated as the fallback value?

background: if(
    style(--status:error): red,
    style(--status:warning): yellow
);

Maybe we can make the else: part optional:

In the hopefully super-rare event that you want literally style(--status:warning): yellow (or similar) as your fallback value, you'd then do:

--foo: if(
    style(--status:error): red,
    else: style(--status:warning): yellow
);

You'd also still use else: to get an empty (but valid) value, if needed.

This would make the above mentioned one-liner just width: if(style(--wide): 200px, 100px), beating the ?: approach by two characters when you adhere to my whitespace preferences. :-)

Loirooriol commented 4 days ago

Ah, I was thinking that the fallback wouldn't be optional. But sure, I guess IACVT if there is no fallback can be useful.

Your idea of an optional else: seems to work too.

tabatkins commented 3 days ago

The problem with omitting else (or something similar) is grammatical, and fundamental.

In theory, we could address the first by the presence/absence of the argument separator. That is, if(foo(): bar) and if(foo(): bar,) could indicate the two states - the first is IACVT if foo() doesn't match, the second has an empty catch-all if foo() doens't match. But that's a tiny, difficult-to-distinguish syntax difference, absolutely a language-design no-no almost all of the time.

The second, tho, is unresolvable. In if(foo(): bar, baz(): qux), baz(): qux is a perfectly valid <declaration-value>! So it's impossible to tell whether it was meant to be "two clauses with no catch-all, evaluating to either bar, qux, or IACVT" or "one clause with a catch-all, evaluating to either foo or baz(): qux".

Without an explicit and required start-of-clause syntax before the <declaration-value>, it's impossible to separate these two cases. A <declaration-value> simply cannot be directly preceded by a repetition or an optional value, ever, unless you explicitly specify that it's not allowed to match the grammar it's ambiguous with.

So, we either need to remove the grammar, or require a prefix. Removing the grammar requires that we either carefully carve out the existing syntax only (which makes it potentially dangerous to evolve things in the future, or handle errors), or carve out a wide syntax (like, basically banning <general-enclosed>: from the start) and that still somewhat limits our future evolution potential. Requiring a prefix has no such downsides; the prefix doesn't have to be spelled else, but if we don't use that, we'd be fighting against the headwind of nearly every conditional syntax in the world.


While writing this comment, I realized that neither syntax can handle "single condition with a comma in its value, and no catch-all" well. That is, if you write if(foo(): bar, baz) it can't be interpreted as having a single condition with the value bar, baz; under the else: grammar it's just invalid syntax (baz isn't a valid condition/value pair), while under the implicit catch-all it's a value of foo and a catch-all value of bar. In both cases, you need to supply a second always-false condition to give yourself the opportunity to put a semicolon in and force the comma to be part of the value.

We should fix this with another special clause, I guess - say, just else none. Also a catch-all, but, resolves to IACVT, so you could write if(foo(): bar, baz; else none). You could do this in other cases, to make it clear that you know your conditions aren't exhaustive and might not match, but normally you'd just say nothing and let it implicitly trigger IACVT.

(Note the lack of a : in the else none; required to make it unambiguous.)

LeaVerou commented 3 days ago

@tabatkins these are all non-problems in the if(?:) syntax, so I'd argue you just listed some cons of the other syntax. 😁 Can we simply disallow : from <declaration-value>? Do we have any declarations where that’s valid? For custom properties, I can ask the HTTPArchive folks for some data.

tabatkins commented 3 days ago

Using ?: doesn't change anything about my critiques. All that ?: does it change the separators (from :, or :;), but once that swap is made, the problems are exactly the same - you'd still be distinguishing between if(foo()? bar) and if(foo()? bar:) for IACVT vs empty catch-all, which isn't good, and if(foo()? bar : baz() ? qux) still might be a catch-all with the value baz(): qux.

The basic takeaway is that you don't want to fight with wide grammars, they're a lot slippier than you'd think. Always just pin them down exactly.

kizu commented 3 days ago

Could the IACVT vs empy be solved via something like valid-empty keyword, see https://github.com/w3c/csswg-drafts/issues/10441, and treating the actual empty in this case as IACVT? That will make it behave differently from how custom properties currently work, but I find it acceptable. And, as expressed in that other issue, I consider an explicit keyword a better design for this fringe edge case anyway. Especially, given most of the usage for the empty value right now are for conditionals :D

Re: the problem with the <declaration-value>, I'd +1 to @LeaVerou for disallowing : from it.

andruud commented 3 days ago

The second, tho, is unresolvable. In if(foo(): bar, baz(): qux), baz(): qux is a perfectly valid <declaration-value>! So it's impossible to tell whether it was meant to be "two clauses with no catch-all, evaluating to either bar, qux, or IACVT" or "one clause with a catch-all, evaluating to either foo or baz(): qux".

My optional-else proposal tries <cond> ':' <declaration-value> first, and if that matches, treats it as a clause, even if the whole thing also matches <declaration-value>. So this example would be "two clauses with no catch-all" (given a reasonable behavior for https://github.com/w3c/csswg-drafts/issues/10818). If you want it the other way, you'd prefix with else:.

Though it might need to be [ <cond> ':' <declaration-value-without-colon> ] | <declaration-value>. So basically any catch-all value with a : requires an else:.

tabatkins commented 3 days ago

if(foo(): bar, valid-empty) is longer than if(foo(): bar, else:), so if the concern was terseness that's worse. It also requires us to reserve a new global keyword, which isn't needed in any other arbitrary-substitution function that wants to resolve to empty for some case.

It also doesn't solve the bigger problem of "can't tell whether the last clause is a catch-all or not".

I'd +1 to @LeaVerou for disallowing : from it.

Banning : doesn't fix anything, you need to ban ? instead. And I'm relatively firmly against imposing arbitrary restrictions on the decl-value, which aren't present in any other arbitrary-substitution function, just to avoid having to type else: when you have a catch-all (like almost every other conditional in the world does).

My optional-else proposal tries ':' first, and if that matches,

We'd have to put some really wide restrictions on what the un-prefixed decl-value can contain, to avoid things like authors using a new condition type being treated like a catch-all in older browsers.

So basically any catch-all value with a : requires an else:.

Yeah, I think that's the wideness we would need.

But I'd prefer to push back on these special restrictions just for the purpose of avoiding else. I think your proposal works if we're forced into it, but I'd like to see if the WG actually feels like this needs addressing first before accepting the compromise.

kizu commented 3 days ago

I spent some time thinking about what I, personally, would want to see as a syntax, without thinking about any potential technical limitations, and so it would fulfill most of the use cases mentioned here.

Here is what I got:


Single branch: bar when isFoo is truthful, IACVT otherwise.

if(isFoo: bar)

the same as

if(isFoo: bar;)

empty (\s*) after the semicolon is not considered as an empty, will be treated as IACVT if isFoo is falsy.


Two branches: bar when isFoo is truthful, baz otherwise. Only use a ; here, do not accept a comma.

if(isFoo: bar; baz)

Same, with an explicit else: branch:

if(isFoo: bar; else: baz)

Same, with an optional semicolon in the end, formatted with newlines:

if(
    isFoo: bar;
    else: baz;
)

This is the reason I want to have only a semicolon, without a comma as a delimiter here, as well as not treat the emptiness after the semicolon as an empty value — this notation looks very close to what we have in CSS with declarations and descriptors.

The optional else: allows for a more concise form when needed, but also a more explicit one, which could also be used for the empty fallback case:

if(isFoo: bar; else:)

Same as

if(isFoo: bar; else: ;)

This will also allow, and work very well in how it would look for multiple conditions:

if(
    isFoo: bar;
    isSomethingElse: baz;
    else: boo;
)

Note: I, personally, don't have a strong preference for any of these variants; I equally like the ? : syntax, and the more expressive else, but I dislike the comma-semicolon disambiguation, as I think it is very difficult to parse for authors, and an enforced semicolon in case of conditions is very easy to remember, as authors are used to writing the code this way (and an optional omitting of the else: is just a nice shortcut).

tabatkins commented 1 day ago

hmmm yeah, I might agree that in this case going straight for a semicolon is the answer, given the rest of the structure so closely resembles the prop: val; syntax we're used to. The comma-upgrading is great when it's just a list of values, so it can look like a normal CSS function with simple values, but in this case the syntax is already funky. And that avoids the "one clause with a comma-containing value, with IACVT if it doesn't match" problem I'd cited earlier, which is nice and elegant.

I also agree that allowing the final clause to end with the semicolon, and having that not mean anything semantic (just like in rules) is good.

However, the "no else, just implicit catch-all" still fails due to the problems I cited previously - can't distinguish an empty catch-all from no catch-all, and can't distinguish a final test clause from a catch-all. You'd need to make the "bare catch-all" grammar specifically exclude some things and still have else: to let you write that stuff, like Anders suggested. Having to navigate those restrictions just doesn't feel worthwhile, versus just requiring the else: on the catch-all clause.

So yeah, I think I support just going straight to using semicolons in this case. That means a grammar of:

if() = if( <<if-clause>> [ ';' <<if-clause>> ]* ';'?  )
<if-clause> = <<if-cond>> ':' <<decl-value>>
<if-cond> = <<boolean>> | else
astearns commented 1 day ago

Chair hat off, I like the semicolons and requiring else.

But wouldn’t the grammar above allow more <<boolean>>s after the else, or multiple elses?

Loirooriol commented 1 day ago

Not opposed to requiring else: due to the 2nd reason provided by Tab, but I don't get the problem of the trailing comma/semicolon changing the meaning. It already happens with var():

background: green var(--invalid); /* IACVT */
background: green var(--invalid,); /* green */