Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[css-values] Introduce self() function #9459

Open
brandonmcconnell opened this issue Oct 11, 2023 · 7 comments
Open

[css-values] Introduce self() function #9459

brandonmcconnell opened this issue Oct 11, 2023 · 7 comments

Comments

@brandonmcconnell
Copy link

brandonmcconnell commented Oct 11, 2023

Problem Statement

With the increasing complexity of CSS architectures and the push for more modular and declarative designs, there's a clear advantage in allowing styles to be derived based on other computed styles without relying on custom properties alone. This proposal seeks to introduce the self() function to fetch and compute styles based on other properties of the same selector.

The self() function

The self() function will allow designers to fetch a specific computed property value of the same selector in which it is being used. This can reduce the reliance on custom properties for shared values between properties, and streamline the styling process.

Use Case

** This is just one use case among countless

Computing the natural nested border-radius for a descendant of another rounded element.

Current Approach

parent {
  --br-tl: 30px;
  --br-tr: 48px;
  --br-br: 82px;
  --br-bl: 130px;
  --p-t: 20px;
  --p-b: 10px;
  --p-r: 26px;
  --p-l: 44px;
  border-radius: var(--br-tl) var(--br-tr) var(--br-br) var(--br-bl);
  padding: var(--p-t) var(--p-r) var(--p-b) var(--p-l);
  --nested-radius:
    calc(var(--br-tl) - var(--p-t))
    calc(var(--br-tr) - var(--p-r))
    calc(var(--br-br) - var(--p-b))
    calc(var(--br-bl) - var(--p-l));
}

child {
  border-radius: var(--nested-radius);
}

Using self() function

parent {
  border-radius: 30px 48px 82px 130px;
  padding: 20px 10px 26px 44px;
  --nested-radius:
    calc(self(border-top-left-radius) - self(padding-top))
    calc(self(border-top-right-radius) - self(padding-right))
    calc(self(border-bottom-right-radius) - self(padding-bottom))
    calc(self(border-bottom-left-radius) - self(padding-left));
}

child {
  border-radius: var(--nested-radius);
}

With declarative functions

With the prospective introduction of declarative functions into CSS, this feature would be even more assistive, as functions could reference other properties without needing to have every related value passed as an argument to that function.

This is how that same example above could look, abstracting the logic away into a custom function so it can be re-used effectively as often as needed, only needing to declare the logic once within the function:

@custom-function --get-nested-radius {
  result: calc(self(border-top-left-radius) - self(padding-top))  calc(self(border-top-right-radius) - self(padding-right)) calc(self(border-bottom-right-radius) - self(padding-bottom)) calc(self(border-bottom-left-radius) - self(padding-left));
}

parent {
  border-radius: 30px 48px 82px 130px;
  padding: 20px 10px 26px 44px;
  --nested-radius: --get-nested-radius();
}

child {
  border-radius: var(--nested-radius);
}

You can think about using self() in declarative functions similarly to how one references this in a prototype method in JavaScript so the function logic can reference the object itself and its other properties, like this:

String.prototype.isEmpty = function() { return this.length === 0; };

Handling circularity

Addressing circularity risks could be a significant challenge with this proposal. Fetching a property's value from itself or from another property that references the original property could lead to an endless loop, but I think there may be some approaches we could take to combat/prevent that.

Examples of circularity

padding-left: self(padding);
padding: self(padding-left);

…or slightly less obviously…

margin-top: self(padding-bottom);
padding-bottom: self(border-width);
border-width: calc(self(padding-top) + 2px);
padding-top: self(margin-top);

Proposed approaches to preventing circularity issues

  1. Explicit Blocking: If the browser detects a circular reference when computing the self() value, it should block the circularity and revert to the initial or default value for that property. This behavior would be similar to the way browsers handle invalid values in CSS.

  2. Limit Depth: Implement a depth limit for the number of times self() can be used consecutively within a property. If this depth is reached, it defaults to the initial or default value. This can be similar to the stack overflow concept in programming, preventing endless loops.

  3. Error Handling: CSS could introduce a new way of handling errors where, if a circular reference is detected, a specific error state is entered for that element, allowing developers to handle it appropriately.

Conclusion

The self() function has the potential to streamline CSS development and make it more modular. By allowing property values to be fetched from other properties in the same selector, it reduces the overhead of using custom properties for shared values. However, care needs to be taken to handle potential circular references, ensuring the system remains robust and predictable.

@LeaVerou
Copy link
Member

LeaVerou commented Oct 12, 2023

Isn't this basically "var() for any property"? That is a common request, and avoiding circularity is more complex than it seems (see this reply by @tabatkins ).

Coincidentally, I posted a proposal a few days ago to reduce implementation complexity: #9454
I discussed it privately with @tabatkins who said it certainly avoids some of the issues, but is still a lot of work, and is unconvinced about the value-add since authors can always use variables.

Note that your code above would not work properly even if self() was a thing. You need to add:

@property --nested-radius {
	syntax: "<length>+"; /* <length>{1,4} not currently supported */
	inherits: true;
	initial-value: 0;
}

This will make the lengths actually resolve and get passed down as lengths. Right now they just get inherited as tokens, and interpreted on the point of usage, so self() will refer to child, and this be circular.

However, your code as specified doesn't even need self() to be a thing. Since you're only referencing parent properties, inherit() would work just fine, and it's already accepted by the WG (spec pending, though L1 will just be custom properties). Solving the use case properly would require self()/var(), but only to take child margins into account.

@brandonmcconnell
Copy link
Author

@LeaVerou Thanks for the added context.

For the example I included, passing down the nested radius to a child is a simpler example. In many cases, someone would add a border-radius on a great-grandparent and need to pass that several layers down to make it visible. afaik inherit() would not solve this, and it would require some long prop-drilling-style implementation.

Also, I think it could be possible to use self(padding-bottom) as self() would only work on known properties, and most if not all native properties have very few possible types. We could restrict this to only work on simpler/primitive types.

I definitely do not intend to down-play the complexity of a proposal like this. I simply do see significant value-add.

One of the primary places I see value is being able to re-use styles more modularly, where styles can work where they are added contextually, without needing to manually create variables every place you want to do something like this.

In the border-radius example, you would need to set up all those different variables each time, but with self() (and declarative functions), anywhere you were to use the --get-nested-radius() it could dynamically pick up those values without needing to set up multiple variables to pass in as args.

This is just one example. I know there's an issue already open to simply nested radii, which is what initially birthed this proposal, but there are countless other use cases for this.


Please know—as tone/intent gets lost so easily online—I'm not trying to be stubborn here. I'm just thinking out loud. Thanks for thinking through this with me. 🙂

@LeaVerou
Copy link
Member

@LeaVerou Thanks for the added context.

For the example I included, passing down the nested radius to a child is a simpler example. In many cases, someone would add a border-radius on a great-grandparent and need to pass that several layers down to make it visible.

I think I might be misunderstanding your proposal because I’m not sure how self() would get grandparent values without custom properties? (and if custom properties are fair game, inherit() on the parent works fine)

afaik inherit() would not solve this, and it would require some long prop-drilling-style implementation.

What do you mean by "long prop-drilling-style implementation"?

Also, I think it could be possible to use self(padding-bottom) as self() would only work on known properties, and most if not all native properties have very few possible types. We could restrict this to only work on simpler/primitive types.

It’s not about whether the value is known, if you're specifying the value on an untyped property, it gets passed down as a list of tokens, the same as if you use another known function, e.g.:

.parent { --foo: calc(1em + 10px); }
.parent .child {  font-size: var(--foo); }

If --foo is untyped here (i.e. not declared via @property, it will just pass down calc(1em + 10px) as a list of tokens, which will be interpreted on .child, and use its own em sizing, even though the function used is typed in itself (if used somewhere else). Tokens only have meaning depending on the place they are used, not independently, and tokens in untyped properties do not have meaning.

In the border-radius example, you would need to set up all those different variables each time, but with self() (and declarative functions), anywhere you were to use the --get-nested-radius() it could dynamically pick up those values without needing to set up multiple variables to pass in as args.

I was talking about the self() proposal, my understanding is we're discussing declarative functions in another issue? This is independent of declarative functions, right? 🤔

Please know—as tone/intent gets lost so easily online—I'm not trying to be stubborn here. I'm just thinking out loud. Thanks for thinking through this with me. 🙂

Same!

@brandonmcconnell
Copy link
Author

@LeaVerou

I think I might be misunderstanding your proposal because I’m not sure how self() would get grandparent values without custom properties? (and if custom properties are fair game, inherit() on the parent works fine)

What do you mean by "long prop-drilling-style implementation"?

self() wouldn't expose grandparent values, but a grandparent could use self() in a custom property which could then be used by its grandchild. Using inherit would require both the grandchild and child to use inherit() (afaik) to retrieve the grandparent value.

It’s not about whether the value is known, if you're specifying the value on an untyped property, it gets passed down as a list of tokens, the same as if you use another known function, e.g.:

.parent { --foo: calc(1em + 10px); }
.parent .child {  font-size: var(--foo); }

If --foo is untyped here (i.e. not declared via @property, it will just pass down calc(1em + 10px) as a list of tokens, which will be interpreted on .child, and use its own em sizing, even though the function used is typed in itself (if used somewhere else). Tokens only have meaning depending on the place they are used, not independently, and tokens in untyped properties do not have meaning.

Ah that makes sense. I was making the case that self() would always be typed naturally, but yes, once we load those values into a var() it would be tokenized

I was talking about the self() proposal, my understanding is we're discussing declarative functions in another issue? This is independent of declarative functions, right? 🤔

Yup! Declarative functions were just a strong use case for this feature, though I also see significant value elsewhere

Same!

🙂

@LeaVerou
Copy link
Member

Using inherit would require both the grandchild and child to use inherit() (afaik) to retrieve the grandparent value.

Nope. As long as the custom property is typed appropriately (and specified inheritable), all you need to pass it down to arbitrarily distant descendants is to set it once on the child of the element you’re querying.

Yup! Declarative functions were just a strong use case for this feature, though I also see significant value elsewhere

I agree that declarative functions would be immensely useful, but let's try to keep the discussion about each feature in its own issue otherwise it becomes difficult to untangle.

@nt1m nt1m changed the title Introduce self() function [css-values] [css-values] Introduce self() function Oct 15, 2023
@brandonmcconnell
Copy link
Author

That makes sense. Someone else recommended I include declarative functions as a use case on this ticket, which is why I did.

Even without declarative functions in the picture, this code easier to reuse (simple copy & paste, more or less):

@property --nested-radius {
  syntax: "<length> <length> <length> <length>";
  inherits: true;
  initial-value: 0px 0px 0px 0px;
}

.parent {
  /* we can implicitly use the below values without needing to explicitly
     set the border-radius or padding values as CSS custom properties */
  --nested-radius:
    calc(self(border-top-left-radius) - self(padding-top))
    calc(self(border-top-right-radius) - self(padding-right))
    calc(self(border-bottom-right-radius) - self(padding-bottom))
    calc(self(border-bottom-left-radius) - self(padding-left));
}

.some-descendant {
  border-radius: var(--nested-radius);
}

…than this:

@property --nested-radius {
  syntax: "<length> <length> <length> <length>";
  inherits: true;
  initial-value: 0px 0px 0px 0px;
}

.parent {
  /* without self(), we need to explicitly declare padding and border-radius
      values as CSS custom properties in order to re-use them, meaning these
      types of values couldn't be provided by any 3rd-party lib without
      knowing your site and styles, as it can't inherit them */
  --br-tl: 30px;
  --br-tr: 48px;
  --br-br: 82px;
  --br-bl: 130px;
  --p-t: 20px;
  --p-b: 10px;
  --p-r: 26px;
  --p-l: 44px;
  border-radius: var(--br-tl) var(--br-tr) var(--br-br) var(--br-bl);
  padding: var(--p-t) var(--p-r) var(--p-b) var(--p-l);
  --nested-radius:
    calc(var(--br-tl) - var(--p-t))
    calc(var(--br-tr) - var(--p-r))
    calc(var(--br-br) - var(--p-b))
    calc(var(--br-bl) - var(--p-l));
}

.some-descendant {
  border-radius: var(--nested-radius);
}

I know the circularity may be a huge hurdle to get over if it's even possible. Just trying to further explain the value and use case here.

@brandonmcconnell
Copy link
Author

brandonmcconnell commented Dec 13, 2023

@LeaVerou fwiw I believe this self() function seeks to achieve the missing lexical reference needed for mixins as mentioned in #9350:

  • Proposal: Custom CSS Functions & Mixins #9350 (comment)

    @LeaVerou

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

    • 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.
    • Non-conditional @-rules in mixins. Think of fonts, animations, custom properties, font palettes, @​scope etc.
  • Proposal: Custom CSS Functions & Mixins #9350 (comment)

    @mirisuzanne

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

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

    @tabatkins

    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.

That doesn't mean that this proposal is the solution by any means, but I hope that does a better job of explaining the purpose of this proposal than I did previously.

self() would expose the values set for other properties on the element, which would be especially valuable in both functions and mixins.

As you mentioned, this could be the same as "var() for any property", in which case, var itself could be redefined to represent "value-reference" (or similar).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants