w3c / csswg-drafts

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

Proposal: Custom CSS Functions & Mixins #9350

Open mirisuzanne opened 9 months ago

mirisuzanne commented 9 months ago

Background

Read the full explainer

There's an existing issue for Declarative custom functions, which forms the basis for much of this proposal. I'm opening a separate issue here, in order to make a broader proposal building on that, which includes both functions and mixins.

From a language perspective, mixins and functions are somewhat distinct – they live at different levels of the syntax, and come with different complications. It may make sense to handle them in different levels of a specification (likely functions first) or even different specifications altogether. Function-specific discussion could move back to the existing thread for that work. However, the features also have a lot in common from the author/syntax perspective, so I wanted to consider them together here, without cluttering the main thread.

Intro

Both Mixins and Functions provide a way to capture and reuse some amount of logic. That can be used for the sake of developer shorthands, and also as a way of ensuring maintainability by avoiding repetition and encouraging consistent use of best practice patterns. For many years, authors have been using pre-processors to perform this sort of CSS abstraction – or experimenting with custom property tricks like the space toggle hack, and recently style queries. There's also an open issue for Higher level custom properties with many mixin-like use-cases.

By providing a native CSS solution for these use-cases, we can help simplify web tooling/dependency requirements – while also providing access to new functionality. Mixins and functions in the browser should be able to accept custom property arguments, and respond to client-side media, container, and support conditions.

The overlapping syntax basics

Both functions and mixins need to be defined with a (custom-ident) name, a parameter-list, some amount of built-in-logic, and some output to return. The difference between the two is where they can be used in CSS, based on the type of output they provide:

For the basics, I'm proposing two new at-rules following a similar pattern:

/* custom functions */
@function <name> [(<parameter-list>)]? {
  <function-logic-and-output>
}

/* custom mixins */
@mixin <name> [(<parameter-list>)]? {
  <mixin-logic-and-output>
}

The parameter lists should be able to define parameters with a (required) name, an (optional) default, and potentially (optional) <syntax>. In order to allow custom-property values with commas inside, we likely need a ; delimiter both in defining and passing arguments, where values are involved. To re-use existing custom-property syntax, we could do something like:

@function --example (--named-parameter; --name-with: default-value) { 
  /* if further description of a parameter is necessary */
  @parameter --named-parameter {
    default: 2em;
    syntax: "<length>";
  }
}

[Edited] @emilio has suggested potentially having parameter names only in the parameter list, and then @parameter-like rules in the body of the function/mixin when default values or syntax descriptor are needed. That would remove the need for ; delimiters in the prelude entirely. I'm not attached to all the details of the syntax here, but borrowed from existing structures. If we don't need the syntax definition for parameters, or can add that later, it might allow us to simplify further.

Internally, both syntaxes should allow conditional at-rules such as @media, @container, and @supports. That's one of the primary functions that CSS-based functions and mixins could provide. I don't think non-conditional or name-defining at-rules would serve any purpose, and should likely be discarded.

Functions

Normal properties inside a function would have no use, and could be discarded and ignored. However, it would be useful for functions to have internally-scoped custom properties. To avoid accidental conflicts, internal function logic would not have access to external custom properties besides the values explicitly passed in to the defined parameters.

In addition to allowing (scoped) custom properties and conditional at-rules, a function would need to define one or more resulting values to return. I like the at-rule (e.g. @return) syntax suggested in the original thread, though the result descriptor could also work. If more than one value would be returned, the final one should be used (to match the established last-takes-precedence rules of the CSS cascade).

An example function with some conditional logic:

@function --at-breakpoints(
  --s: 1em;
  --m: 1em + 0.5vw;
  --l: 1.2em + 1vw;
) {
  @container (inline-size < 20em) {
    @return calc(var(--s));
  }
  @container (20em < inline-size < 50em) {
    @return calc(var(--m));
  }
  @container (50em < inline-size) {
    @return calc(var(--l));
  }
}

h1 {
  font-size: --at-breakpoints(1.94rem, 1.77rem + 0.87vw, 2.44rem);
  padding: --at-breakpoints(0.5em, 1em, 1em + 1vw);
}

Functions would be resolved during variable substitution, and the resulting computed values would inherit (the same as custom properties).

Mixins

Mixins, on the other hand, will mostly contain CSS declarations and nested rules that can be output directly:

@mixin --center-content {
  display: grid;
  place-content: center;
}

body {
  @apply --center-content;
  /*
    display: grid;
    place-content: center;
  */
}

I don't believe there is any need for an explicit @return (though we could provide one if necessary). Instead, if there is any use for mixin-scoped or 'private' custom properties, we could consider a way to mark those specifically. Maybe a flag like !private would be enough?

Another possible example, for gradient text using background-clip when supported:

@mixin --gradient-text(
  --from: mediumvioletred;
  --to: teal;
  --angle: to bottom right;
) {
  color: var(--from, var(--to));

  @supports (background-clip: text) or (-webkit-background-clip: text) {
    --gradient: linear-gradient(var(--angle), var(--from), var(--to));
    background: var(--gradient, var(--from));
    color: transparent;
    -webkit-background-clip: text;
    background-clip: text;
  }
}

h1 {
  @apply --gradient-text(pink, powderblue);
}

There are still many issues to be resolved here, and some syntax that should go through further bike-shed revisions. Read the full explainer for some further notes, including a suggestion from @astearns for eventually providing a builtin keyframe-access mixin to help address the responsive typography interpolation use-case.

andruud commented 9 months ago

@function --at-breakpoints [...]

OK, that seems approachable. Having a @container as a conditional thing means we'll evaluate the container query (and other queries) later than usual, but I don't think it's substantially worse than container units, which already evaluate quite late.

Mixins return [...] rule blocks (including selectors and other at-rules)

[...] mixins [...] would be resolved during variable substitution

Those two things are not compatible. But it's possible that we'll able to impose enough restrictions on this to make it feasible, and still get something useful. For example, limit mixins to only declarations, or only declarations and nested &-rules (which would allow your nested @supports example).

including a suggestion from @astearns for eventually providing a builtin keyframe-access mixin to help address the https://github.com/w3c/csswg-drafts/issues/6245#issuecomment-1715416464 use-case

That's an interesting idea. :-)

lilles commented 9 months ago

I think it will be hard to support @mixins with selectors inside the @mixin. Selectors are typically matched from right to left in impls and would be started inside the @mixin rule. If you have something like:

@mixin --foo {
  .a { }
}

.b {
  @apply --foo;
}

.c {
  @apply --foo;
}

In that case we would need to start matching from the .a in the mixin and branch out to the nested .b and .c selectors. This branch is dynamic based on how @mixin rules cascade. I think that is pretty hard to do in the current implementation in Blink. Especially for style invalidation, but also for efficient selector matching. The implicit & for @supports, @media, etc. should be fine.

It was not clear to me from the "Mixins, on the other hand, will mostly contain CSS declarations and nested rules that can be output directly" that nested selectors would be allowed, but the clearfix example in the full explainer uses nesting.

mirisuzanne commented 9 months ago

If nesting is possible it would be popular. Many existing mixins are used to establish things like consistent hover/focus handling (nested pseudo-classes) or like icon-buttons, including styles for the nested icon, etc.

(updated so that the variable-substitution reference is specific to functions)

romainmenke commented 9 months ago

I always saw @mixins more like syntactic sugar.

Where resolving would be roughly equivalent to wrapping in &{} and inserting that where @apply used to be.

Is there a specific use case where the result would be substantially different?

(I am definitely viewing this to much from the perspective of preprocessors.)


Edit :

(updated so that the variable-substitution reference is specific to functions)

ack

lilles commented 9 months ago

I always saw @mixins more like syntactic sugar.

Where resolving would be roughly equivalent to wrapping in &{} and inserting that where @apply used to be.

Is there a specific use case where the result would be substantially different?

That's a whole different story and much simpler to implement. It would not allow you to apply @mixins across stylesheets, which might be a showstopper?

romainmenke commented 9 months ago

It would not allow you to apply @mixins across stylesheets, which might be a showstopper?

That would be unfortunate. Dev tools will often bundle or chunk stylesheets in ways that aren't immediately clear or controllable by authors.

It would make mixins much harder to use.

kizu commented 9 months ago

Really happy to see this proposal! As an author, I always wanted to have mixins and functions in CSS, and can't wait for the day when we would get anything closer to it. I don't mind the exact syntax, so would mostly focus on features and usage.

To avoid accidental conflicts, internal function logic would not have access to external custom properties besides the values explicitly passed in to the defined parameters.

Is this a strong requirement? I can see a lot of use cases for the implicit access for external custom properties:

Some other questions/thoughts, but very preliminary, as I would need to re-read the proposal (and I just skimmed the explainer for now, would need to read it properly) a few times and think long about it:

  1. For mixins, I'll need to think more about them, but my main question: would we be able to somehow pass the return of one mixin as an argument to another? See “block mixins” from stylus, for example: https://stylus-lang.com/docs/mixins.html#block-mixins (Disclosure: I was a maintainer for Stylus for a while, and was behind some of its weirder features like block mixins (specs-wise, not implementation-wise), so I'm biased).

  2. When thinking about functions and mixins, one thing that I immediately remember I wanted to have in CSS — an ability to have arrays/lists and maps. That might be worth a separate issue (maybe there was one? I did not yet try to find one), but in many many mixins in preprocessors developers are used to have some way of retrieving a value from a map, or manipulating a list of values. Again, this is probably a very separate topic, but I just have to mention it, as maybe having these in the back of our heads when thinking about mixins could help.

  3. Similarly, with conditions in CSS. Having conditionals based on container-queries is good, but we'd for sure would want an ability to do things based on input conditionally, with value comparison etc. Again, probably a slightly separate thing, but I saw it was mentioned as a question in the explainer, so I want to mention it as something that I, as an author, would want to see possible eventually in CSS.

That's it for now. I would try to find time to read the whole explainer properly, and would come back with more feedback.

mirisuzanne commented 9 months ago

@lilles Some follow-up about nesting in mixins… I don't think that constraint would be a blocker for making this a useful feature. I'm curious if there's any useful distinction between between nested selectors that change the subject, and those that only add additional constraints? For example, selectors like & > p or & + p would require finding a new selector subject, while &:hover adds a constraint to the existing subject.

To avoid accidental conflicts, internal function logic would not have access to external custom properties besides the values explicitly passed in to the defined parameters.

Is this a strong requirement?

@kizu I would consider it a strong authoring requirement that nothing inside the function should have accidental consequences on values outside the function. I don't know that we need such a strong requirement in the other direction. I don't know if there would be implementation concerns about allowing external values into a function, but I think you're right that it could work for authoring.

  1. […] would we be able to somehow pass the return of one mixin as an argument to another?

I'm not familiar with the feature in Stylus, but I think this is similar to the @content feature in Sass, and the similar feature discussed in the explainer?

Similarly, there's a section on argument conditions and loops – roughly your points 3 & 2, though I don't go into a lot of detail on lists/maps. In both cases, I considered them potential future extensions rather than essential aspects of a basic function/mixin feature.

tabatkins commented 9 months ago

First, I've talked this over with Miriam already, and broadly am happy with the elaboration on my earlier idea of making functions basically just "fancy variables".


Edited before posting: whoops, yeah, the edited version of argument syntax makes me a lot happier. I think we can still put syntax into the arglist, fwiw.

@function --foo (--bar "<string>", --baz "<number>") {...}

I think that still reads reasonably? And if it is too long and hard to read, you can always move it to @parameters.

(I presume that anything without a default value would be a required arg, making the function invalid if not passed? Since args in the arglist wouldn't have a default, that would match up with the usual practice of required args coming first, followed by optional args.)


To avoid accidental conflicts, internal function logic would not have access to external custom properties besides the values explicitly passed in to the defined parameters.

I disagree with this, and think it's probably pretty important to allow functions to access the variables present on the element. I don't see any way for there to be an accidental conflict; every name that could potentially conflict is function-local and controlled by the function author, no?

Tho, hm, we'd have to be a little careful about nested functions. If we did make ambient variables available we'd have to decide whether all functions saw the element's vars, or saw the function's vars too. The latter does have an accidental shadowing concern.

But I feel like it's important to allow this, because otherwise you can't replace existing variables (that reference other vars) with functions unless you explicitly pass those variables, which can be annoying. In particular, it means you couldn't use custom units unless they were explicitly passed to the function, which feels extra-obnoxious.

However, it would be useful for functions to have internally-scoped custom properties.

Yes, this sounds great. Temp vars are useful.

Internally, both syntaxes should allow conditional at-rules such as @media, @container, and @supports. That's one of the primary functions that CSS-based functions and mixins could provide.

I agree that these are useful, but their usefulness isn't specific to functions. We've talked about a media() function that lets you do MQ-specific value resolution; should we just rely on that rather than having a special syntax just for functions?

(I'm not particularly against doing the @return and allowing conditional rules, fwiw. Just wondering if it would be better to lean on the generic functionality.)


Unmentioned here is what to do about recursive functions. Without the ability to test and branch on the value of one of the input variables, I think a recursive call is guaranteed to be cyclic just like a custom-property self-reference is, right? So presumably that should be detected and auto-failed using the existing anti-cyclic variable tech. If we later allow for the possibility of useful recursion we can relax the requirement and impose a stack limit or something. (Then we can repeat the TC39 arguments about tail recursion, yay!)

mirisuzanne commented 9 months ago

I presume that anything without a default value would be a required arg, making the function invalid if not passed?

I would not presume that, since guaranteed invalid is a reasonable default value. Is there a strong reason that should need to be specified explicitly in an @parameter rule?

think it's probably pretty important to allow functions to access the variables present on the element.

I'm open to this. As mentioned above, my stronger concern is that changes to an external variable don't accidentally escape the function.

Internally, both syntaxes should allow conditional at-rules such as @media, @container, and @supports. That's one of the primary functions that CSS-based functions and mixins could provide.

I agree that these are useful, but their usefulness isn't specific to functions. We've talked about a media() function that lets you do MQ-specific value resolution; should we just rely on that rather than having a special syntax just for functions?

That comment was not about a special syntax for functions, but a normal syntax allowed inside functions. The only potential need for a special syntax would be if we want to allow some (restricted) parameters in function at-rules. But this proposal does not include that as an initial requirement. (It's also not specific to the @return syntax, but should work no matter how the returned value is declared).

jimmyfrasche commented 9 months ago

Functional languages tend to have a 'let' <vars>+ 'in' <expr> syntactic form whose result is <expr> which can make use of the 1 or more <vars> that are private to the let-expression.

I've wanted CSS functions for a long time but some of the places I've wanted them I'd only use it once so I could reuse internal computations without leaking a lot of one-time custom properties (my solution in practice is to just shrug and leak a lot of one-time custom properties and hope it doesn't cause any problems later).

If there were a CSS version of let-expressions you could just tell people using functions to use that if they need private stuff and it would be handy for the odd case where you have a handful of things you want to refer to more than once but a function would be overkill.

tabatkins commented 9 months ago

Mixins return [...] rule blocks (including selectors and other at-rules)

[...] mixins [...] would be resolved during variable substitution

Those two things are not compatible. But it's possible that we'll able to impose enough restrictions on this to make it feasible, and still get something useful. For example, limit mixins to only declarations, or only declarations and nested &-rules (which would allow your nested @supports example).

Right, I assumed that they'd be applied before any value stages, essentially equivalent to doing a preprocessor. As far as I can tell there's nothing dynamic about mixins (save the values passed in themselves, but they're effectively just uniquely-named variables). (And note that we already established, back in the last attempt at @apply, that passing around declaration blocks via computed values is not viable.)

That, I think, would allow us a lot of freedom in what to allow inside the mixin without adding a lot of complexity. However, it limits our ability (somewhat) to do branching/etc based on the values. We could still do, say, a @for based on a static value (like @apply --foo(5)), but not on a dynamic one (like @apply --foo(var(--bar)) or @apply --foo(counter(list-item)). But that's something to worry about in the future; we're not doing conditionals or loops yet.

tabatkins commented 9 months ago

I would not presume that, since guaranteed invalid is a reasonable default value.

Sure, but we could spell that initial, like @parameter --optional { default: initial; }.

And it's not a reasonable default value if you have a syntax. Without the "required" behavior, we'd have to require that with a syntax you also have to provide an initial value.

As mentioned above, my stronger concern is that changes to an external variable don't accidentally escape the function.

Agreed, that's definitely required, but I don't think anything could reasonably cause that. You can't affect any properties on the element from within a function.

[stuff about conditionals]

Yeah, I don't have an issue with the proposal, was just thinking aloud. I think doing conditionals as proposed is better (plus we don't have a plan for an inline CQ function anyway).

lilles commented 9 months ago

@lilles Some follow-up about nesting in mixins… I don't think that constraint would be a blocker for making this a useful feature. I'm curious if there's any useful distinction between between nested selectors that change the subject, and those that only add additional constraints? For example, selectors like & > p or & + p would require finding a new selector subject, while &:hover adds a constraint to the existing subject.

I don't think that would be different. The common thing is that nesting in this case dynamically combines parts of the selectors from the mixin and the apply and that would require the implementations to connect those pieces at a later stage than the parser, and that the multiple applications branch into multiple ancestor selectors. For nesting it's much easier since it's done at parse time and the selector representation can be fully built at parse time.

How complicated and how much of a re-organization of the selector matching/invalidation code this is depends when we can resolve the mixin applications.

lilles commented 9 months ago

Mixins return [...] rule blocks (including selectors and other at-rules) [...] mixins [...] would be resolved during variable substitution

Those two things are not compatible. But it's possible that we'll able to impose enough restrictions on this to make it feasible, and still get something useful. For example, limit mixins to only declarations, or only declarations and nested &-rules (which would allow your nested @supports example).

Right, I assumed that they'd be applied before any value stages, essentially equivalent to doing a preprocessor. As far as I can tell there's nothing dynamic about mixins (save the values passed in themselves, but they're effectively just uniquely-named variables). (And note that we already established, back in the last attempt at @apply, that passing around declaration blocks via computed values is not viable.)

IIUC, it is a requirement that mixins can be applied across stylesheets, so that the connection between the mixin and the apply has to be re-done as new stylesheets are added and requires some cascade order definition for which mixin is found first for a given name.

As mentioned in a different post I think this can be challenging for an implementation if we allow selectors inside the mixin. At least if the mixin contains a selector which becomes part of multiple nested selectors.

tabatkins commented 9 months ago

I'm sorry, Rune, but I don't understand what connection your reply has to what I said. Could you elaborate? I suspect one or both of us is misunderstanding what the other is saying.

lilles commented 9 months ago

I'm sorry, Rune, but I don't understand what connection your reply has to what I said. Could you elaborate? I suspect one or both of us is misunderstanding what the other is saying.

Sorry, I tried commenting on the "essentially equivalent to doing a preprocessor" part. I read "preprocessing" as something that can be done at parse time, which would limit apply to reference mixins in the same sheet.

tabatkins commented 9 months ago

Ah, ok, no, I meant it more in the "not dynamic based on the DOM" way; it only requires information that is available to a preprocessor (the full set of stylesheets for a page). I imagine it would be roughly equivalent to just inserting the rules with JS (after uniquifying the input variable names so they can be replaced with normal custom props set in the selector). So, an expensive operation to perform, and it might need to be repeated as you discover more stylesheets, but still generally a one-and-done operation, after which you just have normal style rules.

justinfagnani commented 9 months ago

@lilles

That's a whole different story and much simpler to implement. It would not allow you to apply @mixins across stylesheets, which might be a showstopper?

I've found a lot of people, including myself sometimes, would prefer to have variables act like references that are lexically scoped, as opposed to names than can be dynamically overridden based on HTML structure and CSS selectors applied to it.

I suspect this will even be more true of functions and mixins where the intent of the author is to import and use a specific function or mixin, not inherit the definition via a property.

This is one reason I filed https://github.com/w3c/csswg-drafts/issues/3714 to try to get to a place where we could make something like a named declaration, and export/import that to/from various places, ie importing rulesets into JS, or importing a mixin into another stylesheet.

The idea from #3714 applied to mixins might look something like:

utils.css:

@mixin $center {
  display: grid;
  place-content: center;
  }

component-a.css:

// assuming we could add `{name} from` support to `@import` syntax
@import {$center} from url("./utils.css");

body {
  @apply $center;
  /*
    display: grid;
    place-content: center;
  */
}

This would act a bit more like preprocessor mixins as well, and play nicely with static analyzers, minifiers, etc.

justinfagnani commented 9 months ago

@mirisuzanne this is awesome!

My first big question about mixins and @apply are how this avoids the problems @tabatkins talks about in https://www.xanthir.com/b4o00 Is it because function and mixin declarations aren't applied with selectors and don't inherit? What is the scope of a mixin declaration then, global?

Also, one of the use cases for the older @apply was that components authors could use it in their styles to allow component users to override arbitrary properties on certain elements. Is that use case supported with @mixin?

tabatkins commented 9 months ago

They avoid the @apply issues because they're not defined using the custom property mechanism and passed around that way. They're first-class citizens acting at the appropriate syntax level.

What is the scope of a mixin declaration then, global?

Yup, same as in Sass/etc.

Is that use case supported with @mixin?

No. ::part() was defined to handle that case instead.

LeaVerou commented 8 months ago

Coming to this quite late (I only discovered it yesterday from @mirisuzanne’s mention in #8738). Sharing some thoughts below.

Commonalties between mixins and functions

I’m not sure about defining functions and mixins together. They have fundamentally different use cases, and I’m worried defining them together could hold one of them back from its full potential.

I'd suggest the opposite process: iterate on them separately then find the common concepts and spec those together at the end.

Use cases

For both mixins and functions, there are two fundamental use cases, and we need to ensure both are served well:

  1. Local, specific purpose mixins and functions to reduce coupling and duplication and facilitate maintainability within a stylesheet
  2. Libraries of mixins and functions intended to be imported in projects and be more broadly useful. Examples:

These are a spectrum. One may start with a local mixin/function, then decide that they are more broadly useful.

An observation is that mixins also facilitate encapsulation. Today if you have multiple classes that share styling, either you need to alter the structure of your CSS code, OR just ask people to use both the base class and the more specific classes (e.g. class="callout warning"). Bootstrap icons does the latter too: every icon needs both a .bi class, AND a .bi-{icon id} class . Mixins can facilitate this both internally, by having a local --bi mixin, but also by exposing all icons as mixins. There could even be mixins like --icon-before and --icon-after so that people can apply them directly.

Requirements

Must-have, even in the MVP

Must-have, but we could be left out in L1 if really needed

Important but not essential

Syntax

Mixins

The current syntax proposed uses a @mixin rule to define the mixin and an @apply rule to apply it. I suspect it may not be feasible implementation-wise, but I would really love to investigate whether using a property-like syntax would be possible for using the mixin. Instead of parentheses, the parameters would be passed in as a property value. I will open a separate issue for this, as it seems a significant deviation from this proposal.

Using parameters in conditional rules

The proposal asserts that we could not use var() to refer to parameters in conditional rules. @tabatkins can correct me but I think we could simply allow them in the grammar, and have prose that only permits them in mixins, only when they refer to params passed?

Function return value: @-rule or descrptor?

I like an at-rule syntax (e.g. @return) rather than a result descriptor.

  • It helps distinguish the final returned value from any internal logic like custom properties and nested rules
  • Result is not a property, but looks a lot like one

It’s more of a property than an @-rule IMO. It's something that has a value and (optionally) a type. There is no precedent for @-rules that have values and types, but plenty for descriptors.

Yes, a generic @-rule is more distinguishable than a descriptor, but that is a syntax highlighting problem, not a language problem.

Being a descriptor allows it to cascade (e.g. to provide alternatives based on browser support — though the utility of that is limited due to var()) and be type checked like a custom property.

Actually, come to think of it, do we even need a dedicated descriptor? What if the result was held by a custom property, and which one was part of the function definition:

@function --at-breakpoints(
  --s: 1em;
  --m: 1em + 0.5vw;
  --l: 1.2em + 1vw;
) returns --result {
  @container (inline-size < 20em) {
    --result: calc(var(--s));
  }
  @container (20em < inline-size < 50em) {
    --result: calc(var(--m));
  }
  @container (50em < inline-size) {
    --result: calc(var(--l));
  }
}

And I like @FremyCompany’s idea as a default: if returns is missing, use the custom property that is named the same as the function.

Oh, or what if what it returns is actually a proper value?

@function --hypot(--a, --b) returns var(--result) {
    --result: calc(sqrt(pow(var(--a), 2) + pow(var(--b), 2));
}

Then you can make the braces optional and have a shortform function definition:

@function --hypot(--a, --b) returns calc(sqrt(pow(var(--a), 2) + pow(var(--b), 2));

Questions

Do mixins cascade down Shadow DOM boundaries? If so, they may help fix the issue of styling Shadow DOM (e.g. having form controls that are styled like the surrounding page with reasonable DX).

mirisuzanne commented 8 months ago

Great feedback - I've already started working some similar adjustments into the explainer. You can open additional issues if you want for individual aspects, but I don't consider this issue specific to the initial syntax - happy to keep iterating on that.

I also don't think mixins/functions should get too tangled. But it did feel useful to explore them together for this initial write-up. That has already helped point to where they are distinct (as you mentioned) and what they seem to share:

But at this point, those all look like things that should be pretty portable. If we get them right in one feature, we should be able to reuse them for the other.

andruud commented 8 months ago

I think being able to reference variables from the outside in mixins is essential.

@LeaVerou Do you just mean something like this, or do you have something more advanced in mind?

@mixin foo {
  color: var(--color);
}

#is-green {
  --color: green;
  @apply foo;
}

#is-blue {
  --color: blue;
  @apply foo;
}
vrubleg commented 7 months ago

A random idea. What if it were allowed to apply a mixin using ++ prefix?

#test
{
    --varname: value;
    ++mymixin: arguments;
    property: value;
}

Looks almost like a custom property =)

tabatkins commented 7 months ago

Mixins aren't properties, tho. And adding a brand new syntax needs some significant justification.

tabatkins commented 7 months ago

I think being able to reference variables from the outside in mixins is essential.

  • This is how functions work in most programming languages: local vars shadow outside vars, but outside vars are still accessible.

This isn't true, tho. What you're describing is usually called "dynamic scope", where a function can get access to the values of variables in the context it was called. Most languages use "lexical scope", where a function has access to variables in the context it was defined, and then when it's called the only additional information comes from the arguments themselves. Dynamic scope is very rare these days, as it's both easy to cause weird errors and harder to optimize. Instead we just pass arguments.

(And CSS variables don't exist in the global context in which @function is executed to define the function, so it wouldn't have lexical access to anything.)

Giving functions access to outside variables would also make it more difficult to work with functions generically - every outside variable it references is effectively an extra argument, and one that gets passed implicitly without having to know anything is happening. That means you can't name your custom properties arbitrarily, since a function you want to call might also use that property for its own purposes.

(The argument is different for mixins, which definitely swizzle your local "state" already by mixing in more properties. Nothing wrong with allowing them the ability to output styles that refer to outside variables.)

Nesting. Without it mixins can only accommodate the simplest of use cases. It can wait for L2, but the syntax must be designed to allow it.

By this do you mean letting a mixin use nesting to target styles at other elements? Then yeah, absolutely.

Non-conditional @-rules in mixins. Think of fonts, animations, custom properties, font palettes, @scope etc.

Only insofar as these rules already work when nested inside of style rules. When they do, they should work in mixins; when they don't, they shouldn't.


@function --at-breakpoints(
  --s: 1em;
  --m: 1em + 0.5vw;
  --l: 1.2em + 1vw;
) returns --result {
  @container (inline-size < 20em) {
    --result: calc(var(--s));
  }
  @container (20em < inline-size < 50em) {
    --result: calc(var(--m));
  }
  @container (50em < inline-size) {
    --result: calc(var(--l));
  }
}

I don't see what benefit we gain here from using a (configurable?) custom property here. Could you elaborate on why this is better than just using @return calc(...) in each location?

Being a descriptor allows it to cascade (e.g. to provide alternatives based on browser support — though the utility of that is limited due to var())

Right, it can't cascade in that way. We have no idea what the return value is - that requires contextual information about exactly how and where it's used, and we don't do that for typed custom properties, so we don't do that here either. The best we can do is know what type it's meant to resolve to, and verify that it does, so DevTools can complain.

and be type checked like a custom property.

This doesn't require it to be a descriptor. We just need to know the expected type somehow.

@function --hypot(--a, --b) returns calc(sqrt(pow(var(--a), 2) + pow(var(--b), 2));

The syntax already allows essentially this:

@function --hypot(--a, --b) { @return calc(sqrt(pow(var(--a), 2) + pow(var(--b), 2)); }`

Scoping. We’ve seen time and time again how anything global becomes a pain for authors. If possible, mixins defined within other rules should be scoped to those subtrees, same as a custom property defined in a nested rule. If not possible yet, then we should completely disallow mixins within other rules, so that it can become possible in the future.

This conflicts very heavily with some core concepts, unfortunately. If you have to do selector matching to even know that a mixin is available, that makes it much more difficult to then apply the mixin, and have it interact with the cascade properly.

Disallowing @mixins from being defined inside of style rules is, luckily, the default case - they're not allowed by the Nesting spec unless we say so. ^_^

mirisuzanne commented 7 months ago

Giving functions access to outside variables would also make it more difficult to work with functions generically… The argument is different for mixins…

Lea only listed this as a requirement for mixins, so I don't think there's any disagreement here.

Non-conditional @-rules in mixins. Think of fonts, animations, custom properties, font palettes, https://github.com/scope etc.

Only insofar as these rules already work when nested inside of style rules. When they do, they should work in mixins; when they don't, they shouldn't.

This gets to the question of top-level mixins, which are a fairly common pattern in Sass. I regularly use mixins to generate code (like font rules and keyframes) at the root of the CSS document, not in any nested context. I'm not sure how that would work with a nesting-driven approach to mixins, but there is certainly a use-case for it. On the other hand, it's a use-case that may not have any advantages running on the client. And it's most often used to access flow control and loops, which we don't have at this point.

mirisuzanne commented 7 months ago

I have some opinions about syntax – I don't see any gain from customizing the name of a return-value descriptor, or forcing it to match the function name – but for the most part my goal with this issue was to get confirmation that we want to pursue something along these lines. Functionally, I don't see a big difference between @apply foo(parameters); or eg ++foo: parameters;, and I'd be happy to bikeshed all of these details in more focused issues. So my focus here is on getting a resolution about taking up the broader project (either functions, or mixins, or both), and then we can open those other issues and continue with more focused discussions on syntax and behavior.

tabatkins commented 7 months ago

Lea only listed this as a requirement for mixins, so I don't think there's any disagreement here.

Ah, right, there was an explicit mention of "how functions work" so I presumed it was being suggested more widely. :+1: then.

And it's most often used to access flow control and loops, which we don't have at this point.

More than that; I presume it would also need access to various things around string manipulation, etc, and the ability to interpolate strings into CSS constructs in various ways. In all, a very significant expansion of responsibility, for a substantially different use-case.

So yeah, agreed, not appropriate to pursue at this point.

mirisuzanne commented 5 months ago

Agenda+ to see if the group is interested in taking up one or both of these projects - and (if so) where we should start working on a draft specification for it (and who should be assigned as editors).

tabatkins commented 4 months ago

Thinking about the syntax for arguments again, both type and default should be optional, and both are potentially complex values. How about:

/* no annotations */
@function --foo(--arg1, --arg2) {
    @return calc(var(--arg1) * (var(--arg2) / 1turn));
}

/* all the annotations */
@function --foo(
    --arg1 type(<calc-length>) default(1em),
    --arg2 type(<angle>) default(90deg),
) returns type(<length>) {
    @return calc(var(--arg1) * (var(--arg2) / 1turn));
}

.foo {
    width: --foo(1em + 1px, 5rad);
}

(I'm imagining <calc-length> is a new term you can use on argument types, to indicate you want a length but allow a raw calculation, so users don't need to write --foo(calc(1em + 1px), 5rad). It wouldn't be allowed for return types, as it's not meaningfully different from <length> there.

nex3 commented 4 months ago

(I'm imagining <calc-length> is a new term you can use on argument types, to indicate you want a length but allow a raw calculation, so users don't need to write --foo(calc(1em + 1px), 5rad). It wouldn't be allowed for return types, as it's not meaningfully different from <length> there.

This would make any kind of low-context parsing a nightmare for tooling like Sass, for what it's worth. The saving grace of calculation syntax right now is that it only appears in consistent, easily-recognizable contexts. It would probably mean that we have to pessimistically treat all plain-CSS function calls as effectively string quotes, which would be a severe usability hit.

LeaVerou commented 4 months ago

@tabatkins I think colon followed by value makes more sense for default arguments, with an optional wrapper if the value itself includes commas; though this is one of these cases where semicolon separators would actually make a lot of sense. Also, types are specified as strings in @property so having a completely different syntax here feels weird.

@nex3

This would make any kind of low-context parsing a nightmare for tooling like Sass, for what it's worth. The saving grace of calculation syntax right now is that it only appears in consistent, easily-recognizable contexts. It would probably mean that we have to pessimistically treat all plain-CSS function calls as effectively string quotes, which would be a severe usability hit.

That ship has sailed; literally all new math functions accept calculations for their arguments. (Also, there’s TAG Principle 2.12. Prioritize usability over compatibility with third-party tools)

That said, while off-topic for this thread, I’d love to understand the Sass problem better, as I firmly believe that few problems genuinely have no win-win solutions. I wonder if a better path forwards for Sass would be to simply deal with fewer calculations: wrap inline calculations with calc() and pass them to CSS for the actual computation. Then it can wrap in calc() optimistically, and these function calls would not be affected at all. Plus, it means you can do things like width: 1em + 100px; without getting an error, which is a usability win for Sass itself. I understand this won't work for all cases, but I think it might work for enough to relegate any negative impact from this to edge cases. I'm likely missing something major, but the overall sentiment is, let's try and figure out a solution to this together, that doesn't involve making CSS less usable.

tabatkins commented 4 months ago

I think colon followed by value makes more sense for default arguments

Hm, perhaps.

@function --foo(
    --arg1 type(<calc-length>): 1em,
    --arg2 type(<angle>): 90deg,
) returns type(<length>) {
    @return calc(var(--arg1) * (var(--arg2) / 1turn));
}

Yeah that doesn't look unreasonable to me.

Also, types are specified as strings in @property so having a completely different syntax here feels weird.

Yeah, I'm thinking of this as an expansion of that syntax, not a replacement. I stuck with strings in @property so I wouldn't have to define the CSS Value Definition Syntax in the CSS Value Definition Syntax, but I've been noodling with it a bit and suspect it's probably reasonable.

That ship has sailed; literally all new math functions accept calculations for their arguments.

Yes, that's fine, because Sass can hardcode knowledge of those functions. (Same with relative color syntax, etc.) Natalie's concern is that custom functions would need knowledge of the function's definition to know if the argument should be a calculation or not.

nex3 commented 4 months ago

I wonder if a better path forwards for Sass would be to simply deal with fewer calculations: wrap inline calculations with calc() and pass them to CSS for the actual computation. Then it can wrap in calc() optimistically, and these function calls would not be affected at all. Plus, it means you can do things like width: 1em + 100px; without getting an error, which is a usability win for Sass itself. I understand this won't work for all cases, but I think it might work for enough to relegate any negative impact from this to edge cases. I'm likely missing something major, but the overall sentiment is, let's try and figure out a solution to this together, that doesn't involve making CSS less usable.

One of the critical issues here is that mathematical syntax isn't mutually exclusive with expression syntax elsewhere in CSS. What does 1 / 2 mean in a CSS property? The answer is "it depends on context"—within a calculation, it's equivalent to 0.5, but in a grid-template property it separates rows from columns. So what is --foo(1 / 2)? If you allow <calc-length>s, we can't possibly know without seeing the definition of --foo, which may be in a totally different stylesheet.

I'd argue that this is a usability issue as well. It's valuable for humans, not just tools, to be able to see a function call and understand generally what it means without having to know all the definitions of everything.

tabatkins commented 4 months ago

Yeah, that seems reasonable to me, and I'm generally fine with still needing a calc() there.


Two more important topics to decide on: variable scoping, and execution model.

Variables

Big decision is between dynamic scope, lexical scope, or some mix.

I think that the strong convention of nearly every programming language in the world suggests that going completely lexical is ideal - the only names visible to the function body are listed in its arg list.

Technically, this means that a function can get access to any custom properties they want, it just requires them to be explicitly passed as arguments. Lea said they'd prefer if this was easy to do, and I think that's handleable. I propose something like:

@function --foo(--arg1, --arg2) using (--var1, --var2) { ... }

That is, a second arglist giving custom property names that they want to pull from the calling environment; the caller doesn't need to do anything for this to happen. They'd just call width: --foo(1, 2); and it would implicitly also fetch the --var1 and --var2 properties. This should be a full arglist, with type and defaults; the default is used if the custom property's value is invalid.

This also puts us in a good spot for future expansion into JS-backed custom functions, since JS is strictly lexical as well. The future CSS.registerFunction(...) call would just take an arg list and a using list, and function identically.

Execution

This is the big one: are the functions declarative, or imperative?

For example, if you write:

@function --foo(--arg1: 1) {
  --arg2: sin(var(--arg1));
  --arg1: 2;
  @return calc(var(--arg1) + var(--arg2));
}

What does each instance of var(--arg1) resolve to? Is it 1 followed by 2, or both 2?

I feel moderately strongly that we should go for a declarative model. That is, the body of the function is a declaration list that only allows custom properties, using normal declaration-list rules (last valid instance of a given property wins; the arglist is treated as setting several declarations at the beginning of the function body). This would give the answer "both 2" in the above, the same that you'd get if you wrote that in a rule:

.foo {
  --arg1: 1;
  --arg2: sin(var(--arg1));
  --arg1: 2;
  --return: calc(var(--arg1) + var(--arg2));
}
tabatkins commented 4 months ago

Hm, if we go with "it's just a declaration list, that only accepts custom properties", maybe it would be more sensible then to make the return value a (non-dashed) property as well:

@function --foo(--arg1: 1) {
  --arg2: sin(var(--arg1));
  --arg1: 2;
  return: calc(var(--arg1) + var(--arg2));
}

The syntax is straightforward, then, and it automatically suggests how it works to have multiple instances of it - the last valid one wins. And if you've provided a return type, we can parse that value and properly reject unknown things, so you can use CSS's normal fallback rules to use new features when they exist and fall back to older stuff otherwise.

DeepDoge commented 4 months ago

This is close to Container style queries

You can do this right now:

@container style(--my-special: "card"){
    .name {
        color: red;
    }
}

But it's missing something atm, you can't select the container itself from the container. If we were able to select the container itself, you would basically be able to do mixins.

Would look like this if we had something like :container

@container style(--my-card-mixin){
    :container {
        padding: 1em
    }
}
@container style(--my-card-mixin: "red"){
    :container {
        background-color: red
    }
}
mirisuzanne commented 4 months ago

Functions:

I agree with @tabatkins notes above. My preference is for a declarative execution model, and explicit shadowing of external variables. I do have one question about the proposed shadowing syntax. Can external variables be used as defaults, or only through the using syntax? Are these two approaches functionally identical?

@function --all-args(--one: 1, --two: var(--two)) { … }
@function --using(--one: 1) using (--two) { … }

Mixins:

This is close to Container style queries… But it's missing something atm

Style queries can be used as a rough stand-in for some mixins, but they work in fundamentally different ways – and allow a whole range of different use-cases. I don't think we should conflate them just because there's some overlap. But to the specific question: style queries can't style the container that they query because that would be a cyclic behavior. This wasn't an oversight in the design, it's a fundamental requirement for them to work as intended. Mixins don't have that limitation because they don't impact selection.

tabatkins commented 4 months ago

Can external variables be used as defaults, or only through the using syntax? Are these two approaches functionally identical?

Hm, I guess that would be a decision we could make. We'll want to enable things like a default of 1em resolving appropriately based on the element, so a var() default would probably work.

kizu commented 4 months ago

I'm not sure if I like the need for explicitly defining the variables that are used from the outer scope. What are the issues does this solve?

And another question: from which scope does it use the variables? From the outermost, closest, or only the immediately above one?

@function --foo() using (--var1, --var2) { … }

@function --bar() {
  --var2: 10;
  @return --foo();
}

.foo {
  --var1: 1;
  --var2: 2;
  --test: --bar();
}

In this case, will the --foo get the --var1 from the .foo? Will it get the --var2: 10 from the parent function?

Overall, I find the need to explicitly mention all the used variables to be something that could hinder a lot of use cases. Especially those that involve design tokens, as there could be potentially tens and hundreds of these which are involved in a single function. For example, when implementing complex typography rules, where we'd want to use tokens for font-sizes, weights, styles, letter-spacing, text-transform and so on. The requirement to explicitly mention all the tokens will make these functions difficult to write and maintain.

chriskirknielsen commented 4 months ago

Hm, if we go with "it's just a declaration list, that only accepts custom properties", maybe it would be more sensible then to make the return value a (non-dashed) property as well:

@function --foo(--arg1: 1) {
  --arg2: sin(var(--arg1));
  --arg1: 2;
  return: calc(var(--arg1) + var(--arg2));
}

The syntax is straightforward, then, and it automatically suggests how it works to have multiple instances of it - the last valid one wins. And if you've provided a return type, we can parse that value and properly reject unknown things, so you can use CSS's normal fallback rules to use new features when they exist and fall back to older stuff otherwise.

I like the CSS-ness of the logic behind this, however, having the last return win seems unexpected from what I'm used to with other languages where the first return wins and stop further code execution.

I'm thinking that conditions would eventually make their way into functions, so it would be nice to be able to leverage an early return pattern for more complex functions to avoid large else blocks, which is why I'd advocate for an @return syntax that would stop executing the function once encountered:

@function --foo(--arg1: 1) {
  @if (var(--arg1) < 0) {
    @return 0; /* The code below gets ignored */
  }

  --arg2: sin(var(--arg1));
  --arg1: 2;
  @return calc(var(--arg1) + var(--arg2));
}

We could also stop executing at the first return without @ but kinda to your point, that doesn't feel as CSS-like if it's presented as a standard property.

tabatkins commented 4 months ago

I'm not sure if I like the need for explicitly defining the variables that are used from the outer scope. What are the issues does this solve?

I gave two reasons for it:

the strong convention of nearly every programming language in the world suggests that going completely lexical is ideal - the only names visible to the function body are listed in its arg list. [...] This also puts us in a good spot for future expansion into JS-backed custom functions, since JS is strictly lexical as well.


from which scope does it use the variables? From the outermost, closest, or only the immediately above one?

The scope it's called in. In your example, --foo() will see an undefined --var1 (aka the guaranteed-invalid value) and a --var2 of 10.

The requirement to explicitly mention all the tokens will make these functions difficult to write and maintain.

If we had to explicitly pass all of them as variables, I'd definitely agree. As written, tho, you just have to state them once per function definition.

Also, design tokens are generally going to be global, which means that as we pursue the custom env() idea, you can switch to using those instead. They won't need to be passed in; like other programming languages, global variables will be globally available. (And unlike other programming languages, they're immutable, so it's actually safe to use them.)


I like the CSS-ness of the logic behind this, however, having the last return win seems unexpected from what I'm used to with other languages where the first return wins and stop further code execution.

Right, but it's not executing imperatively, in this idea. It's just a bag of properties, acting like bags of properties always do. For example, in the following:

@function --foo(--arg1: 1) {
  --arg2: calc(var(--arg1) + 1);
  --arg1: 10;
  return: calc(var(--arg1) * var(--arg2));
}

calling --foo() would return 110, not 20, because --arg1 is set to 10 in the property list, and --arg2 depends on it, per normal variable dependency rules, so it sees the value 10. Again, this is identical to just inlining these custom props into a style rule.

I'm thinking that conditions would eventually make their way into functions,

Yes, we've got plans for conditionals in normal CSS, which will work in the declarative model too:

@function --foo(--arg1: 1) {
  return: cond(
    (var(--arg1) < 0) 0,
    calc(2 + sin(var(--arg1)))
  );
}
mirisuzanne commented 4 months ago

I wonder if it would help to call it something other than return to avoid the imperative expectations. Something like value may feel more like a property in a bag of properties, rather than an imperative action that 'ends execution'.

Crissov commented 4 months ago

Most functions in CSS will probably be simple and not use the parameters passed to them many times. Therefore, I think it would be fine to reference them by index number in a special array-like function, arg(). If authors want to have names parameters or default values they can still have them with normal custom properties.

@function --distance() {
  value: 0px; /* default */
  value: abs(arg(1) - arg(2, 0px));
}

@function --distance() {
--start: arg(1);
--end:   arg(2, 0px);
  value: 0px; /* default */
  value: abs(var(--start) - var(--end));
}

With arg(), the number of arguments would be flexible, but to make good use of that, we would need some kind of for or pop syntax.

@function --sum() {
  value: 0; /* default */
  value: for(value + arg(i));
}

@function --sum() {
  value: 0; /* default */
  value: arg(pop) + --sum(arg());
}

PS: I considered to suggest that the value property could just be any type name, e.g. length here, so a single function would be mutable and could return different values depending on where it is used, but I did not find this useful enough.

Loirooriol commented 4 months ago

Something like value may feel more like a property in a bag of properties

value seems very generic, result may be clearer?

tabatkins commented 4 months ago

Most functions in CSS will probably be simple and not use the parameters passed to them many times. Therefore, I think it would be fine to reference them by index number in a special array-like function, arg().

I don't know of this ability existing in any other language; it's certainly not in any I've ever used. Sometimes it might appear in a lang's super-short lambda syntax when they language is hyper-optimizing for terseness, but even that's rare; JS and Python lambdas need to name their args, for example.

I don't think we should be innovating here, or introducing the complexity of having multiple ways to refer to an argument, without a very good reason.

Crissov commented 4 months ago

I was thinking of the argv[] array in C’s main() function.

Templating languages may also support an arbitrary number of indexed parameters, so they may mix anonymous and named parameters, but they often also lack the possibility to define named variables dynamically.

LeaVerou commented 4 months ago

I was thinking of the argv[] array in C’s main() function.

Why on earth would we want to take inspiration from that? It’s such a pain to use there are entire libraries for parsing arguments!


(lots of things in the thread that I want to respond to but haven't had time, hopefully I’ll get to them soon!)