Skip to main content

perspective_viewer/components/
portal.rs

1// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2// ┃ ██████ ██████ ██████       █      █      █      █      █ █▄  ▀███ █       ┃
3// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█  ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄  ▀█ █ ▀▀▀▀▀ ┃
4// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄   █ ▄▄▄▄▄ ┃
5// ┃ █      ██████ █  ▀█▄       █ ██████      █      ███▌▐███ ███████▄ █       ┃
6// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7// ┃ Copyright (c) 2017, the Perspective Authors.                              ┃
8// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9// ┃ This file is part of the Perspective library, distributed under the terms ┃
10// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
13use 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    /// The element to position relative to. `None` means closed.
31    pub target: Option<HtmlElement>,
32
33    /// Whether the portal manages its own focus and closes on blur.
34    #[prop_or(true)]
35    pub own_focus: bool,
36
37    /// Called when the portal closes (blur, etc).
38    #[prop_or_default]
39    pub on_close: Callback<()>,
40
41    pub tag_name: &'static str,
42
43    pub theme: String,
44
45    /// Which popup CSS surface this portal's ShadowRoot adopts.
46    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            // First pass: position at default anchor, invisible
94            self.top = top + height - 1.0;
95            self.left = left;
96            self.visible = false;
97        } else {
98            // Second pass: compute actual anchor and reposition
99            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        // The host element (`tag_name`) and its adopted sheet (`surface`) are
174        // fixed at `create` — a swap at the same vdom position must be keyed
175        // so Yew recreates the component (see `PanelMenu`'s menu→picker
176        // stages).
177        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                // First render with a target: attach to body, position invisible
239                self.position_against_target(target);
240                self.attach_to_body();
241
242                // Propagate theme from target
243                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                // Schedule second positioning pass
255                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                // Second pass: reposition with correct anchor
262                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}