Open LeaVerou opened 4 months ago
Style container queries: these are great, but only work on descendants.
when i first discovered style queries, i admit i had intuitively tried to do something like this:
@container style(--variant: large) {
& {
/* style the container itself */
}
}
it seems a way to select the container that @container style()
matches would be a nice stepping stone but i have nowhere near enough context/history to know if that is doable.
or maybe enabling style queries as part of the @scope
api?
@scope style(--variant: large) {
:scope {}
}
I had a chat with @andruud a few days ago, and he said that this proposed design would be fairly easy to implement.
Edit: updated first post
Yeah, at least if we choose the path forwards carefully, it should be easy. That carefully chosen path could be something like:
if()
in a declaration's value, you get the "assumed valid at parse time" behavior we currently get when there's a var()
or env()
in the value.
if()
then mostly just becomes an advanced var()
substitution, with some conditionals built in to it.if()
function can evaluate exactly what style CQs can evaluate, and nothing else (for now).
Of course we'll need to deal with cycles. e.g.:
div {
--x: if(style(--y: 1), 0, 1);
--y: var(--x);
}
But we should be well equipped to handle that.
So if the CSSWG determines that this is worthwhile, it should be quite doable overall.
I suppose it's worth thinking about custom functions here as well, since they also kind of provide "inline" conditionals. It might not be ergonomic to use for the use-cases you have in mind, though.
It would seem a bit strange to me if display: if(--x, nonsense, bullshit)
is considered valid at parse time, since we know that both possibilities are invalid.
It would seem a bit strange to me if
display: if(--x, nonsense, bullshit)
is considered valid at parse time, since we know that both possibilities are invalid.
I’m fine to make it invalid at parse time, but it seems like a lot of work for an edge case. Especially given there might be multiple conditionals for a single property value, so it's a combinatorial explosion to tell if any of them would result in a valid value.
Substituting an
if()
then mostly just becomes an advancedvar()
substitution, with some conditionals built in to it.
- The
if()
function can evaluate exactly what style CQs can evaluate, and nothing else (for now).This means only custom properties are supported in practice. (Despite style() currently being specified with support for standard properties, implementations never picked it up.)
Yup. How hard would it be to support other queries beyond style()
? E.g. size queries, supports()
, media()
?
(style()
does address the vast majority of use cases, but if any of these are easy, we may as well support them too.)
I don't see why we couldn't do supports()
and media()
, but size queries would cause cycles with layout that are hard/impossible to even detect. (That's why we needed the restrictions we currently have for size CQs in the first place.)
I don't see why we couldn't do
supports()
andmedia()
, but size queries would cause cycles with layout that are hard/impossible to even detect. (That's why we needed the restrictions we currently have for size CQs in the first place.)
Fair. So we’ll need a distinct token and not <container-query>
.
@tabatkins is there any easy way to do that without copying all the logic for and
/or
/not
?
A few more workarounds authors can use right now:
<number>
custom property.<ident>
custom property.1
or 0
values it is possible to work around the absence of conditions for <length>
by using rather cumbersome but working calculations as a way to compare lengths: https://codepen.io/kizu/pen/WNBEKvWI recommend going to the linked articles and codepens to witness the hacky CSS that is currently required to achieve those conditionals.
Given how often authors reach out to things like space toggles or come up with more and more convoluted ways to achieve conditions for different use cases (Is a custom property defined? Is value A is smaller than B? Are those two conditions true? Etc.), we really need at least some way to have these kinds of conditions.
Ideally, I'd want to have both inline conditions, but also an at-rule-level ones, but even if we will resolve only on inline conditions it will improve the lives of authors tremendously.
is there any easy way to do that without copying all the logic for and/or/not?
Unfortunately not, you gotta just write out the full grammar. It's mostly copy-pasting tho.
there might be multiple conditionals for a single property value
Ah, I was thinking of a <whole-value>
. If it's not, then it makes more sense to accept everything, yes.
If we go with grammar 2 (which I think is the better grammar), I'd note that most programming languages don't call that construct "if", but rather "switch", "select", "case", "cond"… I'd suggest going with "switch", since that's what it's called in JS.
The CSS Working Group just discussed [css-values?] What is the MVP for inline conditionals on custom properties?
.
RESOLVED: Add if() to css-values-5
The syntax as described makes it slightly impossible to say "else be IACVT" in some cases. (For these examples, I'm gonna assume we use :
between the condition and value, btw.)
if(style(...): foo, bar)
could be a single value foo, bar
(and IACVT if false), or two values (foo
if true, bar
if false). This does have a defined parse - it's always two values. But if you wanted it to be one value, and then IACVT for false, you can't write that.
(This ambiguity doesn't occur if there are multiple chained conditions. if(style(...): foo; style(...): bar, baz)
unambiguously doesn't have a default case; the bar, baz
is the whole value for the second condition. It's only in the single-condition case that we haven't yet gotten unambiguous information about what the separator is.)
So I think we'll need an explicit syntax for saying "no default condition". You don't usually need it, but occasionally you will, and it might make some cases clear even when it's not strictly required. Like, if(style(...): foo, bar; no-value)
or something. (Special keyword, allowed only as the whole value of the final argument, always matches but resolves to the guaranteed-invalid value.)
@tabatkins Why not simply make the last argument mandatory and allow empty values (just like var()
)?
Compare:
Option 1 (mandatory last argument that can be empty):
border-radius: if(style(--button-shape: pill), infinity, );
Option 2 (default:
clause):
border-radius: if(style(--button-shape: pill), infinity / default: );
One advantage of having a special value (e.g. revert-declaration
?) is that perhaps we can improve on the IACVT behavior by keeping other declarations around when that is encountered (but probably we can't).
If that’s not an option, I'd rather introduce a different separator than a whole keyword. I’m generally all for prioritizing readability, but readability is a balance: too concise and it doesn't make sense, too wordy and you have to wade through the clutter to understand it. With something that I expect will be used all over the place, conciseness matters quite a lot.
hy not simply make the last argument mandatory and allow empty values (just like var())?
That's possible, I just find it less clear to read. I don't really like how var()
allowed it.
One advantage of having a special value (e.g. revert-declaration?) is that perhaps we can improve on the IACVT behavior by keeping other declarations around when that is encountered (but probably we can't).
I'm not trying to solve any feature lack, just a syntax lack around the existing features.
If that’s not an option, I'd rather introduce a different separator than a whole keyword. I’m generally all for prioritizing readability, but readability is a balance: too concise and it doesn't make sense, too wordy and you have to wade through the clutter to understand it. With something that I expect will be used all over the place, conciseness matters quite a lot.
We can't invent infinite separators. ^_^ But also, this is something that is rarely needed - like I said, the only case that requires it is when you want to supply a single test, with a positive value containing a comma, and no negative value. If you do want to use it for clarity in other cases, I think it's perfectly readable. For example:
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),
no-value
);
If that’s not an option, I'd rather introduce a different separator than a whole keyword.
Actually, let me state this better: this isn't about the separator. The issue is just that the grammar doesn't allow an empty option (and I don't think it should), so there's one particular case you can't express.
@tabatkins Actually, if we don’t use a custom character for separating the condition from the values like the original proposal, this becomes a non-problem. By the time you get to the first value, you know what your separator is, so there’s no ambiguity in if(style(...), foo, bar)
.
@fantasai you raised the issue of using a distinct character for separating the condition from the value(s), and I agree this would be nice. However, given the ambiguity this introduces, are you ok with going with a comma/semicolon for both to avoid having to introduce weird new keywords like no-value
?
@LeaVerou I don't understand the problem.
@LeaVerou I don't understand the problem.
If we use a different character to separate the condition from the values (say ?
or :
), then if(style(...) ? foo, bar)
is ambiguous: is foo, bar
as a whole the if true value, or is the comma a separator? This is because the first time we encounter the separator is when it separates the if true value from the if false value, and we don’t know when to stop parsing these, so we rely on the separator. However, the grammar for conditions is far more restricted, so we know when we’ve stopped parsing the condition, and the character that follows (;
or ,
) is the function separator, so if(style(...), foo, bar)
has no such ambiguity.
The other ways to address it are to make the last argument mandatory and either:
if(style(...) ? foo)
)if(style(...) ? foo, no-value)
)That said, you may want to use an empty value for the if true condition, so these may be orthogonal (though you can always negate the condition, so that’s just syntactic sugar). We could discuss what that value would look like in #10441 .
I hate to be that guy, but I think the name of the function itself could be improved, instead of just "if"? For example, SQL has the IIF() function which works like this IIF(expression, value-if-true, value-if-false)
. In general "if" means branching but here we are not branching, we are "selecting" a value depending on an expression, correct?
By the time you get to the first value, you know what your separator is, so there’s no ambiguity in if(style(...), foo, bar).
urgh, i really wouldn't want if(style(...); foo, bar; ...)
. Gives me aesthetic icks.
In general "if" means branching but here we are not branching, we are "selecting" a value depending on an expression, correct?
This is equivalent to an if/elif/elif/else chain. Whether an if
is a statement (just executing code) or an expression (evaluating to some value) varies between languages. In the context where this exists in CSS, it's clearly an expression-level thing; the equivalent of an if
"statement" would be an @if
rule
@LeaVerou I think it isn't more ambiguous than font-family: random-item(serif, sans-serif, monospace)
, as mentioned https://github.com/w3c/csswg-drafts/issues/9539#issuecomment-1999706240, which needs some spec edits (unless they have already happened), but then it's well-defined?
One advantage of having a special value (e.g.
revert-declaration
?) is that perhaps we can improve on the IACVT behavior by keeping other declarations around when that is encountered
Such a thing is being discussed in #5319
If we use a different character to separate the condition from the values (say ? or :), then if(style(...) ? foo, bar) is ambiguous:
It's not ambiguous - as I said in my earlier comment, it has a defined parse. Absent any semicolons, commas are function argument separators. (#9539 fixes notwithstanding). It's just that you can't indicate you want a comma-containing value, and then nothing for the false case.
@tabatkins Per your example, could we not simply omit a final value to default to no-value
/* Revised example (without `no-value`) */
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)
);
Omitting a final value would be equivalent to using no-value
and may help to avoid introducing a new value:
<if()> = if(<condition>: [<declaration-value>]{1, N}; <optional-fallback-value>?)
Would this grammar paired with semicolon delimiters resolve the ambiguity here, so any of the below expressions are valid?
/* no fallback value defined, equivalent of no-value */
if(style(--x: 1): 1, 2; style(--x: 2): 3); /* 2 values: (1, 2) and (3) */
/* fallback value defined as 4 */
if(style(--x: 1): 1, 2; style(--x: 2): 3; 4); /* 3 values: (1, 2), (3), and (4) */
/* ending with a semicolon could optionally be supported, but it would be the same output as the first example, omitting the value altogether */
if(style(--x: 1): 1, 2; style(--x: 2): 3;); /* 2 values: (1, 2) and (3) */
/* not a primary syntax, but I suppose a space could also pass, which may be useful in more complex situations like when used in inside `var()` */
if(style(--x: 1): 1, 2; style(--x: 2): 3; ); /* 3 values: (1, 2), (3), and ( ) */
since this is more of an inline switch
than an inline if
should the default condition have special syntax like if([...] otherwise: x)
, if([...] default: x)
, if([...] *: x)
? (I'm using [...]
metasyntactically to denote the non-default conditions).
It would make it easier to spot the default when reading
After discussing with @fantasai, we think the best way forwards is:
?
separator between condition and values,
or ;
separator between if-true and if-false values, same as other CSS functionsif(style(...) ? foo, bar)
is parsed as "foo if true, bar if not" and if authors desire foo, bar
to be parsed as a whole as the if-true value, they can do if(style(...) ? foo, bar; )
(or if(style(...) ? foo, bar; /* else nothing */)
with a comment).Is :
as the separator between if-true and if-false values possible?
Is
:
as the separator between if-true and if-false values possible?
The question is not whether it’s possible, but whether it’s somehow better. Is there any reason other than looking like JS?
It avoids the ambiguity around ,
that you mentioned. And I think ternaries in lots of languages use ?
and :
, not just JS.
Given how ,
is a predominant token across it feels like a less used token like :
would be preferable as it does avoid the ambiguity. That it shares similarity with JS seems to be a nice bonus for simplifying the teaching of the syntax.
Ok, here’s a small scorecard:
,
/;
separator:
separatorReason | Argues for… | Points |
---|---|---|
Consistency with other CSS functions | A | 2 |
We’re already going with a different separator for the condition, so the ship has sailed wrt consistency with other CSS functions | B | -1 |
: was a poor syntactic choice to begin with. The meaning of : in natural language is to explain something (e.g. "if(): A conditional function"), not to provide alternatives |
A | 1 |
There is no precedent for both if() and a ? b : c , in every other language it’s either a function or a bare a ? b : c |
A | 2 |
: could throw off existing CSS parsers that assume : separates properties/descriptors from values. |
A | 1 |
Consistency with other C-like languages, including JS. | B | -3 |
: is less likely to conflict with the value |
B | -2 |
Total | Tie! | 0 |
Can anyone think of any other reasons?
I prefer :
as a separator between the condition and the if-true value, instead of ?
.
if(
cond1: val1,
cond2: val2,
fallback
)
Here's an alternative proposal that doesn't require having an if-false value in the function at all:
background: --if-false;
background: if(style(--override-bg: true), --if-true);
The second declaration would be considered invalid if the if()
evaluates to false, falling back to the previous declaration. Maybe that feels more CSSy?
I prefer B, which appears to remove all ambiguity here with ,
/;
.
@LeaVerou Could we support some convention for stacking if contexts without nesting if()
function usages?
* {
/* LESS PREFERABLE ❌ */
--nested-function: if(style(--x: 1) ? 1, 2 : if(style(--x: 2) ? 3));
/* PREFERABLE âś… */
--nested-syntax: if(style(--x: 1) ? 1, 2 : style(--x: 2) ? 3);
}
Note in both of these examples that no else
delimiter is needed and no-value
is assumed when no final :
else/if-false clause is used.
I'm sure someone else in this thread can probably soundboard me on this, but I think this should be doable. As long as an if/else clause returns a boolean followed by a conditional operator ?
/:
, we can assume we are using a nested conditional syntax rather than the boolean value itself. A boolean value could technically be a returned value if not followed by conditional syntax ?
/:
.
Some examples of this:
if(style(--x: 1) ? 1, 2 : style(--x: 2) ? 3)
returns one of…
1, 2
3
if(style(--x: 1) ? 1, 2 : style(--x: 2) ? 3 : )
returns one of…
1, 2
3
if(style(--x: 1) ? 1, 2 : style(--x: 2))
returns one of…
1, 2
TRUE
/FALSE
(dynamic value of style(--x: 2)
)The third of these examples assumes support for boolean values in CSS. I know these have been talked about a lot, and I believe they may have even been accepted for a future version, but I am not entirely sure. If they have not yet been selected, then the third example would be invalid syntax until boolean values are supported.
@benface That syntax doesn’t work with IACVT.
@brandonmcconnell It’s the second grammar we’re going with, so no nesting is needed anyway.
Indeed @LeaVerou, thank you for reminding me how this works. Interestingly, you wrote a related proposal that I hadn't seen shortly before I wrote the above: https://github.com/w3c/csswg-drafts/issues/10443. With !revertable
, we wouldn't necessarily need to handle if-false / fallbacks in if()
, would we?
Agenda+ to resolve on syntax.
Thinking about this some more, I think we should go with if ([cond ? iftrue : iffalse ]+)
?:
is a well-established pair for ternaries, and even though having both a function and ?:
does not have a precedent, I think it’s an ergonomics improvement as it makes the conditional far easier to read (I’ve seen beginners struggle with understanding ?:
ternaries way too often) and means we could have an optional else
.?
to separate the condition from the values and ,
to separate the values from each other seems like a very weird halfway pattern. Also, given the author need, this should not be held up by syntax. If we don’t have consensus on how to represent an empty value, or whether the last else value should be optional, let’s just spec a version of if()
where the last argument is mandatory, and cannot be empty while we figure this out. Worst case, authors can always do this as a workaround:
--empty: ;
background: if(style(--variant: primary), var(--color-primary), var(--empty));
if([cond ? iftrue : iffalse ]+)
I guess, it should have the #
with the comma-semicolon thing we decided earlier? (+
will be repeating it with space, #
with commas, see https://drafts.csswg.org/css-values-4/#component-multipliers)
Overall, I like the if(a ? b : c)
syntax, as the ternaries are common, and we can't use it without the wrapping if()
due to the :
being significant in CSS syntax.
That said, I wonder if we could do this similarly with calc()
, where we could allow using the ternaries in some other contexts without using the wrapping if()
? Like we can use calculations without calc()
inside things like clamp()
and round()
, we could allow using ternaries inside calc()
and anything similar.
Re: empty value, I think we should allow just omitting the value with optional whitespace to follow how the --foo:;
and var(--foo,)
work now: so the if(a?b:)
and if(a?:b)
should work fine.
@kizu @LeaVerou What would be the correct grammar for “ternary delimited” conditions, as it appears the syntax would be closer to the below:
* {
--prop: if(style(--x: 'one') ? 1 : style(--x: 'two') ? 2 : 3);
}
Would that use a #
or +
if we’re not delimiting expressions by commas or semicolons?
Btw @kizu I really like your idea of supporting ternary expressions in certain other functions without needing to nest if()
similar to how we can use calc()
-like expressions inside max()
and others iirc.
We would not want to support them in all parenthetical contexts, would we, making if()
optional altogether? It would need to be in a function, not simply between parentheses?
* {
--prop: (style(--x: 'one') ? 1 : style(--x: 'two') ? 2 : 3);
}
I think the explicit nature of if()
will be useful for readability, but I was curious of the possibility here.
if([cond ? iftrue : iffalse ]+)
I guess, it should have the
#
with the comma-semicolon thing we decided earlier? (+
will be repeating it with space,#
with commas, see drafts.csswg.org/css-values-4/#component-multipliers)
No, because there are no commas. The separators are ?
and :
.
Overall, I like the
if(a ? b : c)
syntax, as the ternaries are common, and we can't use it without the wrappingif()
due to the:
being significant in CSS syntax.
Also because then we wouldn’t know where the condition ends. I suppose both could be addressed with parens, but once you’re adding parens you may as well add two more characters and make this a lot more understandable.
That said, I wonder if we could do this similarly with
calc()
, where we could allow using the ternaries in some other contexts without using the wrappingif()
? Like we can use calculations withoutcalc()
inside things likeclamp()
andround()
, we could allow using ternaries insidecalc()
and anything similar.
I like the idea, but it’s not part of the MVP. Open a new issue, possibly after we've settled the syntax and specced an initial version? But that is another reason to use ?
and :
so thanks for bringing it up.
Re: empty value, I think we should allow just omitting the value with optional whitespace to follow how the
--foo:;
andvar(--foo,)
work now: so theif(a?b:)
andif(a?:b)
should work fine.
That makes sense iff the default value is not the empty value. If it is, we should just be able to not include a :
at all.
@kizu @LeaVerou What would be the correct grammar for “ternary delimited” conditions, as it appears the syntax would be closer to the below:
* { --prop: if(style(--x: 'one') ? 1 : style(--x: 'two') ? 2 : 3); }
Would that use a
#
or+
if we’re not delimiting expressions by commas or semicolons?
Ah good point, my grammar was incorrect. It would be more like if( [ <condition> ? <if-true> : ]+ <default-value>)
@LeaVerou Sure thing, that looks correct afaict.
Also, you addressed the other questions from my comment in your response to @kizu’s. Thanks for the clarifying those!
@LeaVerou Is there already a CSS function in existence or in the works that could be used to check whether or not the matched element matches a specified selector, essentially selector narrowing?
For example…
BEFORE | AFTER |
---|---|
```css /* without using if() */ .item { --symbol: "🩶"; &::before { content: var(--symbol); } &.favorite { --symbol: "🧡"; } } ``` | ```css /* using if() */ .item { --symbol: if(matches(.favorite) ? "🧡" : "🩶"); &::before { content: var(--symbol); } } ``` |
@LeaVerou Is there already a CSS function in existence or in the works that could be used to check whether or not the matched element matches a specified selector, essentially selector narrowing?
For example…
BEFORE AFTER
/* without using if() */ .item { --symbol: "🩶"; &::before { content: var(--symbol); } &.favorite { --symbol: "🧡"; } }
/* using if() */ .item { --symbol: if(matches(.favorite) ? "🧡" : "🩶"); &::before { content: var(--symbol); } }
Possibly, but I think this warrants a new issue, as it’s introducing a new type of condition, and the whole point of the if()
MVP is to reuse existing conditionals.
I think we should go with
if ([cond ? iftrue : iffalse ]+)
I disagree. iftrue
and iffalse
should be allowed to be any <declaration-value>
, which includes :
, so it would be problematic.
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 don't think that consistency with JS justifies diverging from the above for conditionals.
We should definitely allow any <declaration-value>
. But while in theory that could include :
, does it though? If not, we could simply disallow it.
I think using ?
to separate the condition from the first value and then not using :
to separate the values from each other is extremely weird. There is no precedent of such a Frankenstein syntax in any language, ever. I expect authors would get this wrong repeatedly if we go that way.
we could simply disallow it
:
is valid in custom properties, so it seems unnecessary limiting to disallow it in conditionals that may be used in said custom properties.
I think using
?
to separate the condition from the first value
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 */
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:
calc(var(--test) * var(--if-true) + (1 - var(--test)) * var(--if-false))
var(--test, var(--if-false))
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):
--variant: auto | primary | success | neutral | warning | danger
--effect: none | pulse
--button-style: fill | outline
--shape: rect | pill
--avatar-shape: square | rounded | circle
--size: small | medium | large
--suffix: none | caret
`--popup-placement: [top | right | bottom | left] [start | end]?
--popup-arrow-placement: center | start | end
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:
style( <style-query> )
conditionalsEdit (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:
<container-query>
<declaration-value>
I did not discuss this but a nice DX improvement could be this grammar instead:
which would allow for multiple conditions more easily. Compare:
Grammar 1:
Grammar 2:
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?