Re: [WICG/webcomponents] "open-stylable" Shadow Roots (#909)

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`](https://developer.mozilla.org/en-US/docs/Web/API/Document/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](https://drafts.csswg.org/css-cascade-5/#cascade-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](https://github.com/w3c/csswg-drafts/issues/10013), 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.

<details>
<summary>declarative shadow DOM</summary>

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

</details>


<details>
<summary><code>@layer</code></summary>

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](https://github.com/w3c/csswg-drafts/issues/10091). Also, I've suggested [reserving a layer name as an idiomatic way of deprioritizing some styles](https://github.com/w3c/csswg-drafts/issues/10094).

</details>

<details>
<summary>CSS nesting and <code>@scope</code></summary>

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

```html
<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.

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

The same thing can also be written using `@scope`:

```css
@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.

```css
/* 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)

</details>

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

<details><summary>Example syntax (bikesheddable)</summary>

Declarative shadow DOM:
```html
<template shadowrootmode="open" adopthoststyles>…</template>
```
```html
<template shadowrootmode="open" adoptpagestyles>…</template>
```

Imperative shadow DOM:
```js
this.attachShadow({ mode: "open", adoptHostStyles: true });
```
```js
this.attachShadow({ mode: "open", adoptPageStyles: true });
```

</details> 

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"](https://github.com/mayank99/open-styleable) 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`](https://github.com/w3c/csswg-drafts/issues/5629) 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.

<details><summary>Example syntax (bikesheddable)</summary>


```html
<head>
  <style>
    @sheet globals {
      p { color: red; }
    }
    @sheet bootstrap {
      @import "bootstrap.css";
    }
  </style>
</head>

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

</details>

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. The nested shadow-roots can openly adopt all styles from this main shadow-root and it 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. 

-- 
Reply to this email directly or view it on GitHub:
https://github.com/WICG/webcomponents/issues/909#issuecomment-2042059261
You are receiving this because you are subscribed to this thread.

Message ID: <WICG/webcomponents/issues/909/2042059261@github.com>

Received on Monday, 8 April 2024 07:36:57 UTC