0

Let's say I want to create a custom element which bolds every other character. For example, <staggered-bold>Hello</staggered-bold> would become "Hello, where the H, l, and o are all bolded.

There's no nth-letter CSS selector, so as far as I know the only way to achieve this effect is to wrap each individual character with a span programmatically. To do that, I have an implementation that clones the text content into the Shadow Dom, so that the child content as specified by the user is not changed.

Unfortunately, by doing so, something like <staggered-bold><span class="red">red</span></staggered-bold> no longer works, because by cloning the content into the Shadow Dom, the class CSS declarations for the wrapped span no longer apply.

Here's a proof-of-concept implementation, showcasing that the red and blue text are in fact not red and blue:

customElements.define('staggered-bold', class extends HTMLElement {
  constructor() {
    super()
    this
      .attachShadow({ mode: 'open' })
      .appendChild(document.getElementById('staggered-bold').content.cloneNode(true))
  }
  
  connectedCallback() {
    // this is a shadow dom element
    const text = this.shadowRoot.getElementById('text')
    
    this.shadowRoot.querySelector('slot').assignedNodes().forEach(node => {
      const content = node.textContent.split('').map((char) => {
        return `<span class="char">${char}</span>`
      }).join('')
      
      const newNode = node.nodeType === Node.TEXT_NODE ? document.createElement('span') : node.cloneNode(true)
      newNode.innerHTML = content
      text.appendChild(newNode)
    })
  }
})
.red { color: red; }
.blue { color: blue; }
<p><staggered-bold>Some text</staggered-bold></p>
<p><staggered-bold><span class="red">Red</span> <span class="blue">Blue</span></staggered-bold></p>

<template id="staggered-bold">
    <style>
    .hide { display: none; }
    .char:nth-child(odd) {
      font-weight: bold;
    }
  </style>
  <span class="hide"><slot></slot></span>
  <span id="text"></span>
</template>

My question is this: what is a good approach to styling each character in a custom element while preserving characteristics provided in the light dom?

One approach I've considered is to manipulate the light dom directly, but I have been avoiding that since I think of the light dom as being in full control of the usage-site (ie. things get complicated very quickly if external JS is manipulating the child of staggered-bold). I'm open to being convinced otherwise, especially there's no real alternative.

I've also considered cloning the content into a named slot so that the original text is preserved, and yet the content continues to live in the light dom. However, I feel like this is still kind of icky for the same reason as the previous paragraph.

Auroratide
  • 2,299
  • 10
  • 15
  • Go for `nth-child(odd)` . If you can only solve this problem with CSS, then you should go for it. – DecPK Sep 04 '21 at 22:17
  • So that works if the characters have already been split into individual spans. If they haven't (as is the case in the `Hello` example), then they have to be divided programmatically. I know how to do that into either the shadow or light dom, but each approach has drawbacks. – Auroratide Sep 04 '21 at 22:41

2 Answers2

2

You can't have the cake and eat it

Global CSS does NOT style shadowDOM (unless you use CSS properties)

  • Easier to not use shadowDOM at all.

  • With an extra safeguard: store the state so the element is properly redrawn on DOM moves.

  • Note: The setTimeout is always required,
    because the connectedCallback fires early on the opening tag;
    there is no parsed (innerHTML) DOM yet at that time.
    So you have to wait for that DOM to be there.

  • If you do need a TEMPLATE and shadowDOM, dump the whole .innerHTML to the shadowRoot; but Global CSS still won't style it. Or <slot> it.
    Do read: ::slotted CSS selector for nested children in shadowDOM slot

  • If you go with <slot> consider the slotchange Event
    but be aware for an endless loop; changing lightDOM will trigger the slotchange Event again

<staggered-bold>Some text</staggered-bold>
<staggered-bold><span class="red">Red</span> <span class="blue">Blue</span></staggered-bold>

<style>
  staggered-bold { display: block; font: 21px Arial }
  staggered-bold .char:nth-child(even) { color: blue }
  staggered-bold .char:nth-child(odd)  { color: red; font-weight: bold }
</style>

<script>
  customElements.define('staggered-bold', class extends HTMLElement {
    connectedCallback() {
      setTimeout(() => { // make sure innerHTML is all parsed
        if (this.saved) this.innerHTML = this.saved;
        else this.saved = this.innerHTML;
        this.stagger();
      })
    }
    stagger(node=this) {
      if (node.children.length) {
        [...node.children].forEach( n => this.stagger(n) )
      } else {
        node.innerHTML = node.textContent
                             .split('')
                             .map(ch => `<span class="char">${ch}</span>`)
                             .join('');
      }
    }
  })
  document.body.append(document.querySelector("staggered-bold"));//move in DOM
</script>
Danny '365CSI' Engelman
  • 16,526
  • 2
  • 32
  • 49
0

In the end I attempted a strategy I'm calling the mirror node. The idea is the custom element actually creates an adjacent node within which the split characters are placed.

  • The original node remains exactly as specified by the user, but is hidden from view
  • The mirror node actually displays the staggered bold text

The below implementation is incomplete, but gets the idea across:

class StaggeredBoldMirror extends HTMLElement {
  constructor() {
    super()
  }
}

customElements.define('staggered-bold', class extends HTMLElement {
  constructor() {
    super()
    this
      .attachShadow({ mode: 'open' })
      .appendChild(document.getElementById('staggered-bold').content.cloneNode(true))
  }
  
  connectedCallback() {
    setTimeout(() => {
      const mirror = new StaggeredBoldMirror()
      mirror.innerHTML = this.divideIntoCharacters()
      this.parentNode.insertBefore(mirror, this)
    })
  }
  
  divideIntoCharacters = (node = this) => {
    return [...node.childNodes].map(n => {
      if (n.nodeType === Node.TEXT_NODE) {
        return n.textContent
          .split('')
          .map(ch => `<span class="char">${ch}</span>`)
          .join('')
      } else {
        const nn = n.cloneNode(false)
        nn.innerHTML = this.divideIntoCharacters(n)
        return nn.outerHTML
      }
    }).join('')
  }
})

customElements.define('staggered-bold-mirror', StaggeredBoldMirror)
.red {
  color: red;
}

.blue {
  color: blue;
}

staggered-bold-mirror .char:nth-child(odd) {
  font-weight: bold;
}
<p><staggered-bold>Some text</staggered-bold></p>
<p><staggered-bold><span class="red">Red</span> <span class="blue">Blue</span></staggered-bold></p>

<template id="staggered-bold">
  <style>
    .hide { display: none; }
  </style>
  <span class="hide"><slot></slot></span>
</template>

The vanilla component can be outfitted with a slotchange listener in order to rebuild its mirror whenever its inner content changes. The disconnectedCallback method can also ensure that when one node is removed, the other is too.

Of course, there are downsides to this approach, such has potentially having to also mirror events and the fact that it still manipulates the light dom.

Depending on the use case, either this or Danny's answer works.

Auroratide
  • 2,299
  • 10
  • 15