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}