perspective_viewer/components/
portal.rs1use std::cell::Cell;
14use std::rc::Rc;
15
16use perspective_js::utils::global;
17use wasm_bindgen::JsCast;
18use wasm_bindgen::prelude::*;
19use web_sys::*;
20use yew::prelude::*;
21
22use crate::components::modal::ModalOrientation;
23use crate::components::style::{StyleProvider, StyleSurface};
24use crate::utils::*;
25
26#[derive(Properties, PartialEq)]
27pub struct PortalModalProps {
28 pub children: Children,
29
30 pub target: Option<HtmlElement>,
32
33 #[prop_or(true)]
35 pub own_focus: bool,
36
37 #[prop_or_default]
39 pub on_close: Callback<()>,
40
41 pub tag_name: &'static str,
42
43 pub theme: String,
44
45 pub surface: StyleSurface,
47}
48
49pub enum PortalModalMsg {
50 Reposition,
51}
52
53pub struct PortalModal {
54 host: HtmlElement,
55 shadow_root: Element,
56 top: f64,
57 left: f64,
58 visible: bool,
59 rev_vert: ModalOrientation,
60 anchor: Rc<Cell<ModalAnchor>>,
61 _blur_closure: Option<Closure<dyn FnMut(FocusEvent)>>,
62}
63
64impl PortalModal {
65 fn attach_to_body(&self) {
66 if !self.host.is_connected() {
67 let _ = global::body().append_child(&self.host);
68 }
69 }
70
71 fn detach_from_body(&mut self) {
72 if self.host.is_connected() {
73 let _ = global::body().remove_child(&self.host);
74 }
75
76 if let Some(closure) = self._blur_closure.as_ref() {
77 self.host
78 .remove_event_listener_with_callback("blur", closure.as_ref().unchecked_ref())
79 .unwrap()
80 }
81
82 self._blur_closure = None;
83 }
84
85 fn position_against_target(&mut self, target: &HtmlElement) {
86 let target_rect = target.get_bounding_client_rect();
87 let height = target_rect.height();
88 let width = target_rect.width();
89 let top = target_rect.top();
90 let left = target_rect.left();
91
92 if !self.visible {
93 self.top = top + height - 1.0;
95 self.left = left;
96 self.visible = false;
97 } else {
98 let anchor = calc_relative_position(&self.host, top, left, height, width);
100 self.anchor.set(anchor);
101 let modal_rect = self.host.get_bounding_client_rect();
102 let (new_top, new_left) = calc_anchor_position(anchor, &target_rect, &modal_rect);
103 self.top = new_top;
104 self.left = new_left;
105 self.rev_vert.set(anchor.is_rev_vert());
106 }
107 }
108
109 fn setup_blur_handler(&mut self, ctx: &Context<Self>) {
110 let on_close = {
111 let target = ctx.props().target.clone();
112 ctx.props().on_close.reform(move |_| {
113 if let Some(target) = &target {
114 target.class_list().remove_1("modal-target").unwrap();
115 }
116 })
117 };
118
119 let closure = Closure::wrap(Box::new(move |_: FocusEvent| {
120 on_close.emit(());
121 }) as Box<dyn FnMut(FocusEvent)>);
122
123 let _ = self
124 .host
125 .add_event_listener_with_callback("blur", closure.as_ref().unchecked_ref());
126
127 self._blur_closure = Some(closure);
128 }
129}
130
131impl Component for PortalModal {
132 type Message = PortalModalMsg;
133 type Properties = PortalModalProps;
134
135 fn create(ctx: &Context<Self>) -> Self {
136 let host: HtmlElement = global::document()
137 .create_element(ctx.props().tag_name)
138 .unwrap()
139 .unchecked_into();
140
141 host.style().set_property("position", "fixed").unwrap();
142 host.style().set_property("z-index", "10000").unwrap();
143 let init = ShadowRootInit::new(ShadowRootMode::Open);
144 let shadow_root = if let Some(elem) = host.shadow_root() {
145 elem
146 } else {
147 host.attach_shadow(&init).unwrap()
148 }
149 .unchecked_into::<Element>();
150
151 Self {
152 host,
153 shadow_root,
154 top: 0.0,
155 left: 0.0,
156 visible: false,
157 rev_vert: Default::default(),
158 anchor: Default::default(),
159 _blur_closure: None,
160 }
161 }
162
163 fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
164 match msg {
165 PortalModalMsg::Reposition => {
166 self.visible = true;
167 true
168 },
169 }
170 }
171
172 fn changed(&mut self, ctx: &Context<Self>, old_props: &Self::Properties) -> bool {
173 debug_assert_eq!(ctx.props().tag_name, old_props.tag_name);
178 debug_assert_eq!(ctx.props().surface, old_props.surface);
179
180 let new_target = &ctx.props().target;
181 let old_target = &old_props.target;
182
183 match (old_target, new_target, self._blur_closure.as_ref()) {
184 (None, Some(_), Some(closure)) => {
185 self.visible = false;
186 self.host
187 .remove_event_listener_with_callback("blur", closure.as_ref().unchecked_ref())
188 .unwrap();
189
190 self._blur_closure = None;
191 },
192 (None, Some(_), None) => {
193 self.visible = false;
194 self._blur_closure = None;
195 },
196 (Some(_), None, _) => {
197 self.detach_from_body();
198 return true;
199 },
200 _ => {},
201 }
202
203 true
204 }
205
206 fn view(&self, ctx: &Context<Self>) -> Html {
207 let target = &ctx.props().target;
208 if target.is_none() {
209 return html! {};
210 }
211
212 let opacity = if self.visible { "" } else { ";opacity:0" };
213 let css = format!(
214 ":host{{top:{}px;left:{}px{}}}",
215 self.top, self.left, opacity
216 );
217
218 let portal_content = html! {
219 <>
220 <style>{ css }</style>
221 <ContextProvider<ModalOrientation> context={self.rev_vert.clone()}>
222 <StyleProvider root={self.host.clone()} surface={ctx.props().surface}>
223 { for ctx.props().children.iter() }
224 </StyleProvider>
225 </ContextProvider<ModalOrientation>>
226 </>
227 };
228
229 yew::create_portal(portal_content, self.shadow_root.clone())
230 }
231
232 fn rendered(&mut self, ctx: &Context<Self>, _first_render: bool) {
233 if let Some(target) = &ctx.props().target {
234 if !self.host.is_connected() {
235 let theme = ctx.props().theme.as_str();
236 self.host.set_attribute("theme", theme).unwrap();
237
238 self.position_against_target(target);
240 self.attach_to_body();
241
242 if let Some(theme) = target.get_attribute("theme") {
244 let _ = self.host.set_attribute("theme", &theme);
245 }
246
247 target.class_list().add_1("modal-target").unwrap();
248
249 if ctx.props().own_focus {
250 self.host.set_attribute("tabindex", "0").unwrap();
251 self.setup_blur_handler(ctx);
252 }
253
254 let link = ctx.link().clone();
256 wasm_bindgen_futures::spawn_local(async move {
257 request_animation_frame().await;
258 link.send_message(PortalModalMsg::Reposition);
259 });
260 } else if self.visible {
261 self.position_against_target(target);
263
264 if ctx.props().own_focus && self._blur_closure.is_some() {
265 let _ = self.host.focus();
266 }
267 }
268 }
269 }
270
271 fn destroy(&mut self, ctx: &Context<Self>) {
272 if let Some(target) = &ctx.props().target {
273 target.class_list().remove_1("modal-target").unwrap();
274 if target.get_attribute("theme").is_some() {
275 let _ = self.host.remove_attribute("theme");
276 }
277
278 let event = CustomEvent::new("-perspective-close-expression").unwrap();
279 let _ = target.dispatch_event(&event);
280 }
281
282 self.detach_from_body();
283 }
284}