Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

<Teleport> built-in component #112

Merged
merged 10 commits into from
Apr 9, 2020
Merged

<Teleport> built-in component #112

merged 10 commits into from
Apr 9, 2020

Conversation

LinusBorg
Copy link
Member

@LinusBorg LinusBorg commented Jan 20, 2020

This RFC introduces a <teleport> component, which allows to move its slot content to another part of the document.

Rendered

@LinusBorg LinusBorg changed the title add portals rfc Portal functionality Jan 20, 2020
@LinusBorg LinusBorg added 3.x This RFC only targets 3.0 and above core labels Jan 20, 2020
@kiaking
Copy link
Member

kiaking commented Jan 21, 2020

Great work! 🎉 Is it worth mentioning about SSR? I know that Portal Vue is suffering with it.

}
});
</script>
<body></body>
Copy link

@potato4d potato4d Jan 21, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Q.] What does this body tag mean?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It means I have to fix a typo and remove the superflous opening tag.

That there's a body tag at all is because the example is means to be in HTML, not in an SFC.

@aztalbot
Copy link

This will be super useful to have built in. Great job @LinusBorg! Below are some thoughts and questions that came to mind as I read through the proposal (apologies if I misunderstood anything):

Selector
Is the behavior the same as querySelector such that Portal will mount its children only to the first node it finds (and in the case of a less specific selector ignore all other nodes)?

I wonder if it might be simpler to strictly allow only element ids (w/o #) and DOM node objects as the input to the target prop. That way the main use case for targeting a specific element is slightly streamlined and less specific queries are still supported by querying the DOM in setup (and even looping over the resulting nodes if needed). This could also allow custom logic to decide when to re-query for the target(s).

Vue Control
Perhaps to make mounting within a Vue app more predictable, we can have the parent of the target know to ignore all nodes mounted by the portal when patching. And maybe the portal can tell its own parent to skip patching those children after the target has been removed. Perhaps emit an event whenever a target is missing so it can be handled like an error.

That could also be the way to handle missing targets, in general: don’t unmount whatever was mounted to old target unless the new target value is an empty string, meanwhile emit an event so an acceptable target can be found, or the old target can be reverted to, or the logic can give up by using an empty string to unmount. This way the behavior is intentional.

Multiple Portals

The portal functionality proposed in this RFC requires that each portal has its own target element to mount to.

What happens to any existing children under the target? If it’s just a matter of managing order, might it suffice to have a prop called insert-before which accepts a Dom node reference before which to mount the children (this can be queried in setup)? That way a small amount of custom logic or a library can manage placement of multiple portals by determining where to insert based on what already exists under that target (eg: examine data-order attribute and sort). By default just append to end.

Naming
Thinking about potential future naming conflicts, I definitely prefer keeping it as portal. But, if portal were not used as the name, I could also see <Target selector=“#myEl”>, as in these nodes target this selector. Or <Bridge to=“”>, <Append to=“”>, <Adopt target=“”>,
<External target=“”>, <Mount target=“”>.

@cawa-93
Copy link

cawa-93 commented Jan 21, 2020

The name <Portal> can lead to conflict: https://web.dev/hands-on-portals/

I find this proposal useful, but I do not have enough information on how it should behave.
What to do when a portal points to an item that already contains content or another component?

<Portal target=".content">Portal content</Portal>
<div class="content">Div Content</div>

<Portal target=".component">Portal content</Portal>
<my-component class="component">Component Content</my-component>

<Portal class="portal" target=".another-portal">Portal content</Portal>
<Portal class="another-portal" target=".portal">Another Portal content</Portal>

@ycmjason

This comment has been minimized.

@gustojs
Copy link
Member

gustojs commented Jan 21, 2020

If we start calling new components v-stuff, we'd have to rename current ones for them to follow same naming style. Also v-portal suggests it's a Vuetify component.

@cawa-93

This comment has been minimized.

@posva
Copy link
Member

posva commented Jan 21, 2020

@cawa-93 @ycmjason did you check https://github.com/vuejs/rfcs/blob/rfc-portals/active-rfcs/0000-portals.md#naming-conflict-with-native-portals ?

@brandonpittman
Copy link

+1 for <wormhole>

@michaelpumo
Copy link

michaelpumo commented Jan 21, 2020

If <portal> will create a conflict against the HTML spec, perhaps we could simply utilise the existing <template> tag with a directive?

Example:

<template v-portal=“example”>
  Hello Vue 3!
</template>

@udany
Copy link

udany commented Jan 21, 2020

I'm currently using a custom written mixin to make components that can be portaled around, I'll include it in the end in case it's of use.

Two things I'd like to point out are:

  1. I think it would be better if the target could also support an actual DOM element instead of only a query selector.

  2. I'd like to be able to choose an append mode besides replace mode (i.e. Instead of replacing the target's content, merely append to it)

export default {
	props: {
		parent: {
			type: HTMLElement,
			required: true
		}
	},
	watch: {
		parent(newValue, oldValue) {
			if (oldValue) this.detachFromElement(oldValue);
			if (newValue) this.attachToElement(newValue);
		}
	},
	methods: {
		attachToElement(parent) {
			if (!parent) return;
			parent.appendChild(this.$el);
			this.$emit('attachedToElement', parent);
		},
		detachFromElement(parent) {
			if (!parent) return;
			this.$emit('detachedFromElement', parent);
		}
	},
	mounted() {
		this.attachToElement(this.parent);
	},
	beforeDestroy() {
		this.detachFromElement(this.parent);
	}
};

@ycmjason

This comment has been minimized.

@CyberAP
Copy link
Contributor

CyberAP commented Jan 21, 2020

How does Portal work with refs? Should you put a ref on a Portal or on its contents only?

@JosephSilber
Copy link

JosephSilber commented Jan 21, 2020

If we can't use the same target for multiple portals, how can we use it, for example, for multiple stacked modals? Would we need to manually create additional portal targets in setup?

This is slowly starting to feel less data driven, and more like raw DOM manipulation.

@LinusBorg
Copy link
Member Author

If we can't use the same target for multiple portals, how can we use it, for example, for multiple stacked modals? Would we need to manually created additional portal targets in setup?

@JosephSilber That's what the RFC is for: To answer these questions.

@LinusBorg
Copy link
Member Author

LinusBorg commented Jan 21, 2020

How does Portal work with refs? Should you put a ref on a Portal or on its contents only?

I it should not be possible to put a ref on the <portal> - it doesn'T have (or at least doesn't require) a real component instance as it's just symbol telling the virtualdom patch algo to move its contents somewhere else.

If we find, over the course of this discussion, that portals need to have an instance to provide additional functionality not yet in the RFC, then this might change. But putting it on the content should always work.

Do you have a use case for putting a ref on the <portal>?

@LinusBorg
Copy link
Member Author

LinusBorg commented Jan 21, 2020

@udany

I think it would be better if the target could also support an actual DOM element instead of only a query selector.

I agree, will add this.

I'd like to be able to choose an append mode besides replace mode (i.e. Instead of replacing the target's content, merely append to it)

Should be considered, would also solve @JosephSilber's issue, presumably.

@LinusBorg
Copy link
Member Author

@kiaking SSR and Portals are tricky as usually the app is rendered in one go.

I didn't add it to the RFC for now as we are still exploring SSR architecture for Vue 3 and want to see what we come up with in general before re-visiting this in the RFC

@kiaking
Copy link
Member

kiaking commented Jan 21, 2020

@LinusBorg Make sense. Thanks for the confirmation! 🤝

@LinusBorg
Copy link
Member Author

@aztalbot

Selector
Is the behavior the same as querySelector such that Portal will mount its children only to the first node it finds (and in the case of a less specific selector ignore all other nodes)?

Yes. I see you suggest to limit it to ids. I'm unsure about that, it feels unnecessessarily limiting.

Vue Control

I'll need to think about this bit more, will get back to you about it.

Multiple Portals

What happens to any existing children under the target? If it’s just a matter of managing order, might it suffice to have a prop called insert-before which accepts a Dom node reference before which to mount the children (this can be queried in setup)? That way a small amount of custom logic or a library can manage placement of multiple portals by determining where to insert based on what already exists under that target (eg: examine data-order attribute and sort). By default just append to end.

Yeah I see that many people like to have multiple portals moving stuff to the same place, but I'm torn if this should be part of core or we can design it in a way that a small lib can make this happen with little code.

@sqal
Copy link

sqal commented Jan 21, 2020

@JosephSilber

If we can't use the same target for multiple portals, how can we use it, for example, for multiple stacked modals?

Who said you can't? You can use Portal to mount as many elements as you want in the same target node. You can create a functional component that renders Portal and mounts target element in the DOM just once, then you can reuse this component anywhere you want.

// DialogPortal.js

import { h, Portal } from 'vue';

let target = null

export function DialogPortal(_, { slots }) {
  if (!target) {
    target = document.createElement('div')
    target.id = 'dialog-root';
    document.body.appendChild(target)
  }

  return h(Portal, { target }, slots.default())
}

// Dialog.vue

<template>
  <DialogPortal>
    <div class="dialog">
      dialog content
    </div>
  </DialogPortal>
</template>

@emironov-via
Copy link

Maybe make portals look like scoped slot?

Source component:

<template>
  <div>
    ...
    <template v-portal:example="{ foo, bar }">
      <div>{{ foo + bar }}</div>
    </template>
    ...
  </div>
</template>

Target component:

<template>
  <div>
    ...
    <portal
      name="example"
      foo="Hello"
      :bar="'world'"
    >
      default content, when source not found 
    </portal>
    ...
  </div>
</template>

Pros:

  • familiar syntax and behavior;
  • access to target data in the source template;
  • default content;
  • can be used for deep transfer slots, without this.$slots transition;
  • source template it's like render function, and can be used for many targets;
  • without DOM selector, because it feels like a leaky abstraction for Vue.

Cons:

  • name collision.

P.S. It's may be a repetition of the situation with slots. At first there were slots, then scoped slots were added, after they were merged (more precisely, slots were removed).

@codebender828
Copy link

🙏🏽❤️ Great work with this RFC, @LinusBorg , contributors and core team with Vue 3.

I have a few thoughts on handling portals with no visible children and I'd be happy to hear some thoughts about this. It's not a very urgent requirement/feature for the Portal but it would certainly make my live easier. I use the PortalVue in a lot of my projects. However I often find myself having to create my own Portal component that wraps around the MountingPortal component from PortalVue to implement the below mentioned desired behaviour.

I'll also go ahead and say that I might be doing this wrong, and there may be a better way to implement this than I am doing. But this currently is what would work for me. So feel free to suggest your thoughts about this.

Handling portal targets with no children

One of the things I've noticed as a library author is that I might have multiple portals for different kinds of components. I'd have a single portal for toast components, a separate one for popovers, and for tooltips as well, etc. My preference would be to have them exists in different portal targets. So, naturally I'd build my components using this pattern.

In consumer applications, however, if their production app has many implementations of popovers and tooltips, etc, they would end up having multiple portal targets in the DOM since I'm creating the portal targets and mounting them in the DOM only when the Tooltip/Popover/Menu components are consumed by the user. So we would wind up with markup somewhat similar to this:

<!DOCTYPE html>
<html lang="en" dir="ltr">
  <head>
    <meta charset="utf-8">
    <title>Consumer App</title>
  </head>
  <body>
    <!-- Vue app is mounted here -->
    <div id="app"></div>

    <!-- Portal targets are mounted here -->
    <div id="popover-portal"></div>
    <div id="toast-portal"></div>
    <div id="menu-portal"></div>
    <div id="tooltip-portal"></div>
  </body>
</html>

On demand Portal target creation and removal.

This results in having multiple portal targets in the markup that (may not be used). This led me down a path of desiring to create the my own portal targets only when it's children are present/visible either by v-if or v-show directives and then remove the targets from the DOM when their children are no longer displayed.

This may require to some specific internal behaviour from the Portal component. Portal would need to:

  • Create the portal target provided by the consumer if it does not exist in the DOM.
  • Remove the Portal target node from the DOM if it's children are not displayed. It would need to detect this the same way that Vue's Transition and TransitionGroup component detects the presence of it's children components/nodes. If they're present, create and mount the portal target in the DOM provided in the target prop of the Portal component.
  • We could perhaps add an onDemand Boolean prop to implement the above mentioned behaviour. It would default to false so as to maintain the current behaviour as mentioned in rendered rfc

Implementing this could them result in markup shown below:

📭 Targets with invisible children

In Vue template

<template>
  <main>
    <Portal on-demand target="#popover-portal">
      <p v-if="false">Portal target will not be mounted because I'm set to v-if=false</p>
    </Portal>
  </main>
</template>

<script>
import { Portal } from "vue"
export default {
  components: {
    Portal
  }
}
</script>

Rendered markup

<html lang="en" dir="ltr">
  <body>
    <!-- Vue app is mounted here -->
    <div id="app">...</div>

    <!-- #popover-target not created and mounted -->
  </body>
</html>

✅ Targets with visible children

In Vue template

<template>
  <main>
    <!-- 
      on-demand prop set to true to activate
      internal portal target creation and and removal
    -->
    <Portal on-demand target="#popover-portal">
      <p v-if="true">Mounted because I'm set to v-if true</p>
    </Portal>
  </main>
</template>

<script>
import { Portal } from "vue"
export default {
  components: {
    Portal
  }
}
</script>

Rendered markup

<html lang="en" dir="ltr">
  <body>
    <!-- Vue app is mounted here -->
    <div id="app">...</div>

    <!-- Portal targets are mounted here -->
    <div id="popover-portal">
      <p>Mounted because I'm set to v-if true</p>
    </div>
  </body>
</html>

Having this behaviour would keep markup clean and also be useful for authors to not have to create their own wrapper Portals to create this kind of behaviour for their consumers :)

What do you think about this? 🙏🏽❤️

@cawa-93
Copy link

cawa-93 commented Jan 22, 2020

@codebender828 Sounds pretty helpful. However, I see the need to specify WHERE it is necessary to create a target for the portal. And I don't see a way to conveniently set the location of this target. Maybe someone more experienced will point it out.

It might be better for these tasks to implement a new universal component, or directive, etc. that would remove a node if it had no child nodes?

In Vue template

<template>
  <main>
    <div v-remove-if-empty>
      <p v-if="false">Portal target will not be mounted because I'm set to v-if=false</p>
    </div>
  </main>
</template>

Rendered markup

  <main>
    <!-- div not created and mounted -->
  </main>

In Vue template

<template>
  <main>
    <div v-remove-if-empty>
      <p v-if="true">Mounted because I'm set to v-if true</p>
    </div>
  </main>
</template>

Rendered markup

  <main>
    <div>
      <p>Mounted because I'm set to v-if true</p>
    </div>
  </main>

@Janne252
Copy link

What if the syntax for controlling a component's mounting root was a global directive; v-mount? This feature behaves quite similarly to $mount(...). Wouldn't it make sense to name them consistently?

<template>
  <main v-mount="$root">
    <!-- content -->
  </main>
</template>

*Assuming the mounting target is string | VNode | Element

@udany
Copy link

udany commented Jan 22, 2020

@codebender828

While I appreciate the concerns you have for keeping the dom uncluttered, I feel like this could be premature optimization, I can hardly see how a few (even tens) empty divs could harm an application's performance to make the complexity this behavior would add to the implementation worth it.

@cawa-93 suggestion is great for it makes it an independent feature rather than making portals more complex, even feels like a directive that could be implemented in userland

@LinusBorg
Copy link
Member Author

@donnysim

Taken a menu as an example, on desktop the dropdown element is placed on body, but on mobile it is kept in original placement and toggled as a sliding menu - would be nice if we didn't have to resolve to v-if to toggle between portal and normal content (but maybe could be wrapped into a separate component), [...]

A way t essentially "disable" a portal conditionally was already proposed, and I think it would be quite useful:

<portal :disabled="isMobile">
  <!-- -->
</portal>

another need is to catch the re-positioning (from body to initial place and vice verse) of the element for actions like detaching or reattaching Popper.js.

Not sure I totally get that usecase - I get the direction but, lack a detailed image in my head. can you add some details for me?

@LinusBorg
Copy link
Member Author

@CyberAP

[...] Portal strategy used when working with DOM node. [...]
<Portal target="[data-sidebar] " strategy="append">

I like this, but it has to be kept simple.

What kinds of strategies would we see here?

  • append
  • prepend
  • replace (would mean we have to unmount the other component, which might be challenging with the current implementation)

I wonder though, if it would make more sense to let these cases be solved in userland as long as we can make sure in Vue's core that they are possible to be implemented.

I would also like for Portal to retain the original root node it's using as a target and always replace the inner content of that node.

That's already part of the RFC.

@donnysim
Copy link

donnysim commented Feb 27, 2020

Not sure I totally get that usecase - I get the direction but, lack a detailed image in my head. can you add some details for me?

Was kind of thinking about being able to trigger callbacks based on action from the portal - done moving the element etc. to be able to execute further actions without having to resort to $nextTick etc. like:

<portal :disabled="screenWidth < 768">
  <div @after-move-to-portal="attachPopper" @before-move-to-host="detachPopper"/>
</portal>

(I know the event names make no sense)

methods: {
  attachPopper(el) {
    el._popper = createPopper(el);
  },

  detachPopper(el) {
    el._popper.destroy();
  },
},

or something along those lines, but I realize that this kind of makes no sense? like if it's a component and contains multiple root elements etc. it would fail pretty fast. Most likely this should be encapsulated as a component and just toggle the "use-popper" on and off based on the same condition as the parent. Main problem with something like popper is that it uses the parent element on bind, but if you move it using portal, it breaks real fast.

At the moment for something similar I use directives v-inserted, v-removed (basic inserted and unbind hooks) to execute callbacks on element insert/remove to trigger additional actions on that element to simplify dealing with v-if's and similar conditions, but probably not going to work with portal.

Javascript controls essentially *he* whole page  ---> Javascript controls essentially *the* whole page
@yyx990803
Copy link
Member

We have implemented multi-portal-same-target append support and the disabled prop, all with SSR support in vue-next master branch - RFC update coming soon.

Regarding the name conflict, we are currently leaning towards <Teleport> with a to prop:

<Teleport to="#modal-layer" :disabled="isMobile">
  <div class="modal">
    hello
  </div>
</Teleport>

@CyberAP
Copy link
Contributor

CyberAP commented Mar 31, 2020

I had to read about vue-portal's disabled prop first to understand what it actually does. For me disabled means the Teleport contents are not displaying or not functioning and is a bit confusing.
What about using null as a target to disable teleporting?

<Teleport :to="null" />

The example above would look like this:

<Teleport :to="isMobile ? '#modal-layer' : null">
  <div class="modal">
    hello
  </div>
</Teleport>

Also this is a somewhat duplicate of <component :is /> functionality, since you could write it like this:

<component :is="isMobile ? Teleport : 'div'" to="#modal-layer">
  <div class="modal">
    hello
  </div>
</component>

- support of multiple sources for a target
support for prop "disabled"
- rename of component to <teleport>
-  minor corrections and clarifications
@LinusBorg
Copy link
Member Author

I just pushed the update!

@yyx990803
Copy link
Member

yyx990803 commented Mar 31, 2020

@CyberAP neither of your suggestions are as explicit as a disabled prop. A falsy target value should result in a warning, unless you want to treat null as a special value, which is implicit API. Your 2nd example would teardown the inner content and re-mount it, whereas with disabled prop the portal preserves the content and simply moves it.

active-rfcs/0000-portals.md Outdated Show resolved Hide resolved
active-rfcs/0000-portals.md Outdated Show resolved Hide resolved
active-rfcs/0000-portals.md Outdated Show resolved Hide resolved
LinusBorg and others added 3 commits March 31, 2020 21:43
Co-Authored-By: Evan You <yyx990803@gmail.com>
Co-Authored-By: Evan You <yyx990803@gmail.com>
Co-Authored-By: Evan You <yyx990803@gmail.com>
@yyx990803 yyx990803 added the final comments This RFC is in final comments period label Apr 2, 2020
@yyx990803
Copy link
Member

This RFC is now in final comments stage. An RFC in final comments stage means that:

The core team has reviewed the feedback and reached consensus about the general direction of the RFC and believe that this RFC is a worthwhile addition to the framework.
Final comments stage does not mean the RFC's design details are final - we may still tweak the details as we implement it and discover new technical insights or constraints. It may even be further adjusted based on user feedback after it lands in an alpha/beta release.
If no major objections with solid supporting arguments have been presented after a week, the RFC will be merged and become an active RFC.

@leopiccionia
Copy link

Something that occurred me: why not name it <Mount to="..."> (or maybe <Mount on="...">, I'm not a native speaker...) instead of <Teleport to="...">?

It'd be one less buzzword to teach and learn, except if it's somehow misleading. Is it?

(Sorry to address this so late...)

@yyx990803 yyx990803 changed the title Portal functionality <Teleport> Apr 9, 2020
@yyx990803 yyx990803 changed the title <Teleport> <Teleport> component Apr 9, 2020
@yyx990803 yyx990803 changed the title <Teleport> component <Teleport> built-in component Apr 9, 2020
@yyx990803
Copy link
Member

@leopiccionia thanks for the suggestion, but the naming has been extensively discussed and the current choice of <teleport> has consensus on the core team.

@sionzee
Copy link

sionzee commented Aug 19, 2020

Are there any plans on events for teleport?

At my case I need to teleport an element but adjust the coords to old location.
But I cannot gather any information about the element when was teleported.

So events like:
beforeEnterTeleport
enterTeleport

are really needed.


@donnysim have you solved it?

@DRoet DRoet mentioned this pull request Aug 29, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
3.x This RFC only targets 3.0 and above core new feature
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet