mirror of https://github.com/yewstack/yew
Implementation of portals (#2147)
* initial poc implementation of portals * add new_before * add portals example * add shadow dom example * add english website documentation
This commit is contained in:
parent
a5c343dd62
commit
aa9b9b266c
|
@ -25,6 +25,7 @@ members = [
|
|||
"examples/multi_thread",
|
||||
"examples/nested_list",
|
||||
"examples/node_refs",
|
||||
"examples/portals",
|
||||
"examples/pub_sub",
|
||||
"examples/router",
|
||||
"examples/store",
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
[package]
|
||||
name = "portals"
|
||||
version = "0.1.0"
|
||||
authors = ["Martin Molzer <worldsbegin@gmx.de>"]
|
||||
edition = "2018"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[dependencies]
|
||||
yew = { path = "../../packages/yew" }
|
||||
gloo-utils = "0.1"
|
||||
wasm-bindgen = "0.2"
|
||||
|
||||
[dependencies.web-sys]
|
||||
version = "0.3"
|
||||
features = [
|
||||
"Document",
|
||||
"Element",
|
||||
"Node",
|
||||
"HtmlHeadElement",
|
||||
"ShadowRoot",
|
||||
"ShadowRootInit",
|
||||
"ShadowRootMode",
|
||||
]
|
|
@ -0,0 +1,10 @@
|
|||
# Portals Example
|
||||
|
||||
[](https://examples.yew.rs/portals)
|
||||
|
||||
This example renders elements into out-of-tree nodes with the help of portals.
|
||||
|
||||
## Concepts
|
||||
|
||||
- Manually creating `Html` without the `html!` macro.
|
||||
- Using `web-sys` to manipulate the DOM.
|
|
@ -0,0 +1,9 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Yew • Portals</title>
|
||||
</head>
|
||||
|
||||
<body></body>
|
||||
</html>
|
|
@ -0,0 +1,106 @@
|
|||
use wasm_bindgen::JsCast;
|
||||
use web_sys::{Element, ShadowRootInit, ShadowRootMode};
|
||||
use yew::{create_portal, html, Children, Component, Context, Html, NodeRef, Properties};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct ShadowDOMProps {
|
||||
#[prop_or_default]
|
||||
pub children: Children,
|
||||
}
|
||||
|
||||
pub struct ShadowDOMHost {
|
||||
host_ref: NodeRef,
|
||||
inner_host: Option<Element>,
|
||||
}
|
||||
|
||||
impl Component for ShadowDOMHost {
|
||||
type Message = ();
|
||||
type Properties = ShadowDOMProps;
|
||||
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
Self {
|
||||
host_ref: NodeRef::default(),
|
||||
inner_host: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn rendered(&mut self, ctx: &Context<Self>, first_render: bool) {
|
||||
if first_render {
|
||||
let shadow_root = self
|
||||
.host_ref
|
||||
.get()
|
||||
.expect("rendered host")
|
||||
.unchecked_into::<Element>()
|
||||
.attach_shadow(&ShadowRootInit::new(ShadowRootMode::Closed))
|
||||
.expect("installing shadow root succeeds");
|
||||
let inner_host = gloo_utils::document()
|
||||
.create_element("div")
|
||||
.expect("can create inner wrapper");
|
||||
shadow_root
|
||||
.append_child(&inner_host)
|
||||
.expect("can attach inner host");
|
||||
self.inner_host = Some(inner_host);
|
||||
ctx.link().send_message(());
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, _: &Context<Self>, _: Self::Message) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let contents = if let Some(ref inner_host) = self.inner_host {
|
||||
create_portal(
|
||||
html! {
|
||||
{for ctx.props().children.iter()}
|
||||
},
|
||||
inner_host.clone(),
|
||||
)
|
||||
} else {
|
||||
html! { <></> }
|
||||
};
|
||||
html! {
|
||||
<div ref={self.host_ref.clone()}>
|
||||
{contents}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Model {
|
||||
pub style_html: Html,
|
||||
}
|
||||
|
||||
impl Component for Model {
|
||||
type Message = ();
|
||||
type Properties = ();
|
||||
|
||||
fn create(_ctx: &Context<Self>) -> Self {
|
||||
let document_head = gloo_utils::document()
|
||||
.head()
|
||||
.expect("head element to be present");
|
||||
let style_html = create_portal(
|
||||
html! {
|
||||
<style>{"p { color: red; }"}</style>
|
||||
},
|
||||
document_head.into(),
|
||||
);
|
||||
Self { style_html }
|
||||
}
|
||||
|
||||
fn view(&self, _ctx: &Context<Self>) -> Html {
|
||||
html! {
|
||||
<>
|
||||
{self.style_html.clone()}
|
||||
<p>{"This paragraph is colored red, and its style is mounted into "}<pre>{"document.head"}</pre>{" with a portal"}</p>
|
||||
<ShadowDOMHost>
|
||||
<p>{"This paragraph is rendered in a shadow dom and thus not affected by the surrounding styling context"}</p>
|
||||
</ShadowDOMHost>
|
||||
</>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
yew::start_app::<Model>();
|
||||
}
|
|
@ -23,6 +23,7 @@ yew-router = { path = "../../packages/yew-router/" }
|
|||
[dev-dependencies.web-sys]
|
||||
version = "0.3"
|
||||
features = [
|
||||
"Document",
|
||||
"Element",
|
||||
"EventTarget",
|
||||
"HtmlElement",
|
||||
|
|
|
@ -16,12 +16,17 @@ fn main() {
|
|||
let pattern = format!("{}/../../website/docs/**/*.md", home);
|
||||
let base = format!("{}/../../website", home);
|
||||
let base = Path::new(&base).canonicalize().unwrap();
|
||||
let dir_pattern = format!("{}/../../website/docs/**", home);
|
||||
for dir in glob(&dir_pattern).unwrap() {
|
||||
println!("cargo:rerun-if-changed={}", dir.unwrap().display());
|
||||
}
|
||||
|
||||
let mut level = Level::default();
|
||||
|
||||
for entry in glob(&pattern).unwrap() {
|
||||
let path = entry.unwrap();
|
||||
let path = Path::new(&path).canonicalize().unwrap();
|
||||
println!("cargo:rerun-if-changed={}", path.display());
|
||||
let rel = path.strip_prefix(&base).unwrap();
|
||||
|
||||
let mut parts = vec![];
|
||||
|
|
|
@ -10,11 +10,11 @@ pub use component::*;
|
|||
pub use conversion::*;
|
||||
pub use listener::*;
|
||||
|
||||
use crate::virtual_dom::VNode;
|
||||
use crate::virtual_dom::{VNode, VPortal};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use wasm_bindgen::JsValue;
|
||||
use web_sys::Node;
|
||||
use web_sys::{Element, Node};
|
||||
|
||||
/// A type which expected as a result of `view` function implementation.
|
||||
pub type Html = VNode;
|
||||
|
@ -136,6 +136,14 @@ impl NodeRef {
|
|||
}
|
||||
}
|
||||
|
||||
/// Render children into a DOM node that exists outside the hierarchy of the parent
|
||||
/// component.
|
||||
/// ## Relevant examples
|
||||
/// - [Portals](https://github.com/yewstack/yew/tree/master/examples/portals)
|
||||
pub fn create_portal(child: Html, host: Element) -> Html {
|
||||
VNode::VPortal(VPortal::new(child, host))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
@ -387,7 +387,8 @@ pub mod prelude {
|
|||
pub use crate::context::ContextProvider;
|
||||
pub use crate::events::*;
|
||||
pub use crate::html::{
|
||||
Children, ChildrenWithProps, Classes, Component, Context, Html, NodeRef, Properties,
|
||||
create_portal, Children, ChildrenWithProps, Classes, Component, Context, Html, NodeRef,
|
||||
Properties,
|
||||
};
|
||||
pub use crate::macros::{classes, html, html_nested};
|
||||
|
||||
|
|
|
@ -11,6 +11,8 @@ pub mod vlist;
|
|||
#[doc(hidden)]
|
||||
pub mod vnode;
|
||||
#[doc(hidden)]
|
||||
pub mod vportal;
|
||||
#[doc(hidden)]
|
||||
pub mod vtag;
|
||||
#[doc(hidden)]
|
||||
pub mod vtext;
|
||||
|
@ -31,6 +33,8 @@ pub use self::vlist::VList;
|
|||
#[doc(inline)]
|
||||
pub use self::vnode::VNode;
|
||||
#[doc(inline)]
|
||||
pub use self::vportal::VPortal;
|
||||
#[doc(inline)]
|
||||
pub use self::vtag::VTag;
|
||||
#[doc(inline)]
|
||||
pub use self::vtext::VText;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
//! This module contains the implementation of abstract virtual node.
|
||||
|
||||
use super::{Key, VChild, VComp, VDiff, VList, VTag, VText};
|
||||
use super::{Key, VChild, VComp, VDiff, VList, VPortal, VTag, VText};
|
||||
use crate::html::{AnyScope, Component, NodeRef};
|
||||
use gloo::console;
|
||||
use std::cmp::PartialEq;
|
||||
|
@ -21,6 +21,8 @@ pub enum VNode {
|
|||
VComp(VComp),
|
||||
/// A holder for a list of other nodes.
|
||||
VList(VList),
|
||||
/// A portal to another part of the document
|
||||
VPortal(VPortal),
|
||||
/// A holder for any `Node` (necessary for replacing node).
|
||||
VRef(Node),
|
||||
}
|
||||
|
@ -33,6 +35,7 @@ impl VNode {
|
|||
VNode::VRef(_) => None,
|
||||
VNode::VTag(vtag) => vtag.key.clone(),
|
||||
VNode::VText(_) => None,
|
||||
VNode::VPortal(vportal) => vportal.node.key(),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -43,6 +46,7 @@ impl VNode {
|
|||
VNode::VList(vlist) => vlist.key.is_some(),
|
||||
VNode::VRef(_) | VNode::VText(_) => false,
|
||||
VNode::VTag(vtag) => vtag.key.is_some(),
|
||||
VNode::VPortal(vportal) => vportal.node.has_key(),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -58,6 +62,7 @@ impl VNode {
|
|||
VNode::VComp(vcomp) => vcomp.node_ref.get(),
|
||||
VNode::VList(vlist) => vlist.get(0).and_then(VNode::first_node),
|
||||
VNode::VRef(node) => Some(node.clone()),
|
||||
VNode::VPortal(vportal) => vportal.next_sibling(),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -88,6 +93,7 @@ impl VNode {
|
|||
.expect("VList is not mounted")
|
||||
.unchecked_first_node(),
|
||||
VNode::VRef(node) => node.clone(),
|
||||
VNode::VPortal(_) => panic!("portals have no first node, they are empty inside"),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -104,6 +110,7 @@ impl VNode {
|
|||
.expect("VComp has no root vnode")
|
||||
.move_before(parent, next_sibling);
|
||||
}
|
||||
VNode::VPortal(_) => {} // no need to move portals
|
||||
_ => super::insert_node(&self.unchecked_first_node(), parent, next_sibling.as_ref()),
|
||||
};
|
||||
}
|
||||
|
@ -122,6 +129,7 @@ impl VDiff for VNode {
|
|||
console::warn!("Node not found to remove VRef");
|
||||
}
|
||||
}
|
||||
VNode::VPortal(ref mut vportal) => vportal.detach(parent),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -155,6 +163,9 @@ impl VDiff for VNode {
|
|||
super::insert_node(node, parent, next_sibling.get().as_ref());
|
||||
NodeRef::new(node.clone())
|
||||
}
|
||||
VNode::VPortal(ref mut vportal) => {
|
||||
vportal.apply(parent_scope, parent, next_sibling, ancestor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -225,6 +236,7 @@ impl fmt::Debug for VNode {
|
|||
VNode::VComp(ref vcomp) => vcomp.fmt(f),
|
||||
VNode::VList(ref vlist) => vlist.fmt(f),
|
||||
VNode::VRef(ref vref) => write!(f, "VRef ( \"{}\" )", crate::utils::print_node(vref)),
|
||||
VNode::VPortal(ref vportal) => vportal.fmt(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,185 @@
|
|||
//! This module contains the implementation of a portal `VPortal`.
|
||||
|
||||
use super::{VDiff, VNode};
|
||||
use crate::html::{AnyScope, NodeRef};
|
||||
use web_sys::{Element, Node};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VPortal {
|
||||
/// The element under which the content is inserted.
|
||||
pub host: Element,
|
||||
/// The next sibling after the inserted content
|
||||
pub next_sibling: NodeRef,
|
||||
/// The inserted node
|
||||
pub node: Box<VNode>,
|
||||
/// The next sibling after the portal. Set when rendered
|
||||
sibling_ref: NodeRef,
|
||||
}
|
||||
|
||||
impl VDiff for VPortal {
|
||||
fn detach(&mut self, _: &Element) {
|
||||
self.node.detach(&self.host);
|
||||
self.sibling_ref.set(None);
|
||||
}
|
||||
|
||||
fn apply(
|
||||
&mut self,
|
||||
parent_scope: &AnyScope,
|
||||
parent: &Element,
|
||||
next_sibling: NodeRef,
|
||||
ancestor: Option<VNode>,
|
||||
) -> NodeRef {
|
||||
let inner_ancestor = match ancestor {
|
||||
Some(VNode::VPortal(old_portal)) => {
|
||||
let VPortal {
|
||||
host: old_host,
|
||||
next_sibling: old_sibling,
|
||||
mut node,
|
||||
..
|
||||
} = old_portal;
|
||||
if old_host != self.host {
|
||||
// Remount the inner node somewhere else instead of diffing
|
||||
node.detach(&old_host);
|
||||
None
|
||||
} else if old_sibling != self.next_sibling {
|
||||
// Move the node, but keep the state
|
||||
node.move_before(&self.host, &self.next_sibling.get());
|
||||
Some(*node)
|
||||
} else {
|
||||
Some(*node)
|
||||
}
|
||||
}
|
||||
Some(mut node) => {
|
||||
node.detach(parent);
|
||||
None
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
self.node.apply(
|
||||
parent_scope,
|
||||
&self.host,
|
||||
self.next_sibling.clone(),
|
||||
inner_ancestor,
|
||||
);
|
||||
self.sibling_ref = next_sibling.clone();
|
||||
next_sibling
|
||||
}
|
||||
}
|
||||
|
||||
impl VPortal {
|
||||
/// Creates a [VPortal] rendering `content` in the DOM hierarchy under `host`.
|
||||
pub fn new(content: VNode, host: Element) -> Self {
|
||||
Self {
|
||||
host,
|
||||
next_sibling: NodeRef::default(),
|
||||
node: Box::new(content),
|
||||
sibling_ref: NodeRef::default(),
|
||||
}
|
||||
}
|
||||
/// Creates a [VPortal] rendering `content` in the DOM hierarchy under `host`.
|
||||
/// If `next_sibling` is given, the content is inserted before that [Node].
|
||||
/// The parent of `next_sibling`, if given, must be `host`.
|
||||
pub fn new_before(content: VNode, host: Element, next_sibling: Option<Node>) -> Self {
|
||||
Self {
|
||||
host,
|
||||
next_sibling: {
|
||||
let sib_ref = NodeRef::default();
|
||||
sib_ref.set(next_sibling);
|
||||
sib_ref
|
||||
},
|
||||
node: Box::new(content),
|
||||
sibling_ref: NodeRef::default(),
|
||||
}
|
||||
}
|
||||
/// Returns the [Node] following this [VPortal], if this [VPortal]
|
||||
/// has already been mounted in the DOM.
|
||||
pub fn next_sibling(&self) -> Option<Node> {
|
||||
self.sibling_ref.get()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod layout_tests {
|
||||
extern crate self as yew;
|
||||
|
||||
use crate::html;
|
||||
use crate::virtual_dom::layout_tests::{diff_layouts, TestLayout};
|
||||
use crate::virtual_dom::VNode;
|
||||
use yew::virtual_dom::VPortal;
|
||||
|
||||
#[cfg(feature = "wasm_test")]
|
||||
use wasm_bindgen_test::{wasm_bindgen_test as test, wasm_bindgen_test_configure};
|
||||
|
||||
#[cfg(feature = "wasm_test")]
|
||||
wasm_bindgen_test_configure!(run_in_browser);
|
||||
|
||||
#[test]
|
||||
fn diff() {
|
||||
let mut layouts = vec![];
|
||||
let first_target = gloo_utils::document().create_element("i").unwrap();
|
||||
let second_target = gloo_utils::document().create_element("o").unwrap();
|
||||
let target_with_child = gloo_utils::document().create_element("i").unwrap();
|
||||
let target_child = gloo_utils::document().create_element("s").unwrap();
|
||||
target_with_child.append_child(&target_child).unwrap();
|
||||
|
||||
layouts.push(TestLayout {
|
||||
name: "Portal - first target",
|
||||
node: html! {
|
||||
<div>
|
||||
{VNode::VRef(first_target.clone().into())}
|
||||
{VNode::VRef(second_target.clone().into())}
|
||||
{VNode::VPortal(VPortal::new(
|
||||
html! { {"PORTAL"} },
|
||||
first_target.clone(),
|
||||
))}
|
||||
{"AFTER"}
|
||||
</div>
|
||||
},
|
||||
expected: "<div><i>PORTAL</i><o></o>AFTER</div>",
|
||||
});
|
||||
layouts.push(TestLayout {
|
||||
name: "Portal - second target",
|
||||
node: html! {
|
||||
<div>
|
||||
{VNode::VRef(first_target.clone().into())}
|
||||
{VNode::VRef(second_target.clone().into())}
|
||||
{VNode::VPortal(VPortal::new(
|
||||
html! { {"PORTAL"} },
|
||||
second_target.clone(),
|
||||
))}
|
||||
{"AFTER"}
|
||||
</div>
|
||||
},
|
||||
expected: "<div><i></i><o>PORTAL</o>AFTER</div>",
|
||||
});
|
||||
layouts.push(TestLayout {
|
||||
name: "Portal - replaced by text",
|
||||
node: html! {
|
||||
<div>
|
||||
{VNode::VRef(first_target.clone().into())}
|
||||
{VNode::VRef(second_target.clone().into())}
|
||||
{"FOO"}
|
||||
{"AFTER"}
|
||||
</div>
|
||||
},
|
||||
expected: "<div><i></i><o></o>FOOAFTER</div>",
|
||||
});
|
||||
layouts.push(TestLayout {
|
||||
name: "Portal - next sibling",
|
||||
node: html! {
|
||||
<div>
|
||||
{VNode::VRef(target_with_child.clone().into())}
|
||||
{VNode::VPortal(VPortal::new_before(
|
||||
html! { {"PORTAL"} },
|
||||
target_with_child.clone(),
|
||||
Some(target_child.clone().into()),
|
||||
))}
|
||||
</div>
|
||||
},
|
||||
expected: "<div><i>PORTAL<s></s></i></div>",
|
||||
});
|
||||
|
||||
diff_layouts(layouts)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
title: "Portals"
|
||||
description: "Rendering into out-of-tree DOM nodes"
|
||||
---
|
||||
|
||||
## How to think about portals?
|
||||
|
||||
Portals provide a first-class way to render children into a DOM node that exists outside the DOM hierarchy of the parent component.
|
||||
`yew::create_portal(child, host)` returns a `Html` value that renders `child` not hierarchically under its parent component,
|
||||
but as a child of the `host` element.
|
||||
|
||||
## Usage
|
||||
|
||||
Typical uses of portals can include modal dialogs and hovercards, as well as more technical applications such as controlling the contents of an element's [`shadowRoot`](https://developer.mozilla.org/en-US/docs/Web/API/Element/shadowRoot), appending stylesheets to the surrounding document's `<head>` and collecting referenced elements inside a central `<defs>` element of an `<svg>`.
|
||||
|
||||
Note that `yew::create_portal` is a rather low-level building block, on which other components should be built that provide the interface for your specific use case. As an example, here is a simple modal dialogue that renders its `children` into an element outside `yew`'s control, identified by the `id="modal_host"`.
|
||||
|
||||
```rust
|
||||
use yew::{html, create_portal, function_component, Children, Properties};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct ModalProps {
|
||||
#[prop_or_default]
|
||||
pub children: Children,
|
||||
}
|
||||
|
||||
#[function_component(Modal)]
|
||||
fn modal(props: &ModalProps) -> Html {
|
||||
let modal_host = gloo_utils::document()
|
||||
.get_element_by_id("modal_host")
|
||||
.expect("a #modal_host element");
|
||||
|
||||
create_portal(
|
||||
html!{ {for props.children.iter()} },
|
||||
modal_host.into(),
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Further reading
|
||||
- [Portals example](https://github.com/yewstack/yew/tree/master/examples/portals)
|
|
@ -91,6 +91,7 @@ module.exports = {
|
|||
items: [
|
||||
"advanced-topics/how-it-works",
|
||||
"advanced-topics/optimizations",
|
||||
"advanced-topics/portals",
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
Loading…
Reference in New Issue