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}