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-nesting-2] Syntax to customize how a nest-containing selector is resolved? #6330

Closed
flachware opened this issue May 31, 2021 · 29 comments
Closed

Comments

@flachware
Copy link

flachware commented May 31, 2021

I’m very exited about the upcoming nesting feature (https://drafts.csswg.org/css-nesting/). But with nesting new troubles emerge. This is a proposal for a Sass-like :selector-replace() pseudo-class function that allows authors to modify the nesting selector &.

In complex user interfaces elements tend to have multiple states which depend in part on parent elements. The deeper an element is nested the more likely it becomes that you have to define a state that is based on a modified selector somewhere in the middle of the 'selector chain'.

Example:

.component {
  color: green;
  
  & .container {
    color: brown;
  }
  
  & .child {
    color: blue;
    
    &:hover {
      color: yellow;
    }
    
    &:selector-replace(.component, .component:focus) {
      color: red;
    }
    
    &:selector-replace(.component, .component .container.active) {
      color: white;
    }
  }
}

is equivalent to

.component {
  color: green;
}

.component .container {
  color: brown;
}

.component .child {
  color: blue;
}

.component .child:hover {
  color: yellow;
}

.component:focus .child {
  color: red;
}

.component .container.active .child {
  color: white;
}

In this simplified example the .child element has four states, two of these states depend on its ancestors. Of course this could be written by adding these states to .component and .container. But this would not be DRY and the .child styles start to scatter around, in the worst case you would have to replicate the nesting multiple times in various places just for one modified part at a time (and there would be a lot more nesting levels and elements in reality).

The proposed :selector-replace() pseudo-class function addresses this issue by allowing you to modify nested selectors wherever you need it. I do not propose that particular name, I think something like :mod() (modify) might make more sense with regard to brevity.

Disclaimer: I did not come up with this feature, it is part of Sass – for good reason.

@Loirooriol
Copy link
Contributor

I would probably use something like

.component {
  color: green;
  --child-color: unset;
  & .container {
    color: brown;
  }
  &:focus {
    --child-color: red;
  }
  & .container.active {
    --child-color: white;
  }
  & .child {
    color: var(--child-color, blue);
    &:hover {
      color: var(--child-color, yellow);
    }
  }
}

@LeaVerou
Copy link
Member

LeaVerou commented May 31, 2021

I find the proposed solution really difficult to read and process.

Indeed, what @Loirooriol suggests would address a lot of these use cases in a far more readable way, but not all, since the state often needs to toggle multiple properties with different types, that cannot (currently, see #5624 ) be toggled with one custom property.

I wonder if a better solution for those cases would be some way to reference the & of ancestor scopes (maybe &1, &2 etc?)

@flachware
Copy link
Author

@Loirooriol that’s an interesting idea, but as @LeaVerou already pointed out, I’d like to define any type or number of properties for a given state (sorry for the overly simplified example). And in this case that approach would basically double the number of lines, as you have to define multiple custom properties in the parents and assign them in the child afterwards. Also, I believe that all styles belonging to the child should be defined in the child.

@LeaVerou I’m not totally opposed to reference different levels with something like &1 or &2, but I submit that this results in a tighter coupling – whenever you change the nesting these references might break silently.

@LeaVerou
Copy link
Member

LeaVerou commented May 31, 2021

@LeaVerou I’m not totally opposed to reference different levels with something like &1 or &2, but I submit that this results in a tighter coupling – whenever you change the nesting these references might break silently.

Oh that's a good point, we don't want that.

I wonder then if it should be explicit: You'd declare a name for the & in the ancestor scope that would allow you to reference it in descendants and how.

E.g. something like:

.foo {
	scope-name: bar;

	& .bar {
		&bar:focus .baz {

		}
	}
}

Of course lots to figure out: where does & stand in &bar:focus .baz? Is there an implicit & between them? Would it still be mandatory to include & as well somewhere in the selector to make it explicit?

@flachware
Copy link
Author

Would this be the equivalent?

.foo:focus .bar .baz { }

@LeaVerou
Copy link
Member

LeaVerou commented Jun 1, 2021

That depends on how it's defined. I think & should still be mandatory in these selectors, because otherwise the meaning is very ambiguous. And yes, if it's &bar:focus & .baz then it should be equivalent to .foo:focus .bar .baz. Not sure how to define it so that it doesn't become .foo:focus .foo .bar .baz when that & is expanded though. 🤔

@flachware
Copy link
Author

flachware commented Jun 1, 2021

Ok, so if .foo:focus .bar .baz is the intended output, I think I would define it like this, following your proposal:

.foo {
  scope-name: foo;

  & .bar {
    & .baz {
      &foo:focus {
      
      }
    } 
  }
}

I guess you named the scope bar to make it clear that this is an arbitrary label. But in a real case you’d probably use part of the class name of the scope (foo)? And I think &foo:focus should go inside .baz, because this way you can also have .baz styles without the focus modifier.

In my understanding &foo:focus would mean: extend & to the full selector but modify the level the scope-name was defined at with :focus. I think there could only be one & otherwise you would double the selector.

The issues I see with this approach:

  • This introduces a new scope-name property, that is merely used as an aid for the syntactical sugar of nesting. I’m not sure this is legit, I believe this would be a precedential case?
  • I chose the original example above deliberately as it features an edge case: what if you didn’t just want to add a pseudo-class like :focus somewhere in the full selector but wanted to modify it with a more complex selector? E.g. how would you modify .foo .bar .baz to .foo .qux.active .bar .baz with the proposed approach?

The benefit of the originally proposed :selector-replace() function is that it can take selectors of any complexity as arguments.

I believe any solution will contain three ingredients: &, some kind of reference to the part you want to modify, and the actual modification. But let’s take a step backward. To be clear, I’m not attached to the original syntax, first and foremost I would like to come to an understanding in terms of the & nesting selector – I believe the draft of the nesting spec is incomplete. The & allows us to append something to the full selector, like:

.foo {
  & .bar {
    &:hover {
    
    }
  }
}

It also provides means to prepend something to the full selector, like:

.foo {
  & .bar {
    @nest :hover & {
  
    }
  }
}

And you can have media queries right in place (which is great for responsive layouts):

.foo {
  & .bar {
    @media (min-width: 768px) {
      & {
      
      }
    }
  }
}

So you can basically do everything you want within an existing nesting – except if the selector in question is accidentally part of the nesting. In this case the only way (currently) is to replicate the modified nesting (and that’s a bad thing for several reasons). It is kind of 'discriminatory' to the selectors that happen to be in the nesting:

.foo {
  & .bar {
  
  }
  
  &:hover {
    & .bar {
   
    }  
  }
}

So I think once nesting is introduced there should not only be means to prepend and append something to &, but also means to modify &. Are we on the same page here?

@Loirooriol
Copy link
Contributor

I don't like being able to arbitrarily replace parts of parent selectors to something completely different. At that point I think you should just move that part outside of the nesting.

But being able to reference the elements matched by some ancestor rule (not just the parent) may be reasonable.

Using a property (or descriptor, I guess?) seems strange, though. I would add some syntax just after the selector, like

.foo => &(foo) {
  & .bar {
    & .baz {
      @nest &(foo):focus & {}
    }
  }
}

Note that &foo means &:is(foo), so we need a different syntax like &(foo), or change the spec:

The nesting selector is allowed anywhere in a compound selector, even before a type selector, violating the normal restrictions on ordering within a compound selector.

Also, I think you would still need to use a plain & in order to tell where the parent selector is.

@Loirooriol Loirooriol added the css-nesting-1 Current Work label Jun 1, 2021
@flachware
Copy link
Author

flachware commented Jun 1, 2021

I actually don’t necessarily want to replace parts of the parent selectors either, but I’d like to be able to insert arbitrary modifications. Would your proposed approach allow something like this:

.foo => &(foo) {
  & .bar {
    & .baz {
      @nest &(foo) .qux.active & {}
    }
  }
}

as an equivalent to this?

.foo .qux.active .bar .baz {}

I’m not sure about the &(foo) syntax though, I think the & should be used consistently as a representation for the parent selectors only.

And at that point I’m wondering why we couldn’t select the ancestor directly? This would modify all occurrences of .foo in &, but I think that’s ok, the same way any selector can match multiply elements:

.foo {
  & .bar {
    & .baz {
      @nest &(.foo):focus & {}
    }
  }
}

@Loirooriol
Copy link
Contributor

as an equivalent to this? .foo .qux.active .bar .baz {}

Well, technically I think it would be like .foo .qux.active :is(.foo .bar .baz) {}. & matches the elements that match the parent selector. It doesn't directly insert a selector into another, you may need :is() if there are combinators.

I’m not sure about the &(foo) syntax though

&(foo) was just some random syntax different than &foo which has another meaning. Can be something else.

I believe the second & is actually not needed as (.foo) already makes it clear where the :focus belongs?

But it's not clear what's the relationship with .baz.

shouldn’t this then work with direct nesting without @nest?

Probably, if it's at the beginning, I guess.

@Loirooriol
Copy link
Contributor

I guess a possible problem is if you have

<div class="foo"><div class="bar"><div class="foo"><p>Foo</p></div></div></div>
.foo => &(foo) {
  & .bar {
    & p {
      @nest &(foo):focus & {}
    }
  }
}

Then this will be like .foo:focus :is(.foo .bar p), meaning that it will match if e.g. the focus is on the inner .foo, but the author may want the focus to be on the .foo that contains the .bar.

@flachware
Copy link
Author

flachware commented Jun 1, 2021

Well but sadly in that case it’s kind of useless, you could simply write that with existing syntax:

.foo {
  & .bar {
    & p {
      @nest .foo:focus & {}
    }
  }
}

is equivalent to

.foo:focus .foo .bar p {}

This is prepending to &, but this issue is all about modifying & or rather inserting into &. So I’m looking for a way to achieve .foo:focus .bar p or .foo :focus .bar p.

@flachware
Copy link
Author

I’m basically looking for something like that:

.foo {
  & .bar {
    & p {
      &:mod(:ancestor(.foo):focus) {} /* => .foo:focus .bar p */
    }
  }
}

@flachware
Copy link
Author

flachware commented Jun 3, 2021

@Loirooriol @LeaVerou I realize that if my claim that the nesting spec is incomplete holds true, then the conclusion must be to focus on the existing @nest & syntax and add more capabilities right there. Logically, there are three ways the nesting selector can change: A selector is prepended, appended, or inserted. So in addition to being able to append a selector to the nesting selector with @nest &:hover and prepend a selector with @nest :hover & merely by changing the order, I propose a way to append a selector to an ancestor within the nesting selector using a &() modifier.

.foo {
  & .bar {
    & .baz {
      &:hover {
        /* => .foo .bar .baz:hover */
      }
      
      @nest :hover & {
        /* => :hover .foo .bar .baz */
      }

      &(.foo:hover) {
        /* => .foo:hover .bar .baz */
      }
    }
  }
}

&(.foo:hover) would be processed from left to right, so the first selector .foo in the modifier would be used to find any ancestor in the nesting with that class name and then append :hover.

I believe that appending a selector to an ancestor is equally important as prepending a selector to the nesting selector and thus the burden to work around this situation should not be passed onto the users of CSS. Instead, there has to be a way that allows users to append selectors to ancestors right in place. Without a syntax that facilitates this, users have to recreate slightly modified nestings various times – which contradicts the whole point of nesting as an approach to avoid redundancy.

@argyleink
Copy link
Contributor

for me, when I get into scenarios like the above, where i find myself nested kinda deep and needing some specialty syntax adjustments, i trade the deep forming DRY roots for legibility. aka, what @Loirooriol did. less assembling for my brain to do in that moment and less for my brain to do later when i come back. coming back to complex nesting i've made is troublesome, though seemed right in the moment.

i wonder if the complexity cliff here is appropriately ~3-4 deep, where these syntax adjustments tend to be requested, but is also at a point one should consider if they need to nest that deep and maybe there's a more legible / simple way to achieve the style? it might behoove the native nesting spec to have a simpler (than less, stylus, scss) offering with less power but also less debt and complexity? custom props are definitely an escape hatch here, because a selector can change a prop and any children can hook into.

that being said, i'm not opposed to finding a way for @nest & to allow specifying which ancestors to pass forward. numbering like js seems fine, but could have scaling issues. the &() seems fine but means you lose the DRYness of nesting in favor of a nested selector convenience. worth hackin on i think!

@tabatkins
Copy link
Member

Yup, I'm pretty strongly of the opinion that at the point you feel like you need this sort of thing, you should be thinking about how to rewrite it to be simpler instead. "Limit your nesting to 3-4 deep" seems to be a pretty common and reasonable bit of advice in the CSS preprocessor world, and when you're at that point the pain of possible repetition is low compared to the gain of remaining straightforward and readable.

At minimum, I'd block this from the first version of Nesting; we can always add it in a Nesting 2 if, after wide usage, it seems like it's still something useful and necessary.

@flachware
Copy link
Author

Thanks to you all for thinking this through! Just to clarify, this issue is not about deep nesting, it’s about nesting by itself.

It strikes me that @nest & is considered to be a given, but &() is considered to be more advanced. I think it’s not, except that it is harder to come up with a syntax for it.

Yes, there are workarounds, but that’s the whole point of the issue: the nesting mechanism should be powerful enough to be able to handle all possible combinations in a straightforward, readable, and DRY way – without the need to resort to custom properties etc.

Assuming you write the following in a nested way:

.foo .bar

Then the nesting spec should be able to handle the following situations:

:hover .foo .bar

.foo:hover .bar

.foo :hover .bar

.foo .bar:hover

None of these situations is more basic or more important than the others, some of them are just easier to represent in a nested way. But all of them exist and should be handled properly.

@tabatkins
Copy link
Member

It is more complex, which is why all nesting solutions do the simple thing but the extra thing you're asking for is an advanced non-universal feature. ^_^

the nesting mechanism should be powerful enough to be able to handle all possible combinations in a straightforward, readable, and DRY way [...] [snip examples]

As someone who loves designing features to be as powerful and generative as possible, I have to object that this is not a given. If there is a straightforward, readable way to achieve all of these, great; but we can't take it as given that such a way exists. In particular, I think the &() suggestion is definitely not such a way - it makes some assumptions (that a given simple selector will only show up once in the composite selector) that don't necessarily hold, and is, I'd argue, not particularly readable.

In particular, nesting as it exists in the current spec doesn't require you to understand the structure of the parent selector(s) itself; all you need to know is what elements have been selected by the parent selector. You can then build a new selector based on that, with & just serving as a selector that magically selects whatever was selected in the next level up. Selector rewriting, on the other hand, operates on the selector itself as a significant concept, rather than the elements the selector represents. That's a significant mental shift, and one that can be handled well if the language is set up for such, but I don't think CSS, and the nesting feature as designed, is set up for that.

(For example, I think it would be fine if you had some selector-building mechanism in JS, where you could store selectors in objects and chain them together into new selector objects. You could then store a significant early selector in a well-named variable, to indicate that it's important to the reader, and then modify it later on.)

In any case, my earlier point holds:

we can always add it in a Nesting 2 if, after wide usage, it seems like it's still something useful and necessary.

@fantasai fantasai added css-nesting-2 and removed css-nesting-1 Current Work labels Jan 10, 2023
@fantasai
Copy link
Collaborator

fantasai commented Jan 10, 2023

Just wanted to cross-reference the discussion in #6977
particularly the suggestions by Lea Verou and by Griffork.

@LeaVerou
Copy link
Member

My comment was in #6977 was:

First, I have to say I keep stumbling on use cases for this. Things like:

.container {
	& .widget {
		.container.selected & {
			/* FAIL, gets rewritten to .container.selected .container .widget, 
			   not .container.selected .widget */
		}
	}	
}

Also, I agree my proposal in #6330 (comment) is overkill. Also, it introduces a CSS property that is not actually applied on any elements, but is just used to evaluate syntax. Yikes.

Instead, I think we should go for a simpler solution, with predefined names for going up 1, 2, 3, ... levels. Perhaps &1, &2, &3 etc. Then the example above would become:

.container {
	& .widget {
		&1.selected & {
			/* Gets rewritten to .container.selected .widget */
		}
	}	
}

Is &1 cryptic? Yes. But it isn't more cryptic than & itself, and it kinda reminds me of $1, $2 etc in JS string replacement.

It was upvoted by @argyleink and @romainmenke @lubomirblazekcz .

If we are broadening this issue to include suggestions like these, we should probably edit the title to reflect this.

@LeaVerou LeaVerou changed the title [css-nesting] :selector-replace() pseudo-class function [css-nesting-2] Syntax to customize how a nest-containing selector is resolved? Jan 11, 2023
@flachware
Copy link
Author

I realized that the :has() selector provides a workaround for this issue without a new syntax:

.container {
  .widget {
    color: blue;

    :has(.container.selected) & {
      color: red; /* Workaround for .container.selected .widget { color: red } */
    }
  }
}

Upcoming @scope might also allow to address this:

.container {
  .widget {
    color: blue;

    @scope (&) {
      .container.selected :scope {
        color: red; /* Workaround for .container.selected .widget { color: red } */
      }
    }
  }
}

Please note that in Chrome v110 even the following works (https://codepen.io/flachware/pen/OJoMXPM):

.container {
  .widget {
    color: blue;

    .container.selected & {
      color: red;
    }
  }
}

@tabatkins
Copy link
Member

Please note that in Chrome v110 even the following works

Yes, that is correct per spec. Nested selectors are only interpreted as relative if they start with a combinator, or you don't use the & selector at all. Otherwise, we just take the selector exactly as-is, so you can add whatever context you want before the &.

@flachware
Copy link
Author

This is ingenious! So, according to the spec the example above desugars as:

.container .widget { color: blue; }
.container.selected .widget { color: red; }

Whereas in preprocessors it would currently desugar as:

.container .widget { color: blue; }
.container.selected .container .widget { color: red; }

If I’m not mistaken this issue becomes obsolete as a result?

@Loirooriol
Copy link
Contributor

Still has the same problem as #6330 (comment)

@flachware
Copy link
Author

To stick with your example:

<div class="foo"><div class="bar"><div class="foo"><p>Foo</p></div></div></div>

There are many ways to exclude the inner .foo with existing solutions, but @scope may help here too:

.foo {
  .bar {
    & p {
      color: blue;

      .foo:focus * & {
        color: red; /* Outer .foo only */
      }

      /* or */

     .foo:focus .bar & {
       color: red; /* Outer .foo only */
      }

     /* … */
    }
  }
}

@tabatkins
Copy link
Member

tabatkins commented Feb 21, 2023

So, according to the spec the example above desugars as:

No, not quite. It desugars to the equivalent of .container.selected :is(.container .widget). Luckily that is in fact equivalent to .container.selected .widget, so it does what you want.

It does indeed differ from how current preprocessors desugar.

In your later example, tho, .foo:focus * & desugars to the equivalent of .foo:focus * :is(.foo .bar p), which isn't precisely equivalent to .foo:focus .bar p - it's possible for the .foo that's an ancestor of .bar to be different from the .foo that's focused. That is, it could match the following markup:

<div class=foo>
  <div class=bar>
    <div class=foo tabindex=-1 autofocus> <!-- this element is focused -->
      <div> <!-- some unrelated, non-.bar element -->
        <p>I'm red.
...

Whether this is a problem in practice depends on your markup.

@flachware
Copy link
Author

flachware commented Feb 21, 2023

Oh that’s even more ingenious, as :is() bears specificity in mind.

Right, so the spec desugars to the equivalent of .container.selected :is(.container .widget) whereas preprocessors desugar to .container.selected .container .widget – which creates a problem that I tried to address with this issue. But I really think that’s already solved by the spec now.

However, if I’m not mistaken your last example is not accurate: the trick here is that * or .bar prevent p from being selected when it is a direct descendant of .foo:focus. So .foo:focus * :is(.foo .bar p) and .foo:focus .bar :is(.foo .bar p) can never contain the inner .foo element. At least, that’s what I concluded from testing.

@tabatkins
Copy link
Member

tabatkins commented Feb 21, 2023

Sorry, you're right, my markup needs one more wrapper between the focused .foo and the p. But notably it doesn't need to be a .bar. I'll fix, one sec.

But your selector with the .bar explicitly filled in (.foo:focus .bar &) is indeed equivalent to just doing .foo:focus .bar p.

@flachware
Copy link
Author

Agreed. But if I’m not mistaken this is a general problem and not limited to nesting. The following example has the same problem:

.foo:focus p { color: red; }

But there are ways to solve that:

.foo:has(> .bar):focus p { color: red; }

If this is unsatisfactory the upcoming @scope donut selector could help to set a lower boundary for .foo. In any case, this should be a separate issue (if at all).

Coming back to the original issue, as the CSS nesting spec resolves & differently from current preprocessors, the initial example is as simple as that:

.component {
  color: green;
  
  .container {
    color: brown;
  }
  
  .child {
    color: blue;
    
    &:hover {
      color: yellow;
    }
    
    .component:focus & {
      color: red;
    }
    
    .container.active & {
      color: white;
    }
  }
}

Hence this issue is good to close (imho).

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

6 participants