Skip to main content

dioxus_bootstrap_css/
offcanvas.rs

1use dioxus::prelude::*;
2
3/// Bootstrap Offcanvas (slide-in sidebar) — signal-driven, no JavaScript.
4///
5/// # Bootstrap HTML → Dioxus
6///
7/// ```html
8/// <!-- Bootstrap HTML (requires JavaScript) -->
9/// <button data-bs-toggle="offcanvas" data-bs-target="#sidebar">Open</button>
10/// <div class="offcanvas offcanvas-start" id="sidebar">
11///   <div class="offcanvas-header"><h5>Menu</h5><button class="btn-close" data-bs-dismiss="offcanvas"></button></div>
12///   <div class="offcanvas-body">Content</div>
13/// </div>
14/// ```
15///
16/// ```rust,no_run
17/// # use dioxus::prelude::*;
18/// # use dioxus_bootstrap_css::prelude::*;
19/// # fn _doctest() -> Element {
20/// // Dioxus equivalent
21/// let mut show = use_signal(|| false);
22/// rsx! {
23///     Button { onclick: move |_| show.set(true), "Open Sidebar" }
24///     Offcanvas { show: show, title: "Menu", placement: OffcanvasPlacement::Start,
25///         Nav { vertical: true, pills: true,
26///             NavItem { NavLink { active: true, "Home" } }
27///             NavItem { NavLink { "Settings" } }
28///         }
29///     }
30/// }
31/// # }
32/// ```
33///
34/// # Props
35///
36/// - `show` — `Signal<bool>` controlling visibility
37/// - `title` — header title
38/// - `placement` — `OffcanvasPlacement::Start`, `End`, `Top`, `Bottom`
39/// - `backdrop` — show backdrop overlay (default: true)
40/// - `backdrop_close` — close on backdrop click (default: true)
41/// - `responsive` — responsive variant breakpoint (e.g., "lg")
42#[derive(Clone, PartialEq, Props)]
43pub struct OffcanvasProps {
44    /// Signal controlling visibility.
45    pub show: Signal<bool>,
46    /// Title shown in the offcanvas header.
47    #[props(default)]
48    pub title: String,
49    /// Placement (which side it slides in from).
50    #[props(default)]
51    pub placement: OffcanvasPlacement,
52    /// Close when clicking the backdrop.
53    #[props(default = true)]
54    pub backdrop_close: bool,
55    /// Show backdrop overlay.
56    #[props(default = true)]
57    pub backdrop: bool,
58    /// Show close button.
59    #[props(default = true)]
60    pub show_close: bool,
61    /// Responsive variant — offcanvas only below this breakpoint.
62    /// E.g., "lg" makes it offcanvas below lg, regular content above.
63    #[props(default)]
64    pub responsive: String,
65    /// Additional CSS classes.
66    #[props(default)]
67    pub class: String,
68    /// Child elements (offcanvas body content).
69    pub children: Element,
70}
71
72/// Offcanvas slide-in direction.
73#[derive(Clone, Copy, Debug, Default, PartialEq)]
74pub enum OffcanvasPlacement {
75    #[default]
76    Start,
77    End,
78    Top,
79    Bottom,
80}
81
82impl std::fmt::Display for OffcanvasPlacement {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        match self {
85            OffcanvasPlacement::Start => write!(f, "offcanvas-start"),
86            OffcanvasPlacement::End => write!(f, "offcanvas-end"),
87            OffcanvasPlacement::Top => write!(f, "offcanvas-top"),
88            OffcanvasPlacement::Bottom => write!(f, "offcanvas-bottom"),
89        }
90    }
91}
92
93#[component]
94pub fn Offcanvas(props: OffcanvasProps) -> Element {
95    let is_shown = *props.show.read();
96    let mut show_signal = props.show;
97
98    if !is_shown {
99        return rsx! {};
100    }
101
102    let placement = props.placement;
103    let show = " show";
104
105    let offcanvas_base = if props.responsive.is_empty() {
106        "offcanvas".to_string()
107    } else {
108        format!("offcanvas-{}", props.responsive)
109    };
110
111    let full_class = if props.class.is_empty() {
112        format!("{offcanvas_base} {placement}{show}")
113    } else {
114        format!("{offcanvas_base} {placement}{show} {}", props.class)
115    };
116
117    let backdrop_close = props.backdrop_close;
118
119    rsx! {
120        // Backdrop
121        if props.backdrop {
122            div {
123                class: "offcanvas-backdrop fade show",
124                onclick: move |_| {
125                    if backdrop_close {
126                        show_signal.set(false);
127                    }
128                },
129            }
130        }
131        // Offcanvas panel
132        div {
133            class: "{full_class}",
134            style: "visibility: visible;",
135            tabindex: "-1",
136            "aria-modal": "true",
137            role: "dialog",
138            if !props.title.is_empty() || props.show_close {
139                div { class: "offcanvas-header",
140                    if !props.title.is_empty() {
141                        h5 { class: "offcanvas-title", "{props.title}" }
142                    }
143                    if props.show_close {
144                        button {
145                            class: "btn-close",
146                            r#type: "button",
147                            "aria-label": "Close",
148                            onclick: move |_| show_signal.set(false),
149                        }
150                    }
151                }
152            }
153            div { class: "offcanvas-body",
154                {props.children}
155            }
156        }
157    }
158}