w3c / csswg-drafts

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

[css-scoping] Allow elements to expose a subset of their shadow tree, which can then be styled with regular CSS #10939

Open LeaVerou opened 1 month ago

LeaVerou commented 1 month ago

(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:

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:

  1. 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.
  2. 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 that my-component >>> [part=foo] is essentially ::part(foo) with better ergonomics.

Going further:

Nice synergies:

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

  1. 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

emilio commented 1 month ago

Conditionally changing the tree you match selectors against is probably not great for performance (ignoring all the other issues about where do styles come from), fwiw.

EisenbergEffect commented 1 month ago

I would hesitate to call the attribute export without exploring a few other things we might want to do in the future. For example, there are a bunch of PoCs for HTML modules and some of them propose using the export attribute to indicate what should be exposed from the module. Maybe the same attribute can be used, and the browser can figure it out based on context. I don't know enough about HTML parser implementations to know whether that's possible or would be considered acceptable. I'd like to keep the broader modularity story of HTML in mind while working through this though, if only to make sure we don't end up painting ourselves into a corner and then have to do something very strange for HTML modules.

michaelwarren1106 commented 1 month ago

I definitely like where this proposal is going! The idea of creating a subtree of exposed elements/subtrees is a great idea imo.

I worry that as it stands right now, this proposal might cause:

I'll provide some more details below

Brittle selectors

I'll agree that being able to write complex selectors, including relationship selectors, would be hugely beneficial over todays implementation of parts. However, if component consumers write selectors that depend on other characteristics of the exported element, then those selectors are immediately brittle for changes to the component later on.

Any kind of selector in the shadow root can't necessarily be depended on to always exist. A <button> may one day become an <a>, a <div> turn into a <span>, etc. Likewise, classes, IDs, attributes and any other characteristics of exported elements would be considered, in the vast majority of situations, to be "internal" to the component. In any kind of middlingly complex component, the internal architecture of elements, classes and attributes won't be part of the public API of the component like DOM properties, methods, and events are.

Personally speaking, I've never devoted exhaustive testing to make sure that certain css classes are on certain elements in the shadow root, unless thats the only way I have to test a public API feature. Therefore, any css selector used to style an exported element will instantly break when any of those selectors no longer exist on that exported element. Component authors will make hundreds of internal-only type changes that restructure the shadow root template without changing the public API of the component in a breaking way.

IMO, a solution to theming shouldn't encourage component consumers to rely on internal structure because it makes those styles very easily breakable in a way that will be very difficult and tedious to discover and fix. Anyone that writes selectors like the above will have to examine the new contents of the shadow root of the patch-bumped component to find what changed and how in order to fix their theme styling selectors accordingly.

Backwards compatibility burden

With the export attribute relying mostly on the dom elements themselves, and allowing for component consumers to use the full power of css comes a very heavy burden on component authors. So much so that I fear that authors would shy away from theming this way. Adding the export attribute as described above would basically make adjusting ANYTHING about that element, classes, ids, attributes, and the element tag itself a breaking change. If relationship selectors were allowed, then by extension any change to that subtree relationship would ALSO be a breaking change. Component authors couldn't add another export sibling element between two existing elements because some consumer, unbeknownst to the component author, might have written an nth-child(2) selector referring to one of the existing exported elements.

This would mean that component authors would have a LOT more breaking changes to think about.

Proposal

I love the idea of the subtree and the attribute to denote exporting the element to create it, but I think it needs some stable identifier that CAN be a part of the public API of the component without being related to the actual shape of the dom itself. Imo the stable identifiers are part of the good part of css parts. (lol, "parts" has no meaning to me now)

Currently a CSS part is just a name. Its not an element, class, or attribute. A component author can move the name around in the DOM independent of styles, attributes, IDs, or elements. For this proposal, I think it would be important to do the same. There needs to be an independent identifier associated with each exported thing. Unfortunately, I think that the issue with stable identifiers might also mean that exporting a whole subtree might not be tenable?

What if the subtree was not a tree of DOM elements, but was a "part tree" constructed of part names and not elements? IMO that could be what exportparts maybe should have been? Each element with an export="{identifier}" gets assembled into the exposed subtree as described above, but the css that consumers would write wouldn't be using element/class/id/attr selectors, it would be using part selectors?

<foo-spinner>
    <template shadowrootmode="open">
        <div class="wrapper" export="actions">
            <input export="main-input">
            <div class="buttons">
                <button class="increment" export="plus-button">+</button>
                <button class="decrement" export="minus-button">-</button>
            </div>
        </div>
    </template>
</foo-spinner>
<foo-spinner>
    < :: exposed parts tree >
        :part(main-input)
        :part(plus-button)
        :part(minus-button)
    < / :: exposed parts tree >
</foo-spinner>

and css like:

my-component >>> :part(main-input) + :part(minus-button) { /* some thing */}
my-component >>> :part(actions):has(:part(minus-button)) { /* some thing */}
sorvell commented 1 month ago

I really like the direction this is going. At TPAC, I suggested that we want the old deep/shadow combinators (similar to >>> here) restricted to the virtual part tree, conceptually similar to the export tree proposed here. A few initial thoughts in case it sparks any other ideas:

  1. A number of CSS features seem to be swirling around a general concept I've been thinking of as a CSS policy. This is the idea that there are often multiple sources of truth for how something can and should be styled (or not) and the page needs a way to rationalize what styling actually applies. Features which are either directly or indirectly related to this include: the cascade in general, Shadow DOM style encapsulation, @scope, @layer, css custom properties, appearance, and probably others. I wonder if there is some grand unified theory here, and yeah, that is intended to make it sound hard.
  2. With the previous point in mind, it would be really valuable if this ability to create an explicit styling structure was not coupled with Shadow DOM and could apply to other arbitrary areas of the document. This would allow templating that wants control over styling not to require using Shadow DOM. The @scope rule introduced a type of more limited scoping very different from Shadow DOM but often serving some of the same needs. Perhaps this could be integrated with it.
  3. While there is clarifying value in explicitly annotating the DOM for this, could we envision a way to do this purely in CSS? Really roughly (inspired by @scope): @isolate (.foo) to (.bar) { @export .zot; } }
mirisuzanne commented 1 month ago

At very least, I would expect something like this would have to come from shadow styles, and not from the page.

But broadly I don't think the DOM annotation is really expendable here. There's a strange circularity to using external selectors as a way of isolating parts of the DOM from external selection. That's why the @scope rule is itself intentionally 'scoped' to impacting the code inside the at-rule block.

LeaVerou commented 1 month ago

@michaelwarren1106 I do agree that this could lead to brittle selectors, though in many cases there is very obviously a single element type that can be targeted. E.g. for the spinner example above, it is highly unlikely you’d want to change the <input> or <button>s to something else.

A big pain point with parts that this was designed to solve was the burden of defining names for the component author and learning names for the component consumer, which balloons for complex use cases (e.g. think of styling an entire date picker popup). A solution with less cognitive overhead could be in line with the tree subsetting idea, by introducing higher granularity around what is exported (which I mentioned in the OP as a Level 2 feature): being able to hide certain attributes from the exported subtree or even the element type (simply by defining that no type selector can ever match it). It is an open question what should the default behavior be and what the syntax for this would be exactly.

@sorvell I suspect that doing this in CSS makes it much harder to implement, (currently) impossible to monitor changes to by authors, and introduces the potential for cycles (we can design the feature in such a way that it avoids cycles, but it’s an additional design consideration). It is also unclear what the benefit is: CSS is good for applying things to elements in aggregate, but here you typically only have one instance of each element to deal with, so this seems like it introduces friction and indirection without much benefit.

michaelwarren1106 commented 1 month ago

I guess my question is about the priority of the pain points.

I wonder how much of a pain point learning part names would be if we could compare it to brittle selectors? As a component author and consumer, if some third-party component pushes a patch bump and breaks my code and tests without me knowing that was going to happen, that strikes me as a MUCH bigger pain the butt than reading the docs for some names?

We can't compare because parts doesn't have the brittle code/test concern because of the naming feature. But I do know that shoelace/web awesome will update constantly. As a component consumer I would want component authors to be updating constantly if they can do those updates in patch bumps that are seamless and backwards compatible.

Broken code and tests leads to junk GH issues ("update your code, the internals have changed"). An API that forces component authors into a choice of "if I use the export attribute everything about this element is basically permanent" will undoubtedly mean less usage of the feature.

If we were to try to compare the pain of establishing and learning disconnected names vs the pain of having to update your entire application because a component changed its internals in a patch bump, I would argue that the pain of the latter is much greater.

I would also argue that exposing internals via names isn't actually going to do much in the way of lessening the pain of learning which pieces are exposed. Component authors are still going to have to document the exposed subtree. Component consumers are still going to have to read those docs to know which elements are available for custom styling. What difference does it really make if the string in the docs is input vs main-input? Component consumers can't really make any assumptions about the exposed sub tree, so they cant just write generic styles and then set them loose on a component library and see what happens. Each component in a library will have a different set of elements in their exposed subtree so the list of what is available will just go in the same place as the parts table currently is on shoelace's docs. It'll just be a list of elements to learn instead of names to learn.

Jamesernator commented 1 month ago

A use case I would like to point out in favour of this approach, is that it enables shadow roots to contain content on behalf of their users (i.e. "include"/"import"-like elements).

For example suppose we have a tooltip that shows some content from another file:

<!-- index.html -->
At the event we had <j-tooltip class="cheese-nane" src="./cheese-facts/brie.html">brie</j-tooltip> ...

then the shadow root might look something like:

<j-tooltip src="./cheese-facts/brie.html">
    ::shadow-root
        <div id="tooltip-target"><slot></slot></div>

        <div id="tooltip-content" popover="auto">
            <!-- We have an extra div with a shadowroot here to prevent cheese-facts/brie.html stylesheets affecting our shadow root -->
            <div BIKESHED_EXPOSE_SUBTREE>
                ::shadow-root
                    <div BIKESHED_EXPOSE_SUBTREE>
                        <!-- Content from some template in cheese-facts/brie.html -->
                        Brie is a soft cheese ... similar to <j-tooltip class-name="cheese-name" src="./cheese-facts/camembert.html">camembert</j-tooltip>
                    </div>
             </div>
        </div>
</j-tooltip>

Users could then target .cheese-name (which is their content in both trees) easily with the suggested:

/* Just works? */
.cheese-name {
    color: blue;
}

without affecting the j-tooltip's internal parts like #tooltip-target.

thadguidry commented 1 month ago

@scope feels so close, and @LeaVerou is basically asking for a rule-to-filter-for-shadow-styles-exclusively, doesn't it? But @scope is code-related only. What if we had one for style/css-related? @scopecss or @scopestyle ?

Oops, nevermind, looks like we already sort of have that through: :scope pseudo-class to apply styles to the scope root itself and also seems we can prepend with nesting selector & and a whole lot more now that I read all the specificity features that @scope actually offers. Hmm...

:scopeshadow ?

jjenzz commented 1 month ago

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%.

agree with this 100%. it makes more sense to me that consumers would be able to style things as normal (with all the existing selectors/combinators they're used to) unless they're explicitly declared as "private".