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