-
Notifications
You must be signed in to change notification settings - Fork 747
Description
(This is a very ambitious proposal that I don’t imagine would gain much support from implementors anytime soon — however I think there’s still value in filing these "north star UIs", as we often find a way down the line)
Background
The need to expose certain shadow DOM elements to styling from the outside is well-recognized. However, the current mechanism of using ::part()
pseudo-elements suffers from poor ergonomics, still doesn’t fully cover authors' use cases, and constantly brings up new design questions, such as:
- [css-shadow-parts] need definition of which pseudo-classes can follow
::part()
that is sufficient to produce interoperability #10787 - [css-shadow-parts][css-scoping] Is ::slotted() allowed after ::part()? #10807
- [css-pseudo] more clearly define which pseudo-elements are tree-abiding or part-like #10794
- [css-shadow-parts][css-nesting] is & allowed after ::part() #10788
- [css-anchor-position] anchor-scope and part descendant styling #10525
- [css-shadow-parts] What's the purpose of multiple idents in ::part()? #4412
::details-content
vsdetails::part(content)
#9951
Furthermore, I’ve seen many (most?) components adding part
attributes on almost every element in their shadow DOM. I would go as far as to say that for most WCs I’ve seen that use parts, expose more than half of their shadow DOM elements, often more than 90%. For example, take a look at this list of parts from Shoelace’s <sl-tab-group>
.
This is not only tedious for WC authors, it makes things harder for WC users as they need to learn the various part names, understand what type of element each part corresponds to and where it stands in the hierarchy and there is no way to target relationships between parts, even if every element involved is a part
.
open-stylable
shadow roots is one solution to this problem, but it’s an all-or-nothing solution that requires giving up encapsulation entirely.
Proposal
I’ve been wondering what could describe author intent more directly here. The intent is to keep certain elements encapsulated (e.g. wrappers) while exposing others that WC users may want to customize, ideally as a tree. What if they could do just that?
tl;dr: Authors can expose a subset of their shadow tree that can be styled from the outside with regular CSS.
MVP:
- An HTML attribute opts an element in to being exposed (name TBB, e.g.
export
).- If used without a value, it only shallowly exports that one element. This is the MVP.
- We introduce a combinator (could re-introduce
>>>
but with this distinct meaning rather than the previous "anything goes" semantics) that pierces into the shadow DOM, but only has access to that exposed subtree.- The subtree only consists of exposed elements, but within it everything works as expected, child combinators, sibling combinators, tree pseudos, you name it.
- The matching root (i.e. the part that follows after
>>>
) does not need to be at the top-level, it can be anywhere on the tree. This means thatmy-component >>> [part=foo]
is essentially::part(foo)
with better ergonomics.
Going further:
- We may decide to make this even more granular, with values to not expose element names, classes, ids, certain attributes etc.
- A special value (e.g.
export="subtree"
) could export an entire subtree. If the IDL attribute is available on shadow roots, an entire shadow root can be opted in to this with 1 loc.- In that case, there could be a counter attribute to remove subtrees as well (donut scope).
Nice synergies:
- If we do end up introducing
/slotted/
as a combinator (see [css-shadow-parts] Make::slotted()
a combinator #7922), it works with these subtrees out of the box, no need to introduce even more syntax about what you can have after a::part()
(see [css-shadow-parts][css-scoping] Is ::slotted() allowed after ::part()? #10807 ). - We could introduce syntax that allows ARIA to also hook into this exposed subtree
- This could also be used by native elements to expose the various elements that should be open to being styled rather than introducing dozens of pseudos
Example
Suppose we have a <foo-spinner>
with this structure and these export
attributes:
<foo-spinner>
<template shadowrootmode="open">
<div class="wrapper">
<input export>
<div class="buttons">
<button class="increment" export>+</button>
<button class="decrement" export>-</button>
</div>
</div>
</template>
</foo-spinner>
This would expose the following subtree that >>>
would "see":
<foo-spinner>
< :: exposed subtree >
<input>
<button class="increment">+</button>
<button class="decrement">-</button>
< / :: exposed subtree >
</foo-spinner>
This means that selectors like foo-spinner >>> .increment:active + .decrement
actually work.
Or even foo-spinner > input:not(:blank) ~ button
, even though that matches on a tree relationship that does not actually exist in the shadow tree.
Issues
- Because the parent-child relationships are not necessarily the same as in the shadow DOM, applying CSS properties that depend on parent-child relationships, such as flexbox and grid could have surprising results. However,
::part()
also has this issue and probably any mechanism that allows exposing only a subset of the tree. One solution could be to define that exposition preserves the general shape of the tree, and non-exposed nodes simply cannot be targeted, but this seems both harder to spec, harder to implement and harder to conceptualize for authors.
Open questions
- Do nested shadow trees need another
>>>
or once you have one it has access to the flattened exposed subtree? - Thinking about it some more, I wonder if the best approach is opting nodes out rather than opting them in, since in most use cases the number of nodes exposed is way over 50%.