Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Overriding externally-defined styles in a web component

I'm taking my first steps into web components without using any third-party libraries, such as Polymer. One of the main selling points is that web component styles are separated from styles defined elsewhere, allowing the component's shadow-DOM to be styled in a sandbox-like environment.

The issue I'm running into is how styles cascade through slotted elements. Since slotted elements are not part of the shadow DOM, they can only be targed with the ::slotted() selector within the component template. This is great, but it makes it almost impossible to guarantee a web component will display correctly in all contexts, since externally-defined styles also apply with undefeatable specificity* to slotted elements.

*besides !important.

This issue can be distilled down to this:

customElements.define("my-nav",
  class extends HTMLElement {
    constructor() {
      super();

      const template = document.querySelector("template#my-nav").content;
      this.attachShadow({ mode: "open" })
        .appendChild(template.cloneNode(true));
    }
  }
);
a {
  color: red; /*  >:(  */
}
<template id="my-nav">
  <style>
    .links-container ::slotted(a) {
      color: lime;
      font-weight: bold;
      margin-right: 20px;
    }
  </style>

  <div class="links-container">
    <slot name="links"></slot>
  </div>
</template>

<p>I want these links to be green:</p>
<my-nav>
  <a href="#" slot="links">Link 1</a>
  <a href="#" slot="links">Link 2</a>
  <a href="#" slot="links">Link 3</a>
</my-nav>

I'm having a hard time understanding the value of this "feature". I either have to specify my links in some other format and create their nodes with JS, or add !important to my color property - which still doesn't guarantee consistency when it comes to literally any other property I haven't defined.

Has this issue been addressed somewhere, or is this easily solved by changing my light DOM structure? I am not sure how else to get a list of links into a slot.

like image 606
Scott Avatar asked Apr 23 '18 15:04

Scott


2 Answers

The <slot> is intentionally designed to allow the outer code to style the content placed into it. This is a great feature when used correctly.

But if you want better control of what shows in the web component then you need to copy cloned copies of the content from this.childNodes into the shadow DOM. Then you have 100% control over the CSS.

OK. You really only have 90% control because the person using your component can still set the style attribute.

customElements.define("my-nav",
  class extends HTMLElement {
    constructor() {
      super();

      const template = document.querySelector("template#my-nav").content;
      this.attachShadow({ mode: "open" })
        .appendChild(template.cloneNode(true));
    }
    
    connectedCallback() {
      var container = this.shadowRoot.querySelector('.links-container');
      var children = this.childNodes;
      if (children.length > 0 && container) {
      
        while(container.firstChild) {
          container.removeChild(container.firstChild);
        }
        
        for (var i = 0; i < children.length; i++) {
          container.appendChild(children[i].cloneNode(true));
        }
      }
    }
  }
);
a {
  color: red;
}
<template id="my-nav">
  <style>
    .links-container a {
      color: lime;
      font-weight: bold;
      margin-right: 20px;
    }
  </style>

  <div class="links-container">
  </div>
</template>

<p>I want these links to be green:</p>
<my-nav>
  <a href="#">Link 1</a>
  <a href="#">Link 2</a>
  <a href="#" style="color: red">Link 3</a>
</my-nav>

As you can see in the example above the third link is still red because we set the style attribute.

If you want to prevent that from happening then you would need to strip the style attribute from the inner content.

customElements.define("my-nav",
  class extends HTMLElement {
    constructor() {
      super();

      const template = document.querySelector("template#my-nav").content;
      this.attachShadow({ mode: "open" })
        .appendChild(template.cloneNode(true));
    }
    
    connectedCallback() {
      var container = this.shadowRoot.querySelector('.links-container');
      var children = this.childNodes;
      if (children.length > 0 && container) {
      
        while(container.firstChild) {
          container.removeChild(container.firstChild);
        }
        
        for (var i = 0; i < children.length; i++) {
          container.appendChild(children[i].cloneNode(true));
        }
        
        container.querySelectorAll('[style]').forEach(el => el.removeAttribute('style'));
      }
    }
  }
);
a {
  color: red;
}
<template id="my-nav">
  <style>
    .links-container a {
      color: lime;
      font-weight: bold;
      margin-right: 20px;
    }
  </style>

  <div class="links-container">
  </div>
</template>

<p>I want these links to be green:</p>
<my-nav>
  <a href="#">Link 1</a>
  <a href="#">Link 2</a>
  <a href="#" style="color: red">Link 3</a>
</my-nav>

I have even created some components that allow unique children that I read in and convert into custom internal nodes.

Think of the <video> tag and its <source> children. Those children don't really render anything, they are just a way of holding data that is used to indicate the source location of the video to be played.

The key here is to understand what <slot> is supposed to be used for and only use it that way without trying to force it to do something it was never intended to do.

BONUS POINTS

Since ConnectedCallback is called every time this node in placed into the DOM you have to be careful to remove anything within the shadow DOM each time or you will duplicate the children over and over.

customElements.define("my-nav",
  class extends HTMLElement {
    constructor() {
      super();

      const template = document.querySelector("template#my-nav").content;
      this.attachShadow({ mode: "open" })
        .appendChild(template.cloneNode(true));
    }
    
    connectedCallback() {
      var container = this.shadowRoot.querySelector('.links-container');
      var children = this.childNodes;
      if (children.length > 0 && container) {
        for (var i = 0; i < children.length; i++) {
          container.appendChild(children[i].cloneNode(true));
        }
      }
    }
  }
);

function reInsert() {
  var el = document.querySelector('my-nav');
  var parent = el.parentNode;
  el.remove();
  parent.appendChild(el);
}

setTimeout(reInsert, 1000);
setTimeout(reInsert, 2000);
a {
  color: red;
}
<template id="my-nav">
  <style>
    .links-container a {
      color: lime;
      font-weight: bold;
      margin-right: 20px;
    }
  </style>

  <div class="links-container">
  </div>
</template>

<p>I want these links to be green:</p>
<my-nav>
  <a href="#">Link 1</a>
  <a href="#">Link 2</a>
  <a href="#" style="color: red">Link 3</a>
</my-nav>

So removing the duplicated nodes is important:

customElements.define("my-nav",
  class extends HTMLElement {
    constructor() {
      super();

      const template = document.querySelector("template#my-nav").content;
      this.attachShadow({ mode: "open" })
        .appendChild(template.cloneNode(true));
    }
    
    connectedCallback() {
      var container = this.shadowRoot.querySelector('.links-container');
      var children = this.childNodes;
      if (children.length > 0 && container) {
        while(container.firstChild) {
          container.removeChild(container.firstChild);
        }
        for (var i = 0; i < children.length; i++) {
          container.appendChild(children[i].cloneNode(true));
        }
      }
    }
  }
);

function reInsert() {
  var el = document.querySelector('my-nav');
  var parent = el.parentNode;
  el.remove();
  parent.appendChild(el);
}

setTimeout(reInsert, 1000);
setTimeout(reInsert, 2000);
a {
  color: red;
}
<template id="my-nav">
  <style>
    .links-container a {
      color: lime;
      font-weight: bold;
      margin-right: 20px;
    }
  </style>

  <div class="links-container">
  </div>
</template>

<p>I want these links to be green:</p>
<my-nav>
  <a href="#">Link 1</a>
  <a href="#">Link 2</a>
  <a href="#" style="color: red">Link 3</a>
</my-nav>
like image 105
Intervalia Avatar answered Oct 15 '22 14:10

Intervalia


You're right, there's no solution other that using !important for every CSS property.

Instead, I would not use <slot> and copy the nodes you need:

customElements.define("my-nav",
  class extends HTMLElement {
    constructor() {
      super();

      const template = document.querySelector("template#my-nav").content;
      this.attachShadow({ mode: "open" })
        .appendChild(template.cloneNode(true));
    }
    
    connectedCallback() {
      var links = this.querySelectorAll( 'a[slot]' )
      var container =  this.shadowRoot.querySelector( '.links-container' )
      links.forEach( l => container.appendChild( l ) )
    }
  }
);
a {
  color: red; /*  >:(  */
}
<template id="my-nav">
  <style>
    .links-container > a {
      color: lime;
      font-weight: bold;
      margin-right: 20px;
    }
  </style>

  <div class="links-container">

  </div>
</template>

<p>I want these links to be green:</p>
<my-nav>
  <a href="#" slot="links">Link 1</a>
  <a href="#" slot="links">Link 2</a>
  <a href="#" slot="links">Link 3</a>
</my-nav>
like image 44
Supersharp Avatar answered Oct 15 '22 15:10

Supersharp