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

"open-stylable" Shadow Roots #909

Open
justinfagnani opened this issue Dec 10, 2020 · 279 comments
Open

"open-stylable" Shadow Roots #909

justinfagnani opened this issue Dec 10, 2020 · 279 comments

Comments

@justinfagnani
Copy link
Contributor

We keep hearing that the strong style encapsulation is a major hinderance - perhaps the primary obstacle - to using shadow DOM.

The problems faced are usually a variant of trying to use a web component in a legacy context where global styles of the page are expected to be applied deeply through out the DOM tree, or a modern context with tools that don't work with scoping, eg Tailwind.

I've see requests to workaround encapsulation formed in a number of ways:

  1. Turn off shadow DOM (as is an option in LitElement), but keep <slot> working (which obviously isn't).
  2. Allow styles to apply from the outside
  3. Add a way to automatically inject page styles into shadow roots.
  4. Implement a custom CSS scoping mechanism for when shadow dom is off
  5. Some specific library, eg Tailwind, converted to work with shadow roots
  6. etc...

Would it be possible to address some of these difficulties with scoping more or less directly, by adding a new shadow root mode that allows selectors to reach into a shadow root from above? Something like:

this.attachShadow({mode: 'open-stylable'});

Application developers could then make sure that elements from the root of the page on down to where they need styles to apply use open-stylable shadow roots. Library authors could offer control over the mode for legacy situations, etc.

I'm not sure how combinators would work here. ie, would .a > .b {} apply to <div class="a"><x-foo>#shadow<div class="b">? It may be that's not even needed to be able to use most of the stylesheets in question, which often rely more heavily on classes than child/descendent selectors.

Would this be viable performance-wise?

Related to #864

@rniwa
Copy link
Collaborator

rniwa commented Dec 10, 2020

We can't evaluate a selector across shadow boundaries because of the way we architected our engine, and we don't want to change that.

@bahrus
Copy link

bahrus commented Dec 11, 2020

I think there's something to be said for this idea:

Turn off shadow DOM (as is an option in LitElement), but keep <slot> working (which obviously isn't).

Possibly a bit off topic, but StencilJS went through great lengths to support both ShadowDOM and non-ShadowDOM, using the same component / syntax. It sounds like the biggest challenge was how to emulate slots without the help of ShadowDOM. Performance is an issue with this emulation, apparently.

I think userland has demonstrated that the slot concept is quite useful, even without ShadowDOM. React has something roughly similar, perhaps.

I'm guessing the most natural fit for supporting this would be as part of the template instantiation initiative. I know that initiative is currently awaiting a determination whether there are underlying primitives that would benefit the platform more generally.

Perhaps in parallel to that effort, template instantiation could start, but focus squarely on this (additional?) requirement, which does seem to be quite widely applicable, but much harder to implement in a performant way than other features of that proposal? Or maybe there are also some underlying primitives that would make slot emulation easier to implement / faster performing?

If not, I would still suggest looking at supporting slots when/if the discussion does move on to actual declarative template instantiation.

@LarsDenBakker
Copy link

With adopted stylesheets, how bad would it be to apply the same stylesheet to all elements? Is there a negative performance impact even when it's same stylesheet in memory?

Component libraries could offer easy mechanisms to inject those global stylesheets into the components.

@justinfagnani
Copy link
Contributor Author

@rniwa interesting, thanks. If selectors weren't evaluated across shadow boundaries, would it be possible to run them whole inside a child shadow root?

@LarsDenBakker I was wondering if an open-stylable concept could essentially be that open-stylable roots inherit the sheets from the scope above them. There are a lot of tricky problems with a userland library trying to make this work. You need mutation observers to listen for all <style> and <link> elements, and since their stylesheets are not adoptable, you couldn't get changes to them. You'd also need to patch adoptedStyleSheets.

@matthewp
Copy link

@justinfagnani That idea does indeed sound tricky but assuming we have CSS modules you could easily build a Tailwind class mixin that an app team folds into their base class, no?

@justinfagnani
Copy link
Contributor Author

@bahrus I don't think <slot> makes much sense outside of shadow DOM because you end up with contention of the children. Composition works because the light and shadow DOM are separated.

Say a component rendering to it's own light DOM, something like:

<my-card>
  <slot name="title">Alert</slot>
  <slot></slot>
  <button>OK</button>
</my-card>

And then a user would like to use it:

<my-element>
  <h1 slot="title">Welcome</h1>
  <p>Thanks for reading my card...</p>
</my-element>

What should render? When? We have two timings to deal with: 1) the usual content exists before element's 2) the element's content exist before the users. In 1) The element could accidentally overwrite the user's content. Or it could append to itself. Then it would have to move the user's content into the slot, but the user wouldn't know that and could keep appending into what's now the element's semi-private DOM. In 2) If the user uses a template system like vdom, they may remove all the element's content.

The only way to get this to work in any stable way is make a contract that separates user-provided children from element provided with a special child element. Then either all user provided content must go in the child, or all element provided:

And then you can't really use <slot> because that would be projecting content from an outer scope. So i'll invent <content> and <content-slot> to allow projection between siblings:

<my-element>
  <content>
    <h1 slot="title">Welcome</h1>
    <p>Thanks for reading my card...</p>
  </content>
  <content-slot name="title">Alert</content-slot>
  <content-slot></content-slot>
  <button>OK</button>
</my-element>

This gets really complicated really quick. There's a reason I keep rejecting this feature in LitElement.

@justinfagnani
Copy link
Contributor Author

@matthewp yeah, for any given CSS library we can probably formulate a way to make that library more compatible with shadow DOM. Component authors might not have the time, ability, or context to do so though - they might not know what context they're running in exactly - what specific style sheets are used - but they might know that the existing DOM they're replacing is styled via certain class names. This is when we hear that for some projects it's simply not feasible to use shadow DOM.

@Jamesernator
Copy link

Jamesernator commented Dec 14, 2020

I am wary of the all-or-nothingness of open-stylable in two ways as it basically makes it impossible to actually isolate some styles.

e.g. Suppose you want to use tailwind at the root level, this means that every shadow root needs to be open-stylable. But now what do I do if I want to isolate styles to a shadow root? If I add the following sheet to my shadow root:

<style>
    #container {
        --some-styles: some-values;
    }
</style>

Then I have unintentionally targeted any element that happens to use id="container" within nested components.


My inclination is that the best way for dealing with stylesheets not designed for shadow roots is just to do what @matthewp suggested and add the stylesheets that need to be inherited into each root.

I think it would be good though if StyleSheet and StyleSheetList had change events so that we could respond to changes more accurately. (This would be useful beyond this use case as well, e.g. for building things like StyleObserver, etc).

they might not know what context they're running in exactly - what specific style sheets are used - but they might know that the existing DOM they're replacing is styled via certain class names.

This is another place where an API would be nice, e.g. give it an element and gets a list of rules that target that element e.g.:

const el = document.createElement("div");
el.classList.add("my-class");

const rules = document.getMatchingRules({
  element: el,
  // Allow matching rules if it were in the DOM, without actually needing to add it to the DOM
  where: {
    anchorElement: document.body,
    relativePosition: "child", // Or nextSibling, previousSibling etc
  },
});

const sheets = rules.map(rule => rule.parentStyleSheet);
// Do whatever with sheets

Like above, this would be more generally useful for building things like StyleObserver, css polyfills/extensions/etc.

@justinfagnani
Copy link
Contributor Author

@rniwa any thoughts about this:

If selectors weren't evaluated across shadow boundaries, would it be possible to run them whole inside a child shadow root?

We pretty constantly get questions about how to use <slot> without shadow DOM. Being close to the browser/spec, we may know that question doesn't make a lot of sense, but the intention is pretty clear: users want the composition of shadow DOM without the upper-bound style scope.

I feel like we really need some answer for those who want to incrementally adopt web components into existing apps with existing styling. It's a huge use case.

@rniwa
Copy link
Collaborator

rniwa commented Feb 27, 2021

@rniwa any thoughts about this:

If selectors weren't evaluated across shadow boundaries, would it be possible to run them whole inside a child shadow root?

Can't we already do that by inserting that the same stylesheet into the shadow root?

@justinfagnani
Copy link
Contributor Author

Can't we already do that by inserting that the same stylesheet into the shadow root?

Potentially... can we make style sheets created by <style> and <link> adoptable?

@rniwa
Copy link
Collaborator

rniwa commented Feb 27, 2021

Can't we already do that by inserting that the same stylesheet into the shadow root?

Potentially... can we make style sheets created by <style> and <link> adoptable?

Why would that matter? You can just insert a new style / link, right?

@justinfagnani
Copy link
Contributor Author

I presume the pattern we're talking about is a component iterating over the stylesheets in it's root and copying them to into its shadow root. I think it'll be easiest if no matter where the stylesheets came from they could be added to the shadow root with one API.

Also, if this is left to userland it'll be very difficult to do correctly. First it'll have to look for three different types of styles in its root: <style>, <link> and adopted styles. Then it'll have to handle loading state the tags. Finally it'll have to handle dynamic updates with a mutation observer for tags, and I'm not sure what for adopted stylesheets.

@calebdwilliams
Copy link

calebdwilliams commented Feb 27, 2021

Could a key be added to ShadowRootInit that would cause the shadowRoot.styleSheets or shadowRoot.adoptedStyleSheets to mirror the contents of document.styleSheets shadowHost.styleSheets?

this.attachShadow({
  mode: 'open',
  adoptHostStyles: true
});

I would imagine, then, that any local styles (say via a <style> block) would append to the end of the StyleSheetList, but keeping those lists in sync could be gnarly from an implementation perspective.

@castastrophe
Copy link

castastrophe commented Mar 5, 2021

I really like the idea of an opt-in approach to allowing styles to cascade. Personally, the encapsulation is a big part of why we wanted to use web components for our design components but I can understand why that's not always the aim. I like the idea of having at least 2 options:

  1. Allow all CSS to penetrate the shadow DOM using an opt-in approach such as
    this.attachShadow({
      mode: 'open',
      styles: 'all'
    });
  2. Allow only specific stylesheets to penetrate the shadow DOM:
    this.attachShadow({
      mode: 'open',
      styles: [stylesheetPointer] // array of pointers to specific stylesheets?
    });

Having a separate property like styles that can accept a few different inputs gives us a lot of flexibility to add functionality as we go.

Perhaps the default being something like:

this.attachShadow({
  mode: 'open',
  styles: 'closed'
});

@castastrophe
Copy link

I'm wondering if an approach like I described above keeps us open to supporting CSS Modules should they ever be included in the spec and in the interm, allows us to pass in Stylesheet objects or StylesheetLists.

@calebdwilliams
Copy link

If non-constructed stylesheet were adoptable, that would be a potential solution.

@eriklharper
Copy link

eriklharper commented Mar 10, 2021

Turn off shadow DOM (as is an option in LitElement), but keep slots working (which obviously isn't).

+10000 for this idea for the ability to write form-associated custom elements that use native form inputs.

@eriklharper
Copy link

@bahrus RE:

Possibly a bit off topic, but StencilJS went through great lengths to support both ShadowDOM and non-ShadowDOM, using the same component / syntax. It sounds like the biggest challenge was how to emulate slots without the help of ShadowDOM. Performance is an issue with this emulation, apparently.

This is a hugely nice feature of Stencil that I find myself taking advantage of when building form-associated custom elements, but I have come across bugs with this, namely this one ionic-team/stencil#2801 which has to do with their internal logic that relocates slotted content in non-shadow components.

@bahrus
Copy link

bahrus commented Mar 10, 2021

Aurelia seems to be doing something similar.

@rniwa
Copy link
Collaborator

rniwa commented Mar 12, 2021

FWIW, we need to very carefully understand how a concrete proposal for this will work with declarative shadow DOM and scoped custom element registry.

@justinfagnani
Copy link
Contributor Author

I don't have that concrete of a proposal in mind. I would love to figure out more constraints in the area before getting too specific. If any implementors have ideas how something that addresses the needs here could practically work that'd be great.

I do think that there are likely straight forward answers for how something like this would interact with declarative shadow DOM and scoped custom element registries. For declarative shadow DOM, this would need to be a mode describes in attributes, so that the declarative shadow root is created in the right mode. And right now I don't see any interaction scoped custom element registries if this is completely on the shadow root side. A scoped registry shouldn't effect the mode of a shadow root.

@trusktr
Copy link

trusktr commented Apr 3, 2021

This still won't address the issues that @dflorey described in #864: an end user, importing and using 3rd party elements, can not force the components to call their own attachShadow methods with a setting to make styles leak in, except by monkey patching attachShadow (which may not be possible with declarative ShadowDOM and elementInternals where attachShadow is not called unless we specify in the spec that attachShadow always will be called).

People are still going to be asking how to style 3rd-party elements from outside when the element authors have not called attachShadow with open-stylable or similar.

@justinfagnani
Copy link
Contributor Author

an end user, importing and using 3rd party elements, can not force the components to call their own attachShadow methods with a setting to make styles leak in

The should not be able to. A component should only expose internal DOM to styling if it opts-into it. Otherwise it's taking on potentially breakable API contracts it might not want to.

If a user patches attachShadow() or the element definition directly, they know they're off the well-lit path for that element.

@nolanlawson
Copy link

"how do I use bootstrap in web components"

It seems to me that this is already somewhat solvable in userland using adoptedStyleSheets. As long as we're talking about open shadow roots, a page author can traverse the DOM, find all shadowRoots, and append a constructable stylesheet into their adoptedStyleSheets.

With adopted stylesheets, how bad would it be to apply the same stylesheet to all elements? Is there a negative performance impact even when it's same stylesheet in memory?

I ran a quick benchmark and, in Chrome at least, there doesn't seem to be a big perf difference between injecting the same stylesheet into multiple shadow roots versus just having a single <style> tag in the <head> (using light DOM). So it seems viable perf-wise.

The main blocking issues with this approach seem to be:

  1. adoptedStyleSheets only work with constructable stylesheets. So e.g. a remote bootstrap.css wouldn't work unless its content could be injected into a constructable stylesheet (and dealing with relative paths, @imports, etc.).
  2. As previously said, you would have to use MutationObserver or something to watch for any DOM changes (unless you have full control over the page).
  3. It doesn't easily solve scoping, e.g. having one part of the DOM use Bootstrap whereas another uses Bulma. But I'm not sure how common of a use case this is.

Maybe solving the first issue (adoptedStyleSheets requiring constructable stylesheets) would solve most usecases of "I just want to use Bootstrap everywhere"?

@emilio
Copy link

emilio commented Apr 22, 2021

I ran a quick benchmark and, in Chrome at least, there doesn't seem to be a big perf difference between injecting the same stylesheet into multiple shadow roots versus just having a single <style> tag in the (using light DOM). So it seems viable perf-wise.

Chrome's style system looks very different from Firefox's / Safari's, fwiw. Chrome collects invalidations globally, not per shadow root.

So we discussed some of this today, and there are different use-cases. From what I understand, the most common one (from @justinfagnani / @bkardell) was ability to import "global" stylesheets into the shadow root automatically. A proposal could be something like:

host.attachShadow({ importGlobalStyles: true }) // Or such

Combined with something like <style global> or <link rel=stylesheet global> or whatever.

In which order / with which priority / etc these sheets would apply seems a bit TBD and probably requires CSSWG discussion. It looks sorta like a "page UA sheet" of sorts, but I don't know if we want it to behave similarly to how UA sheets behave (!important in UA sheets can't be overridden by the page for example, in this case it wouldn't be overridable by the web component author for example), or maybe it'd be easier to just treat them as "earlier in source order" or such.

But then there was another use-case from @gregwhitworth which (if I understood correctly) would additionally allow descendant selectors on those stylesheets to pierce through, querySelectorAll to break the shadow DOM boundary, etc. I think that'd be much more challenging to implement.

@gregwhitworth
Copy link

@emilio I spun up a document here so that @justinfagnani @bkardell @dfreedm and others can start putting pain points and the various gradient of web component capabilities that are currently bundled with encapsulation to have meaningful discussions around them each on their own merits.

https://docs.google.com/document/d/1SToB0yip8tFvJSY9rFQDhUVTr8GUjdYFGFWhAq9NQds/edit

I'm sure many can add concrete use-cases to each of them so please do so in a meaningful way (eg: please don't write numerous paragraphs for your usecase :) )

@justinfagnani
Copy link
Contributor Author

@emilio I don't think that the global scope should be specialized in any way - that would limit composability. I think it would be more ideal, and work similarly, is allowing shadow roots to be open to styles from their containing scope. Shadow roots would transitively be open to document styles if all ancestor scopes were open.

So something like:

host.attachShadow({ mode: 'open', openStyleable: true });

@mirisuzanne
Copy link

mirisuzanne commented Mar 21, 2024

To me, "access" couldn't happen at either the layers or scope proximity step in the cascade. It must happen before, I assume in the context step, and would pass first through the element-attached styles (style attribute in cascade 6) step.

To clarify: 'Cascading' happens as a resolution step after 'filtering' is complete. That's my point, they are separate steps. While both layers and scope show up in the 'cascading' process, with an impact on declaration priority:

  • The @layer syntax is only a mechanism for managing cascade priority. That's all it does currently.
  • The @scope syntax is a selector mechanism primarily for managing access. It's primary function is 'filtering' before the cascade. But (like other selectors) it has some heuristic implications on cascade priority.

One of them is a filtering mechanism (with some cascade implications), and the other one is a cascading mechanism (full stop). That was very intentional. Layers were designed specifically to provide a cascade mechanic that would not be entangled with filtering. The fact that @scope shows up in the cascade is the most controversial thing about it. Linking those two things is risky, as we've seen over the years managing selector specificity.

When it comes to filtering which styles are attached, there are two existing mechanisms that are more appropriate:

  • If we're defining styles that apply to a specific context, that's @scope
  • If we just want easy access to named chunks of CSS, we have @sheet

In both cases, layers have roughly the right shape for what we want - letting us divide and label parts of a CSS file. So that makes it very tempting to use @layer syntax for the whole thing. But @layer is the only unencumbered cascade mechanic we have, designed specifically to disentangle filtering from cascading – because otherwise it was impossible to control cascade priority without also manipulating selection. Let's not make that mistake again.

Still, I agree that layers do seem essential to the solution here. We're concerned with both which styles apply, and also how they cascade. So we absolutely want ways to put sheets or scopes (or whatever) into layers. That would be similar to the way we can import entire CSS files into layers. And it would be useful for component authors to expose layers, for more nuanced cascading between page and shadow-dom.

We should be able to combine these features, but we should not conflate these features.

@DarkWiiPlayer
Copy link

@mirisuzanne Thank you for that explanation. Looking at it that way, it makes sense how @layer might be a bad choice for narrowing down which styles to pull into a shadow-dom.

My personal mental model of "layers" is that in practice they end up representing something like "phases" of styling: first we reset browser defaults, and get to some neutral state; then we apply a framework, and get to a basic styled state; then we apply site-wide styles, and get to an overall finished page; then we might add some more specific layers based on what page we're on, etc.

In that sense, it makes sense to express a concept like "Take the resets and the framework styles from the light dom, but not the other stuff" using @layer names.

@sheet seems like a good alternative for this, at least on a conceptual level, although at a glance it seems like it could end up lacking some relevant features. The first thing that comes to mind, without having read the whole proposal yet: It would be useful, if not essential, to name a sheet when <link>ing it directly from the HTML, otherwise we might end up with situations like "I want to tell this component to only pull resets, but my resets.css is just linked in the HTML, so now I have to change my HTML and wrap it in a <style> block, or worse, edit the resets.css itself and wrap the entire content in a @sheet rule", which I would find very undesireable both as a user and author of components.

As a more general design consideration for this feature, one could phrase it like this:

Using a web component should not dictate or narrow down how page authors can organise their CSS


Also, please note that this applies to the selection of which styles to inherit; it has nothing to do with the requirement to wrap inherited styles in a layer inside the shadow-dom, as that really is just a matter of handling cascade specificity (in the most common case, making sure no inherited styles end up overriding inner styles of the shadow-dom)

@mayank99
Copy link

mayank99 commented Mar 21, 2024

I completely agree that @layer is the wrong tool for selecting which styles make into shadow DOM. It just seemed convenient because it is an already-available way of grouping styles. I've been guilty of suggesting layers myself.

Also there is a big problem with the shadowlayers proposal (and some existing demonstrations like half-light): all document styles are wrapped in a layer. This may be fine for specific cases, but it's not a good default. In fact the default should be flipped, because that's how the cascade already works today: page styles cascade after the shadow-root's own styles. Perhaps more importantly, it should be possible to place different layers from the document into different spots (not possible if everything is wrapped in a single inherit layer).

A few comments up, I showed an example similar to this:

@layer inherit.A, A, B, inherit.B;

This would not work. Layers cannot be split like this. The browser will "autocorrect" it to:

@layer inherit.A, inherit.B, A, B;

I've been a huge fan of layers since day one, and I still think they'll help us manage the cascade priorities of "outside" vs "inside" styles. But this should be opt-in.

The solution to open-styleable should allow some way of using already-written styles.

  1. The vast majority of CSS written today (both outside and inside shadow-roots) doesn't use layers. These styles might already manage the specificity in a way that open-styleable will work fine out-of-the-box. For example, all styles in their reset might be deprioritized using :where and their utilities might be reinforced with !important.
  2. Similarly, a lot of CSS written today is already properly scoped (e.g. using build tools or namespacing). Open-styleable will work fine with these styles out-of-the-box.
    • In fact, there's good precedent for this: framework components (built with light DOM) distributed to npm are widely used (way more than shadow DOM is used). And it has been totally fine.
    • The key difference here is that the expectations for light DOM components were clear from the get-go, so they likely do not use vague classes like "button". Existing shadow DOM components never had this same expectation. That's why open-styleable shadow-roots should be opt-in, using a new attribute/property.

@DarkWiiPlayer
Copy link

In my opinion, although it isn't a very strong one, I think imported styles should not be added to a layer by default, but users should have the option to do so on demand.

I suspect the most common case will be that component authors will want their own styles to override the inherited ones and might therefore inherit them into a layer. If selectively inheriting different stylesheets (or layers, for that matter) then there should also be an option to inherit them into different layers, so, for example, resets could be overridden by internal styles, but some user-overrides would in turn override the internal styles if the author wants to give the user this much freedom.

@trusktr
Copy link

trusktr commented Mar 21, 2024

I am not entirely sure why shadow crossing combinators have such a mindshare in this issue and keep coming up.

The answer is simple: framework components f.e. in React, Preact, Svelte, Vue, Solid, Angular, etc, all have this feature out of the box. We need to make the migration story from framework components to custom elements easy for all web developers. Cross-root selectors like .foo .bar are essentially required for this.

Web Components are used to facilitate interoperability and bridge the gaps between the different tech stacks. In these use cases, "fitting-in" with existing CSS is highly desirable.

Cross-root selectors are required for this too. Currently custom elements with ShadowDOM get in the way of this, and many of the above proposed solutions are too complicated compared to framework components and global styles to be desirable.

I’ve said it before, but I’ll say it here for the record: making web component styling exactly like framework components would be a huge regression.

The idea isn't to make all web components stylable like light DOM, but to allow the web components author to choose to make their shadow DOM-using components stylable like light DOM. The alternative is to not use shadow DOM and lose composition.

I don't think it would be a regression, but perhaps page authors should be able to opt into global styling, including selectors like .foo .bar applying across roots, and the page author is then responsible for any ensuing style conflicts.

People haven't really mentioned it, but really what cross-root selectors means is styling the composed tree. It is simple and reasonable to want that.


All frameworks today (React/Vue/Svelte/Solid/etc) have both global stylability out of the box with cross-component selectors, and libraries or build features/plugins that enabled component-scoped styles.

I believe we should highly consider simple solutions that make both patterns possible, and right now we already have component-scoped styles, so we really just need a simple opt-in document-level global styling solution. Maybe also component-on-down scoping (like a component's style is global for all elements below it in the composed tree).

Other proposals in this thread may still be useful to add for better patterns (layers, scope, etc), but they are simply too complicated for what most people need coming from frameworks.

@rniwa
Copy link
Collaborator

rniwa commented Mar 21, 2024

I am not entirely sure why shadow crossing combinators have such a mindshare in this issue and keep coming up.

The answer is simple: framework components f.e. in React, Preact, Svelte, Vue, Solid, Angular, etc, all have this feature out of the box. We need to make the migration story from framework components to custom elements easy for all web developers. Cross-root selectors like .foo .bar are essentially required for this.

What do you mean by "all have this feature out of the box"? Clearly, cross-shadow combinator isn't a thing today.

People haven't really mentioned it, but really what cross-root selectors means is styling the composed tree. It is simple and reasonable to want that.

That sounds like an entirely different problem statement. Please go file a separate issue instead of conflating that in this issue.

@justinfagnani
Copy link
Contributor Author

justinfagnani commented Mar 21, 2024

I am not entirely sure why shadow crossing combinators have such a mindshare in this issue and keep coming up.

The answer is simple: framework components f.e. in React, Preact, Svelte, Vue, Solid, Angular, etc, all have this feature out of the box. We need to make the migration story from framework components to custom elements easy for all web developers. Cross-root selectors like .foo .bar are essentially required for this.

What do you mean by "all have this feature out of the box"? Clearly, cross-shadow combinator isn't a thing today.

I think he means that by using a proprietary composition mechanism and not using shadow DOM, the frameworks inherently let you shadow their composed tree, because the composed tree is the light DOM. So what would be to us cross-shadow combinators are to them just combinators that cross scope boundaries that the browser isn't aware of.

People haven't really mentioned it, but really what cross-root selectors means is styling the composed tree. It is simple and reasonable to want that.

That sounds like an entirely different problem statement. Please go file a separate issue instead of conflating that in this issue.

It's not unrelated though. I though such a thing was impossible, but if it were possible, I think it could subsume this proposal and probably be strictly better.

@robglidden
Copy link

@layer inherit.A, A, B, inherit.B;

There is a relevant open issue, Allow layers to use different names in different contexts #10091

Also, Collection of user stories #1052 is offering to collect user stories.

@robglidden
Copy link

Also there is a big problem with the shadowlayers proposal (and some existing demonstrations like half-light): all document styles are wrapped in a layer. This may be fine for specific cases, but it's not a good default. In fact the default should be flipped, because that's how the cascade already works today: page styles cascade after the shadow-root's own styles.

Cascade layers inside a shadow tree cascade independently of page styles (except the context step), but if this means that in a shadow tree unlayered styles should have a way to be at a user settable priority, there is an open issue Allow authors to explicitly place unlayered styles in the cascade layer order #6323.

@robglidden
Copy link

I am not entirely sure why shadow crossing combinators have such a mindshare in this issue and keep coming up.

The answer is simple: framework components f.e. in React, Preact, Svelte, Vue, Solid, Angular, etc, all have this feature out of the box. We need to make the migration story from framework components to custom elements easy for all web developers. Cross-root selectors like .foo .bar are essentially required for this.

There is also the :host-context() pseudo-class function which tests whether there is an ancestor, outside the shadow tree, which matches a particular selector. But it is not supported in either Safari or Firefox. And unlikely to ever be? In 2016, Apple stated its opposition to :host-context() as an anti-pattern that sets behavior of elements based on context, and subsequent discussion tends towards favoring dropping it from the specification.

@trusktr
Copy link

trusktr commented Mar 21, 2024

What do you mean by "all have this feature out of the box"? Clearly, cross-shadow combinator isn't a thing today.

@rniwa Yeah, what Justin described. Framework components don't use ShadowDOM as core part of their implementation, so what I mean is that when you make a "composed tree" with frameworks (React/Vue/etc), that "composed tree" ends up as a light tree without ShadowDOM, and because of that document styles ("global styles" in typical framework terms) apply everywhere.

I want to write custom elements with ShadowDOM and style scoping, but at the same time I want to simply stick Bootstrap (or alternatives) in my top level HTML file and have it simply work. This is easy with Framework components out of the box, but not yet with Custom Elements that have shadow roots.

That sounds like an entirely different problem statement. Please go file a separate issue instead of conflating that in this issue.

Various proposals, ideas, and desires from the above conversation want this in different ways essentially, so it is indeed related. Allowing a selector to be cross-root (to behave like "global styles" in Frameworks(React/Svelte/etc) that have no ShadowDOM) is essentially the same behavior that styling the native composed tree (with enabling cross-root selectors, or something) would have. In various definitions of "open stylable" web components above, this end result is desired.

Maybe the OP is a specific way to allow styles across roots, and perhaps what I Iightly expressed is a different approach (and very unspecified, not really a technical proposal, more of a description of an end result), but overall this thread has become also about various use cases not covered by the OP as well as alternative potential solutions to those use cases.

@rniwa
Copy link
Collaborator

rniwa commented Mar 21, 2024

Maybe the OP is a specific way to allow styles across roots, and perhaps what I Iightly expressed is a different approach (and very unspecified, not really a technical proposal, more of a description of an end result), but overall this thread has become also about various use cases not covered by the OP as well as alternative potential solutions to those use cases.

I don't think we want to conflate those use cases with what this issue is tracking, which is about applying CSS rules that are defined in the document level. Whether people want shadow crossing combinator or not, or whether that topic is related to this issue or not, it should be tracked in a separate issue. It's not helpful to further expand the scope of the issue, which already has 250+ replies.

@robglidden
Copy link

I am not entirely sure why shadow crossing combinators have such a mindshare in this issue and keep coming up.

As the person who said this, please let me reemphasize the word combinators and the previous elaboration that to me .foo .bar inside a shadow tree does matter.

@robglidden
Copy link

@mirisuzanne:

Still, I agree that layers do seem essential to the solution here. We're concerned with both which styles apply, and also how they cascade. So we absolutely want ways to put sheets or scopes (or whatever) into layers. That would be similar to the way we can import entire CSS files into layers. And it would be useful for component authors to expose layers, for more nuanced cascading between page and shadow-dom.

We should be able to combine these features, but we should not conflate these features.

(Github Issues sadly don't have a reply-to-comment button, I am talking about your whole comment, in this thread (original comment, shadow layer proposal, layers access @scope, syntax)

I share your optimism that combining without conflating is possible.

The shadow layer proposal is in two parts: declarative shadow DOM, and syntax(s?) to access page styles (the ones that are already on the page outside the shadow tree, not ones that can be pulled in through a link or import tag which already work in shadow trees).

Declarative shadow DOM solves an even more fundamental "mistake" than you reference. The declarative subtree problem in markup languages predates even HTML itself. Perhaps the old object tag debacle delayed an HTML solution by a decade, but the problem was so long and so well known when modern shadow DOM was introduced in 2011 that providing a Javascript-only shadow tree to me was just a huge mistake.

But that is behind. Anyone using or authoring a web component (or designing a shadow tree) now can use declarative shadow DOM. So to me of course any proposal now would use it to "pull" in page styles. And any syntax would therefore of necessity be declarative and work from inside, not outside, the shadow tree.

To write a POC, I had to pick only declarative syntaxes that are polyfillable and spec-defensible. I even explored how to synchronize multiple different syntaxes.

I am a little surprised one of those syntaxes is so intuitive that it is even used to point out what you can't do with @layer today anywhere else on a page.

So please, any syntax is fine by me, but I do think a polyfillable syntax would be much preferable overall.

All syntaxes, even on a style tag, like all solutions will have tradeoffs, to me it is a question of picking the best tradeoffs overall.

Yes, to me a solution would be similar to the way we can import entire CSS files into layers and into shadow trees now -- I was doing that into declarative shadow trees, but got annoyed reimporting resets.css files and relinking components.css files.

And since to me @scope inside a shadow tree is a good thing, @scope inside a shadow tree could also fix shortcomings in ::slotted (the bottom side of encapsulation).

@robglidden
Copy link

Updating the shadow layers proposal, readme, and user story tests in response to:

@knowler Allow layers to use different names in different contexts #10091

@mayank99 interweaving priorities of inner and outer context layers

@mayank99 referencing and ordering of unlayered layers (?), see Allow authors to explicitly place unlayered styles in the cascade layer order #6323

@mirisuzanne "So we absolutely want ways to put sheets or scopes (or whatever) into layers."

@mirisuzanne referencing named @sheet groups ("If we just want easy access to named chunks of CSS, we have @sheet") (see Multiple stylesheets per file #5629)

The updated syntaxes below are polyfillable (and thus require no change to how @layer currently works), as demonstrated in the user story tests.

Adding a layer renaming syntax:

  • inherit.reset.as.shadowreset: uses an as keyword to assign a different layer name to an outer context layer when inherited into a shadow tree's inner context.
//Inherit resets layer as higher priority renamed layer:
@layer inherit.resets.as.shadowresets, resets, shadowresets;

//Inherit resets layer as lower priority renamed layer:
@layer inherit.resets.as.shadowresets, shadowresets, resets;

//Interweave priorities of outer and inner context layers:
@layer inherit.A.as.outerA, inherit.B.as.outerB, outerA, A, B, outerB;

The .as. items can appear in any order in the @layer statement rule, because the renamed layer name must also appear in the @layer statement rule.

Inherit @scope page style

The user story "Inherit @scope page style" that brings an outer context @scope rule into a shadow tree was already handled in the original shadow layers proposal, but now works with layer renaming. See user story test 17 "Inherit @scope page style".

Adding a group referencing mechanism

  • inherit.unlayered: inherits outer context unlayerd page styles
  • inherit.layered: inherits outer context layered page styles
//Inherit unlayered page styles as layer named unlayered:
@layer inherit.unlayered.as.unlayered, unlayered;

//Interweave priorities of outer layered and unlayered styles:
@layer inherit.layered.as.layered, inherit.unlayered.as.unlayered, layered, A, B, unlayered;

//Inherit all outer page styles:
@layer inherit.layered.as.layered, inherit.unlayered.as.unlayered, layered, unlayered;

@sheet

  • inherit.sheet.sheetname: inherits outer context @sheet as an inner context layer

At-rule support detection in @supports is not available, so @sheet would not be polyfillable (see Multiple stylesheets per file #5629), so the shadow layers POC does not implement @sheet. However, @layer is widely deployed so polyfillability is not needed for it, and @layer also provides the essential priority mechanism.

Nonetheless, an @sheet supporting syntax would be possible:

//Inherit named @sheet as layer
@layer inherit.sheet.mysheet.as.mysheet, mysheet;

@DarkWiiPlayer
Copy link

I don't know where that syntax even comes from, but it seems extremely out of place. Something like @layer inherit(outside-layer-name as inside-layer-name) would feel a lot more CSS than the weird dots that make it look like classes or some sort of indexing operation.

@knowler
Copy link

knowler commented Mar 27, 2024

@DarkWiiPlayer I made a proposal for a standalone CSS feature which would use a syntax like that. I believe the dots syntax that @robglidden included above is specific to the shadow layers proof of concept.

In general, I think it would be helpful to keep proof-of-concept specific syntax out of this thread as it’s too hard to keep track of all of the proposed syntaxes as well as the proof of concept syntaxes.

@robglidden
Copy link

@knowler:

@DarkWiiPlayer I made a proposal for a standalone CSS feature which would use a syntax like that. I believe the dots syntax that @robglidden included above is specific to the shadow layers proof of concept.

Yes, that's the same issue I referenced above and previously.

I think it is very insightful, thanks for proposing!

@robglidden
Copy link

I don't know where that syntax even comes from, but it seems extremely out of place. Something like @layer inherit(outside-layer-name as inside-layer-name) would feel a lot more CSS than the weird dots that make it look like classes or some sort of indexing operation.

The simpler forms (inherit, inherit.resets) are still there.

But even a prototypical <md-block> web component with page styles could use multiple forms, see user stories:

27 Markdown component with automatic page styles
28 Markdown component with author-defined page layer
29 Markdown component with author-defined lower priority shadow layer
30 Markdown component with author-defined higher priority shadow layer
31 Markdown component with user-selectable page styles
32 Markdown component with page @scope

These directly address the subject of this issue and work with a declarative shadow DOM, a shadow tree created with this.attachShadow(), cascade @layer, and @scope without breaking or requiring change to any of them. And it's polyfillable.

@robglidden
Copy link

In general, I think it would be helpful to keep proof-of-concept specific syntax out of this thread as it’s too hard to keep track of all of the proposed syntaxes as well as the proof of concept syntaxes.

I updated the shadow layers proposal to put both functionality and syntax in the broader context of:

  • proposed specification language
  • proposed CSS working group resolution

See Add an adoptStyles capability in shadowRoots (WICG 909: open-stylable Shadow Roots) #10176.

Table of Contents

  • Background
    • Bringing Page Styles into Shadow Trees
    • New Tools, New Solutions
    • Declarative Shadow DOM
    • Cascade Layers
  • Proposal
    • DOM Interface ShadowRoot
    • CSS Cascading and Inheritance Context
    • Declarative Parse Locations
  • Rationale
  • Web Platform Tests & User Stories
  • Potential Extensions
  • Polyfill
  • Conclusion & Proposed CSS Working Group Resolution

@mayank99
Copy link

mayank99 commented Apr 8, 2024

I've been thinking a lot about open-styleable these past few weeks, and I've come up with a very simple-yet-powerful proposal that tries to avoid introducing too many new concepts. All feedback welcome!

The road to open-styleable

First, lets examine the currently available features that will play an important role in open styling.

The most important one of them is adoptedStyleSheets, which provides a performant way to reference existing stylesheets in multiple places. I think this should form the basis of open-styleable. The crucial constraint here is that styles adopted by a shadow-root are evaluated in the shadow-root context, rather than the host context. This immediately shuts down any ambiguity around things like cross-boundary selectors.

There's an active/upcoming discussion around making document.styleSheets adoptable, which will help with the ergonomics and perhaps also with observing changes and keeping adopted stylesheets in sync with document stylesheets.

It's worth mentioning some other features, but I will put them in disclosures, since this comment is already getting too long.

declarative shadow DOM

DSD greatly lowers the barrier to creating shadow-roots, but at the same time introduces important constraints. Declaratively created shadow-roots are often not tied to custom elements, and they cannot be observed on the client.

adoptedStyleSheets currently do not have a declarative counterpart. The closest equivalent is to include <link> tags in the DSD template. This is a workable solution in theory, especially since browsers will optimize the repeat occurrences of <link> to point to the same stylesheet. However, this tends to be difficult to use in practice, because it requires knowing the stylesheet URL (which component authors may not know about, and even page authors may find difficult to access, depending on their bundler).

@layer

By default, adoptedStyleSheets will cascade after regular styleSheets. All things being equal, this means that outer context (host/page) styles will win over the shadow-root's own styles. Things like specificity and !important can, of course, alter the outcome in practice.

What's more interesting is what this means for cascade layers. Since layers are ordered in the same order that they first appear in, this allows a shadow-root's own styles to define a layer order that will take priority over the outer context's layer order. In other words, a shadow-root has full control over whether its own styles should cascade before or after the host styles. (This would not be possible if the outer layer order appeared first; this is what makes adoptedStyleSheets such a good fit here)

However, for this to work effectively, the shadow-root needs to be aware of the outside layers. To make this easier, @knowler has suggested allowing layers to use different names in different contexts. Also, I've suggested reserving a layer name as an idiomatic way of deprioritizing some styles.

CSS nesting and @scope

Since adoptedStyleSheets are evaluated in the (inner) shadow context, we can use :host() selectors as a way of filtering.

For example, the following style, once adopted into all shadow-roots, will only match my-component.

<head>
  <style>
    :host(my-component) { color: red; }
  </style>
</head>

CSS nesting is interesting here specifically because it provides a nicer authoring experience for repeated selectors.

:host(my-component) {
  & { … }
  button { … }
}

The same thing can also be written using @scope:

@scope(:host(my-component)) {
  :scope { … }
  button { … }
}

@scope is perhaps even more interesting because it allows us to write stylesheets that work only in light DOM or only in shadow DOM or in both.

/* light DOM only */
@scope(:root) { … }

/* all shadow roots */
@scope(:host) { … }

/* only my-component */
@scope(:host(my-component)) { … }

/* light DOM and shadow DOM */
@scope(:root, :host) { … }

(All of these examples work in Chrome today)

The proposal (open-styleable)

At the shadow-root level, provide a way to adopt all styles from the host or from the page. This could be done using a boolean attribute on the DSD template, and an equivalent option on the attachShadow method.

This is very similar to @justinfagnani's original idea, except I've made a clear distinction between host and page.

Example syntax (bikesheddable)

Declarative shadow DOM:

<template shadowrootmode="open" adopthoststyles></template>
<template shadowrootmode="open" adoptpagestyles></template>

Imperative shadow DOM:

this.attachShadow({ mode: "open", adoptHostStyles: true });
this.attachShadow({ mode: "open", adoptPageStyles: true });

Adopting all styles from host is already sufficient for a large number of cases. It is most useful particularly when the same party controls the shadow-root and the host context. The most obvious example is when the page author wants to openly style their own shadow-roots. Another good example is when a component author wants to openly style their nested shadow-roots, while keeping the outer component boundary closed for styling. In both of these cases, the (inner) shadow-root has a lot of trust in its host.

Adopting all styles from page might initially sound like it's already covered, since the page is the first host and nested shadow-roots can eventually access page styles if trickled down properly (similar to CSS inheritance). However, sometimes a nested shadow-root might want page styles but not necessarily want the styles of its host shadow-root. A good example is styles scoped to the :host selector, which are probably not meant for being adopted by nested shadow-roots.

Perhaps the most interesting and useful thing about this idea is that it works with existing stylesheets. This is important because:

  1. It is not always possible to change legacy styles.
  2. Developers do not always have control over how the stylesheets are generated and included on the page.
  3. It is not realistic to ask everyone to rework their entire styling architecture in order to use shadow DOM.

If this idea sounds interesting to you, I've created a proof-of-concept "polyfill" which you can play with and install in your projects.

Filtering (@sheet)

Ever since @rniwa said that filtering is a key design constraint, I've been thinking about how we can incorporate filtering as something that can ship later but still be part of the initial discussion.

Near the beginning of this comment, I showed how nesting and @scope can be used to filter which rules get applied to which shadow roots. This, in combination with open-styleable shadow-roots, may already be sufficient for a good number of use cases in practice. However, if you look closely, this filtering is happening after the stylesheets have been adopted by the shadow-root.

A more proper solution would involve filtering which stylesheets get adopted in the first place. This is where @sheet comes in. This new at-rule would allow us to create named stylesheets. A shadow-root should then be able to specify which named stylesheets it wants to adopt. The same boolean attribute/property used for open-styleable can be reused to accept a list of named stylesheets.

Example syntax (bikesheddable)
<head>
  <style>
    @sheet globals {
      p { color: red; }
    }
    @sheet bootstrap {
      @import "bootstrap.css";
    }
  </style>
</head>

<template shadowrootmode="open" adoptpagestyles="globals bootstrap">
  <p>Red!</p>
</template>

This kind of functionality would open up some very useful opportunities, such as allowing a component library author to distribute a single named stylesheet to style all their components. A component author could even allow the consumer to pass in their own list of sheets that should be adopted by the component's main shadow-root. Any nested shadow-roots can openly adopt all styles from this main shadow-root and they would automatically get access to these named sheets.

Even more styling flexibility

While adopting outside styles into shadow DOM is a necessary addition, I don't think it's the full answer.

We also need:

  • a way to surgically style shadow-roots from outside (e.g. something like the various ideas around ::shadow, /deep/, >>>)
  • more flexibility in :host(), ::slotted() and ::part()
  • HTML modules for sharing arbitrary pieces of markup, including raw <style> tags across shadow-roots

But all of those are separate topics that probably do not belong in this thread, so I won't go into more detail.

@jaredcwhite
Copy link

@mayank99 I really like the adoption aspect of this via @sheet, and I imagine this could be a way to configure a whole third-party design system, aka:

// in an application:
import { designSystemConfig} from "some-design-system"

designSystemConfig.adoptPageStyles("sheet1 sheet2")

import { lotsof, components, here } from "some-design-system"

and in a component:

// within the design system
import { designSystemConfig} from "../config.js"

this.attachShadow({ mode: "open", adoptPageStyles: designSystemConfig.getPageStyles() });

Or of course in server-rendering there could be a mechanism to specify/inject the right values for DSD templates.

@michaelwarren1106
Copy link

michaelwarren1106 commented Apr 9, 2024

@mayank99 i also agree 100% with the shape of this api. It’s minimal new concepts that dovetail nicely with existing concepts. i agree that named sheets are the right granularity for filtering rulesets before the cascade. It also solves @mirisuzanne concerns by not using @layer for filtering.

fyi, here is @justinfagnani proposal for @sheet in the CSSWG

w3c/csswg-drafts#5629

That proposal already accounts for named sheets.

@o-t-w
Copy link

o-t-w commented Apr 26, 2024

Isn't it already possible to share styles between the page and component? The user of your component can write this:

 import stylesheet from '/shared-styles.css' with {type: 'css'};
 document.querySelector('whatever-element').shadowRoot.adoptedStyleSheets.push(stylesheet);

And they can request the same stylesheet in the <head> for the light DOM:

<link rel="stylesheet" href="shared-styles.css">

@justinfagnani
Copy link
Contributor Author

@o-t-w that's only true in very limited situations where the owner of the document knows what components to inject styles to, and the shadow roots are open, and the component doesn't somehow remove anything from adoptedStyleSheets.

And it doesn't work for existing pages that don't have such a script.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests