w3c / csswg-drafts

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

[selectors][css-scoping] Should `:host:has()` match? #10693

Closed tabatkins closed 2 weeks ago

tabatkins commented 1 month ago

In https://github.com/web-platform-tests/wpt/pull/47441 a few tentative tests are suggested that test whether :host:has(...) works. The test author correctly notes that this isn't currently specified to work - only the logical combination pseudo-classes are allowed to match featureless elements and :has(), while defined in the "logical combination" section, isn't on the list of logical combo pseudo-classes.

I agree with the tester, tho, that this should work. :host h1 works just fine, so :host:has(h1) should work the same. I suggest that we add :has() to the list of pseudo-classes allowed to work on featureless elements.

(Note the first few tests in the WPT PR are testing the different scenario of :host(:has(...)), which is already well-defined to match, in a different way to what's discussed here. I'm talking about the latter three which are marked "tentative" in their filenames.)

Westbrook commented 1 month ago

Thanks for bringing the conversation here @tabatkins! I think the :host:has(…) combination will also be a boon to the in progress slot:has-slotted(…) API that is pending in https://github.com/w3c/csswg-drafts/pull/10586, and will be quite powerful as an appropriate customization of:host:has(slot:has-slotted). 🥹

Loirooriol commented 1 month ago

To clarify, are you saying that both :host:has(h1) and :has(h1) should be able to match the host, or only the former?

In https://github.com/w3c/csswg-drafts/issues/10179#issuecomment-2093869789 the idea is

Compound selectors are allowed to match the host only if all the contained simple selectors are allowed to match the host.

tabatkins commented 1 month ago

I think it should match in both cases, just like the logical combination pseudos. The reason the host is featureless is because you can't control the markup of the host element, and so it's easy to write a selector you expect to only match the markup you control (in the shadow) and have it accidentally match the host element due to the outer page doing things you can't predict.

This doesn't apply to :has() - it's matching based on markup you do control (in the shadow), and thus is completely predictable. Even if you use a complex selector in the :has() argument, because it's matching inside the shadow and uses that matching context, it can't match the host element unexpectedly. (That is, :has(.foo .bar) wouldn't be able to match just from the host element having a foo class; you'd need to write :has(:host(.foo) .bar) to make that happen, explicitly indicating that you're looking at the host element's markup.)

The only way to get an unexpected match is if you just forget the host element exists at all (because you generally don't pay attention to it), but that's a brain fart, not an unpredictable situation. I think that's okay. In particular, writing an unqualified :has() seems like a weird thing to do in the first place, which is the only way it would match the host without actually mentioning :host.

kbabbitt commented 1 month ago

This would allow https://github.com/w3c/csswg-drafts/issues/10492 to be solved using :host:has(:popover-open), correct? We just had an internal developer run into that issue recently.

tabatkins commented 1 month ago

Yes, that would work.

tabatkins commented 1 month ago

(We should discuss this at the same time as #10179 fwiw; they're covering the same space.)

emilio commented 1 month ago

Shouldn't this be about :host(:has(...))? I think :host:has() should generally not work to be consistent with other selectors like :host:nth-child(...) (which IIRC doesn't match)

Westbrook commented 1 month ago

:host(:has(…)) matches against light DOM children already, at least in Firefox and WebKit, and should continue to go so and expand to Baseline status as soon as possible. This aligns with :host(.class) et al also referencing things outside of the shadow root.

:host:has(…) should introspect the shadow root the same way :host > .child does. It would seem that:host:nth-child(…) should work in this way, as well.

sorvell commented 1 month ago

I think there are a few cases here that it would be great to untangle and specify.

Background

The spec defines Tree-Structural pseudo-classes, but it doesn't specifically distinguish the scope or relationship: ancestor or descendent.

Similarly, :host() matches the argument in the host's "normal context," the scope in which the host itself exists, not its Shadow DOM.

Additionally, there's a special carve out for the flat tree against which match :hover, :active, :target-within, and :focus-within. And the syntax uses the function version::host(:focus-within).

Currently, :host(:empty) works and matches against the host's light tree. As noted, :host(:has(...)) seems like it should work, but it's currently not well supported.

Discussion

When Shadow DOM is involved, for the cases defined in the spec, only a descendent relationship is relevant to consider since this could be either via the light or shadow tree or both, the composed tree.

The example noted above :host:nth-child(…) needs no consideration since it's based on an ancestor relationship (host within parent). It should never be able to match.

I believe only :has(...) and :empty are potentially ambiguous since they could match against the light, shadow, or flat tree.

Using the functional :host(...) syntax, it does seem like the spec is suggesting these should match against the light tree, and they do (minus bugs).

Matching these against the shadow tree does seem useful as is suggested here, and using the non-functional syntax also seems to make sense :host:has(...) or :host:empty (seems like this should also work).

Caveats

Adding the capability makes sense, but I see 2 downsides to using this syntax for it: 1. It's unclear if there's a mental model for when to use the functional syntax :host() and doing so changes the meaning. For example, why is it :host:after and not :host(:after)? At least only one of those works. 2. Using light v. shadow tree as a key for when to use the functional v. non syntax would break down for flat tree matching selectors like :host(:focus-within).

tabatkins commented 1 month ago

Shouldn't this be about :host(:has(...))?

No. As I said in the initial comment, :host(:has(...)) is a separate thing that's already well-defined. This is about :host:has(...).

I think :host:has() should generally not work to be consistent with other selectors like :host:nth-child(...) (which IIRC doesn't match)

It's not clear if you've read my later comment giving a more thorough explanation for why I think :host:has() is fine to match. If you have read that, could you elaborate on how you disagree?

(And correct, :host:nth-child() does not match. While it would not be problematic for it to do so, since it only has access to the information in the shadow tree and thus always sees the host element as a lone sibling, it wouldn't be useful for it to do so either, for the exact same reason.)

tabatkins commented 1 month ago

@sorvell You seem to be confusing yourself about what it means for :host(...) to take a selector argument, versus having selectors following :host. (And possibly have let emilio confuse you into thinking this issue is about :host(:has(...)), which it is not.)

:host(::after) does not work because the host element does not match the selector ::after - it's not an ::after pseudo-element! This happens to be true in either context, but is certainly true in the light context. :host::after works because :host is allowed to match the host element, and then ::after isn't trying to match the host element, it's just a pseudo-combinator shifting you into matching the host's pseudo-elements; it's identical to :host > div in action.

emilio commented 1 month ago

Yeah, sorry, it wasn't clear to me that the intention was to have :host:has() match the contents of the shadow tree.

I think it's a bit confusing that :host:has() and :host(:has()) would do such different things, fwiw, but it's not like I have a counter-proposal if this is something people need... It just feels a bit weird conceptually that we'd match the fragment's subtree but then forward the selector to the real element...

sorvell commented 1 month ago

:host(::after) does not work because the host element does not match the selector ::after

Thanks for the clarification, that makes sense.

Overall, this would be a valuable feature to add.

@tabatkins Do you think it makes sense to support :host:empty as well?

tabatkins commented 1 month ago

@emilio

I'm not sure I understand the confusion. You have two entirely separate subtrees, both rooted in the same element; the two syntaxes match against one or the other. It seems unremarkable to me that they would do different things, because that's what happens for every other possible selector as well: normal selectors match against the shadow tree (which is where the stylesheet in question lives, so that's exactly what you'd expect), and :host(...) arguments match against the light tree.

@sorvell

Do you think it makes sense to support :host:empty as well?

Hm, yeah, that's workable under the same argument (it's only matching based on the non-host contents of the shadow, which are under the stylesheet author's control). It's only different from :host:has(*) in the case that the shadow contains text nodes but no elements, tho.

Not sure it's particularly useful - it seems a lot more likely that that component knows whether it's put anything inside the shadow tree, and in particular, a <style> in the shadow would prevent it from matching, but there are ways to attach stylesheets without inserting nodes, so I guess it's reasonable...

css-meeting-bot commented 4 weeks ago

The CSS Working Group just discussed [selectors][css-scoping] Should `:host:has()` match?, and agreed to the following:

The full IRC log of that discussion <TabAtkins> :host:has(.foo)
<keithamus> TabAtkins: someone added tests in WPT combining :host:has. Some test well specified behaviour, but they added some tentative about using :host:has next to eachother in a compound selector
<keithamus> ... tentatively wrote assuming this works and it matches the host element assuming a .foo is in the tree
<keithamus> ... that doesn't work because :has doesn't match featureless elements in general
<keithamus> ... the :has pseudo class should be able to match the host. Again, :host is featureless because authors of the shadow tree cannot predict the markup so it would have to be written defensively
<emilio> q+
<keithamus> ... the :has pseudo class only matches based on descendents, ie stuff in the shadow tree thus already in control of the author
<keithamus> ... so predictable behavior and no defensive coding
<keithamus> ... there's also some examples this satisfies in the thread
<TabAtkins> :host:has(.foo) and :has(.foo) both are allowed to match
<keithamus> ... so :has should be added to the list of things to match the host element
<keithamus> ... also unqualified :has(.foo) should be able to match the host element
<keithamus> ... This is potentially confusing because no selectors currently match :host... nothing else can do this right now so it might not be expected.
<keithamus> ... The benefit of allowing is that matching behavior becomes much more sensible if it is allowed to match unreservedly
<emilio> Feels wrong that `*:has(..)` and `:has(..)` behaves differently
<keithamus> ... it makes speccing more complex if not
<keithamus> ... the simpler model, I think, is a little better
<astearns> ack emilio
<TabAtkins> *:is(:host) and :is(:host) already match different
<keithamus> emilio: I get the use case of matching inside the shadow tree, I'm not sure I agree with making it match when not qualified. It feels wrong that * doesnt match but unqualified does.
<keithamus> ... I guess you're right that's already the case per spec
<keithamus> ... something matching host that doesnt contain :host feels bad
<keithamus> ... I find it confusing. Especially as styles go outside your component
<keithamus> ... either force :host or do a new pseudo or something
<astearns> q+
<keithamus> ... I think I have a preference for enforcing :host especially as it doesn't change the behaviour for unqualified selectors
<keithamus> TabAtkins: I suspect that unqualified :has is rare to non-existent as it could potentially match all elements. I would be surprised if it causes problems
<keithamus> ... open to possibility that it would though
<astearns> ack astearns
<keithamus> astearns: I didn't go into the use cases but are the use cases presented for unqualified has that cannot be done with qualified has? Or is it ergonomics
<keithamus> TabAtkins: purely ergonomics, purely a matter of ergonomics/spec complexity to make it work one way or another
<keithamus> emilio: implementation complexity implies there is a benefit, then you can avoid looking at those selectors altogether
<keithamus> TabAtkins: but you would still know which selectors are potentially able to match
<keithamus> ... this expands the set of potential matches from things to the :host to things with unqualified :has
<keithamus> emilio: :is also complicates, but if you have :host in the subject it can only match the host. :has can match random stuff in the tree
<keithamus> emilio: I think unqualified :has matching :host is not great as an author
<keithamus> ... other than my gut feeling I dont have strong arguments one way or another
<keithamus> TabAtkins: are you implying theres a benefit to saying these selectors only apply to host or not? Being able to match either host or shadow tree is more complex?
<keithamus> emilio: yeah. We can put the selector in a separate place to style the element, otherwise it's in the general place
<keithamus> TabAtkins: the spec side, it means adding another clause to the conditions for what allows a compound selector to match a host element
<keithamus> ... not a huge complexity but one more thing in the list
<keithamus> emilio: spec or implementation complexity aside, I wonder what other authors think? A bare :has with random stuff inside accidentally matching the :host?
<keithamus> astearns: the person who wrote the tests is not thinking about this accidentally perhaps
<keithamus> ... I'd like to see what the valid use case is. Speaking personally, I think I'd like the use cases to justify this
<keithamus> TabAtkins: for the feature entirely? Or needing :host to be spelled out?
<keithamus> astearns: allowing a selector that doesn't explicitly use :host to match the host
<keithamus> emilio: you claim the test author mentioned that? As far as I can tell they don't test that
<keithamus> TabAtkins: all tests have :host:has. In the mindset of testing that :host matches appropriately.
<keithamus> TabAtkins: I'm fine with resolving with emilio's ammendment
<TabAtkins> :host:has() can match, :has() can't
<keithamus> PROPOSED RESOLUTION: :host:has() can match, :has() can't
<oriol> q+
<keithamus> oriol: I think this breaks the assumption from the previous issue
<keithamus> ... when we have compound selector allowed to match host, here has wouldnt be allowed but the combination would
<keithamus> ... so it breaks the general rule?
<keithamus> TabAtkins: changing that rule to special case this would be part of that resolution
<keithamus> oriol: so what would the general rule be?
<keithamus> TabAtkins: I'll show the spec
<keithamus> RESOLVED: :host:has() can match, :has() can't