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

[selectors][css-nesting] Move nest-containing and nest-prefixed selector definitions to Selectors #5745

Open
LeaVerou opened this issue Nov 25, 2020 · 27 comments

Comments

@LeaVerou
Copy link
Member

Nest-prefixed: https://drafts.csswg.org/css-nesting/#nest-prefixed
Nest-containing: https://drafts.csswg.org/css-nesting/#nest-containing

These types of relative selectors are useful beyond nesting, as the ampersand can become a universal CSS this equivalent. E.g. I can imagine a qSA-like JS API that allows us to do things like input.find("& + label") or even compositions like find("&.foo", "& > .bar", ".baz"), or even HTML attributes that allow us to refer to elements relative to the current element (how useful that would have been with label[for]!)

Sure, there is also :scope, but there’s a reason we didn't base nesting off that.

@Loirooriol
Copy link
Contributor

So & would behave as :scope (but without falling back to :root, I guess)? Or maybe each JS API would define what & means?

@LeaVerou
Copy link
Member Author

So & would behave as :scope (but without falling back to :root, I guess)? Or maybe each JS API would define what & means?

I suppose each API could define what it means. It could default to :scope for APIs that don't define anything.

@fantasai
Copy link
Collaborator

fantasai commented Oct 5, 2022

Discussing with @LeaVerou, @jensimmons, @bradkemper, @tabatkins, and @mirisuzanne today, we all agree that making & have meaning outside of nested context is a good idea, as it makes selectors containing these portable between nested context, regular context, and @scope contexts.

The definition would basically be, within nested context it refers to the nesting selector, and otherwise it's an alias for :scope. IIRC this expands to the following consequences:

  • Within nested context, it refers to the nesting element
  • Within @scope context, it refer to the scope root
  • In shadow DOM style sheets it matches the shadow root element
  • In .querySelector it matches the context element
  • Outside of any more specific context, it matches :root

Open questions:

  • Should it do anything special anywhere else, e.g. in :has(), which also takes relative selectors?
  • If not, should :scope and & be syntactic aliases (i.e. make :scope expand out the same as & in nested contexts) or should they differ in some cases (nested selectors, or other cases)?

@mirisuzanne
Copy link
Contributor

I think there's a missing context there. At the root level of the document, & refers to the document :root?

@tabatkins
Copy link
Member

Yup, that last bulllet point seems to have been

@fantasai
Copy link
Collaborator

fantasai commented Oct 5, 2022

Edited. Thanks. :)

@mirisuzanne
Copy link
Contributor

We didn't discuss it today, but I think it would make sense to also clarify in @scope that the root selector is a relative selector when @scope is nested:

.media {
  /* elements matching `.media .h-card` act as a scope root */
  @scope (.h-card) {
    img { object-fit: cover; }
  }

  /* elements matching `.media` act as a scope root */
  @scope (&) {
    img { object-fit: cover; }
  }
}

@romainmenke
Copy link
Member

The definition would basically be, within nested context it refers to the nesting selector, and otherwise it's an alias for :scope. IIRC this expands to the following consequences:

Within nested context, it refers to the nesting element

If not, should :scope and & be syntactic aliases (i.e. make :scope expand out the same as & in nested contexts) or should they differ in some cases (nested selectors, or other cases)?

  • & : any element that would be matched by the selector of the enclosing rule (i.e. :is(...))
  • :scope either a true element or a virtual one

I think this is too different and making them aliases would be confusing.

& + & vs. :scope + :scope

@brandonmcconnell
Copy link

@mirisuzanne @fantasai @LeaVerou I also think this could be confusing for nesting contexts opened within scoped contexts, like in the example below:

.media {
  /* & === .media */
  @scope (&) {
    /* & === :scope */
    img {
      /* What would `&` be here— `img` or `:scope` ? */
    }
  }
}

What would the below example be? Would I then be unable to reference :scope via & again once I re-open a new nesting context?

@mirisuzanne
Copy link
Contributor

I don't think that's too confusing if we're consistent about it. The meaning of & always changes at different levels of nesting, and scoping is a form of nesting. At each layer, & refers to the nesting context. The context of the scope rule is .media, the context of the img rule (and siblings to it) is the :scope, and the context inside the img rule is the img itself. This is not really different from other multi-level nesting situations.

@brandonmcconnell
Copy link

@mirisuzanne What benefit is there to aliasing :scope as &, just to make it easier to reach for :root/:scope when possible? Why not always use :scope explicitly, or does this work differently from either :root or :scope?

Purely looking for clarification here, as I don't think I understand how this enables developers. Thanks!

@mirisuzanne
Copy link
Contributor

mirisuzanne commented Oct 7, 2022

The goal is to give the & selector a consistent meaning, no matter where you use it. Rather than treating it as a special-case syntax - only for direct selector nesting, and an error everywhere else - we treat it as a first class selector with a built-in meaning of 'the current nesting context'. Roots and scopes are nesting contexts, so we should support those as well. This makes & behave more like any other selector, valid anywhere selectors are valid. Which is useful, because it often makes sense to move selectors from one context to another (especially between nesting and scoped contexts). If they still have a reasonable meaning, they should continue to work.

So this isn't really about a special-case 'alias' between two selectors - it's just that in this case, the selectors refer to the same element. The & always refers to the nesting context, no matter how that context is generated. In both root and scoped contexts, we know what nesting context we're in, so we have a reasonable interpretation of the & selector. At the root level, the nesting context is the :root element (which also happens to be the default :scope). Inside a scope rule, the nesting context is also the :scope - but may or may not be the :root.

Already, at the root level :scope and :root overlap. Now we're adding &. All three refer to 'my current context' but with different levels of fidelity. At the root level, they all refer to the root element. Selector nesting and scope both update the reference elements for & (the highest-fidelity option), while the scope rule updates the reference element for :scope as well. All three selectors maintain their conceptual meaning wherever you use them - but sometimes those meanings overlap.

As far as selectors are concerned, a scope is already just fancy nesting. :)

@romainmenke
Copy link
Member

@mirisuzanne If I understand that correctly it would mean that & and :scope aren't aliases of the same concept?

It makes sense to give both a well defined meaning everywhere.

@mirisuzanne
Copy link
Contributor

Right. They're different concepts that overlap in some cases.

@brandonmcconnell
Copy link

@mirisuzanne Thanks, Miriam! That explanation helped to clear things up for me. I greatly appreciate it!

@css-meeting-bot
Copy link
Member

The CSS Working Group just discussed this issue, and agreed to the following:

  • RESOLVED: Accept to make & valid everywhere, maps to :scope where not otherwise defined
The full IRC log of that discussion <fantasai> Topic:
<fantasai> 5. [selectors][css-nesting] Move nest-containing and nest-prefixed selector definitions to Selectors
<fantasai> github:
<fantasai> 5. [selectors][css-nesting] Move nest-containing and nest-prefixed selector definitions to Selectors
<fantasai> github: https://github.com//issues/5745#issuecomment-1271874448
<fantasai> TabAtkins: separate from discussion of which exactly nesting syntax
<fantasai> TabAtkins: all of our proposals use the &
<fantasai> TabAtkins: we have a few different contexts where we do nesting
<fantasai> TabAtkins: and they don't currently allow &
<fantasai> TabAtkins: right now assumption is that & only has meaning and possibly only valid in direct nesting
<fantasai> TabAtkins: this is not great, particularly if use & > .foo
<fantasai> TabAtkins: meaning of this is clear in any nestable context
<fantasai> TabAtkins: so being able to copy-paste rule between different things, from nesting to @scope or querySelector
<fantasai> TabAtkins: even globally, makes sense, just say parent context is :root
<fantasai> TabAtkins: similarly in shadow DOM
<fantasai> TabAtkins: so proposal is, to avoid authors being forced to edit selectors as they move nesting context
<fantasai> TabAtkins: defined & to be valid and to have meaning in other context
<fantasai> TabAtkins: if not defined specially, is equivalent to :scope
<fantasai> TabAtkins: and this is already defined globally, top level it is host element of shadow stylesheet or :root otherwise
<fantasai> TabAtkins: so make this analogous unless context explicitly defines it analogously
<fantasai> florian: Seems reasonable, but haven't thought about it much
<fantasai> Rossen_: I'm convinced, too
<fantasai> Rossen_: Objections?
<fantasai> ??: Gotten to comments about how used inside scope would be referencing, if possible to get up to another nested context
<fantasai> ??: Getting confused to understand, anyone can describe clearly?
<fantasai> ??: "What would the below example be? Would be unable to reference :scope in a nested context"
<TabAtkins> (comment is https://github.com//issues/5745#issuecomment-1271646202)
<dbaron> s/??/PaulG/
<florian> s/??/PaulG/
<fantasai> The answer is later in the thread, where the call was to not to change meaning of :scope
<fantasai> s/??/PaulG/
<fantasai> TabAtkins: Doesn't change the meaning, & is always using the local definition of it
<fantasai> TabAtkins: Question was if you put nested style rule under the img style rule, what would & refer to, it would refer to img
<fantasai> TabAtkins: Direct nesting doesn't change :scope
<fantasai> PaulG: Thanks
<fantasai> Rossen_: Back to objections?
<fantasai> RESOLVED: Accept to make & valid everywhere, maps to :scope where not otherwise defined

@romainmenke
Copy link
Member

romainmenke commented Nov 3, 2022

Can we "link" this to css-nesting-1?

If implementers ship this and nesting at different times we will have issues with @supports selector(&) {}

Currently we use it as a way to detect support for nested CSS.

maybe it is obvious that these need to ship at the same time

@romainmenke
Copy link
Member

romainmenke commented Nov 18, 2022

Should it do anything special anywhere else, e.g. in :has(), which also takes relative selectors?

/* 1 */
.foo {
  :has(& + .bar) { /* "&" is ".foo" */
    /* styles */
  }
}

/* 2 */
.other :has(& + .bar) { /* "&" is ":scope" */
  /* styles */
}

First example is obvious I think, second is not.

/* 1 */
:has(.foo + .bar) {
  /* styles */
}

/* 2.a */
.other :has(.other + .bar) { /* "&" is scope element of ":has()" */
  /* styles */
}

/* 2.b */
.other :has(:root + .bar) { /* "&" is scope element outside of the selector */
  /* styles */
}

I think & should be defined by the parent context and that :has() or other pseudo class functions do not create such a context.

That would be 2.b:

.other :has(& + .bar) {
  /* styles */
}

.other :has(:root + .bar) {
  /* styles */
}

@fantasai
Copy link
Collaborator

This is edited into the Nesting spec in 45d2efc

We will move it to Selectors in the future (but at least it's defined for now).

@brandonmcconnell
Copy link

@romainmenke @fantasai With that change, how could one target a selector dependant on not having a specific selector before it?

Some examples:

.b:is(.a ~ &) { ... }
.b:is(.a &) { ... }

Surely, the expectation out, like this, right?:

.some-long-classname#and-an-id:is(.prev-sibling ~ .some-long-classname#and-an-id) { ... }
.some-long-classname#and-an-id:is(.ancestor .some-long-classname#and-an-id) { ... }

Would the best alternative be to swap & or the universal selector *? Would this cause any speed deficits that & would not have already caused in its original spec?:

.b:is(.a ~ *) { ... }
.b:is(.a *) { ... }

Something like this ☝🏼 seems like a good alternative. I just want to clarify whether this change affects any optimization benefits achieved by using & to select the current element within :is, :where, or :has in the previous spec.

Essentially…

  • .b:is(.a ~ &) { ... } vs. .b:is(.a ~ *) { ... }
  • .b:is(.a &) { ... } vs. .b:is(.a *) { ... }

@brandonmcconnell
Copy link

Another consideration…

TL;DR: Any way to achieve the effect of the below example without using nesting, now that & cannot be used to reference the selector :is, :where, or :has was used on?

.a {
 &:has(:is(& > .b:unsupported-pseudo, .c)) { ... } ✅
}

The below selector would not match .a:has(> .b), right?

.a:has(:is(& > .b))

Instead, it would match .a:has(:is(:root > .b:unsupported-pseudo)), presumably… CMIIW.

So how might someone target a selector like .a:has(> .b:unsupported-pseudo) but make it forgiving if whatever assuming :unsupported-pseudo represents here. Per the recent jQuery scuffle over :has(), the official direction to make :has() forgiving has been to use :has(:is()), but how would that work without & in this case?:

.a:has(:is(& > .b:unsupported-pseudo, .c)) { ... }  ❌ (according to this new requirement)

Without &, that would look like this:

.a:has(:is(> .b:unsupported-pseudo, .c)) { ... } ❌

☝🏼 This wouldn't produce the intended result, would it, since it has no reference to use for the parent? With this new requirement, it seemingly necessitates using nesting in order to reference the parent like this.

What would a viable alternative be without having to spell the entire selector out again?

.long-classname:has(:is(.long-classname > .b:unsupported-pseudo, .c)) { ... } ❌

This should work, I think, but is there a way to achieve this same result without using nesting?:

.a {
  &:has(:is(& > .b:unsupported-pseudo, .c)) { ... } ✅
}

@tabatkins
Copy link
Member

Apologies, but I'm not sure what you're referring to in either of these replies.

@brandonmcconnell
Copy link

@tabatkins No worries. My two questions, put more succinctly and clearly (I hope) are…

  • If & will now refer to :root instead of the current element when used in :is, :where, and :has, how can we target an element as not being preceded by a certain selector. Before, I would have used something like .b:is(.a ~ &) to target .b when it does not have a previous .a sibling, but IIUC, that would not work now, as & would refer to the parent selector or :scope respectively when used in :is, :where, or :has.

    In other words, would this still work the way it would have before, where & referred to the element the selector targets?:

    .b:is(.a ~ &) { ... }
  • To make a selector list forgiving inside :has(), the general direction has been to nest an :is() inside the :has() like this: :has(:is()).

    However, I'm not sure how that would work for selectors within has that start with combinators, such as .a:has(> .b:unsupported-pseudo). My understanding is that :has() can start with a combinator like :has(> _) but :has(:is(> _)) does not have that same capability. With that in mind, how might I make this selector forgiving?: .a:has(> .b:unsupported-pseudo), presuming that .a:has(:is(> .b:unsupported-pseudo)) not work?

    In other words, would this work the way it would have before, where & referred to the element the selector targets?:

    .a:has(:is(& > .b:unsupported-pseudo)) { ... }

    I was using & to refer to the current selector since I assumed I couldn't simply start with a combinator within :has(:is()) like .a:has(:is(> .b:unsupported-pseudo))

@tabatkins
Copy link
Member

tabatkins commented Jan 13, 2023

If & will now refer to :root instead of the current element when used in :is, :where, and :has

That is not the case. & refers to the elements matched by the parent rule's selector. If you use & in a non-nested rule, it'll be the same as :scope. It's never been given a special behavior in the :is()/etc explicitly. (The most recent change was just to make a non-nested & match :scope, rather than matching nothing.)

There was discussion a while ago about having :scope in :is()/etc refer to the element the pseudo-class was filtering, but we ended up not doing that. (It causes conflicts with other uses of :scope, like @scope or querySelector(), and makes implementation more difficult.)

To make a selector list forgiving inside :has(), the general direction has been to nest an :is() inside the :has() like this: :has(:is()).

Note that just wrapping the whole selector argument in :is() has always not been correct if the selector contains any combinators. That is, A:has(B C) and A:has(:is(B C)) aren't the same thing - both will match against <A><B><C>, but second will also match against <B><A><C> - it looks for a C element with a B ancestor, regardless of its relationship with A. This is because the :has() argument is a <relative-selector> and thus always contains a combinator even if you don't write one - it just defaults to the descendant combinator in that case.

You can either wrap the :is() around the :has() instead, or if you really do want to make the interior forgiving, wrap an :is() around the specific compound selectors you're worried about. That is, to make .a:has(> :unknown) forgiving, either write .a:is(:has(> :unknown)), or .a:has(> :is(:unknown)).

(If we did have a way of opting :has()'s argument out of being relative, such as by keying off the presence of some specialized selector like :has-subject or something, then wrapping the whole argument in :is() would work.)

@Loirooriol
Copy link
Contributor

how can we target an element as not being preceded by a certain selector.

Why not just * (or *|* is you use namespaces)? So .b:not(.a ~ *) { ... } selects elements with a b class which are not preceded by a sibling with the a class.

the general direction has been to nest an :is() inside the :has()

Be aware there is an important difference: a:has(b c) selects a elements that contain a b descendant which contains a c descendant. While a:has(:is(b c)) selects a elements that contain a c descendant which has a b ancestor; note that b doesn't need to be a descendant of a!

And :is() doesn't take a relative selector, but why not use .a:has(> :is(.b:unsupported-pseudo))?

@mirisuzanne
Copy link
Contributor

mirisuzanne commented Jan 13, 2023

I don't think we can give & a special meaning inside is/has/etc. This would become very confusing:

.a {
  .b:is(.c + &) { ... }
}

We can't have & refer to both the parent context (.a) and also the origin element for :is() (.b). One meaning would have to override the other in these situations, making it very fragile to nest/unnest these selectors, and entirely unclear at a glance which behavior is intended. I think .b:is(.c + *) { ... } is the proper way to handle the use-case.

The current proposal doesn't require two different meanings for & - we've just defined what the 'parent context' is (:scope) when there's no wrapping selector block.

@brandonmcconnell
Copy link

@tabatkins @Loirooriol @mirisuzanne Incredible feedback. Thank you all so much for clearing up both of those questions! 🙌🏼

Wrapping only the unsupported selector or pseudo in the :is() ist a Great solution, and I'll use .b:not(.a ~ *) for the other question.

Thanks again!

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

8 participants