Rendering the contents of a slot into multiple places inside of a webcomponent

452 Views Asked by At

The Problem

I am using Angular and its Angular Elements to generate Webcomponents. I want clients to be able to pass these webcomponents icons into slots, so that the client can determine what the icons that get used look like.

However, that means having a template that may need to render the slot-contents into multiple places where the icon is needed. Here an example of what I would like to be able to do:

# my-awesome-webcomponent.html

<ul>
  <ng-container *ngFor="let entry of entries">
    <li> 
      {{entry.name}}
      <slot name="icon-delete"></slot>
    </li>
  </ng-container>
</ul>
<button> 
  <slot name="icon-delete"></slot>
  Delete entire list? 
</button>

# Client using webcomponent - HTML
  <my-awesome-webcomponent>
    <span slot="icon-delete" class="my-icon-css-class"></span>
  </my-awesome-webcomponent>

Which means for a list with n entries inside "my-awesome-webcomponent" I have n+1 occurences of <slot name="icon-delete"></slot> and thus would render <span slot="icon-delete" class="my-icon-css-class"></span> n+1 times.

First attempt

I tried the above and got essentially nowhere.

It is impossible to render the contents of a slot in multiple different places, as per this github issue.. What will happen instead is that the content only gets rendered into the first slot and all later slots are ignored.

Second Attempt

Given that slots themselves appear to not support this, I tried accessing the contents of a slot via JS. I then wanted to just clone whatever content the slot receives (I assumed them to just be Nodes) and append that Node to wherever I need it:

# app-slot-example.ts
@Component({
  selector: 'app-slot-example',
  templateUrl: './slot-example.component.html',
  styleUrls: ['./slot-example.component.scss'],
  encapsulation: ViewEncapsulation.ShadowDom,
})
export class SlotExampleComponent {
  @ViewChild('icon') icon!: ElementRef;
  @ViewChildren('placeholder') placeholders!: QueryList<any>;
  iconHTML?: string;
  
  entries = [
    { name: "bla1" },
    { name: "bla2" },
    { name: "bla3" },
    { name: "bla4" },
  ];
  
  onSlotChange(): void{
    console.log("SLOT CHANGED");
    this.setIconHTML();
  }
  
  private setIconHTML(): void{
    const iconNode: HTMLElement = this.icon.nativeElement.childNodes[0].cloneNode();
    console.log(iconNode);
    
    this.placeholders.forEach(node => {
      const placeholderElement = node.nativeElement;
      placeholderElement.appendChild(iconNode);
    });
  }
}
# app-slot-example.html
<ul>
  <ng-container *ngFor="let entry of entries">
    <li> 
      <span #placeholder></span>
      {{entry.name}}
    </li>
  </ng-container>
</ul>
<button> 
  <span #placeholder></span>
  Delete entire list? 
</button>

<span #icon>
  <slot (slotchange)="onSlotChange()" name="icon-delete"></slot>
</span>

However, that does not work either because there simply is no content inside of a slot. If I am understanding things right slot is literally just a placeholder for the browser to visually render the HTML the client inputs to where the slot is, without actually putting it there.

So what are my options?

1

There are 1 best solutions below

0
Philipp Doerner On

This is essentially not answer I'll accept. I am writing this to note that it's a possibility that exists albeit with many drawbacks. You could simply not use slots and instead pass an HTMLElement to @Input.

Code:

# Webcomponent.ts
export class ExampleComponent implements OnChanges{
  @ViewChildren('placeholder') placeholders!: QueryList<any>;
  
  @Input() iconInput!: HTMLElement;
  
  ngOnChanges(): void{
    console.log("INPUT CHANGED");
    this.setIconHTML();
  }
  
  private setIconHTML(): void{
    this.placeholders.forEach(node => {
      const placeholderElement: HTMLElement = node.nativeElement;
      placeholderElement.innerHTML = ""; //Resets content of placeholderElements
      const iconElementClone = this.iconInput.cloneNode(true);
      placeholderElement.appendChild(iconElementClone);
    });
  }
}
# webcomponent.html
<ul>
  <ng-container *ngFor="let entry of entries">
    <li> 
      <span #placeholder></span>
      {{entry.name}}
    </li>
  </ng-container>
</ul>
<button> 
  <span #placeholder></span>
  Delete entire list? 
</button>
# client.html
...
  <web-slot></web-slot>
  
  <span style="display: none;">
    <span id="icon1"> Potato </span>
    <span id="icon2"> Chips </span>
  </span>
  
  <script>
    const slotElement = document.querySelector('web-slot');
    const iconElement = document.querySelector("#icon1");
    
    setTimeout(() => {
      const newIconElement = document.querySelector('#icon1');
      slotElement.iconInput = newIconElement;
    }, 20); //We wait 20ms to be sure that the webcomponent is ready and responsive
    
    setTimeout(() => {
      const newIconElement = document.querySelector('#icon2');
      slotElement.iconInput = newIconElement;
    }, 2000); //Simulates the client swapping out the icon due to some state-change
  </script>
...

This is a solution, but it has multiple drawbacks.

  1. It is slow in the sense that you can only pass the icon after some time has passed and the webcomponent is ready. That slight delay will be noticeable by users.
  2. It requires fiddling a lot with vanilla JS APIs which Angular usually does its best to abstract away from you, which feels unclean
  3. It is convoluted in the way that you can pass data to the webcomponents. The fact that you need setTimeout is very annoying and boilerplate-y.