Vue: Teleport inheritAttrs false not working

1k Views Asked by At

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>
2

There are 2 best solutions below

0
On BEST ANSWER

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.

1
On

I would consider moving the modal/overlay dependency out of the component and into the app composition to make it more reusable.

Note the isMounted check check - this is to add a delay if the outlet containers have not yet been defined. You may need to add additional handling if your outlets are not pressent on mount e.g. <div id="outlet" v-if="false">

const {
  createApp,
  defineComponent,
  ref,
  onMounted
} = Vue


// 
// Modal 
// 
const MyModal = defineComponent({
  name: 'MyModal',
  props: {
    to: {
      type: String,
      required: true
    },
    show: Boolean
  },
  setup(){
    const isMounted = ref(false);
    onMounted(()=> isMounted.value = true )
    return { isMounted }
  },
  template: `
    <teleport :to="to" v-if="isMounted">
      <div class="overlay-container" v-show="show">
        <slot />
      </div>
    </teleport>
  `
})


// 
// Component 
// 
const MyComp = defineComponent({
  name: 'MyComp',
  template: `<div>Component</div>`
})




// 
// App 
// 
const MyApp = defineComponent({
  name: 'MyApp',
  components: {
    MyModal,
    MyComp
  },
  setup() {
    const modalShow = ref(false);
    const modalOutlet = ref('#outletOne');

    const toggleModalShow = () => {
      modalShow.value = !modalShow.value
    }
    const toggleModalOutlet = () => {
      modalOutlet.value = modalOutlet.value == '#outletOne' 
        ? '#outletTwo' 
        : '#outletOne'
    }
    return {
      toggleModalShow,
      modalShow,
      toggleModalOutlet,
      modalOutlet,
    }
  },
  template: `
    <div>
      <button @click="toggleModalShow">{{ modalShow ? 'Hide' : 'Show' }} Modal</button>
      <button @click="toggleModalOutlet">Toggle Modal Outlet {{ modalOutlet }} </button>    
    </div>
 

    <MyModal :to="modalOutlet" :show="modalShow">
      <MyComp /> 
    </MyModal>

    <div id="outletOne">
      <h2>Outlet One</h2>
      <!-- outlet one -->
    </div>

    <div id="outletTwo">
      <h2>Outlet Two</h2>   
      <!-- outlet two -->
    </div>

  `
})



//
// Assemble
// 
const app = createApp(MyApp)
app.mount('body')
/* just some styling */
#outletOne { color: tomato; }
#outletTwo { color: olive; }
h2 { margin: 0; }
[id*=outlet]{ display: inline-flex; flex-direction: column; padding: 1rem; }
button { margin: 1rem; }
<script src="https://unpkg.com/[email protected]/dist/vue.global.js"></script>