2

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

  • What would be great is if I could combine the `:host`, `:has` and `::slotted` selectors to set the parent size at any component that has another slotted inside it, like so: ```:host([theme~="spacing"]):has(::slotted([theme~="spacing"])){ --parent-spacing-size: var(--spacing-size);}``` Alas that doesn't work. – user2711693 Apr 27 '21 at 14:28
  • I tried, but its too much to take in and understand what it is you want. Can you make smaller snippets? And use that [ < > ] button in the editor to add SO snippets instead of a CodePen. – Danny '365CSI' Engelman Apr 28 '21 at 08:43
  • @Danny'365CSI'Engelman, thanks for looking, I made some edits, is it any clearer? I'm really thinking I just can't do it with one custom property, unless...maybe there is a way to wrap the elements that get added into the slots? I also wonder is it possible for JS running inside the connectedCallback to look into any parent instance (so past the shadow root) and generate the --parent-size variables I added by hand into my codepen? – user2711693 Apr 28 '21 at 20:09
  • Perhaps you could wrap the nested `vertical-layout` with a `div`? The div will get the parent's margin. – Auroratide Apr 29 '21 at 00:40
  • @Auroratide I had the same idea, is there a neat way to do this from the web component? I tried to do it from the backend and one problem I ran into is that, in practice, the components have styles applied (e.g. width: 100%), so my layout looked pretty broken - I think I need some way to copy the styles onto the wrapper too. – user2711693 Apr 29 '21 at 07:31

1 Answers1

4

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

  • You want to specify the margin-top for all CHILDREN (except the first child)
    with: <vertical-layout childmargin="2em">
  • For nested <vertical-layout> the element should have the margin-top of its PARENT container

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

  • * is a bit brutal.. you might want to use a more specific selector if you have loads of child elements; maybe:
[...this.children].forEach((child,idx)=>{
  if(idx) ....
};
  • You are looping all children; could also set the style direct here.. no need for CSS then

  • The setTimeoutis required because all child have not been parsed yet when the connectedCallback fires.

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>
Danny '365CSI' Engelman
  • 16,526
  • 2
  • 32
  • 49
  • Thanks so much for taking the time to do this, sorry I couldn't make the problem easier to understand. You've taught me lots of things here, your effort is *much* appreciated. – user2711693 Apr 30 '21 at 12:51