Skip to main content

yewlish_popover/
lib.rs

1use std::{
2    fmt::{Display, Formatter},
3    rc::Rc,
4};
5use web_sys::wasm_bindgen::JsCast;
6use web_sys::{wasm_bindgen::prelude::Closure, Element};
7use yew::prelude::*;
8use yewlish_attr_passer::*;
9use yewlish_presence::*;
10use yewlish_roving_focus::helpers::get_focusable_element;
11use yewlish_utils::hooks::{use_controllable_state, use_interaction_outside, use_viewport_move};
12
13#[derive(Debug, Clone, PartialEq)]
14pub struct PopoverContext {
15    pub host: NodeRef,
16    pub is_open: bool,
17    pub on_toggle: Callback<bool>,
18}
19
20pub enum PopoverAction {
21    Open,
22    Close,
23    Toggle,
24}
25
26impl Reducible for PopoverContext {
27    type Action = PopoverAction;
28
29    fn reduce(self: Rc<PopoverContext>, action: Self::Action) -> Rc<PopoverContext> {
30        match action {
31            PopoverAction::Open => PopoverContext {
32                is_open: true,
33                ..(*self).clone()
34            }
35            .into(),
36            PopoverAction::Close => PopoverContext {
37                is_open: false,
38                ..(*self).clone()
39            }
40            .into(),
41            PopoverAction::Toggle => PopoverContext {
42                is_open: !self.is_open,
43                ..(*self).clone()
44            }
45            .into(),
46        }
47    }
48}
49
50pub type ReduciblePopoverContext = UseReducerHandle<PopoverContext>;
51
52#[derive(Clone, Debug, PartialEq, Properties)]
53pub struct PopoverProps {
54    pub children: Children,
55    #[prop_or_default]
56    pub open: Option<bool>,
57    #[prop_or_default]
58    pub on_open_change: Callback<bool>,
59    #[prop_or_default]
60    pub default_open: bool,
61    #[prop_or_default]
62    pub class: Option<AttrValue>,
63}
64
65#[function_component(Popover)]
66pub fn popover(props: &PopoverProps) -> Html {
67    let node_ref = use_node_ref();
68
69    let (is_open, dispatch) = use_controllable_state(
70        props.default_open.into(),
71        props.open,
72        props.on_open_change.clone(),
73    );
74
75    let on_toggle = use_callback(dispatch.clone(), {
76        move |new_state, dispatch| {
77            dispatch.emit(Box::new(move |_| new_state));
78        }
79    });
80
81    let context_value = use_reducer(|| PopoverContext {
82        host: node_ref.clone(),
83        is_open: *is_open.borrow(),
84        on_toggle,
85    });
86
87    use_effect_with(
88        (*(*is_open).borrow(), context_value.clone()),
89        |(is_open, context_value)| {
90            if *is_open != context_value.is_open {
91                context_value.dispatch(PopoverAction::Toggle);
92            }
93        },
94    );
95
96    html! {
97        <ContextProvider<ReduciblePopoverContext> context={context_value}>
98            <div ref={node_ref} class={&props.class}>
99                {props.children.clone()}
100            </div>
101        </ContextProvider<ReduciblePopoverContext>>
102    }
103}
104
105#[derive(Clone, Debug, PartialEq, Properties)]
106pub struct PopoverTriggerRenderAsProps {
107    pub toggle: Callback<MouseEvent>,
108    pub is_open: bool,
109    #[prop_or_default]
110    pub children: Children,
111    #[prop_or_default]
112    pub class: Option<AttrValue>,
113}
114
115#[derive(Clone, Debug, PartialEq, Properties)]
116pub struct PopoverTriggerProps {
117    #[prop_or_default]
118    pub children: Children,
119    #[prop_or_default]
120    pub class: Option<AttrValue>,
121    #[prop_or_default]
122    pub render_as: Option<Callback<PopoverTriggerRenderAsProps, Html>>,
123}
124
125#[function_component(PopoverTrigger)]
126pub fn popover_trigger(props: &PopoverTriggerProps) -> Html {
127    let context = use_context::<ReduciblePopoverContext>()
128        .expect("PopoverTrigger must be a child of Popover");
129
130    let toggle = use_callback(context.is_open, {
131        let context = context.clone();
132
133        move |_event: MouseEvent, is_open| {
134            context.on_toggle.emit(!is_open);
135        }
136    });
137
138    let data_state = use_memo(
139        context.is_open,
140        |is_open| {
141            if *is_open {
142                "open"
143            } else {
144                "closed"
145            }
146        },
147    );
148
149    let element = if let Some(render_as) = &props.render_as {
150        html! {{
151            render_as.emit(PopoverTriggerRenderAsProps {
152                children: props.children.clone(),
153                class: props.class.clone(),
154                toggle,
155                is_open: context.is_open,
156            })
157        }}
158    } else {
159        html! {
160            <button class={&props.class} onclick={&toggle}>
161                {props.children.clone()}
162            </button>
163        }
164    };
165
166    html! {
167        <AttrPasser name="popover-trigger" ..attributify! {
168            "data-state" => *data_state,
169            "role" => "button",
170        }>
171            { element }
172        </AttrPasser>
173    }
174}
175
176#[derive(Clone, Debug, PartialEq, Default)]
177pub enum PopoverSide {
178    Top,
179    Right,
180    #[default]
181    Bottom,
182    Left,
183}
184
185impl Display for PopoverSide {
186    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
187        match self {
188            PopoverSide::Top => write!(f, "top"),
189            PopoverSide::Right => write!(f, "right"),
190            PopoverSide::Bottom => write!(f, "bottom"),
191            PopoverSide::Left => write!(f, "left"),
192        }
193    }
194}
195
196#[derive(Clone, Debug, PartialEq, Default)]
197pub enum PopoverAlign {
198    Start,
199    #[default]
200    Center,
201    End,
202}
203
204impl Display for PopoverAlign {
205    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
206        match self {
207            PopoverAlign::Start => write!(f, "start"),
208            PopoverAlign::Center => write!(f, "center"),
209            PopoverAlign::End => write!(f, "end"),
210        }
211    }
212}
213
214#[derive(Clone, Debug, PartialEq, Properties)]
215pub struct PopoverContentProps {
216    #[prop_or_default]
217    pub children: Children,
218    #[prop_or_default]
219    pub class: Option<AttrValue>,
220    #[prop_or_default]
221    pub container: Option<Element>,
222    #[prop_or_default]
223    pub viewport: Option<Element>,
224    #[prop_or_default]
225    pub side: PopoverSide,
226    #[prop_or_default]
227    pub align: PopoverAlign,
228    #[prop_or_default]
229    pub on_esc_key_down: Callback<KeyboardEvent>,
230    #[prop_or_default]
231    pub on_interaction_outside: Callback<Event>,
232}
233
234#[function_component(PopoverContent)]
235pub fn popover_content(props: &PopoverContentProps) -> Html {
236    let context = use_context::<ReduciblePopoverContext>()
237        .expect("PopoverContent must be a child of Popover");
238
239    let host = props.container.clone().unwrap_or_else(|| {
240        context
241            .host
242            .cast::<Element>()
243            .expect("PopoverContent must be a child of Popover")
244    });
245
246    {
247        let context = context.clone();
248
249        use_effect_with(
250            (host.clone(), props.on_esc_key_down.clone()),
251            |(host, on_esc_key_down)| {
252                let on_esc_key_down = on_esc_key_down.clone();
253
254                let listener = Closure::wrap(Box::new(move |event: KeyboardEvent| {
255                    if event.key() != "Escape" {
256                        return;
257                    }
258
259                    on_esc_key_down.emit(event.clone());
260
261                    if event.default_prevented() {
262                        return;
263                    }
264
265                    context.on_toggle.emit(false);
266                }) as Box<dyn FnMut(_)>);
267
268                let _ = host
269                    .add_event_listener_with_callback("keydown", listener.as_ref().unchecked_ref());
270
271                let host = host.clone();
272
273                move || {
274                    let _ = host.remove_event_listener_with_callback(
275                        "keydown",
276                        listener.as_ref().unchecked_ref(),
277                    );
278                }
279            },
280        );
281    }
282
283    let dom_rect = host.get_bounding_client_rect();
284    let adjusted_height = use_state(|| None::<f64>);
285
286    let auto_update_handler = use_callback(host.clone(), {
287        let adjusted_height = adjusted_height.clone();
288
289        move |(), host| {
290            let dom_rect = host.get_bounding_client_rect();
291            adjusted_height.set(dom_rect.height().into());
292        }
293    });
294
295    let style = stringify!(
296        position: fixed;
297        top: 0;
298        left: 0;
299        will-change: transform;
300    )
301    .to_string();
302
303    use_viewport_move(&context.host, auto_update_handler);
304
305    let transform = format!(
306        "transform: translate({}, {});",
307        match props.side {
308            PopoverSide::Right => format!("calc({}px + {}px)", dom_rect.x(), dom_rect.width()),
309            PopoverSide::Top | PopoverSide::Bottom => match props.align {
310                PopoverAlign::Start => format!("calc({}px)", dom_rect.x()),
311                PopoverAlign::Center => format!(
312                    "calc({}px - (100% - {}px) / 2)",
313                    dom_rect.x(),
314                    dom_rect.width(),
315                ),
316                PopoverAlign::End =>
317                    format!("calc({}px - 100% + {}px)", dom_rect.x(), dom_rect.width()),
318            },
319            PopoverSide::Left => format!("calc({}px - 100%)", dom_rect.x()),
320        },
321        match props.side {
322            PopoverSide::Top => format!("calc({}px - 100%)", dom_rect.y()),
323            PopoverSide::Bottom => format!(
324                "calc({}px + {}px)",
325                dom_rect.y(),
326                adjusted_height.unwrap_or_else(|| dom_rect.height())
327            ),
328            PopoverSide::Right | PopoverSide::Left => match props.align {
329                PopoverAlign::Start => format!("calc({}px)", dom_rect.y()),
330                PopoverAlign::Center => format!(
331                    "calc({}px - {}px)",
332                    dom_rect.y(),
333                    adjusted_height.unwrap_or_else(|| dom_rect.height())
334                ),
335                PopoverAlign::End => format!(
336                    "calc({}px + {}px - 100%)",
337                    dom_rect.y(),
338                    adjusted_height.unwrap_or_else(|| dom_rect.height())
339                ),
340            },
341        },
342    );
343
344    let style = format!("{style} {transform}");
345    let content_ref = use_node_ref();
346
347    use_interaction_outside(
348        {
349            let mut nodes = vec![];
350            nodes.push((&host).into());
351            nodes.push((&content_ref).into());
352
353            if props.container.is_some() {
354                nodes.push((&context.host.clone()).into());
355            }
356
357            nodes
358        },
359        {
360            let context = context.clone();
361            let on_interaction_outside = props.on_interaction_outside.clone();
362
363            move |event: Event| {
364                on_interaction_outside.emit(event.clone());
365
366                if event.default_prevented() {
367                    return;
368                }
369
370                context.on_toggle.emit(false);
371            }
372        },
373    );
374
375    let focus_on_present = use_callback(content_ref.clone(), |(), content_ref| {
376        if let Some(content) = content_ref.cast::<Element>() {
377            if let Some(element) = get_focusable_element(&content) {
378                match element.focus() {
379                    Ok(()) => {}
380                    Err(error) => {
381                        log::error!("Failed to focus the popover content: {error:?}");
382                    }
383                }
384            }
385        }
386    });
387
388    let viewport = props.viewport.clone().unwrap_or_else(|| {
389        host.owner_document()
390            .and_then(|document| document.body())
391            .and_then(|body| body.dyn_into::<Element>().ok())
392            .expect("Failed to get viewport")
393    });
394
395    let side = props.side.clone();
396    let align = props.align.clone();
397
398    create_portal(
399        html! {
400            <Presence
401                r#ref={content_ref.clone()}
402                class={&props.class}
403                name="popover-content"
404                present={context.is_open}
405                class={&props.class}
406                on_present={focus_on_present}
407                render_as={Callback::from(move |presence_props: PresenceRenderAsProps| {
408                    if !presence_props.presence {
409                        return html! {};
410                    }
411
412                    html! {
413                        <div
414                            ref={presence_props.r#ref.clone()}
415                            data-state={if context.is_open { "open" } else { "closed" }}
416                            data-side={side.to_string()}
417                            data-align={align.to_string()}
418                            role="dialog"
419                            style={style.clone()}
420                            class={&presence_props.class}
421                        >
422                            {presence_props.children.clone()}
423                        </div>
424                    }
425                })}
426            >
427                {props.children.clone()}
428            </Presence>
429        },
430        viewport,
431    )
432}