csscss-selectorsweb-componentcss-variables

Style nested web component where child can be same type as, but needs different styles to, parent


I have developed a HTML web component, with a slot that uses a HTML theme attribute to enable spacing out the child elements (by applying a margin to them). I want to control the size of the spacing using a CSS custom property, --spacing-size, set on the HTML style attribute of each component instance.

class VerticalLayout extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({mode: "open"});
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = 
      `<style>
         :host {
           display: flex;
           flex-direction: column;
         }

         :host([theme~="spacing"]) ::slotted(:not(:first-child)) {
           margin-top: var(--spacing-size);
         }
       </style>
       <slot></slot>`;
  }
}

customElements.define("vertical-layout", VerticalLayout);

I run into problems when I add an instance of my component into the slot of another instance, because my CSS targets slotted elements to give them the margin. Since in this case the slotted element is my component, with a value of --spacing-size meant for its children, it gets the margin meant for its children and not the margin needed inside the parent.

<vertical-layout theme="spacing" style="--spacing-size: 2em;">
  <div>No margin on this, it is the first child</div>
  <!-- I want a 2em margin on the next element (the size  -->
  <!-- from the layout it sits within), but it gets 0.2em -->
  <!-- because --spacing-size has been redefined -->
  <vertical-layout theme="spacing" style="--spacing-size: 0.2em;">
    <div>No margin on this, it is the first child</div>
    <div>0.2em margin above this</div>
  </vertical-layout>
</vertical-layout>

I have created a codepen. See in the codepen I have overcome the issue by adding a second custom property, --parent-size. The codepen illustrates the spacing I expect on the layouts, but is there a clever way to achieve the same behaviour with just one custom property?

See in the codepen, an additional complication is that I am explicitly setting the --spacing-size to a default value in the CSS, to be applied when the theme is turned on but a size is not specified. I suppose this could make it pretty difficult to inherit whatever the value is in the parent...

I feel like the :host-context() selector might be the answer, but I can't quite grasp how I could use it (and, since Safari doesn't support that, I would have to look for another solution anyway).


Solution

  • Took some time to fully understand what you want (and I could be wrong)

    Problem with your: <vertical-layout style="--spacing-size: 2em">, is that the 2em is set on the <vertical-layout> itself (and all its children)

    You want it applied to children only

    You can't do that with CSS in shadowDOM; because that doesn't style slotted content.
    See: ::slotted CSS selector for nested children in shadowDOM slot


    I have changed your HTML and attributes to reflect the margins you want:

    (px notation for better comprehension)

        0px <vertical-layout id="Level1" childmargin="15px">
        15px  <div>child1-1</div>
        15px  <div>child1-2</div>
        15px  <div>child1-3</div>
        15px  <vertical-layout id="Level2" childmargin="10px">
        0px    <div>child2-1</div>
        10px   <div>child2-2</div>
        10px   <vertical-layout id="Level3" childmargin="5px">
        5px      <div>child3-1</div>
        5px      <div>child3-2</div>
        5px      <div>child3-3</div>
               </vertical-layout>
        10px    <div>child2-3</div>
             </vertical-layout>
        15px <div>child1-4</div>
        15px <div>child1-5</div>
            </vertical-layout>
    

    CSS can not read that childmargin value; so JS is required to apply that value to childelements

    As you also don't want to style the first-child...

    The code for the connectedCallback is:

        connectedCallback() {
          let margin = this.getAttribute("childmargin");
          setTimeout(() => {
            let children = [...this.querySelectorAll("*:not(:first-child)")];
            children.forEach(child=>child.style.setProperty("--childmargin", margin));
          });
        }
    

    Notes

    [...this.children].forEach((child,idx)=>{
      if(idx) ....
    };
    

    Because all your <vertical-layout> are in GLOBAL DOM (and get refelected to <slot> elements)

    You style everything in GLOBAL CSS:

      vertical-layout > *:not(:first-child)  {
        margin-top: var(--childmargin);
      }
    

    Then all Web Component code required is:

    customElements.define("vertical-layout", class extends HTMLElement {
      constructor() {
        super()
         .attachShadow({mode:"open"})
         .innerHTML = "<style>:host{display:flex;flex-direction:column}</style><slot></slot>";
      }
      connectedCallback() {
        let margin = this.getAttribute("childmargin");
        setTimeout(() => {
            let children = [...this.querySelectorAll("*:not(:first-child)")];
            children.forEach(child=>child.style.setProperty("--childmargin", margin));
          });
      }
    });
    

    <vertical-layout id="Level1" childmargin="15px">
      <div>child1-1</div>
      <div>child1-2</div>
      <div>child1-3</div>
      <vertical-layout id="Level2" childmargin="10px">
        <div>child2-1</div>
        <div>child2-2</div>
        <vertical-layout id="Level3" childmargin="5px">
          <div>child3-1</div>
          <div>child3-2</div>
          <div>child3-3</div>
        </vertical-layout>
        <div>child2-3</div>
      </vertical-layout>
      <div>child1-4</div>
      <div>child1-5</div>
    </vertical-layout>
    
    <style>
      body {
        font: 12px arial;
      }
      vertical-layout > *:not(:first-child)  {
        font-weight: bold;
        margin-top: var(--childmargin);
      }
      vertical-layout::before {
        content: "<vertical-layout " attr(id) " childmargin=" attr(childmargin);
      }
      vertical-layout > vertical-layout {
        background: lightblue;
        border-top: 4px dashed red;
      }
      vertical-layout > vertical-layout > vertical-layout {
        background: lightcoral;
      }
    </style>
    
    <script>
      customElements.define("vertical-layout", class extends HTMLElement {
        constructor() {
          super()
            .attachShadow({
              mode: "open"
            })
            .innerHTML =
            `<style>
             :host {
               display: flex;
               flex-direction: column;
               background:lightgreen;
               padding-left:20px;
               border:2px solid red;
             }
             ::slotted(*){margin-left:20px}
             :host([childmargin]) ::slotted(:not(:first-child)) {
               color:blue;
             }
           </style>
           &lt;slot>
           <slot></slot>
           &lt;/slot>`;
        }
        connectedCallback() {
          let margin = this.getAttribute("childmargin");
          setTimeout(() => {
            let children = [...this.querySelectorAll("*:not(:first-child)")];
            children.map(child=>{
              child.style.setProperty("--childmargin", margin);
              child.append(` margin-top: ${margin}`);
            })
          });
        }
      });
    
    </script>


    Also look at ::part
    https://developer.mozilla.org/en-US/docs/Web/CSS/::part