Search code examples
javascriptvue.jsvue-teleport

Vue: Teleport inheritAttrs false not working


I'm trying to create a BaseOverlay component that basically teleports its content to a certain area of my application. It works just fine except there's an issue when using it with v-show... I think because my component's root is a Teleport that v-show won't work because Teleport is a template.

I figured I could then use inheritAttrs: false and v-bind="$attrs" on the inner content... this throws a warning from Vue saying Runtime directive used on component with non-element root node. The directives will not function as intended. It results in v-show not working on MyComponent, but v-if does work.

Any clues as to what I'm doing wrong?

Example

App.vue

<script setup>
import MyComponent from "./MyComponent.vue";
import {ref} from "vue";
  
const showOverlay = ref(false);
function onClickButton() {
  showOverlay.value = !showOverlay.value;
}
</script>

<template>
  <button @click="onClickButton">
    Toggle Showing
  </button>
  <div id="overlays" />
  <div>
    Hello World
  </div>
  <MyComponent v-show="showOverlay" text="Doesn't work" />
  <MyComponent v-if="showOverlay" text="Works" />
</template>

BaseOverlay.vue

<template>
  <Teleport to="#overlays">
    <div
      class="overlay-container"
      v-bind="$attrs"
    >
      <slot />
    </div>
  </Teleport>
</template>

<script>
export default {
  name: "BaseOverlay",
  inheritAttrs: false,
};
</script>

MyComponent.vue

<template>
    <BaseOverlay>
    {{text}}
  </BaseOverlay>
</template>

<script>
import BaseOverlay from "./BaseOverlay.vue";

export default {
  name: "MyComponent",
  components: {
    BaseOverlay
  },
  props: {
    text: {
      type: String,
      default: ""
    }
  }
}
</script>

Solution

  • Wanted to follow-up on this. I started running into a lot of other issues with Teleport, like the inability to use it as a root element in a component (like a Dialog component because I want to teleport all dialogs to a certain area of the application), and some other strange issues with KeepAlive.

    I ended up rolling my own WebComponent and using that instead. I have an OverlayManager WebComponent that is used within the BaseOverlay component, and every time a BaseOverlay is mounted, it adds itself to the OverlayManager.

    Example

    OverlayManager.js

    export class OverlayManager extends HTMLElement {
      constructor() {
        super();
        this.classList.add("absolute", "top-100", "left-0")
        document.body.appendChild(this);
      }
      
      add(element) {
        this.appendChild(element);
      }
      
      remove(element) {
        this.removeChild(element);
      }
    }
    
    customElements.define("overlay-manager", OverlayManager);
    

    BaseOverlay.vue

    <template>
      <div class="overlay-container" ref="rootEl">
        <slot />
      </div>
    </template>
    
    <script>
    import {ref, onMounted, onBeforeUnmount, inject} from "vue";
    
      export default {
      name: "BaseOverlay",
      setup() {
        const rootEl = ref(null);
        const OverlayManager = inject("OverlayManager");
        onMounted(() => {
          OverlayManager.add(rootEl.value);
        });
        onBeforeUnmount(() => {
          OverlayManager.remove(rootEl.value);
        });
        return {
          rootEl
        }
      }
    };
    </script>
    

    App.vue

    <script setup>
    import {OverlayManager} from "./OverlayManager.js";
    import MyComponent from "./MyComponent.vue";
    import {ref, provide} from "vue";
      
    const manager = new OverlayManager();
    provide("OverlayManager", manager);
      
    const showOverlay = ref(false);
    function onClickButton() {
      showOverlay.value = !showOverlay.value;
    }
    </script>
    
    <template>
      <button @click="onClickButton">
        Toggle Showing
      </button>
      <div>
        Hello World
      </div>
      <MyComponent v-show="showOverlay" text="Now Works" />
      <MyComponent v-if="showOverlay" text="Works" />
    </template>
    
    <style>
      .absolute {
        position: absolute;
      }
      .top-100 {
        top: 100px;
      }
      .left-0 {
        left: 0;
      }
    </style>
    

    This behaves exactly how I need it, and I don't have to deal with the quirks that Teleport introduces, and it allows me to have a singleton that is in charge of all of my overlays. The other benefit is that I have access to the parent of where BaseOverlay is initially added in the HTML (not where it's moved). Honestly not sure if this is a good practice, but I'm chuffed at how cute this is and how well Vue integrates with it.