radix_leptos_popper/
popper.rs

1use floating_ui_leptos::{
2    use_floating, Alignment, ApplyState, Arrow, ArrowData, ArrowOptions, AutoUpdateOptions,
3    Boundary, DetectOverflowOptions, Flip, FlipOptions, Hide, HideData, HideOptions, HideStrategy,
4    IntoReference, LimitShift, LimitShiftOptions, Middleware, MiddlewareReturn, MiddlewareState,
5    MiddlewareVec, Offset, OffsetOptions, OffsetOptionsValues, Padding, Placement, Shift,
6    ShiftOptions, Side, Size, SizeOptions, Strategy, UseFloatingOptions, UseFloatingReturn,
7    ARROW_NAME, HIDE_NAME,
8};
9use leptos::{
10    html::{AnyElement, Div},
11    *,
12};
13use radix_leptos_arrow::Arrow as ArrowPrimitive;
14use radix_leptos_compose_refs::use_composed_refs;
15use radix_leptos_primitive::Primitive;
16use radix_leptos_use_size::use_size;
17use serde::{Deserialize, Serialize};
18use web_sys::wasm_bindgen::JsCast;
19
20#[derive(Copy, Clone, Debug, PartialEq)]
21pub enum Align {
22    Start,
23    Center,
24    End,
25}
26
27impl Align {
28    pub fn alignment(self) -> Option<Alignment> {
29        match self {
30            Align::Start => Some(Alignment::Start),
31            Align::Center => None,
32            Align::End => Some(Alignment::End),
33        }
34    }
35}
36
37impl From<Option<Alignment>> for Align {
38    fn from(value: Option<Alignment>) -> Self {
39        match value {
40            Some(Alignment::Start) => Align::Start,
41            Some(Alignment::End) => Align::End,
42            None => Align::Center,
43        }
44    }
45}
46
47#[derive(Copy, Clone, Debug, PartialEq)]
48pub enum Sticky {
49    Partial,
50    Always,
51}
52
53#[derive(Copy, Clone, Debug, PartialEq)]
54pub enum UpdatePositionStrategy {
55    Optimized,
56    Always,
57}
58
59#[derive(Clone)]
60struct PopperContextValue {
61    pub anchor_ref: NodeRef<AnyElement>,
62}
63
64#[component]
65pub fn Popper(children: ChildrenFn) -> impl IntoView {
66    let anchor_ref: NodeRef<AnyElement> = NodeRef::new();
67
68    let context_value = PopperContextValue { anchor_ref };
69
70    view! {
71        <Provider value={context_value}>
72            {children()}
73        </Provider>
74    }
75}
76
77#[component]
78pub fn PopperAnchor(
79    #[prop(into, optional)] as_child: MaybeProp<bool>,
80    #[prop(optional)] node_ref: NodeRef<AnyElement>,
81    #[prop(attrs)] attrs: Vec<(&'static str, Attribute)>,
82    children: ChildrenFn,
83) -> impl IntoView {
84    let context: PopperContextValue = expect_context();
85    let anchor_ref = use_composed_refs(vec![node_ref, context.anchor_ref]);
86
87    view! {
88        <Primitive
89            element=html::div
90            as_child=as_child
91            node_ref=anchor_ref
92            attrs=attrs
93        >
94            {children()}
95        </Primitive>
96    }
97}
98
99#[derive(Clone)]
100struct PopperContentContextValue {
101    pub placed_side: Signal<Side>,
102    pub arrow_ref: NodeRef<AnyElement>,
103    pub arrow_x: Signal<Option<f64>>,
104    pub arrow_y: Signal<Option<f64>>,
105    pub should_hide_arrow: Signal<bool>,
106}
107
108#[component]
109pub fn PopperContent(
110    #[prop(into, optional)] side: MaybeProp<Side>,
111    #[prop(into, optional)] side_offset: MaybeProp<f64>,
112    #[prop(into, optional)] align: MaybeProp<Align>,
113    #[prop(into, optional)] align_offset: MaybeProp<f64>,
114    #[prop(into, optional)] arrow_padding: MaybeProp<f64>,
115    #[prop(into, optional)] avoid_collisions: MaybeProp<bool>,
116    #[prop(into, optional)] collision_boundary: MaybeProp<Vec<web_sys::Element>>,
117    #[prop(into, optional)] collision_padding: MaybeProp<Padding>,
118    #[prop(into, optional)] sticky: MaybeProp<Sticky>,
119    #[prop(into, optional)] hide_when_detached: MaybeProp<bool>,
120    #[prop(into, optional)] update_position_strategy: MaybeProp<UpdatePositionStrategy>,
121    #[prop(into, optional)] on_placed: Option<Callback<()>>,
122    #[prop(into, optional)] as_child: MaybeProp<bool>,
123    #[prop(optional)] node_ref: NodeRef<AnyElement>,
124    #[prop(attrs)] attrs: Vec<(&'static str, Attribute)>,
125    children: ChildrenFn,
126) -> impl IntoView {
127    let side = move || side.get().unwrap_or(Side::Bottom);
128    let side_offset = move || side_offset.get().unwrap_or(0.0);
129    let align = move || align.get().unwrap_or(Align::Center);
130    let align_offset = move || align_offset.get().unwrap_or(0.0);
131    let arrow_padding = move || arrow_padding.get().unwrap_or(0.0);
132    let avoid_collisions = move || avoid_collisions.get().unwrap_or(true);
133    let collision_boundary = move || collision_boundary.get().unwrap_or_default();
134    let collision_padding = move || collision_padding.get().unwrap_or(Padding::All(0.0));
135    let sticky = move || sticky.get().unwrap_or(Sticky::Partial);
136    let hide_when_detached = move || hide_when_detached.get().unwrap_or(false);
137    let update_position_strategy = move || {
138        update_position_strategy
139            .get()
140            .unwrap_or(UpdatePositionStrategy::Optimized)
141    };
142
143    let context: PopperContextValue = expect_context();
144
145    let content_ref: NodeRef<AnyElement> = NodeRef::new();
146    let composed_refs = use_composed_refs(vec![node_ref, content_ref]);
147
148    let desired_placement = Signal::derive(move || Placement::from((side(), align().alignment())));
149
150    let arrow_ref: NodeRef<AnyElement> = NodeRef::new();
151    let arrow_size = use_size(arrow_ref);
152    let arrow_width = move || {
153        arrow_size
154            .get()
155            .map(|arrow_size| arrow_size.width)
156            .unwrap_or(0.0)
157    };
158    let arrow_height = move || {
159        arrow_size
160            .get()
161            .map(|arrow_size| arrow_size.height)
162            .unwrap_or(0.0)
163    };
164
165    let floating_ref: NodeRef<Div> = NodeRef::new();
166
167    let UseFloatingReturn {
168        floating_styles,
169        placement,
170        is_positioned,
171        middleware_data,
172        ..
173    } = use_floating(
174        context.anchor_ref.into_reference(),
175        floating_ref,
176        UseFloatingOptions::default()
177            .strategy(Strategy::Fixed.into())
178            .placement(desired_placement.into())
179            .while_elements_mounted_auto_update_with_options(MaybeSignal::derive(move || {
180                AutoUpdateOptions::default()
181                    .animation_frame(update_position_strategy() == UpdatePositionStrategy::Always)
182            }))
183            .middleware(MaybeProp::derive(move || {
184                let detect_overflow_options = DetectOverflowOptions::default()
185                    .padding(collision_padding())
186                    .boundary(Boundary::Elements(collision_boundary()))
187                    .alt_boundary(!collision_boundary().is_empty());
188
189                let mut middleware: MiddlewareVec =
190                    vec![Box::new(Offset::new(OffsetOptions::Values(
191                        OffsetOptionsValues::default()
192                            .main_axis(side_offset() + arrow_height())
193                            .alignment_axis(align_offset()),
194                    )))];
195
196                if avoid_collisions() {
197                    let mut shift_options = ShiftOptions::default()
198                        .detect_overflow(detect_overflow_options.clone())
199                        .main_axis(true)
200                        .cross_axis(false);
201
202                    if sticky() == Sticky::Partial {
203                        shift_options = shift_options
204                            .limiter(Box::new(LimitShift::new(LimitShiftOptions::default())));
205                    }
206
207                    middleware.push(Box::new(Shift::new(shift_options)));
208
209                    middleware.push(Box::new(Flip::new(
210                        FlipOptions::default().detect_overflow(detect_overflow_options.clone()),
211                    )));
212                }
213
214                middleware.push(Box::new(Size::new(
215                    SizeOptions::default()
216                        .detect_overflow(detect_overflow_options.clone())
217                        .apply(&|ApplyState {
218                                     state,
219                                     available_width,
220                                     available_height,
221                                 }| {
222                            let MiddlewareState {
223                                elements, rects, ..
224                            } = state;
225
226                            let content_style = (*elements.floating)
227                                .clone()
228                                .unchecked_into::<web_sys::HtmlElement>()
229                                .style();
230
231                            content_style
232                                .set_property(
233                                    "--radix-popper-available-width",
234                                    &format!("{}px", available_width),
235                                )
236                                .expect("Style should be updated.");
237                            content_style
238                                .set_property(
239                                    "--radix-popper-available-height",
240                                    &format!("{}px", available_height),
241                                )
242                                .expect("Style should be updated.");
243                            content_style
244                                .set_property(
245                                    "--radix-popper-anchor-width",
246                                    &format!("{}px", rects.reference.width),
247                                )
248                                .expect("Style should be updated.");
249                            content_style
250                                .set_property(
251                                    "--radix-popper-anchor-height",
252                                    &format!("{}px", rects.reference.height),
253                                )
254                                .expect("Style should be updated.");
255                        }),
256                )));
257
258                middleware.push(Box::new(Arrow::new(
259                    ArrowOptions::new(arrow_ref).padding(Padding::All(arrow_padding())),
260                )));
261
262                middleware.push(Box::new(TransformOrigin::new(TransformOriginOptions {
263                    arrow_width: arrow_width(),
264                    arrow_height: arrow_height(),
265                })));
266
267                if hide_when_detached() {
268                    middleware.push(Box::new(Hide::new(
269                        HideOptions::default()
270                            .detect_overflow(detect_overflow_options)
271                            .strategy(HideStrategy::ReferenceHidden),
272                    )));
273                }
274
275                Some(middleware)
276            })),
277    );
278
279    let placed_side = Signal::derive(move || placement.get().side());
280    let placed_align = move || Align::from(placement.get().alignment());
281
282    Effect::new(move |_| {
283        if is_positioned.get() {
284            if let Some(on_placed) = on_placed {
285                on_placed.call(());
286            }
287        }
288    });
289
290    let arrow_data = move || -> Option<ArrowData> { middleware_data.get().get_as(ARROW_NAME) };
291    let arrow_x = Signal::derive(move || arrow_data().and_then(|arrow_data| arrow_data.x));
292    let arrow_y = Signal::derive(move || arrow_data().and_then(|arrow_data| arrow_data.y));
293    let cannot_center_arrow = Signal::derive(move || {
294        arrow_data().map_or(true, |arrow_data| arrow_data.center_offset != 0.0)
295    });
296
297    let (content_z_index, set_content_z_index) = create_signal::<Option<String>>(None);
298    Effect::new(move |_| {
299        if let Some(content) = content_ref.get() {
300            set_content_z_index.set(Some(
301                window()
302                    .get_computed_style(&content)
303                    .expect("Element is valid.")
304                    .expect("Element should have computed style.")
305                    .get_property_value("z-index")
306                    .expect("Computed style should have z-index."),
307            ));
308        }
309    });
310
311    let transform_origin_data = move || -> Option<TransformOriginData> {
312        middleware_data.get().get_as(TRANSFORM_ORIGIN_NAME)
313    };
314    let transform_origin = move || {
315        transform_origin_data().map(|transform_origin_data| {
316            format!("{} {}", transform_origin_data.x, transform_origin_data.y)
317        })
318    };
319    let hide_data = move || -> Option<HideData> { middleware_data.get().get_as(HIDE_NAME) };
320    let reference_hidden = move || {
321        hide_data()
322            .and_then(|hide_data| hide_data.reference_hidden)
323            .unwrap_or(false)
324    };
325
326    let dir = attrs
327        .iter()
328        .find_map(|(key, value)| (*key == "dir").then_some(value.clone()));
329
330    let content_context = PopperContentContextValue {
331        placed_side,
332        arrow_ref,
333        arrow_x,
334        arrow_y,
335        should_hide_arrow: cannot_center_arrow,
336    };
337
338    let mut attrs = attrs.clone();
339    attrs.extend([
340        (
341            "data-side",
342            (move || format!("{:?}", placed_side.get()).to_lowercase()).into_attribute(),
343        ),
344        (
345            "data-align",
346            (move || format!("{:?}", placed_align()).to_lowercase()).into_attribute(),
347        ),
348        // If the PopperContent hasn't been placed yet (not all measurements done),
349        // we prevent animations so that users's animation don't kick in too early referring wrong sides.
350        (
351            "style",
352            (move || (!is_positioned.get()).then_some("animation: none;")).into_attribute(),
353        ),
354    ]);
355
356    view! {
357        <div
358            _ref={floating_ref}
359            style:position=move || floating_styles.get().style_position()
360            style:top=move || floating_styles.get().style_top()
361            style:left=move || floating_styles.get().style_left()
362            style:transform=move || match is_positioned.get() {
363                true => floating_styles.get().style_transform(),
364                // Keep off the page when measuring
365                false => Some("translate(0, -200%)".into())
366            }
367            style:will-change=move || floating_styles.get().style_will_change()
368            style:min-width="max-content"
369            style:z-index=content_z_index
370            style=("--radix-popper-transform-origin", transform_origin)
371
372            // Hide the content if using the hide middleware and should be hidden set visibility to hidden
373            // and disable pointer events so the UI behaves as if the PopperContent isn't there at all.
374            style:visibility=move || reference_hidden().then_some("hidden")
375            style:pointer-events=move || reference_hidden().then_some("none")
376
377            // Floating UI interally calculates logical alignment based the `dir` attribute on
378            // the reference/floating node, we must add this attribute here to ensure
379            // this is calculated when portalled as well as inline.
380            dir={dir}
381        >
382            <Provider value={content_context}>
383                <Primitive
384                    element=html::div
385                    as_child=as_child
386                    node_ref=composed_refs
387                    attrs=attrs
388                >
389                    {children()}
390                </Primitive>
391            </Provider>
392        </div>
393    }
394}
395
396#[component]
397pub fn PopperArrow(
398    #[prop(into, optional)] width: MaybeProp<f64>,
399    #[prop(into, optional)] height: MaybeProp<f64>,
400    #[prop(into, optional)] as_child: MaybeProp<bool>,
401    #[prop(optional)] node_ref: NodeRef<AnyElement>,
402    #[prop(attrs)] attrs: Vec<(&'static str, Attribute)>,
403    #[prop(optional)] children: Option<ChildrenFn>,
404) -> impl IntoView {
405    let children = StoredValue::new(children);
406
407    let content_context: PopperContentContextValue = expect_context();
408    let arrow_ref = content_context.arrow_ref;
409    let base_side = move || content_context.placed_side.get().opposite();
410
411    let mut attrs = attrs.clone();
412    attrs.extend([("style", "display: block".into_attribute())]);
413
414    view! {
415        // We have to use an extra wrapper, because `ResizeObserver` (used by `useSize`)
416        // doesn't report size as we'd expect on SVG elements.
417        // It reports their bounding box, which is effectively the largest path inside the SVG.
418        <span
419            style:position="absolute"
420            style:left=move || match base_side() {
421                Side::Left => Some("0px".into()),
422                _ => content_context.arrow_x.get().map(|arrow_x| format!("{}px", arrow_x))
423            }
424            style:top=move || match base_side() {
425                Side::Top => Some("0px".into()),
426                _ => content_context.arrow_y.get().map(|arrow_y| format!("{}px", arrow_y))
427            }
428            style:right=move || match base_side() {
429                Side::Right => Some("0px"),
430                _ => None
431            }
432            style:bottom=move || match base_side() {
433                Side::Bottom => Some("0px"),
434                _ => None
435            }
436            style:transform-origin=move || match content_context.placed_side.get() {
437                Side::Top => "",
438                Side::Right => "0 0",
439                Side::Bottom => "center 0",
440                Side::Left => "100% 0",
441            }
442            style:transform=move || match content_context.placed_side.get() {
443                Side::Top => "translateY(100%)",
444                Side::Right => "translateY(50%) rotate(90deg) translateX(-50%)",
445                Side::Bottom => "rotate(180deg)",
446                Side::Left => "translateY(50%) rotate(-90deg) translateX(50%)",
447            }
448            style:visibility=move || content_context.should_hide_arrow.get().then_some("hidden")
449        >
450            <ArrowPrimitive width=width height=height as_child=as_child node_ref=node_ref attrs=attrs>
451                {children.with_value(|children| children.as_ref().map(|children| children()))}
452            </ArrowPrimitive>
453        </span>
454    }
455    .into_any()
456    .node_ref(arrow_ref)
457}
458
459const TRANSFORM_ORIGIN_NAME: &str = "transformOrigin";
460
461/// Options for [`TransformOrigin`] middleware.
462#[derive(Clone)]
463struct TransformOriginOptions {
464    arrow_width: f64,
465    arrow_height: f64,
466}
467
468/// Data stored by [`TransformOrigin`] middleware.
469#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
470struct TransformOriginData {
471    pub x: String,
472    pub y: String,
473}
474
475#[derive(Clone)]
476struct TransformOrigin {
477    options: TransformOriginOptions,
478}
479
480impl TransformOrigin {
481    fn new(options: TransformOriginOptions) -> Self {
482        Self { options }
483    }
484}
485
486impl Middleware<web_sys::Element, web_sys::Window> for TransformOrigin {
487    fn name(&self) -> &'static str {
488        TRANSFORM_ORIGIN_NAME
489    }
490
491    fn compute(
492        &self,
493        state: MiddlewareState<web_sys::Element, web_sys::Window>,
494    ) -> MiddlewareReturn {
495        let MiddlewareState {
496            placement,
497            rects,
498            middleware_data,
499            ..
500        } = state;
501
502        let arrow_data: Option<ArrowData> = middleware_data.get_as(ARROW_NAME);
503        let cannot_center_arrow = arrow_data
504            .as_ref()
505            .map_or(true, |arrow_data| arrow_data.center_offset != 0.0);
506        let is_arrow_hidden = cannot_center_arrow;
507        let arrow_width = match is_arrow_hidden {
508            true => 0.0,
509            false => self.options.arrow_width,
510        };
511        let arrow_height = match is_arrow_hidden {
512            true => 0.0,
513            false => self.options.arrow_height,
514        };
515
516        let placed_side = placement.side();
517        let placed_align = Align::from(placement.alignment());
518        let no_arrow_align = match placed_align {
519            Align::Start => "0%",
520            Align::Center => "50%",
521            Align::End => "100%",
522        };
523
524        let arrow_x_center = arrow_data
525            .as_ref()
526            .and_then(|arrow_data| arrow_data.x)
527            .unwrap_or(0.0)
528            + arrow_width / 2.0;
529        let arrow_y_center = arrow_data
530            .as_ref()
531            .and_then(|arrow_data| arrow_data.y)
532            .unwrap_or(0.0)
533            + arrow_height / 2.0;
534
535        let (x, y) = match placed_side {
536            Side::Top => (
537                match is_arrow_hidden {
538                    true => no_arrow_align.into(),
539                    false => format!("{}px", arrow_x_center),
540                },
541                format!("{}px", rects.floating.height + arrow_height),
542            ),
543            Side::Right => (
544                format!("{}px", -arrow_height),
545                match is_arrow_hidden {
546                    true => no_arrow_align.into(),
547                    false => format!("{}px", arrow_y_center),
548                },
549            ),
550            Side::Bottom => (
551                match is_arrow_hidden {
552                    true => no_arrow_align.into(),
553                    false => format!("{}px", arrow_x_center),
554                },
555                format!("{}px", -arrow_height),
556            ),
557            Side::Left => (
558                format!("{}px", rects.floating.width + arrow_height),
559                match is_arrow_hidden {
560                    true => no_arrow_align.into(),
561                    false => format!("{}px", arrow_y_center),
562                },
563            ),
564        };
565
566        MiddlewareReturn {
567            x: None,
568            y: None,
569            data: Some(
570                serde_json::to_value(TransformOriginData { x, y })
571                    .expect("Data should be valid JSON."),
572            ),
573            reset: None,
574        }
575    }
576}
OSZAR »