dioxus-floating 0.3.2

A floating positioning engine for Dioxus 0.7. Handles flips, shifts, and scrollable containers with ease.
Documentation
use std::{
    rc::Rc,
    sync::atomic::{AtomicU64, Ordering},
};

use dioxus::{
    document::{Eval, eval},
    prelude::*,
};
use dioxus_floating::{
    FloatingOptions, OffsetOptions, Placement, ScrollableView, use_click_outside, use_placement,
    use_scroll_context,
};

static ID_COUNTER: AtomicU64 = AtomicU64::new(0);

fn main() {
    launch(App);
}

#[component]
fn App() -> Element {
    rsx! {
        DemoAssets {}
        ScrollableView {
            class: "h-screen overflow-auto bg-linear-to-br from-slate-100 via-white to-blue-100 text-slate-900",
            TailwindLayoutListener {}
            div {
                class: "mx-auto flex min-h-[140vh] max-w-6xl flex-col gap-8 px-6 py-10",
                DemoHeader {}
                div {
                    class: "grid gap-6 lg:grid-cols-[1.3fr_0.9fr]",
                    ProfileCard {}
                    ActionsCard {}
                }
                div {
                    class: "rounded-box border border-dashed border-base-content/20 bg-base-100/70 p-6 text-sm text-base-content/70 shadow-sm",
                    "Прокрути контейнер, чтобы проверить, что floating-позиционирование остаётся корректным внутри ",
                    code { "ScrollableView" },
                    ". Первый dropdown выровнен по правому краю триггера, второй показывает другой placement и длинный список действий."
                }
            }
        }
    }
}

#[component]
fn DemoAssets() -> Element {
    rsx! {
        document::Stylesheet {
            href: "https://cdn.jsdelivr.net/npm/flyonui@2.4.1/flyonui.css",
        }
        document::Script {
            src: "https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4",
        }
    }
}

#[component]
fn DemoHeader() -> Element {
    rsx! {
        div { class: "space-y-3",
            span { class: "badge badge-soft badge-primary",
                "dioxus-floating examples"
            }
            h1 { class: "text-4xl font-black tracking-tight text-balance",
                "Dropdown examples on top of FlyonUI + Tailwind"
            }
            p { class: "max-w-3xl text-base leading-7 text-slate-600",
                "Эти примеры живут рядом с библиотекой и не зависят от пакета ",
                code { "ui" },
                ". Их можно использовать как smoke-test, когда начнёшь выносить UI-пакеты из воркспейса."
            }
        }
    }
}

#[component]
fn ProfileCard() -> Element {
    rsx! {
        div { class: "card border border-base-content/10 bg-base-100 shadow-xl",
            div { class: "card-body gap-6",
                div { class: "space-y-2",
                    h2 { class: "card-title text-2xl", "Profile dropdown" }
                    p { class: "text-sm text-base-content/70",
                        "Bottom-end placement с широким меню и привычным профайловым триггером."
                    }
                }
                div { class: "flex items-center justify-between gap-4 rounded-box bg-slate-50 p-4",
                    div {
                        h3 { class: "font-semibold", "Alexandr" }
                        p { class: "text-sm text-slate-500", "Maintainer of dioxus-floating" }
                    }
                    DropdownExample {
                        button_class: "btn btn-primary rounded-full px-2 pe-4",
                        menu_class: "min-w-72",
                        placement: Placement::BottomEnd,
                        offset: 12.0,
                        trigger: rsx! {
                            div { class: "flex items-center gap-3",
                                div { class: "avatar avatar-online",
                                    div { class: "w-10 rounded-full bg-primary/15 text-primary",
                                        span { class: "flex h-full w-full items-center justify-center text-sm font-bold",
                                            "AB"
                                        }
                                    }
                                }
                                div { class: "text-left leading-tight",
                                    div { class: "font-semibold", "Workspace profile" }
                                    div { class: "text-xs opacity-70", "Owner access" }
                                }
                            }
                        },
                        li {
                            a { class: "dropdown-item",
                                span { class: "icon-[tabler--layout-dashboard] size-4" }
                                "Dashboard"
                            }
                        }
                        li {
                            a { class: "dropdown-item",
                                span { class: "icon-[tabler--settings] size-4" }
                                "Library settings"
                            }
                        }
                        li {
                            a { class: "dropdown-item",
                                span { class: "icon-[tabler--book] size-4" }
                                "Open README"
                            }
                        }
                        hr { class: "border-base-content/10 -mx-2" }
                        li { class: "dropdown-footer",
                            button { class: "btn btn-error btn-soft btn-block",
                                span { class: "icon-[tabler--logout] size-4" }
                                "Sign out"
                            }
                        }
                    }
                }
            }
        }
    }
}

#[component]
fn ActionsCard() -> Element {
    rsx! {
        div { class: "card border border-base-content/10 bg-base-100 shadow-xl",
            div { class: "card-body gap-6",
                div { class: "space-y-2",
                    h2 { class: "card-title text-2xl", "Action dropdown" }
                    p { class: "text-sm text-base-content/70",
                        "Bottom-start placement с другим триггером и длинным меню для проверки shift/flip."
                    }
                }
                div { class: "flex min-h-72 flex-col justify-between rounded-box bg-slate-950 p-5 text-slate-100",
                    div { class: "space-y-2",
                        div { class: "badge badge-soft badge-warning", "Build queue" }
                        h3 { class: "text-xl font-semibold", "Release candidate 0.2.2" }
                        p { class: "text-sm text-slate-300",
                            "Открой меню у карточки ближе к правой границе и проверь, как оно удерживается в пределах scrollable-контейнера."
                        }
                    }
                    div { class: "flex justify-end",
                        DropdownExample {
                            button_class: "btn btn-soft btn-warning",
                            menu_class: "min-w-64",
                            placement: Placement::BottomStart,
                            offset: 10.0,
                            trigger: rsx! {
                                span { class: "inline-flex items-center gap-2",
                                    span { class: "icon-[tabler--dots] size-4" }
                                    "Quick actions"
                                }
                            },
                            li {
                                a { class: "dropdown-item",
                                    span { class: "icon-[tabler--bug] size-4" }
                                    "Open bug triage"
                                }
                            }
                            li {
                                a { class: "dropdown-item",
                                    span { class: "icon-[tabler--flask] size-4" }
                                    "Run regression sandbox"
                                }
                            }
                            li {
                                a { class: "dropdown-item",
                                    span { class: "icon-[tabler--arrows-shuffle] size-4" }
                                    "Re-check placements"
                                }
                            }
                            li {
                                a { class: "dropdown-item",
                                    span { class: "icon-[tabler--package-export] size-4" }
                                    "Prepare crate publish"
                                }
                            }
                            li {
                                a { class: "dropdown-item text-error",
                                    span { class: "icon-[tabler--trash] size-4" }
                                    "Drop draft release"
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

#[component]
fn DropdownExample(
    button_class: String,
    menu_class: String,
    placement: Placement,
    offset: f64,
    trigger: Element,
    children: Element,
) -> Element {
    let dropdown_id = use_stable_id();
    let mut is_open = use_signal(|| false);
    let mut menu_ref = use_signal(|| Option::<Rc<MountedData>>::None);
    let mut trigger_ref = use_signal(|| Option::<Rc<MountedData>>::None);

    use_click_outside(dropdown_id, use_callback(move |_| {
        if is_open() {
            is_open.set(false);
        }
    }));

    use_effect(move || {
        if !is_open() && menu_ref().is_some() {
            menu_ref.set(None);
        }
    });

    let position = use_placement(
        menu_ref,
        trigger_ref,
        FloatingOptions {
            placement,
            offset: OffsetOptions::rect(offset),
            ..FloatingOptions::default()
        },
    );

    let menu_style = use_memo(move || {
        position.with(|pos| format!(
            "position: fixed; inset: 0px auto auto 0px; margin: 0px; transform: translate3d({}px, {}px, 0px);",
            pos.x, pos.y,
        ))
    });
    let open_class = use_memo(move || {
        if position().is_ready {
            "open"
        } else {
            ""
        }
    });
    let menu_visibility_class = use_memo(move || {
        if position().is_ready {
            "opacity-100"
        } else {
            "opacity-0"
        }
    });

    rsx! {
        div { id: dropdown_id(), class: "dropdown relative inline-flex {open_class}",
            button {
                class: "dropdown-toggle {button_class}",
                onclick: move |evt: MouseEvent| {
                    evt.prevent_default();
                    evt.stop_propagation();
                    is_open.toggle();
                },
                onmounted: move |evt: MountedEvent| trigger_ref.set(Some(evt.data.clone())),
                {trigger}
            }
            if is_open() {
                ul {
                    class: "dropdown-menu block z-50 transition-opacity {menu_class} {menu_visibility_class}",
                    style: menu_style,
                    onclick: move |evt: MouseEvent| {
                        evt.stop_propagation();
                    },
                    onmounted: move |evt: MountedEvent| menu_ref.set(Some(evt.data.clone())),
                    {children}
                }
            }
        }
    }
}

#[component]
fn TailwindLayoutListener() -> Element {
    let mut eval_handle = use_signal(|| Option::<Eval>::None);
    let mut scroll_context = use_scroll_context();

    use_effect(move || {
        spawn(async move {
            let mut eval = eval(
                r#"
                const observer = new MutationObserver(() => {
                    clearTimeout(window.__floating_tailwind_timer);
                    window.__floating_tailwind_timer = setTimeout(() => {
                        dioxus.send("updated");
                    }, 50);
                });

                observer.observe(document.head, { childList: true, subtree: true });

                await dioxus.recv();
                observer.disconnect();
                "#,
            );

            if let Some(old_eval) = eval_handle() {
                let _ = old_eval.send("drop");
            }
            eval_handle.set(Some(eval.clone()));

            while eval.recv::<String>().await.is_ok() {
                scroll_context.reload().await;
            }
        });
    });

    use_drop(move || {
        if let Some(eval) = eval_handle() {
            let _ = eval.send("drop");
        }
    });

    rsx! {}
}

fn use_stable_id() -> ReadSignal<String> {
    use_hook(|| {
        let id = ID_COUNTER.fetch_add(1, Ordering::Relaxed);
        Signal::new(format!("floating-demo-{id}")).into()
    })
}