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:
WorldSEnder 2021-11-16 16:48:04 +01:00 committed by GitHub
parent a5c343dd62
commit aa9b9b266c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 411 additions and 4 deletions

View File

@ -25,6 +25,7 @@ members = [
"examples/multi_thread",
"examples/nested_list",
"examples/node_refs",
"examples/portals",
"examples/pub_sub",
"examples/router",
"examples/store",

View File

@ -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",
]

View File

@ -0,0 +1,10 @@
# Portals Example
[![Demo](https://img.shields.io/website?label=demo&url=https%3A%2F%2Fexamples.yew.rs%2Fportals)](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.

View File

@ -0,0 +1,9 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Yew • Portals</title>
</head>
<body></body>
</html>

View File

@ -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>();
}

View File

@ -23,6 +23,7 @@ yew-router = { path = "../../packages/yew-router/" }
[dev-dependencies.web-sys]
version = "0.3"
features = [
"Document",
"Element",
"EventTarget",
"HtmlElement",

View File

@ -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![];

View File

@ -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::*;

View File

@ -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};

View File

@ -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;

View File

@ -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),
}
}
}

View File

@ -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)
}
}

View File

@ -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)

View File

@ -91,6 +91,7 @@ module.exports = {
items: [
"advanced-topics/how-it-works",
"advanced-topics/optimizations",
"advanced-topics/portals",
]
},
{