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
59/// Anchor point enum, `ModalCornerTargetCorner`
60#[derive(Clone, Copy, Debug, Default)]
61enum ModalAnchor {
62    BottomRightTopLeft,
63    BottomRightBottomLeft,
64    BottomRightTopRight,
65    BottomLeftTopLeft,
66    TopRightTopLeft,
67    TopRightBottomRight,
68
69    #[default]
70    TopLeftBottomLeft,
71}
72
73impl ModalAnchor {
74    const fn is_rev_vert(&self) -> bool {
75        matches!(
76            self,
77            Self::BottomLeftTopLeft
78                | Self::BottomRightBottomLeft
79                | Self::BottomRightTopLeft
80                | Self::BottomRightTopRight
81        )
82    }
83}
84
85/// Given the bounds of the target element as previous computed, as well as the
86/// browser's viewport and the bounds of the already-connected
87/// `<perspectuve-style-menu>` element itself, determine a new (top, left)
88/// coordinates that keeps the element on-screen.
89fn calc_relative_position(
90    elem: &HtmlElement,
91    _top: f64,
92    left: f64,
93    height: f64,
94    width: f64,
95) -> ModalAnchor {
96    let window = global::window();
97    let rect = elem.get_bounding_client_rect();
98    let inner_width = window.inner_width().unwrap().as_f64().unwrap();
99    let inner_height = window.inner_height().unwrap().as_f64().unwrap();
100    let rect_top = rect.top();
101    let rect_height = rect.height();
102    let rect_width = rect.width();
103    let rect_left = rect.left();
104
105    let elem_over_y = inner_height < rect_top + rect_height;
106    let elem_over_x = inner_width < rect_left + rect_width;
107    let target_over_x = inner_width < rect_left + width;
108    let target_over_y = inner_height < rect_top + height;
109
110    // modal/target
111    match (elem_over_y, elem_over_x, target_over_x, target_over_y) {
112        (true, _, true, true) => ModalAnchor::BottomRightTopLeft,
113        (true, _, true, false) => ModalAnchor::BottomRightBottomLeft,
114        (true, true, false, _) => {
115            if left + width - rect_width > 0.0 {
116                ModalAnchor::BottomRightTopRight
117            } else {
118                ModalAnchor::BottomLeftTopLeft
119            }
120        },
121        (true, false, false, _) => ModalAnchor::BottomLeftTopLeft,
122        (false, true, true, _) => ModalAnchor::TopRightTopLeft,
123        (false, true, false, _) => {
124            if left + width - rect_width > 0.0 {
125                ModalAnchor::TopRightBottomRight
126            } else {
127                ModalAnchor::TopLeftBottomLeft
128            }
129        },
130        _ => ModalAnchor::TopLeftBottomLeft,
131    }
132}
133
134impl<T> ModalElement<T>
135where
136    T: Component,
137    T::Properties: ModalLink<T>,
138{
139    pub fn new(
140        custom_element: web_sys::HtmlElement,
141        props: T::Properties,
142        own_focus: bool,
143        on_blur: Option<Callback<()>>,
144    ) -> Self {
145        custom_element.set_attribute("tabindex", "0").unwrap();
146        let init = web_sys::ShadowRootInit::new(web_sys::ShadowRootMode::Open);
147        let shadow_root = custom_element
148            .attach_shadow(&init)
149            .unwrap()
150            .unchecked_into::<web_sys::Element>();
151
152        let cprops = yew::props!(ModalProps<T> {
153            child: Some(html_nested! {
154                <T ..props />
155            }),
156        });
157
158        let root = Rc::new(RefCell::new(Some(
159            yew::Renderer::with_root_and_props(shadow_root, cprops).render(),
160        )));
161
162        let blurhandler = Rc::new(RefCell::new(None));
163        Self {
164            root,
165            custom_element,
166            target: Rc::new(RefCell::new(None)),
167            own_focus,
168            blurhandler,
169            resize_sub: Rc::new(RefCell::new(None)),
170            anchor: Default::default(),
171            on_blur,
172        }
173    }
174
175    fn calc_anchor_position(&self, target: &HtmlElement) -> (f64, f64) {
176        let elem = target.unchecked_ref::<HtmlElement>();
177        let rect = elem.get_bounding_client_rect();
178        let height = rect.height();
179        let width = rect.width();
180        let top = rect.top();
181        let left = rect.left();
182
183        let self_rect = self.custom_element.get_bounding_client_rect();
184        let rect_height = self_rect.height();
185        let rect_width = self_rect.width();
186
187        match self.anchor.get() {
188            ModalAnchor::BottomRightTopLeft => (top - rect_height, left - rect_width + 1.0),
189            ModalAnchor::BottomRightBottomLeft => {
190                (top - rect_height + height, left - rect_width + 1.0)
191            },
192            ModalAnchor::BottomRightTopRight => {
193                (top - rect_height + 1.0, left + width - rect_width)
194            },
195            ModalAnchor::BottomLeftTopLeft => (top - rect_height + 1.0, left),
196            ModalAnchor::TopRightTopLeft => (top, left - rect_width + 1.0),
197            ModalAnchor::TopRightBottomRight => (top + height - 1.0, left + width - rect_width),
198            ModalAnchor::TopLeftBottomLeft => ((top + height - 1.0), left),
199        }
200    }
201
202    async fn open_within_viewport(&self, target: HtmlElement) -> ApiResult<()> {
203        let elem = target.unchecked_ref::<HtmlElement>();
204        let rect = elem.get_bounding_client_rect();
205        let width = rect.width();
206        let height = rect.height();
207        let top = rect.top();
208        let left = rect.left();
209        *self.target.borrow_mut() = Some(target.clone());
210
211        // Default, top left/bottom left
212        let msg = ModalMsg::SetPos {
213            top: top + height - 1.0,
214            left,
215            visible: false,
216            rev_vert: false,
217        };
218
219        self.root.borrow().as_ref().unwrap().send_message(msg);
220        global::body().append_child(&self.custom_element)?;
221        request_animation_frame().await;
222
223        // Check if the modal has been positioned off-screen and re-locate if necessary
224        self.anchor.set(calc_relative_position(
225            &self.custom_element,
226            top,
227            left,
228            height,
229            width,
230        ));
231
232        let (top, left) = self.calc_anchor_position(&target);
233        let msg = ModalMsg::SetPos {
234            top,
235            left,
236            visible: true,
237            rev_vert: self.anchor.get().is_rev_vert(),
238        };
239
240        self.root.borrow().as_ref().unwrap().send_message(msg);
241
242        if self.own_focus {
243            let mut this = Some(self.clone());
244            *self.blurhandler.borrow_mut() = Some(Closure::new(move |_| {
245                this.take().and_then(|x| x.hide().ok()).unwrap_or(())
246            }));
247
248            self.custom_element
249                .dataset()
250                .set("poscorrected", "true")
251                .unwrap();
252
253            self.custom_element.add_event_listener_with_callback(
254                "blur",
255                self.blurhandler
256                    .borrow()
257                    .as_ref()
258                    .unwrap()
259                    .as_ref()
260                    .unchecked_ref(),
261            )?;
262
263            Ok(self.custom_element.focus()?)
264        } else {
265            Ok(())
266        }
267    }
268
269    pub fn send_message(&self, msg: T::Message) {
270        self.root
271            .borrow()
272            .as_ref()
273            .unwrap()
274            .send_message(ModalMsg::SubMsg(msg))
275    }
276
277    pub fn send_message_batch(&self, msgs: Vec<T::Message>) {
278        self.root
279            .borrow()
280            .as_ref()
281            .unwrap()
282            .send_message_batch(msgs.into_iter().map(ModalMsg::SubMsg).collect())
283    }
284
285    pub fn send_future_batch<Fut>(&self, future: Fut)
286    where
287        Fut: Future + 'static,
288        Fut::Output: SendAsMessage<Modal<T>>,
289    {
290        self.root
291            .borrow()
292            .as_ref()
293            .unwrap()
294            .send_future_batch(future)
295    }
296
297    /// Open this modal by attaching directly to `document.body` with position
298    /// absolutely positioned relative to an alread-connected `target`
299    /// element.
300    ///
301    /// Because the Custom Element has a `blur` handler, we must invoke this
302    /// before attempting to re-parent the element.
303    pub async fn open(
304        self,
305        target: web_sys::HtmlElement,
306        resize_pubsub: Option<&PubSub<()>>,
307    ) -> ApiResult<()> {
308        if let Some(resize) = resize_pubsub {
309            let this = self.clone();
310            let target = target.clone();
311            let anchor = self.anchor.clone();
312            *self.resize_sub.borrow_mut() = Some(resize.add_listener(move |()| {
313                let (top, left) = this.calc_anchor_position(&target);
314                let msg = ModalMsg::SetPos {
315                    top,
316                    left,
317                    visible: true,
318                    rev_vert: anchor.get().is_rev_vert(),
319                };
320
321                this.root.borrow().as_ref().unwrap().send_message(msg);
322            }));
323        };
324
325        target.class_list().add_1("modal-target").unwrap();
326        let theme = get_theme(&target);
327        self.open_within_viewport(target).await.unwrap();
328        if let Some(theme) = theme {
329            self.custom_element.set_attribute("theme", &theme).unwrap();
330        }
331
332        Ok(())
333    }
334
335    pub fn is_open(&self) -> bool {
336        self.custom_element.is_connected()
337    }
338
339    /// Remove from document.
340    pub fn hide(&self) -> ApiResult<()> {
341        if self.is_open() {
342            if self.own_focus {
343                self.custom_element.remove_event_listener_with_callback(
344                    "blur",
345                    self.blurhandler
346                        .borrow()
347                        .as_ref()
348                        .unwrap()
349                        .as_ref()
350                        .unchecked_ref(),
351                )?;
352
353                *self.blurhandler.borrow_mut() = None;
354            }
355
356            global::body().remove_child(&self.custom_element)?;
357            if let Some(blur) = &self.on_blur {
358                blur.emit(());
359            }
360
361            let target = self.target.borrow_mut().take().unwrap();
362            let event = web_sys::CustomEvent::new("-perspective-close-expression")?;
363            target.class_list().remove_1("modal-target").unwrap();
364            if get_theme(&target).is_some() {
365                self.custom_element.remove_attribute("theme")?;
366            }
367
368            target.dispatch_event(&event)?;
369        }
370
371        Ok(())
372    }
373
374    /// Remove from document and cleanup.
375    pub fn destroy(self) -> ApiResult<()> {
376        self.hide()?;
377        self.root.borrow_mut().take().unwrap().destroy();
378        Ok(())
379    }
380}
381
382fn get_theme(elem: &HtmlElement) -> Option<String> {
383    let styles = global::window().get_computed_style(elem).unwrap().unwrap();
384    styles
385        .get_property_value("--theme-name")
386        .ok()
387        .and_then(|x| {
388            let trimmed = x.trim();
389            if !trimmed.is_empty() {
390                Some(trimmed[1..trimmed.len() - 1].to_owned())
391            } else {
392                None
393            }
394        })
395}