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

idea: Allow light DOM to delegate styles/behavior to shadow DOM #883

Open
calebdwilliams opened this issue Jun 22, 2020 · 15 comments
Open

Comments

@calebdwilliams
Copy link

I have an idea that is tangentially related to the "allow complex selectors in ::slotted: and related issues (as referenced in #745, #881 and some others), but I felt the concept was different enough to warrant its own post (if the community disagrees, I'm happy to delete this issue and move it to one of those other threads).

I would like an API that is an inversion of the ::part API (I don't have a good name for it right now), but one where slotted content (even if it is nested within a DOM structure) could defer its styling to the shadow root's styles (like a dialog styling certain buttons to look a specific way but keeping them visible to the light DOM for integration testing, removal, etc.).

This same API could potentially be used to allow custom elements to control the behaviors attached to certain slotted elements (again, a dialog might be able to say a slotted button with a certain attribute might act as a close button).

This is different from previous proposals in that all content within the light DOM isn't available to the shadow root, but only those elements the light DOM author chooses to defer to the shadow root..

@rniwa
Copy link
Collaborator

rniwa commented Jun 26, 2020

What does "defer its styling to the shadow root's styles" mean? How does shadow root specifies styles on those elements? Also, are you talking about assigned nodes or any descendent of assigned nodes? Because if it's latter, then there is a problem with ambiguity. A node can have N ancestor nodes each of which can have its own shadow root.

@calebdwilliams
Copy link
Author

I am imagining the latter and had assumed that more local shadow roots would have higher specificity (that is, lower in the cascade).

@trusktr
Copy link
Contributor

trusktr commented Jul 10, 2020

I'm not clear what is meant here. Can you provide code examples (with bikesheddable naming)?

@calebdwilliams
Copy link
Author

Sure thing, what I'm thinking is that a light DOM author might want to give control to part of its markup to some parent. Imagine this being like a design system implementation:

const sheet = new CSSStyleSheet();
sheet.replace(`
  ::control(ds-input) {
    border: 2px solid tomato;
    background: gainsboro;
  }
`);
class DesignSystem extends HTMLElement {
  constructor() {
    super();
    const root = this.attachShadow({ mode: 'open' });
    root.adoptedStyleSheets = [sheet];
    root.innerHTML = `<slot></slot>`;
    // other stuff maybe
  }
}

customElements.define('x-ds', DesignSystem);

then in the HTML

<x-ds>
  <h1>Whatever</h1>

  <form>
    <label for="whatever">
    <input id="whatever" name="whatever" control="ds-input">
  </form>
</x-ds>

That's a really simple example, but it illustrates the idea. Let's make it a bit more concrete, imagine you're defining a specialized dialog component that your users might have a need to control the close button but still want it to be styled by the shadow host:

const sheet = new CSSStyleSheet();
sheet.replace(`
  ::control(close-btn) {
    background: url(someSVGThatLooksLikeAnX) no-repeat;
  }
`);

class Dialog extends HTMLElement {
  constructor() {
    super();
    const root = this.attachShadow({ mode: 'open' });
    root.adoptedStyleSheets = [sheet];
    root.innerHTML = `<slot></slot>`;

    if (this.controls['close-btn']) {
      this.controls['close-btn'].addEventListener('click', this.close.bind(this));
    }
  }

  close() { /* magic */ }
}

Then there are two uses of the above component:

  1. A confirmation/decision dialog where the close button might not be present because the user needs to make a decision
  2. An informational dialog that can be dismissed by clicking an optional button.

Let's take scenario two further, say our dialog also has special heading styles, it doesn't matter how deeply nested the headings might be in the DOM structure, an API like this could allow the shadow host to style those and still not break encapsulation because the child has opted in to the change rather than having it forced.

<x-dialog>
  <heading>
    <h1 control="dialog-h1">Hello world</h1>
    <button control="close-btn" aria-label="close" onclick="someCallback"></button>
  </heading>
  <section>
    <h2 control="dialog-h2">How are you doing?</h2>
    <p>Lorem ipsum dolor sit amet</p>
  </section>
</x-dialog>

@trusktr
Copy link
Contributor

trusktr commented Jul 11, 2020

Ah

Sure thing, what I'm thinking is that a light DOM author might want to give control to part of its markup to some parent. Imagine this being like a design system implementation:

// ... elided ...
customElements.define('x-ds', DesignSystem);

then in the HTML

<x-ds>
    ...
    <input id="whatever" name="whatever" control="ds-input">
</x-ds>

Hah! Funny! That's very similar to what I wanted to do and asked about on StackOverflow yesterday: https://stackoverflow.com/questions/62842418

The example from that question is

<div>
  <p><span>test</span></p>
  <p><span>test</span></p>
  <p><span>test</span></p>
  <p><span>test</span></p>
  <p><span>test</span></p>
</div>

<script>
const d = document.querySelector('div')
const r = d.attachShadow({mode: 'open'})

r.innerHTML = `
  <style>
    /* This doesn't work as I was hoping: */
    ::slotted(p) span {
      border: 1px solid deeppink;
    }
    
    /* This doesn't work (and I wouldn't expect it to), but I tried it anyways: */
    ::slotted(span) {
      border: 1px solid deeppink;
    }
    
    /* This doesn't work either: */
    :host span {
      border: 1px solid deeppink;
    }
    
    /* This works, but not what I'm trying to do. */
    ::slotted(p) {
      background: #f9f9f9
    }
  </style>
  <slot></slot>
`
</script>

But you can also imagine how it would work with a custom element that creates its own ShadowRoot.

I think I like the ::slotted(*) .foo method that I attempted in my SO question (f.e. in your last example it may be ::slotted(*) .close-btn) because it doesn't add a new pseudo element in CSS (though honestly I don't know that "pseudo element" is even a good description of what slotted is) and doesn't add a new attribute in the HTML (the control attribute in your example).

Here's what your example would look like if what I was asking about worked (as one would intuitively expect it to):

const sheet = new CSSStyleSheet();
sheet.replace(`
  ::slotted(*) .ds-input {
    border: 2px solid tomato;
    background: gainsboro;
  }
`);
class DesignSystem extends HTMLElement {
  constructor() {
    super();
    const root = this.attachShadow({ mode: 'open' });
    root.adoptedStyleSheets = [sheet];
    root.innerHTML = `<slot></slot>`;
    // other stuff maybe
  }
}

customElements.define('x-ds', DesignSystem);

and the user would write

<x-ds>
  <h1>Whatever</h1>

  <form>
    <label for="whatever">
    <input id="whatever" name="whatever" class="ds-input">
  </form>
</x-ds>

This is interesting because the user can write familiar markup using class attributes like they already do.

What I find unintuitive is why ::slotted(*) .ds-input does not work. One would expect that ::slotted(.foo) targets some set of elements with class foo, and therefore one would expect that ::slotted(.foo) .bar would target any elements with class bar that are descendants of the elements targeted by ::slotted(.foo).

Your idea with this.controls['close-btn'] would be convenient, but I believe this should also be achieved with the shadow root like follows (extending from my ::slotted selector idea):

this.shadowRoot.querySelector('::slotted(*) .close-btn').addEventListener('click', this.close.bind(this));

I think these things should be on shadowRoot for encapsulation. For example, your idea would be

this.shadowRoot.controls['close-btn'].addEventListener('click', this.close.bind(this));

This is especially important because if the shadow root mode is closed, then someone could still run a querySelector or access controls on the custom element. I think it's good to just keep it on shadowRoot.

ShadowRoot instances already have the querySelector method, so we could allow it to query for things like ::slotted(.foo) .bar without adding any new properties.

@trusktr
Copy link
Contributor

trusktr commented Jul 11, 2020

I forgot that we can programmatically select nodes with this.shadowRoot.querySelector('slot').assignedNodes(...)[0].querySelector('.bar') (or more complicated with querySelectorAll or looping on assignedNodes) to get the descendants of the distributed nodes, but this is not so convenient compared to this.shadowRoot.querySelector('::slotted([slot=foo]) .bar'). Note that I used [slot=foo] to target only nodes distributed to a certain slot.

Although we can currently select nodes using that assignedNodes-with-querySelector technique, we still can not target them with CSS. I think it'd be great for ::slotted(.foo) .bar to do what it seems it would intuitively do.

Your last end-user example would be as follows if the custom element could rely on ::slotted() doing the intuitive thing:

<x-dialog>
  <heading>
    <h1 class="dialog-h1">Hello world</h1>
    <button class="close-btn" aria-label="close" onclick="someCallback"></button>
  </heading>
  <section>
    <h2 class="dialog-h2">How are you doing?</h2>
    <p>Lorem ipsum dolor sit amet</p>
  </section>
</x-dialog>

@trusktr
Copy link
Contributor

trusktr commented Jul 12, 2020

are you talking about assigned nodes or any descendent of assigned nodes?

@rniwa Now that @calebdwilliams cleared it up with examples, he is referring to styling descendants of assigned nodes. We both proposed a way to do it, and also ways to make it easier to select those elements.

if it's latter, then there is a problem with ambiguity. A node can have N ancestor nodes each of which can have its own shadow root.

@rniwa what did you mean by that? And now that the examples are clear, does that concern still hold?

I would imagine that the styling (and selection) would only apply to elements in the custom element's light tree, and does not penetrate into any shadow roots of that light tree.

However I can imagine no-penetration being undesirable for certain cases. For this I think we can incorporate the existing ::part selector. For example the selector ::slotted(.foo) .bar::part(lorem).

If we improve ::slotted to allow more targeting like above examples within CSS, it would be nice to allow APIs like querySelector to also be able to select those things.

Perhaps querySelector would only return results when called on elements or ShadowRoots that have descendant <slot> elements within their light tree and those slot elements have assigned nodes that match.

For example,

  • this.shadowRoot.querySelector('::slotted(*) .foo'): Returns all elements with class foo that are descendants of any elements slotted into <slot> elements within this.shadowRoot.
  • this.shadowRoot.children[0].querySelector('::slotted(*) .foo::part(bar)'): Returns all the elements that are ::part(bar) within custom elements that have class foo (there could be multiple custom elements with class foo, so we can select multiple elements that are ::part(bar)), where the custom elements are descendants of any elements slotted into <slot> elements within this.shadowRoot.children[0].
  • Of course any combinators like root.querySelector('::slotted(*) > ul > li'), querySelector('::slotted(.foo) + p') (even if the <p> is distributed to a different slot), etc, would work (same in CSS)

It seems to me like this would be great and super useful.

@Danny-Engelman
Copy link

Danny-Engelman commented Jul 12, 2020

A reason why ::slotted became a simple selector is performance
See:

::slotted(.foo) + P will never work because: There is no <P> -IN- shadowDOM

From Google Developer Documentation:

Conceptually, distributed nodes can seem a bit bizarre.
Slots don't physically move DOM; they render it at another location inside the shadow DOM.

I prefer to teach <slot> content is reflected to shadowDOM.
That means you can style the .foo skin with :slotted(.foo), but not the content inside.
Content remains hidden! in lightDOM, so you style it in the same DOM the lightDOM is in.
Any style changes will immediately reflect to the <slot>

<div>
  <p><span>test</span></p>
  <p><span>test</span></p>
</div>

<script>
  document.querySelector('div').attachShadow({mode: 'open'}).innerHTML = `
  <style>
    /* This doesn't work as I was hoping: */
    ::slotted(p) span {
      border: 1px solid deeppink;
    }
  </style>
  <slot></slot>`
</script>

<style>
    /* This correctly styles the reflected (slotted) content: */
    p span {
      border: 1px solid deeppink;
    }
</style>

Yes, that means you need a container element with shadowDOM, if you do not want to bleed CSS to your main DOM

It also means content from multiple sources can reflect to one slot:

<todo-list>
  <to-do slot=todo><span>One</span></to-do>
  <to-do slot=todo><span>Two</span></to-do>
  <to-do slot=todo><span>Three</span></to-do>
  <to-do slot=done><span>42</span></to-do>
  <to-do slot=todo><span>Five</span></to-do>
</todo-list>

I presume that makes a more advanced ::slotted selector a performance issue because content can now come from anywhere in the AST, not just a sub-tree

More: https://stackoverflow.com/questions/61626493/slotted-css-selector-for-nested-children

@calebdwilliams
Copy link
Author

@Danny-Engelman hence the proposal to introduce an inverted part. I don’t pretend to understand all of the things that go into selector optimization, but I imagine that introducing a new API could fix that but also address the concerns of not breaking encapsulation.

@Danny-Engelman
Copy link

Danny-Engelman commented Jul 12, 2020

hence the proposal to introduce an inverted part.

Key here is slotted content is not a copy, and not the original, but a reflection
(with the limitation only one reflection is possible)

You want the mirror (shadowDOM slot) to style the displayed reflection (slotted lightDOM)

That was tried in V0 of the spec with ::content in 2015

The main takeway from the W3C standards discussions (@hayatoito #331 here and #745 here) is:

@trusktr
Copy link
Contributor

trusktr commented Jul 12, 2020

@Danny-Engelman Thanks for the thoughts!

::slotted(.foo) + P will never work because: There is no <P> -IN- shadowDOM

That selector would do what it says verbatim: select the sibling <p> element of the element targeted by ::slotted(.foo). The sibling <p> element (if any) would be in the same light tree as the element targeted by ::slotted(.foo). I wouldn't expect it to select anything in the shadow DOM as it wouldn't make intuitive sense. (I could imagine a ::shadow-sibling() selector for that.)

If the sibling <p> element is not distributed and therefore not visible, there's no styling applied to it (intuitively). However if the author of a custom element adds a <slot> element later (f.e. due to some user interaction or other feature) and the sibling <p> element thus becomes distributed and therefore visible, then the styling would apply.

It would also be convenient for getting references: root.querySelector('::slotted(.foo) + p')

The code for querySelector('::slotted(*) span') roughly equates to this:

const d = document.querySelector('div')
const r = d.attachShadow({mode: 'open'})

r.innerHTML = `
 <div>
  <slot></slot>
 </div>
`

const start = performance.now()

// Emulate querySelector('::slotted(*) span')

const slots = r.querySelectorAll('slot')
const queryResult = []

// outer loops gets result for `::slotted(*)`
for (let i = 0, l = slots.length, slot, elems; i < l; i += 1) {
  slot = slots[i]
  elems = slot.assignedElements()

  // inner loop gets result for ` span`
  for (let j = 0, l = elems.length; j < l; j += 1) {
    queryResult.push(...elems[j].querySelectorAll('span'))
  }
}

const end = performance.now()
const total = end - start

console.log(`The query took ${total} milliseconds.`)
console.log(...queryResult)

and similar for polyfilling other queries.

Putting it this way, it is easy to imagine how querySelector('::slotted(*) + p') would work, by replacing

    queryResult.push(...elems[j].querySelectorAll('.bar'))

with

    if (elems[j].nextElementSibling?.tagName === 'P')
      queryResult.push(elems[j].nextElementSibling)

A reason why ::slotted became a simple selector is performance

The performance claims are speculative. 🤔 As with any selector, users should save the results to variables rather than executing them over and over (f.e. in an animation).

I presume that makes a more advanced ::slotted selector a performance issue because content can now come from anywhere in the AST, not just a sub-tree

I'm not sure what you mean by "anywhere in the AST, not just a sub-tree", but what I propose is that the postfix combinators run in the light tree (which to me intuitively makes sense).

What I've proposed actually doesn't change the performance of ::slotted(whatever). In the query ::slotted(whatever) > ul li.bar the performance of ::slotted(whatever) is exactly the same as it is today. Then the performance of > ul li.bar (which is an existing feature today) would be the same as if you tacked that onto the result of any other non-slotted selector.


Here's a live example of the above code emulating querySelector('::slotted(*) span'):

https://codepen.io/trusktr/pen/0a71670d0da693a4758bc4f36463452f?editors=1010

On my computer the output is The query took 0.005993600000097104 milliseconds on average.. It ranged from 0.005 to 0.15 when I tried it many times. Probably I had other things going on during the slower ones.

I really don't think there's a performance issue here that is any different than all other existing selectors. I don't think we can really use this claim against allowing combinators postfixed to ::slotted(whatever).

I believe that performance claim is a moot point because we can easily make selectors that will perform a lot worse than ::slotted(whatever) > ul li.bar using the selector features of today.


<style>
    /* This correctly styles the reflected (slotted) content: */
    p span {
      border: 1px solid deeppink;
    }
</style>

There's a problem with this: this is how a user styling can be applied to content that is passed to a custom element. This does not allow a custom element author to perform the styling.

Allowing custom element authors to perform the styling of the light tree (without users having to think about it) is a valid use case and makes a variety of things possible (f.e. user provides markup and the output looks good without the user also having to stick additional styles or stylesheets outside of the custom element).


Key here is slotted content is not a copy, and not the original, but a reflection

I think teaching people using the standard spec terminology is the best way to go, so that the concepts match up across articles and guides.

(with the limitation only one reflection is possible)

But the terminology that you use really has no bearing on the topic; it only describes what currently exists. If the spec were expanded to allow the combinators postfixed after ::slotted selectors, then you would simply update your custom language to reflect the new reality (no pun intended! 😄).


On a similar note, querySelector('::slotted(.foo)') doesn't even work although it is a valid selector. Demo:

https://codepen.io/trusktr/pen/d4a45f1efc9eccc0fdb20164566bada4?editors=1010 (shows null in console)


In my opinion there's really not a good reason why a custom element author should not be able to style deeper elements within slotted elements (and also select them with querySelector).

@trusktr
Copy link
Contributor

trusktr commented Jul 12, 2020

It seems to me that any performance issue is specific to the particular vendor implementation. I really can't imagine why a postfix combinator like > .whatever + .lorem > ipsum is going to perform any worse than it does postfixed to any existing selectors of today.

To those people re-running selectors 60 times per second during an animation: just don't do that! Instead save the result to a variable before the animation.

@calebdwilliams
Copy link
Author

calebdwilliams commented Jul 13, 2020

I'm not really talking about an extension of ::slotted here (although I think @trusktr is right ::slotted(.wrapper) p should select any p child of the slotted .wrapper). While I'd be more than happy to have the functionality @trusktr described, my current belief is that the selection of any arbitrarily-nested element inside a slot is a non-starter based on previous discussions in this repo. What I am hoping is that some sort of opt-in system could solve for any performance and/or concerns around encapsulation.

@trusktr
Copy link
Contributor

trusktr commented Aug 5, 2020

@calebdwilliams Basically what you're proposing is an alternative that (supposedly) avoids perf costs of what I am describing, right?

I asked at #745 (comment) and #889 (comment) about the actual performance metrics. It would be helpful if browser authors could point to some performance evidence. The use case that I showed the snippet of code for in #889 (comment) seems very valid, and I doubt it would cause any performance issue, for example.

Of course, I also don't have performance metrics, but then again I'm not authoring the web engine.

What I can do is try to make a polyfill, and see how that performs.

@calebdwilliams
Copy link
Author

@trusktr thats exactly right. Based on previous discussions in this repo I’m led to believe that complex ::slotted selectors are a non-starter. Hopefully a technique can be worked out to make that more efficient. I think inverting ::part could be the answer.

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

No branches or pull requests

4 participants