Skip to main content

perspective_viewer/custom_elements/
modal.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, RefCell};
14use std::rc::Rc;
15
16use derivative::Derivative;
17use futures::Future;
18use perspective_js::utils::{global, *};
19use wasm_bindgen::JsCast;
20use wasm_bindgen::prelude::*;
21use web_sys::*;
22use yew::html::SendAsMessage;
23use yew::prelude::*;
24
25use crate::components::modal::*;
26use crate::utils::*;
27
28type BlurHandlerType = Rc<RefCell<Option<Closure<dyn FnMut(FocusEvent)>>>>;
29
30/// A `ModalElement` wraps the parameterized yew `Component` in a Custom
31/// Element.
32///
33/// Via the `open()` and `close()` methods, a `ModalElement` can be positioned
34/// next to any existing on-page elements, accounting for viewport,
35/// scroll position, etc.
36///
37/// `#[derive(Clone)]` generates the trait bound `T: Clone`, which is not
38/// required because `Scope<T>` implements Clone without this bound;  thus
39/// `Clone` must be implemented by the `derivative` crate's
40/// [custom bounds](https://mcarton.github.io/rust-derivative/latest/Debug.html#custom-bound)
41/// support.
42#[derive(Derivative)]
43#[derivative(Clone(bound = ""))]
44pub struct ModalElement<T>
45where
46    T: Component,
47    T::Properties: ModalLink<T>,
48{
49    root: Rc<RefCell<Option<AppHandle<Modal<T>>>>>,
50    pub custom_element: HtmlElement,
51    target: Rc<RefCell<Option<HtmlElement>>>,
52    blurhandler: BlurHandlerType,
53    own_focus: bool,
54    resize_sub: Rc<RefCell<Option<Subscription>>>,
55    anchor: Rc<Cell<ModalAnchor>>,
56    on_blur: Option<Callback<()>>,
57}
58
59impl<T> ModalElement<T>
60where
61    T: Component,
62    T::Properties: ModalLink<T>,
63{
64    pub fn new(
65        custom_element: web_sys::HtmlElement,
66        props: T::Properties,
67        own_focus: bool,
68        on_blur: Option<Callback<()>>,
69    ) -> Self {
70        custom_element.set_attribute("tabindex", "0").unwrap();
71        let init = web_sys::ShadowRootInit::new(web_sys::ShadowRootMode::Open);
72        let shadow_root = custom_element
73            .attach_shadow(&init)
74            .unwrap()
75            .unchecked_into::<web_sys::Element>();
76
77        let cprops = yew::props!(ModalProps<T> {
78            child: Some(html_nested! {
79                <T ..props />
80            }),
81        });
82
83        let root = Rc::new(RefCell::new(Some(
84            yew::Renderer::with_root_and_props(shadow_root, cprops).render(),
85        )));
86
87        let blurhandler = Rc::new(RefCell::new(None));
88        Self {
89            root,
90            custom_element,
91            target: Rc::new(RefCell::new(None)),
92            own_focus,
93            blurhandler,
94            resize_sub: Rc::new(RefCell::new(None)),
95            anchor: Default::default(),
96            on_blur,
97        }
98    }
99
100    fn calc_anchor_pos(&self, target: &HtmlElement) -> (f64, f64) {
101        let target_rect = target.get_bounding_client_rect();
102        let modal_rect = self.custom_element.get_bounding_client_rect();
103        calc_anchor_position(self.anchor.get(), &target_rect, &modal_rect)
104    }
105
106    async fn open_within_viewport(&self, target: HtmlElement) -> ApiResult<()> {
107        let elem = target.unchecked_ref::<HtmlElement>();
108        let rect = elem.get_bounding_client_rect();
109        let width = rect.width();
110        let height = rect.height();
111        let top = rect.top();
112        let left = rect.left();
113        *self.target.borrow_mut() = Some(target.clone());
114
115        // Default, top left/bottom left
116        let msg = ModalMsg::SetPos {
117            top: top + height - 1.0,
118            left,
119            visible: false,
120            rev_vert: false,
121        };
122
123        self.root.borrow().as_ref().unwrap().send_message(msg);
124        global::body().append_child(&self.custom_element)?;
125        request_animation_frame().await;
126
127        // Check if the modal has been positioned off-screen and re-locate if necessary
128        self.anchor.set(calc_relative_position(
129            &self.custom_element,
130            top,
131            left,
132            height,
133            width,
134        ));
135
136        let (top, left) = self.calc_anchor_pos(&target);
137        let msg = ModalMsg::SetPos {
138            top,
139            left,
140            visible: true,
141            rev_vert: self.anchor.get().is_rev_vert(),
142        };
143
144        self.root.borrow().as_ref().unwrap().send_message(msg);
145
146        if self.own_focus {
147            let mut this = Some(self.clone());
148            *self.blurhandler.borrow_mut() = Some(Closure::new(move |_| {
149                this.take().and_then(|x| x.hide().ok()).unwrap_or(())
150            }));
151
152            self.custom_element
153                .dataset()
154                .set("poscorrected", "true")
155                .unwrap();
156
157            self.custom_element.add_event_listener_with_callback(
158                "blur",
159                self.blurhandler
160                    .borrow()
161                    .as_ref()
162                    .unwrap()
163                    .as_ref()
164                    .unchecked_ref(),
165            )?;
166
167            Ok(self.custom_element.focus()?)
168        } else {
169            Ok(())
170        }
171    }
172
173    pub fn send_message(&self, msg: T::Message) {
174        self.root
175            .borrow()
176            .as_ref()
177            .unwrap()
178            .send_message(ModalMsg::SubMsg(msg))
179    }
180
181    pub fn send_message_batch(&self, msgs: Vec<T::Message>) {
182        self.root
183            .borrow()
184            .as_ref()
185            .unwrap()
186            .send_message_batch(msgs.into_iter().map(ModalMsg::SubMsg).collect())
187    }
188
189    pub fn send_future_batch<Fut>(&self, future: Fut)
190    where
191        Fut: Future + 'static,
192        Fut::Output: SendAsMessage<Modal<T>>,
193    {
194        self.root
195            .borrow()
196            .as_ref()
197            .unwrap()
198            .send_future_batch(future)
199    }
200
201    /// Open this modal by attaching directly to `document.body` with position
202    /// absolutely positioned relative to an alread-connected `target`
203    /// element.
204    ///
205    /// Because the Custom Element has a `blur` handler, we must invoke this
206    /// before attempting to re-parent the element.
207    pub async fn open(
208        self,
209        target: web_sys::HtmlElement,
210        resize_pubsub: Option<&PubSub<()>>,
211    ) -> ApiResult<()> {
212        if let Some(resize) = resize_pubsub {
213            let this = self.clone();
214            let target = target.clone();
215            let anchor = self.anchor.clone();
216            *self.resize_sub.borrow_mut() = Some(resize.add_listener(move |()| {
217                let (top, left) = this.calc_anchor_pos(&target);
218                let msg = ModalMsg::SetPos {
219                    top,
220                    left,
221                    visible: true,
222                    rev_vert: anchor.get().is_rev_vert(),
223                };
224
225                this.root.borrow().as_ref().unwrap().send_message(msg);
226            }));
227        };
228
229        target.class_list().add_1("modal-target").unwrap();
230        let theme = get_theme(&target);
231        self.open_within_viewport(target).await.unwrap();
232        if let Some(theme) = theme {
233            self.custom_element.set_attribute("theme", &theme).unwrap();
234        }
235
236        Ok(())
237    }
238
239    pub fn is_open(&self) -> bool {
240        self.custom_element.is_connected()
241    }
242
243    /// Remove from document.
244    pub fn hide(&self) -> ApiResult<()> {
245        if self.is_open() {
246            if self.own_focus {
247                self.custom_element.remove_event_listener_with_callback(
248                    "blur",
249                    self.blurhandler
250                        .borrow()
251                        .as_ref()
252                        .unwrap()
253                        .as_ref()
254                        .unchecked_ref(),
255                )?;
256
257                *self.blurhandler.borrow_mut() = None;
258            }
259
260            global::body().remove_child(&self.custom_element)?;
261            if let Some(blur) = &self.on_blur {
262                blur.emit(());
263            }
264
265            let target = self.target.borrow_mut().take().unwrap();
266            let event = web_sys::CustomEvent::new("-perspective-close-expression")?;
267            target.class_list().remove_1("modal-target").unwrap();
268            if get_theme(&target).is_some() {
269                self.custom_element.remove_attribute("theme")?;
270            }
271
272            target.dispatch_event(&event)?;
273        }
274
275        Ok(())
276    }
277
278    /// Remove from document and cleanup.
279    pub fn destroy(self) -> ApiResult<()> {
280        self.hide()?;
281        self.root.borrow_mut().take().unwrap().destroy();
282        Ok(())
283    }
284}
285
286fn get_theme(elem: &HtmlElement) -> Option<String> {
287    let styles = global::window().get_computed_style(elem).unwrap().unwrap();
288    styles
289        .get_property_value("--psp-theme-name")
290        .ok()
291        .and_then(|x| {
292            let trimmed = x.trim();
293            if !trimmed.is_empty() {
294                Some(trimmed[1..trimmed.len() - 1].to_owned())
295            } else {
296                None
297            }
298        })
299}