vue.jsweb-componentvue-cli-3

How to properly use slot inside of vue js web component and apply styles


I have come across an issue where the implementation of slots in a webcomponent is not functioning as expected. My understanding of Web Components, Custom Elements and Slots is that elements rendered in a slot should inherit their style from the document and not the Shadow DOM however the element in the slot is actually being added to the Shadow DOM and therefore ignoring the global styles. I have created the following example to illustrate the issue that I am having.

shared-ui

This is a Vue application that is compiled to web components using the cli (--target wc --name shared-ui ./src/components/*.vue)

CollapseComponent.vue
<template>
    <div :class="[$style.collapsableComponent]">
        <div :class="[$style.collapsableHeader]" @click="onHeaderClick" :title="title">
            <span>{{ title }}</span> 
        </div>
        <div :class="[$style.collapsableBody]" v-if="expanded">
            <slot name="body-content"></slot>
        </div>
    </div>
</template>

<script lang="ts">
    import { Vue, Component, Prop } from 'vue-property-decorator'

    @Component({})
    export default class CollapsableComponent extends Vue {
        @Prop({ default: "" })
        title!: string;

        @Prop({default: false})
        startExpanded!: boolean;

        private expanded: boolean = false;

        constructor() {
            super();
            this.expanded = this.startExpanded;
        }

        get isVisible(): boolean {
            return this.expanded;
        }

        onHeaderClick(): void {
            this.toggle();
        }

        public toggle(expand?: boolean): void {
            if(expand === undefined) {
                this.expanded = !this.expanded;
            }
            else {
                this.expanded = expand;
            }
            this.$emit(this.expanded? 'expand' : 'collapse');
        }

        public expand() {
            this.expanded = true;

        }

        public collapse() {
            this.expanded = false;
        }
    }
</script>

<style module>
    :host {
        display: block;
    }

    .collapsableComponent {
        background-color: white;
    }

    .collapsableHeader {
        border: 1px solid grey;
        background: grey;
        height: 35px;
        color: black;
        border-radius: 15px 15px 0 0;
        text-align: left;
        font-weight: bold;
        line-height: 35px;
        font-size: 0.9rem;
        padding-left: 1em;
    }

    .collapsableBody {
        border: 1px solid black;
        border-top: 0;
        border-radius: 0 0 10px 10px;
        padding: 1em;
    }
</style>

shared-ui-consumer

This is a vue application that imports the shared-ui web component using a standard script include file.

App.vue
<template>
  <div id="app">
    <shared-ui title="Test">
      <span class="testClass" slot="body-content">
        Here is some text
      </span>
    </shared-ui>
  </div>
</template>

<script lang="ts">
import 'vue'
import { Component, Vue } from 'vue-property-decorator';

@Component({ })
export default class App extends Vue {

}
</script>

<style lang="scss">
#app {
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}

.testClass{
  color: red;
}
</style>
main.ts
import Vue from "vue";
import App from "./App.vue";

Vue.config.productionTip = false;

// I needed to do this so the web component could reference Vue
(window as any).Vue = Vue;

new Vue({
  render: h => h(App),
}).$mount('#app');

In this example I would expect the content inside the container to have red text however because Vue is cloning the element into the Shadow DOM the .testClass style is being ignored and the text is rendered with a black fill.

How can I apply .testClass to the element inside of my web component?


Solution

  • Ok, so I managed to find a workaround for this that uses native slots and renders the child components correctly in the correct place in the DOM.

    In the mounted event wire up the next tick to replace the innerHtml of your slot container with a new slot. You can get fancy and do some cool replacements for named slots and whatnot but this should suffice for illustrating the workaround.

    shared-ui

    This is a Vue application that is compiled to web components using the cli (--target wc --name shared-ui ./src/components/*.vue)

    CollapseComponent.vue
    <template>
        <div :class="[$style.collapsableComponent]">
            <div :class="[$style.collapsableHeader]" @click="onHeaderClick" :title="title">
                <span>{{ title }}</span> 
            </div>
            <div ref="slotContainer" :class="[$style.collapsableBody]" v-if="expanded">
                <slot></slot>
            </div>
        </div>
    </template>
    
    <script lang="ts">
        import { Vue, Component, Prop } from 'vue-property-decorator'
    
        @Component({})
        export default class CollapsableComponent extends Vue {
            @Prop({ default: "" })
            title!: string;
    
            @Prop({default: false})
            startExpanded!: boolean;
    
            private expanded: boolean = false;
    
            constructor() {
                super();
                this.expanded = this.startExpanded;
            }
    
            get isVisible(): boolean {
                return this.expanded;
            }
    
            onHeaderClick(): void {
                this.toggle();
            }
            //This is where the magic is wired up
            mounted(): void {
                this.$nextTick().then(this.fixSlot.bind(this));
            }
            // This is where the magic happens
            fixSlot(): void {
                // remove all the innerHTML that vue has place where the slot should be
                this.$refs.slotContainer.innerHTML = '';
                // replace it with a new slot, if you are using named slot you can just add attributes to the slot
                this.$refs.slotContainer.append(document.createElement('slot'));
            }
    
            public toggle(expand?: boolean): void {
                if(expand === undefined) {
                    this.expanded = !this.expanded;
                }
                else {
                    this.expanded = expand;
                }
                this.$emit(this.expanded? 'expand' : 'collapse');
            }
    
            public expand() {
                this.expanded = true;
    
            }
    
            public collapse() {
                this.expanded = false;
            }
        }
    </script>
    
    <style module>
        :host {
            display: block;
        }
    
        .collapsableComponent {
            background-color: white;
        }
    
        .collapsableHeader {
            border: 1px solid grey;
            background: grey;
            height: 35px;
            color: black;
            border-radius: 15px 15px 0 0;
            text-align: left;
            font-weight: bold;
            line-height: 35px;
            font-size: 0.9rem;
            padding-left: 1em;
        }
    
        .collapsableBody {
            border: 1px solid black;
            border-top: 0;
            border-radius: 0 0 10px 10px;
            padding: 1em;
        }
    </style>
    

    shared-ui-consumer

    This is a vue application that imports the shared-ui web component using a standard script include file.

    App.vue
    <template>
      <div id="app">
        <shared-ui title="Test">
          <span class="testClass" slot="body-content">
            Here is some text
          </span>
        </shared-ui>
      </div>
    </template>
    
    <script lang="ts">
    import 'vue'
    import { Component, Vue } from 'vue-property-decorator';
    
    @Component({ })
    export default class App extends Vue {
    
    }
    </script>
    
    <style lang="scss">
    #app {
      font-family: 'Avenir', Helvetica, Arial, sans-serif;
      -webkit-font-smoothing: antialiased;
      -moz-osx-font-smoothing: grayscale;
      text-align: center;
      color: #2c3e50;
      margin-top: 60px;
    }
    
    .testClass{
      color: red;
    }
    </style>
    
    main.ts
    import Vue from "vue";
    import App from "./App.vue";
    
    Vue.config.productionTip = false;
    
    // I needed to do this so the web component could reference Vue
    (window as any).Vue = Vue;
    
    new Vue({
      render: h => h(App),
    }).$mount('#app');