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] selecting grandparent selector with @nest #6977

Closed
lubomirblazekcz opened this issue Jan 22, 2022 · 28 comments
Closed

[css-nesting] selecting grandparent selector with @nest #6977

lubomirblazekcz opened this issue Jan 22, 2022 · 28 comments
Labels
Closed as Question Answered Used when the issue is more of a question than a problem, and it's been answered. Closed Deferred css-nesting-1 Current Work

Comments

@lubomirblazekcz
Copy link

The current spec might seem to have missing grandparent selector, which is also very used feature in many preprocessors.

This is currently still possible with postcss-nesting, with noIsPseudoSelector: true option. Which results in following:

.a {
  color: blue;
  & .b {
    @nest :not(.c)& {
      color: red
    }
  }
}

.a:not(.c) .b {
    color: red;
}

This is also the same way Less uses grandparent, and Sass uses @at-rule

.a {
  color: blue;
  .b {
    :not(.c)& {
      color: red
    }
  }
}

.a:not(.c) .b {
    color: red;
}

My question, is this something that should also be added to spec? Becuase I didn't find any mention of this in there. Or should some different syntax be proposed for selecting gradparent selector? Any thoughts on this @tabatkins ?

This issue is also related to grandparent selector - #6330

Also related postcss issue here - csstools/postcss-plugins#195 and supposedly grandselector syntax that previously worked in postcss-nesting was a bug

Current way that works in postcss-nestning is following, which I am not sure if is corect

.a {
  color: blue;
  & .b {
    @nest .a:not(.c) & {
      color: red
    }
  }
}

.a:not(.c) :is(.a .b) {
    color: red;
}
@romainmenke
Copy link
Member

romainmenke commented Jan 23, 2022

Adding a bit more context from postcss-nesting plugin.

The example above fed to the plugin.

pbpaste | npx @csstools/csstools-cli postcss-nesting --no-map

Source

.a {
  color: blue;
  & .b {
    @nest :not(.c)& {
      color: red
    }
  }
}

Result

.a {
  color: blue
}

:not(.c):is(.a .b) {
  color: red
}

pbpaste | npx @csstools/csstools-cli postcss-nesting --no-map --plugin-options '{ "noIsPseudoSelector": true }'

Source

.a {
  color: blue;
  & .b {
    @nest :not(.c)& {
      color: red
    }
  }
}

Result

.a {
  color: blue
}

.a:not(.c) .b {
      color: red
}

This behaviour is not intended as a feature or an operating mode.

The flag exists to give CSS authors the option of having CSS output without :is() selectors. This was added because browser support for :is() is not good enough yet.

We could do a better job explaining that this flag should be avoided as it introduces two issues : specificity and complex selector matching.

see : https://github.com/csstools/postcss-plugins/tree/main/plugins/postcss-nesting#noispseudoselector

Currently it reads more like a feature if you do not have in depth knowledge of the nesting spec.


There is now also a plugin for :is() which can almost match specificity behaviour of :is(). This works well enough and is better than not doing anything for specificity.

see : https://github.com/csstools/postcss-plugins/tree/main/plugins/postcss-is-pseudo-class#%EF%B8%8F-known-shortcomings

However for complex selectors there is not much we can do.
It is not possible to write a selector transform that removes :is() and always matches the intended element. If there was a way we would not have needed :is() in CSS.


Part of the issue here (imho) is that nesting has existed for so long in CSS pre-processors and that there are certain expectations of how it work. These do not always match the actual spec.

Without any actual implementation CSS authors also do not have a good reference point.

Maybe more can be done to explain and illustrate the feature for CSS authors?


I think the suggestions by @LeaVerou in #6330 are really insightful and would introduce an explicit mechanism to reference the selector you intend.

@lubomirblazekcz
Copy link
Author

lubomirblazekcz commented Jan 23, 2022

The suggestions by @LeaVerou in #6330 are insightful yes, but maybe to much complex. To be clear, following behaviour in postcss was working even prior noIsPseudoSelector

.a {
  color: blue;
  & .b {
    @nest :not(.c)& {
      color: red
    }
  }
}

.a {
  color: blue
}

.a:not(.c) .b {
      color: red
}

That why I though it was something that was part of the spec previously. Which is not the case, hence why I've created this issue. I think this syntax for selecting grandparent is less complex and it would benefit the spec.

There are a lot of cases where this might be useful, for example

.ui-input {
  & input {
    @nest :not(.is-active)& {
      color: red
    }
  }
}

.ui-input:not(.is-active) input {
    color: red;
}

Once you are nested little deeper (for example in pseudo selector too), you have no simple clear way of accessing parent selector, for example component with different states.

Here are also some real world examples in UI library written in nesting syntax - https://github.com/newlogic-digital/ui/blob/main/src/styles/Ui/Input.css#L200

@romainmenke
Copy link
Member

romainmenke commented Jan 23, 2022

Can you clarify with comments in your source CSS which selector each & would become?

To me it is not fully clear what "selecting grandparent" means.
As I currently interpret it :

.grandparent {
  & .parent { /* "&" references ".grandparent" */
    & .child { /* "&" references ".grandparent .parent" */
      color: red;
    }
  }
}

Only set a style on .child if .grandparent matches :hover.

.grandparent {
  & .parent { /* "&" references ".grandparent" */
    @nest .grandparent:hover & .child { /* "&" references ".grandparent .parent" */
      color: red;
    }
  }
}

equivalent to .grandparent:hover :is(.grandparent .parent) .child


But then a new addition to the spec to write this without needing to add .grandparent.

Taking the first example from #6330 :

.grandparent {
  & .parent { /* "&" references ".grandparent" */
    /* "&1" references ".grandparent" */
    /* "&" references ".grandparent .parent" */
    @nest &1:hover & .child { 
      color: red;
    }
  }
}

This final example would be equivalent to :

.grandparent:hover :is(.grandparent .parent) .child

Which matches this html

<div class="grandparent">
  <!-- possible with extra html elements in between -->
  <div class="parent">
    <!-- possible with extra html elements in between -->
    <div class="child"></div>
  </div>
</div>

@lubomirblazekcz
Copy link
Author

lubomirblazekcz commented Jan 23, 2022

I just found out that following syntax is also possible to select grandparent in postcss-nesting, but it is still something that should be added to the spec to clear things up, if it's the correct way to do this.

grandparent {
  & .parent { /* "&" references ".grandparent" */
    @nest :hover > & .child { /* "&" references ".grandparent .parent" */
      color: red;
    }
  }
}

equivalent to :hover > :is(.grandparent .parent) .child

And to be honest, according to spec I would expect the result be :hover > .grandparent .parent .child, which would not be good for this case obviouslly.

It might be possible to select grandparent, but it might not.. it's not very clear in spec right now.

EDIT:
@romainmenke your examples are correct, my only concern is if the wraping the parents in :is. It's something that is not very clear in spec It is also the important part for grandparent selection to work.

@romainmenke
Copy link
Member

romainmenke commented Jan 23, 2022

And to be honest, according to spec I would expect the result be :hover > .grandparent .parent .child, which would not be good for this case obviouslly.

Can you reference the spec sections that imply this?
Maybe these are not well defined yet?


grandparent {
  & .parent { /* "&" references ".grandparent" */
    @nest :hover > & .child { /* "&" references ".grandparent .parent" */
      color: red;
    }
  }
}

This can have unexpected effects.

works as intended :

<div class="grandparent"><!-- CSS matches when this is hovered -->
  <div class="parent">
    <div class="child"></div>
  </div>
</div>

does not work as intended :

<div class="grandparent">
  <div class="other"><!-- CSS matches when this is hovered -->
    <div class="parent">
      <div class="child"></div>
    </div>
  </div>
</div>

@lubomirblazekcz
Copy link
Author

lubomirblazekcz commented Jan 23, 2022

@romainmenke yes that is what I mean, it is not well defined.

obrazek

According to spec, there is no wrapping :is here for example

Also you are correct, that solution could have unexpected effect, hmm :/

@LeaVerou
Copy link
Member

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

.container {
	& .widget {
		@nest .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 {
		@nest &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.

@lubomirblazekcz
Copy link
Author

Whatabout :parent pseudo-class? Same as your proposal, but maybe more cleaner syntax.

.container {
	& .widget {
		@nest &:parent.selected {
			/* Gets rewritten to .container.selected .widget */
		}
	}	
}

Still this as an advanced usage, and maybe needed only in few cases.

Selecting top level grandparent is much common use as I mentioned previously. But that is not clear if it's possible with the current spec or not - that is my main concern

@romainmenke
Copy link
Member

romainmenke commented Jan 23, 2022

@LeaVerou

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

Doesn't that get rewritten to this :

.container.selected :is(.container .widget)

Which would actually match this :

<div class="container selected">
  <div class="widget">

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

Becomes :

:is(.container).selected :is(.container .widget)

or :

.container.selected :is(.container .widget)

@romainmenke
Copy link
Member

romainmenke commented Jan 23, 2022

@lubomirblazekcz

#6977 (comment)

According to spec, there is no wrapping :is here for example

These example mostly have simple selectors, no compound or complex selectors.
Writing them with or without :is() is equivalent.

The example with .bar .foo.baz is indeed confusing.
It happens to work without :is() because the next bit is a simple selector and there is no combinator in between.

Maybe it should be written as : :is(.bar .foo).baz.

@fantasai fantasai added the css-nesting-1 Current Work label Jan 26, 2022
@Griffork
Copy link

Griffork commented Jan 28, 2022

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 {
		@nest &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.

Personally if you're going to go the &1, &2, &3 route I'd argue that & should be all of them and &0 should be the last child selector, so

 .container {
 	& .widget {
		@nest &1.selected &0 {
/* Gets rewritten to .container.selected .widget */
 .container {
 	& .widget {
		@nest &.selected {
/* Gets rewritten to .container .widget.selected */

The & referring to the whole of the parent selector is the most common use-case, users shouldn't have to write long chains of & that match exactly how many nested selectors there are just to add another selector on the end, instead use &0 to get the last selector without the previous.

Because forcing this is a recipe for disaster:

.container {
    & .widget {
         &1 &.selected {
              &2 &1&:hover {
/* Becomes .container .widget.selected:hover */

Particularly if you have to remember which ones have spaces and which ones don't.

Unless the suggestion is that & always includes the parent selector except if &1 (or another number is present) which I think is unnecessarily inconsistant and confusing when you could just use &0 for that.

Most of the bugs resulting from & changing behaviour would occur when refactoring.

@romainmenke
Copy link
Member

The & referring to the whole of the parent selector is the most common use-case, users shouldn't have to write long chains of & that match exactly how many nested selectors there are just to add another selector on the end, instead use &0 to get the last selector without the previous.

@Griffork I think there is some confusion here :)

In general I don't think it is or should be possible to start writing a completely unrelated selector with nesting.

For example :

.container {
	& .widget {
		@nest &1.selected {
			color: red;
		}
	}	
}

@nest &1.selected should be invalid as it doesn't contain &.
Logically it might resolve to .container.selector but it would not make sense to support this in the spec in my opinion.

You can simply write this :

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

The issue brought up here is that you might want to manipulate two parts of a selector when nesting.

example :

Only apply a color to .widget on :hover when .container is .selected.
You might want to write this in the same nesting blocks.

Works with todays spec :

.container {
        /* base styles for .container */
	& .widget {
		/* base styles for .widget in .container */

                &:hover {
                  /* base styles for .widget:hover in .container */
                }
	}

	&.selected .widget:hover {
		color:red
	}
}

Or :

.container {
        /* base styles for .container */
	& .widget {
		/* base styles for .widget in .container */

		&:hover {
			/* base styles for .widget:hover in .container */

			@nest .container.selected & {
				color:red
			}
		}
	}
}

But in both cases you have repetition of either .container or .widget.
Something like @nest &<N>.selected & would remove this repetition.

It would not change how & works or should be used.

@romainmenke
Copy link
Member

@lubomirblazekcz relevant comment on the use of :is() : #2881 (comment)

@lubomirblazekcz
Copy link
Author

@romainmenke I see, but it's still something that is not very clear in spec here - https://www.w3.org/TR/css-nesting-1/#direct and that was my main concern

@romainmenke
Copy link
Member

romainmenke commented Jan 28, 2022

I fully agree now that it is not clear and reads almost like a note on the side and less like a critical part of any implementation.

I was first looking for a good reference where this was discussed.

@tabatkins Is this something that could be clarified more in the spec and the examples there?

After we updated postcss-nesting to desugar with :is() we have had multiple comments about this.

@lubomirblazekcz
Copy link
Author

Regarding the gradparent selector, here is another example how it's done in postcss currently https://github.com/toomuchdesign/postcss-nested-ancestors

Personally I would go with something more simple, as it was said - referring to the whole of the parent selector is the most common use-case.

@romainmenke
Copy link
Member

romainmenke commented Jan 28, 2022

Personally I would go with something more simple, as it was said - referring to the whole of the parent selector is the most common use-case.

Do you mean the outer most selector?
As "parent selector" is relative as I interpret it :)

this is complicated without concrete examples or very specific wording :)

.container {
  & .widget {
    /* whole parent : .container */
    /* outer most selector : .container */
    
    &:hover {
      /* whole parent : .container .widget */
      /* outer most selector : .container */

    }
  }
}

Or the parent of &?

edit :

re-reading the preceding comments I now understand you mean :

.container {
  & .widget {
    
    @nest &<something>.selected &:hover {}
  }
}

Where <something> only does one thing : make &<something> resolve to parent of &.
In this case .container


I think the proposal by LeaVerou is an elegant and non-ambiguous solution.
It not only covers the most common case, which is good.

I do wonder if this complicates implementing nested selectors?

Not relevant here, but this behaviour would make a postcss plugin much more complex.

@lubomirblazekcz
Copy link
Author

Maybe the previous 'lucky' behaviour of postcss-nesting, would be simpliest to implement spec-wise, & with space equals .a :not(.c) .b and without space .a:not(.c) .b

.a {
  & .b {
    @nest :not(.c)& {
      color: red
    }
  }
}

.a:not(.c) .b {
      color: red
}

But I agree that &<something> approach looks good to.

@Griffork
Copy link

Griffork commented Feb 1, 2022

@lubomirblazekcz please enlighten to me (css noob) why your proposal would work?

Why does this:

.a {
  & .b {
    @nest :not(.c)& {
      color: red
    }
  }
}

become:

.a:not(.c) .b {
    color: red
 }

and not:

:not(.c).a .b {
    color: red
 }

Is it because the second example is not valid css? Does the & behave differently based on whether or not the css is valid? What if I do this:

.a {
  & .b {
    @nest .fail& {
      color: red
    }
  }
}

does that become:

.a.fail .b {
    color: red
 }

or (what I'd expect):

.fail.a .b {
    color: red
 }

If it becomes .fail.a .b in the second example but :not(.c).a .b in the first example, is that then a property of pseudo selectors to modify how & works?

Also weirdly in my mind I'd expect if :not(.c)& was to be appended to anything in a selector with multiple parts, i'd naively expect it to be appended to every part, not arbitrarily the first one (like so: .a:not(.c) .b:not(.c)). Reminder that I'm a css noob.

As I said before, I'm not a fan of & working differently in different scenarios, but since you also seem happy with the &<something> syntax I think you already understand where I'm coming from.

@lubomirblazekcz
Copy link
Author

@Griffork

.a:not(.c) .b {
    color: red
 }

and

:not(.c).a .b {
    color: red
 } 

Is the same, only the order is changed and it works in all browsers, if it's the valid syntax i'm not sure to be honest. I'm only pointing out how it works (worked) in postcss-nesting and less, since it might be the the easiest way to do it.

.a {
  & .b {
    @nest :not(.c) & {
      color: red
    }
  }
}

equals

:not(.c) .a .b {
    color: red
 } 

And if you remove the space between :not and .a, you get the grandparent selector instead. Hence :not(.c)&

@romainmenke
Copy link
Member

@lubomirblazekcz Maybe more interesting to reference less and other tools that intentionally have the behaviour you desire? :)

postcss-nesting behaviour was absolutely a bug and it was intended to work like it works today.

This will be clearer in examples and discussions here as people can then try those tools today and see how they work.

This also avoids a circular mutation :
spec -> broken implementation -> change spec to make broken implementation work.

@lubomirblazekcz
Copy link
Author

@romainmenke yes I agree. I only referenced postcss because that implementation previously worked as a bug, so that's why it might be easy to adapt. But I might be totally wrong.

@WCWedin
Copy link

WCWedin commented Sep 13, 2022

By my reading of the spec, this construction already has an unambiguous meaning.

.a {
  & .b {
    @nest :not(.c)& {
      color: red;
    }
  }
}

/* With explicit `:is` expansion,
the innermost selector would be: */

:not(.c):is(:is(.a) .b) {}

/* reordering pseudo-classes */
:is(:is(.a) .b):not(.c) {}
/* removing redundant inner :is */
:is(.a .b):not(.c) {}
/* I'm pretty sure the remaining :is
is also always redundant. */
.a .b:not(.c) {}

I think I got all that right, anyway.

The other case bottoms out, by my understanding, into something vaguely close to the desired output, but much more permissive by matching :not(.c) in any ancestor position.

.a {
  & .b {
    @nest :not(.c) & {
      color: red;
    }
  }
}

/* expansion of innermost selector */
:not(.c) :is(:is(.a) .b) {}
/* removing inner :is */
:not(.c) :is(.a .b) {}
/* This transformation is probably less
tractable generally, but distributing
:not(.c) is valid in this case, I think. */
:is(
  :not(.c) .a .b,
  .a:not(.c) .b,
  .a :not(.c) .b
) {}

Here is something that simplifies in the desired way, but requires the repeating of the .a in the innermost selector.

.a {
  & .b {
    @nest .a:not(.c) & 
  }
}

/* expansion of innermost selector */
.a:not(.c) :is(:is(.a) .b) {}
/* removing inner :is */
.a:not(.c) :is(.a .b) {}
/* because .a:not(.c) is more selective than .a */
.a:not(.c) .b {}

In this case, .a could instead be any single selector, but repeating a gnarly outer selector deeper down is obviously not that good.

This is where I see the semantic and syntactic holes lining up. Since the syntax that motivated this request already has a simple, consistent meaning, we would need new syntax to ease the repetition burden.

I think the direct solution would be a way to refer to outer selectors, for instance allowing &1, &2, etc to appear in any selector that also includes either a bare & or the equivalent &0.

Another possibility: Custom selectors would solve the repetition problem generally, at the cost of a little cruft to define the selector name. Being able to place a hypothetical @custom-selector inside a declaration block to give a scoped name to & would be a delightful trick.

.a {
  @custom-selector :--granny &

  & .b {
    @nest :--granny:not(.c) & 
  }
}

This way, there would be no truly new syntax (just a novel combination of two proposed standards). Parsing custom selectors, I think, doesn't require much more than expanding the syntactic sugar, but there could also be some dark corners there.

At any rate, it's something a preprocessor could support as long as it properly wraps the referent of & in :is during expansion. It's probably worth seeing if any preprocessors can be configured to perform the necessary transformations correctly; if they follow the spec as it stands, I don't think things would get out of hand.

@romainmenke
Copy link
Member

The spec does not currently allow nesting @custom-selector :
https://www.w3.org/TR/css-nesting-1/#conditionals

@WCWedin
Copy link

WCWedin commented Sep 13, 2022

The @custom-selector spec, such as it is, is also part of a truly bonkers editor's draft; we're deep in the realm of hypothetical here, and there is general interest is introducing some scope control when nesting (#6809, #6330).

In this case, though, that detail just makes things nice to use without having to resort to shadow DOM shenanigans. You could just as easily define :--granny in the global scope (though it is admittedly much less cool this way):

@custom-selector :--granny .a 
:--granny {
  & .b {
    @nest :--granny:not(.c) & 
  }
}

@romainmenke
Copy link
Member

romainmenke commented Sep 13, 2022

@custom-selector :--granny .a 
:--granny {
  & .b {
    @nest :--granny:not(.c) & {}
  }
}

Is this intended to become :

:--granny:not(.c) :is(:--granny .b) {}

or :

:--granny:not(.c) .b {}

I do like the idea of using @custom-selector as a way to store a resolved & from nesting.

@WCWedin
Copy link

WCWedin commented Sep 13, 2022

Technically it would expand to the former, but because the :--granny inside the :is is a less selective version of the :--granny:not(.c) outside the :is, the two are equivalent in this case (except for specificity). That is, the pattern I outlined was designed specifically to produce roughly that outcome.

You could instead do all sorts of wacky things that don't simplify to something so intuitive. :not(:--granny) & is a fun bit of nonsense to chew on, but I'm sure other possibilities are actually useful.

@tabatkins
Copy link
Member

tabatkins commented Jan 10, 2023

Re: the original comment #6977 (comment)

I believe there was some confusion about how triply (and deeper) nested rules worked, seemingly based on an intuition of nesting working via string concatenation. In the OP's example

.a {
  color: blue;
  & .b {
    @nest :not(.c)& {
      color: red
    }
  }
}

.a is concatenated to .b yielding .a .b which is then itself concatenated to :not(.c) yielding :not(.c).a .b. This is not how CSS Nesting works. Instead, each & matches the elements matched by the parent rule, so the selector is assembled as if with :is()-- .a is combined with .b yielding :is(.a) .b, which is then combined with :not(.c) as :not(c):is(:is(.a) .b) (which simplifies down to :not(c):is(.a .b)). This is well-defined in the spec.

Re @LeaVerou's comment #6977 (comment) ; this is something we think might be worth exploring in the next level, and is currently filed as issue #6330. @LeaVerou and @Griffork, maybe you can copy your suggestions into #6330?

Re @romainmenke's comment #6977 (comment)

The spec was somewhat clarified, to make it clear that the nesting selector is defined by matching elements, not by desugaring; the :is() desugaring is just given as an example of equivalent behavior. <https://drafts.csswg.org/css-nesting-1/#nest-selector>

Lastly, any comments about the custom selectors spec should be filed separately against that spec. :)

Closing out this very meandering combo issue, please re-file individual specific problems as individual, specific issues if we missed anything!

@tabatkins tabatkins added Closed Deferred Closed as Question Answered Used when the issue is more of a question than a problem, and it's been answered. labels Jan 10, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Closed as Question Answered Used when the issue is more of a question than a problem, and it's been answered. Closed Deferred css-nesting-1 Current Work
Projects
None yet
Development

No branches or pull requests

7 participants