w3c / csswg-drafts

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

[css-values-5] Add `spread()` to CSS #8391

Open brandonmcconnell opened 1 year ago

brandonmcconnell commented 1 year ago

Proposal

It would help to add support for a new spread() in CSS that would work identically to var() except that it would spread any chained values contained in the variable as if they had each been used individually.

Syntax

The syntax would be something like this, expressed in JS/TS:

function spread(variable: string, currentDelimiter = ',', newDelimiter = currentDelimiter) {
  return variable.split(currentDelimiter).join(newDelimiter);
}

It could then be used in CSS like this:

* {
  --multiple-items: 1, 2, 3, 4, 5;
  --single-item: 6;
  some-prop: random-item(var(--ident); spread(--multiple-items); var(--multiple-items));
}

Using spread(), the random value in the example above would have an equal chance of being any of the six values—

Without spread(), the random value generated would only be one of two options—

Alternative syntax

To keep this feature in line with others in CSS, I took a more functional approach using spread(), however, if we wanted to follow the trend of other languages, the traditional ... spread syntax could be employed, which would look like this:

* {
  --multiple-items: 1, 2, 3, 4, 5;
  --single-item: 6;
  some-prop: random-item(var(--ident); ...var(--multiple-items); var(--multiple-items));
}
Loirooriol commented 1 year ago

Values might contain commas themselves (note random-item uses ; as a separator), so maybe it should be

--multiple-items: (1; 2; 3; 4; 5);
--single-item: 6;
some-prop: random-item(--rnd1; spread(--multiple-items); var(--single-item));
brandonmcconnell commented 1 year ago

@Loirooriol Thanks! Yes, I mistakenly used , ;P

I think a comma would be a reasonable default for most properties, and this spec could support other delimiters like the ; used in random() using either of these:

Example A: custom non-comma delimiter

* {
  --multiple-items: (1; 2; 3; 4; 5);
  --single-item: 6;
  some-prop: random-item(var(--ident); spread(--multiple-items, ';'); var(--multiple-items));
}

Example B: delimiter conversion

* {
  --multiple-items: 1, 2, 3, 4, 5;
  --single-item: 6;
  some-prop: random-item(var(--ident); spread(--multiple-items, ',', ';'); var(--multiple-items));
}

This way, any set of values stored in a variable can be used regardless of what delimiter is used in it, and then converted to match the format needed for its new context.

tabatkins commented 1 year ago

I agree that there's a need for something in this vein, to allow storing arglists for functions like random-item() (and other semicolon-delimited lists) in a variable. Not a fan of exactly how it's proposed here, tho - it runs into some awkward grammar issues. If you want to actually use the full power of random-item() and store a comma-separated list as one of the arguments, along others, you can't use spread() as proposed without using a third thing as a delimiter.

The core issue here is that a function can take <any-value>, while properties are limited to <declaration-value>. To store an <any-value> in a property, you need something to wrap it so it's not top-level; then you need some way to discard the wrapper when it's actually used (but not before then - if you sub a var() into another custom prop, it needs to retain the wrapper so the value remains valid).

So I think there are two possible approaches:

  1. Define a function, call it spread1(), that is valid as a whole argument in random-item() and any other functions that accept anything broader than <declaration-value>. It has the grammar spread(<any-value>) (and thus can contain itself). Its only effect is to discard itself at computed-value time, lifting its own arguments into the parent function.

    If used anywhere else it's just invalid syntax. Custom properties using * grammar can of course use it, since they just see a token stream rather than a function.

  2. Define a function, call it spread2(), that isn't valid anywhere - only the * grammar can use it. It has special substitution behavior:

    • if subbed into another * grammar, it subs as-is
    • if subbed into anything else, it discards itself and leaves its argument behind.

The two are basically identical in usage:

.foo {
  --multiple-items: spread(1; 2; 3; 4; 5);
  --single-item: 6;
  some-prop: random-item(--x; var(--multiple-items); var(--single-item));
  /* equivalent to */
  some-prop: random-item(--x; 1; 2; 3; 4; 5; 6);
}

/* and nesting them works */
.foo {
  --some-items: spread(1; 2; 3);
  --more-items: spread(var(--some-items); 4; 5; 6);
  --some-prop: random-item(--x; var(--more-items));
}

There are only meaningful difference between the two approaches is whether they're allowed in places that don't require them - spread1() isn't (it's defined to only be valid in the functions that need it) while spread2() is (can be used anywhere, but doesn't add any ability). Having the function be invalid where it's not needed (as in spread1()) might be good at discouraging confusing overuse, but having it be more explicitly tied to variable substitution (as in spread2()) might also be good for understanding. Really not sure which side I prefer.

brandonmcconnell commented 1 year ago

@tabatkins Thanks for all the feedback! I agree, and either of those sounds good to me also. I'm still thinking about the pros and cons of each.

With that model in mind, would CSS sets always work use a semicolon delimiter rather than another symbol?

Also, would this be valid syntax, not including the spread() at the top-level but rather in the random-item() function itself, where the set is wrapped in parentheses without any function:

.foo {
  --some-items: (1; 2; 3);
  --some-prop: random-item(--x; spread(var(--some-items)));
}

If this wouldn't be considered valid syntax, it may be helpful to include some other function like set() as well to denote creating a set (named so as not to be confused with [css-lists]) that doesn't need immediate spreading, randomizing, or nth-ing, thereby making the set more reusable.

That way, all of these would be valid:

.foo {
  --some-items: set(1; 2; 3);

  /* spreading into `nth()` */
  --first-item: nth(1; spread(--some-items));

  /* spreading into `random-item()` */
  --random-item: random-item(--x; spread(--some-items));
}

I think it could be useful for spread() to take in variables without needing to use var() (e.g. spread(--a; --b; 1; 2) vs. spread(var(--a); var(--b); 1; 2)), though I think either is perfectly acceptable if you or the CSSWG think otherwise.

I do wonder in regard to the delimiter if it would help to be able to spread into other properties using a different delimiter. For example:

:root {
  --colors: red, green, blue;
}
div {
  --overlay-color: rgb(0 0 0 / 75%);
  --overlay: linear-gradient(rgb(0 0 0 / 75%), rgb(0 0 0 / 75%));
  background: var(--overlay), linear-gradient(var(--colors)); /* requires comma delimiter */
  color: random-item(--x per-element; spread(--colors)); /* requires semicolon delimiter */
}

Similar to how random() uses a by keyword, I wonder if spread() could be extended to use something similar like spread(--colors; black; using ,) and spread(--colors; black; using ;) respectively, where the default would be ; but could be manually set to another delimiter when needed for values that might use a different delimiter like , or /.

Maybe that's a possibility to consider later though. For starters, I think either of your proposed examples more than meets the use case I was aiming for with this proposal. Thanks!

brandonmcconnell commented 1 year ago

On second thought, I think the word list—if a function is needed there—would be too easily confused with [css-lists], so it's probably best to call that something else like sets, I think. Anyway, I'll make that change above ☝🏼

~list()~ → set()

tabatkins commented 1 year ago

With that model in mind, would CSS sets always work use a semicolon delimiter rather than another symbol?

If we need to separate <declaration-value>s, yes, our only options for delimiters are ; and !. All other values are potentially valid parts of the <declaration-value>. (See https://w3c.github.io/csswg-drafts/css-syntax/#any-value)

Also, would this be valid syntax,

Invalid under spread2() (only allowed in a * grammar); valid but does the wrong thing in spread1() (it'll be equivalent to random-item(--x; (1; 2; 3)), which has only a single item to choose from).

I think it could be useful for spread() to take in variables

Note that in either option, you only use spread() in the custom property. In the function you're spreading into you just use var() normally. Using spread() directly inside of random-item() doesn't do anything.

(That is, we're not defining a higher-level concept of "a list of values", and then invoking it at the usage point to spread it into multiple args, like the JS ... operator. All we're doing is putting a wrapper around some values, so we can safely use <any-value> rather than <declaration-value>, and then removing the wrapper once actual substitution happens.

I do wonder in regard to the delimiter if it would help to be able to spread into other properties using a different delimiter.

None of those examples need any help. All of them can be accomplished today using custom properties and var(). The sole problem that needs solving is storing something with a ; in it (or, theoretically, a !) in a custom property, because those two tokens aren't allowed at the top-level of a property.

(This does make me lean towards spread2(), fwiw, because making it unambiguously a thing only usable in custom properties probably avoids this sort of confusion with JS's ... operator.)

brandonmcconnell commented 1 year ago

@tabatkins @Loirooriol It may even be worth including the functions set() and nth() in this proposal as all three go hand-in-hand. What do you think? I could write up separate proposals for them, but set() is rather fundamental to this ticket unless it's unneeded in which case I'll just omit that one.

nth() I'm not sure about — should that get its own ticket?

Also, relatedly, @Crissov had a pretty interesting proposal in a comment on the random() ticket last week, proposing that instead of separate nth() and random-item() functions, introducing one new consolidated function like select() or choose(), though nth() would probably still work fine for naming.

For the sake of continuity, let's call this function nth(). nth() would support all the usual nth values include formulaic values:

.foo {
  --items: set(1; 2; 3);

  /* spreading into dynamic `nth()` */
  --random-item: nth(random --x per-element; spread(--items)); /* random becomes a keyword here rather than a dedicated `random-pick` function */
  --first-item: nth(1; spread(--items));
  --also-first-item: nth(first; spread(--items));
  --last-item: nth(last; spread(--items));
  --odd-items: nth(odd; spread(--items)); /* returns filtered list: list(1; 3) */
  --even-items: nth(even; spread(--items)); /* returns filtered list: list(2) */
  --range-of-items: nth(3 to 5; spread(--items)); /* returns filtered list: list(3) */
  --two-and-down-items: nth(-n + 2; spread(--items)); /* returns filtered list: list(1; 2) */
  --all-items: nth(n; spread(--items)); /* essentially copies the list, pointless but valid */
}
brandonmcconnell commented 1 year ago

@tabatkins

(This does make me lean towards spread2(), fwiw, because making it unambiguously a thing only usable in custom properties probably avoids this sort of confusion with JS's ... operator.)

Yeah, I could lean towards option 2 as well, though I think it would help to whitelist certain usages of spread() like using it within the random-item() function.

If we were to introduce some kind of list/set function that actually houses lists of args and makes them reusable, then it may be worth discussing my other comment and how such a function could benefit all random-item(), nth(), and spread(). However, I'm not sure if that discussion would be best had here or in another thread.

tabatkins commented 1 year ago

though I think it would help to whitelist certain usages of spread() like using it within the random-item() function.

What are you imagining this would help with? When used directly in random-item() the function would do literally nothing. The point of my comment was that I think spread2() only being valid in custom properties would reduce the chance of such confusing/redundant usage.

If we were to introduce some kind of list/set function that actually houses lists of args and makes them reusable

There's no need for such a function; as long as your list isn't semicolon-separated, custom properties already hold "lists of args" just fine, and var() makes them reusable.

brandonmcconnell commented 1 year ago

TL;DR: That makes sense. In fact, I think spread() would essentially suffice for the same use case I'm co-proposing list() for.

In that case, spread would only need to be used in variables as you suggested. Could we still interface other functions with it down the road like nth()?

:root {
  --colors: spread(blue; red);
}

main {
  background: linear-gradient(
    nth(first; var(--colors)),
    nth(last; var(--colors))
  );
}

  more exhaustive thoughts probably only half-worth even reading   > > though I think it would help to whitelist certain usages of spread() like using it within the random-item() function. > > > > What are you imagining this would help with? When used directly in `random-item()` the function would do literally nothing. The point of my comment was that I think `spread2()` only being valid in custom properties would reduce the chance of such confusing/redundant usage. I was thinking that if a list could be stored in a variable on its own without needing `spread()`, it could then be used in other functions like `nth()` as well as `random-item()` via `spread()`. In other words, this would be valid: ```css :root { --colors: (blue; red); /* possibly wrapped by `list()` other something equivalent } main { background: linear-gradient( nth(first; spread(--colors)), nth(last; spread(--colors)) ); color: random-item(--x; spread(--colors)); } ``` ^ Example 1 > > If we were to introduce some kind of list/set function that actually houses lists of args and makes them reusable > > > > There's no need for such a function; as long as your list isn't semicolon-separated, custom properties already hold "lists of args" just fine, and `var()` makes them reusable. I guess my thought here was that the general syntax for lists could be that of a semicolon-delimited list, which could be used in other functions like `nth()`, `random-item()`, and likely others via `spread()`. To that point, I think it boils down to whether there is ever a case we _**wouldn't**_ want such a list to be spread. If the only value of a list is for its arguments/values to be later spread, then yes, I think either of your proposed options would work perfectly. In that case, we wouldn't need any sort of `list()` function, as `spread()` would essentially do the same thing. And in this case, per your note, we wouldn't use `spread()` one functions then— only custom properties. If that's the case, could we still interface other functions to be able to consume those arg lists? The `nth()` idea, for example: ```css :root { --colors: spread(blue; red); } main { background: linear-gradient( nth(first; var(--colors)), nth(last; var(--colors)) ); } ```
tabatkins commented 1 year ago

Yes, spread2() as I defined is generic and doesn't care about where you use it; it just throws itself away and leaves its arguments behind any time you sub it into a non-universal grammar. So it'll automatically work in any new semicolon-delimited argument lists we define in the future.

(It'll also work anywhere else, for anything, it's just unnecessary in any other case.)

brandonmcconnell commented 1 year ago

Okay, awesome. I dig that, and it'll basically remove the need for any separate list() function down the road.

I'd love to move forward with the model from spread2().

Would it possibly be worth also introducing a second function like unspread() specifically to be able to pass an entire spread list as a single object, which would also then immediately remove itself?

Like this, for example:

.foo {
  --colors-rgb: spread(red; green; blue);
  --colors-cmy: spread(cyan; magenta; yellow);
  --random-item: random-item(color-single; random-item(color-group; unspread(--colors-rgb); unspread(--colors-cmy));
}

In this example, the innermost nested random-item() usage randomly selects either variable var(--colors-rgb) or var(--colors-cmy), and then the outer wrapping random-item() chooses an item from the inner-selected group of colors.

I'm wondering if this holds any value— being able to pass a spread group as a group rather than its individual pieces in certain contexts, but if you think this would complicate things too much, I can understand and appreciate that viewpoint as well. Thanks.

tabatkins commented 1 year ago

Making that sort of nesting work would require actually thinking about these values as a "list" structure rather than just a sequence of tokens. It's possible, but I question the need for it. ^_^

brandonmcconnell commented 1 year ago

@tabatkins Fair enough — thanks!

Loirooriol commented 1 year ago

This spread() is actually not that different from the tokens() function from #2749, so maybe they can be unified?

tabatkins commented 1 year ago

Nah, they're pretty different. tokens() from that issue prevents any resolution of what's inside of it - tokens(var(--foo)), in particular, doesn't sub in the --foo property, it stays as an unresolved var(--foo) token.

spread2() here doesn't affect the interpretation of the function at all, just substitution behavior.

Loirooriol commented 1 year ago

They are similar in the sense that they wrap something inside a function, and then the value is unwrapped when using var(). spread() doesn't need to prevent the resolution of what's inside it, but on most cases the difference might not matter. So I was just wondering if we could make do with just one of these functions, but I'm fine with adding both.

tabatkins commented 1 year ago

The execution ends up extremely different, is the thing - holding something for one substitution (or arbitrary substitutions until explicitly un-rawed), vs holding it for arbitrary substitutions until it hits a non-universal grammar.

Loirooriol commented 1 year ago

It's not a given that the executions need to be different. The original spread() proposal had explicit unrawing, and "arbitrary substitutions until it hits a non-universal grammar" might be fine for tokens().

tabatkins commented 1 year ago

and "arbitrary substitutions until it hits a non-universal grammar" might be fine for tokens().

It's not; one of the use-cases that tokens() was solving was the ability to sub a global keyword into a custom property.

Loirooriol commented 1 year ago

Yeah, so:

The difference is if you have a chain of custom properties referencing each other, and you want to unraw earlier. But spread() could also benefit from that. So the execution for both could be: hold something for arbitrary substitutions until unrawed either explicitly or implicitly by reaching a non-universal grammar.