Skip to main content

dioxus_bootstrap_css/
offcanvas.rs

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