1use crate::breakpoint::{
2 BreakpointConfig, DesktopSidebar, MobileSidebar, SheetSnap, ShellBreakpoint,
3 use_shell_breakpoint,
4};
5use crate::{ShellContext, use_shell_context};
6use dioxus::prelude::*;
7use std::fmt;
8
9#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
14pub enum ShellLayout {
15 #[default]
17 Horizontal,
18 Vertical,
20 Sidebar,
22}
23
24impl ShellLayout {
25 pub fn as_data_attr(&self) -> &'static str {
27 match self {
28 Self::Horizontal => "horizontal",
29 Self::Vertical => "vertical",
30 Self::Sidebar => "sidebar",
31 }
32 }
33}
34
35impl fmt::Display for ShellLayout {
36 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37 f.write_str(self.as_data_attr())
38 }
39}
40
41#[derive(Clone, Copy)]
44struct ShellSignals {
45 layout: Signal<ShellLayout>,
46 sidebar_visible: Signal<bool>,
47 sidebar_mobile_open: Signal<bool>,
48 mobile_sidebar: Signal<MobileSidebar>,
49 desktop_sidebar: Signal<DesktopSidebar>,
50 stack_depth: Signal<u32>,
51 modal_open: Signal<bool>,
52 search_active: Signal<bool>,
53 sheet_snap: Signal<SheetSnap>,
54 on_modal_change: Signal<Option<EventHandler<bool>>>,
55 on_search_change: Signal<Option<EventHandler<bool>>>,
56}
57
58fn use_shell_signals(
61 layout: ShellLayout,
62 mobile_sidebar: MobileSidebar,
63 desktop_sidebar: DesktopSidebar,
64) -> ShellSignals {
65 ShellSignals {
66 layout: use_signal(|| layout),
67 sidebar_visible: use_signal(|| true),
68 sidebar_mobile_open: use_signal(|| false),
69 mobile_sidebar: use_signal(|| mobile_sidebar),
70 desktop_sidebar: use_signal(|| desktop_sidebar),
71 stack_depth: use_signal(|| 1u32),
72 modal_open: use_signal(|| false),
73 search_active: use_signal(|| false),
74 sheet_snap: use_signal(|| SheetSnap::Hidden),
75 on_modal_change: use_signal(|| None),
76 on_search_change: use_signal(|| None),
77 }
78}
79
80#[component]
106pub fn AppShell(
107 children: Element,
109 #[props(default)]
111 sidebar: Option<Element>,
112 #[props(default)]
114 preview: Option<Element>,
115 #[props(default)]
117 footer: Option<Element>,
118 #[props(default)]
120 layout: ShellLayout,
121 #[props(default)]
123 mobile_sidebar: MobileSidebar,
124 #[props(default)]
126 desktop_sidebar: DesktopSidebar,
127 #[props(default)]
130 breakpoints: BreakpointConfig,
131 #[props(default)]
135 external_breakpoint: Option<ReadSignal<ShellBreakpoint>>,
136 #[props(default)]
138 class: Option<String>,
139 #[props(into, default = "complementary".to_string())]
141 sidebar_role: String,
142 #[props(into, default = "Preview".to_string())]
144 preview_label: String,
145 #[props(default)]
147 tabs: Option<Element>,
148 #[props(default)]
150 sheet: Option<Element>,
151 #[props(default)]
153 modal: Option<Element>,
154 #[props(default)]
156 fab: Option<Element>,
157 #[props(default)]
160 action_bar: Option<Element>,
161 #[props(default)]
163 search: Option<Element>,
164 #[props(default)]
167 modal_open: Option<ReadSignal<bool>>,
168 #[props(default)]
170 on_modal_change: Option<EventHandler<bool>>,
171 #[props(default)]
174 search_active: Option<ReadSignal<bool>>,
175 #[props(default)]
177 on_search_change: Option<EventHandler<bool>>,
178 #[props(default)]
181 additional_attributes: Vec<Attribute>,
182) -> Element {
183 let signals = use_shell_signals(layout, mobile_sidebar, desktop_sidebar);
184
185 let runtime_bp = use_shell_breakpoint(breakpoints.compact_below, breakpoints.expanded_above);
186 let breakpoint = external_breakpoint.unwrap_or(runtime_bp);
187
188 if *signals.mobile_sidebar.peek() != mobile_sidebar {
191 let mut s = signals.mobile_sidebar;
192 s.set(mobile_sidebar);
193 }
194 if *signals.desktop_sidebar.peek() != desktop_sidebar {
195 let mut s = signals.desktop_sidebar;
196 s.set(desktop_sidebar);
197 }
198
199 use_effect(move || {
201 if let Some(controlled) = modal_open {
202 let mut s = signals.modal_open;
203 s.set(controlled());
204 }
205 });
206 use_effect(move || {
207 if let Some(controlled) = search_active {
208 let mut s = signals.search_active;
209 s.set(controlled());
210 }
211 });
212
213 {
217 let mut s = signals.on_modal_change;
218 s.set(on_modal_change);
219 }
220 {
221 let mut s = signals.on_search_change;
222 s.set(on_search_change);
223 }
224
225 let ctx = use_context_provider(|| ShellContext {
226 layout: signals.layout,
227 breakpoint,
228 sidebar_visible: signals.sidebar_visible,
229 sidebar_mobile_open: signals.sidebar_mobile_open,
230 mobile_sidebar: signals.mobile_sidebar.into(),
231 desktop_sidebar: signals.desktop_sidebar.into(),
232 stack_depth: signals.stack_depth,
233 modal_open: signals.modal_open,
234 search_active: signals.search_active,
235 sheet_snap: signals.sheet_snap,
236 on_modal_change: signals.on_modal_change,
237 on_search_change: signals.on_search_change,
238 });
239
240 let is_mobile = (breakpoint)().is_compact();
241 let mobile_sidebar_val = mobile_sidebar;
242 let desktop_sidebar_val = desktop_sidebar;
243
244 let has_sidebar = sidebar.is_some();
245 let sidebar_for_desktop = sidebar.clone();
246
247 let derived_columns: &'static str = if is_mobile || !has_sidebar || !(signals.sidebar_visible)()
248 {
249 "1"
250 } else {
251 "2"
252 };
253
254 rsx! {
255 div {
256 class: class.unwrap_or_default(),
257 "data-shell": "",
258 "data-shell-layout": (ctx.layout)().as_data_attr(),
259 "data-shell-breakpoint": (breakpoint)().as_str(),
260 "data-shell-sidebar-state": ctx.sidebar_state(),
261 "data-shell-columns": derived_columns,
262 "data-shell-display-mode": if is_mobile { "stack" } else { "side-by-side" },
263 "data-shell-stack-depth": (signals.stack_depth)().to_string(),
264 "data-shell-can-go-back": ((signals.stack_depth)() > 1).to_string(),
265 "data-shell-search-active": (signals.search_active)().to_string(),
266 "data-shell-modal-state": if (signals.modal_open)() { "presented" } else { "dismissed" },
267 ..additional_attributes,
268
269 if sidebar_for_desktop.is_some() && !is_mobile {
272 div {
273 role: sidebar_role.as_str(),
274 "data-shell-sidebar": "",
275 "data-shell-sidebar-visible": (signals.sidebar_visible)().to_string(),
276 "data-shell-desktop-variant": match desktop_sidebar_val {
277 DesktopSidebar::Full => "full",
278 DesktopSidebar::Rail => "rail",
279 DesktopSidebar::Expandable => "expandable",
280 },
281 {sidebar_for_desktop}
282 }
283 }
284
285 if sidebar.is_some() && is_mobile && mobile_sidebar_val != MobileSidebar::Hidden {
287 div {
288 "data-shell-sidebar": "",
289 "data-shell-sidebar-mobile": "true",
290 "data-shell-sidebar-variant": match mobile_sidebar_val {
291 MobileSidebar::Drawer => "drawer",
292 MobileSidebar::Rail => "rail",
293 MobileSidebar::Hidden => "hidden",
294 },
295 "data-shell-sidebar-state": if (signals.sidebar_mobile_open)() { "open" } else { "closed" },
296 {sidebar}
297 }
298 }
299
300 div {
301 role: "main",
302 "data-shell-content": "",
303 {children}
304 }
305
306 if let Some(preview_el) = preview {
307 div {
308 role: "region",
309 "aria-label": preview_label.as_str(),
310 "data-shell-preview": "",
311 {preview_el}
312 }
313 }
314
315 if let Some(footer_el) = footer {
316 div {
317 role: "contentinfo",
318 "data-shell-footer": "",
319 {footer_el}
320 }
321 }
322
323 if let Some(tabs_el) = tabs {
325 div {
326 role: "navigation",
327 "data-shell-tabs": "",
328 {tabs_el}
329 }
330 }
331
332 if let Some(sheet_el) = sheet {
334 div {
335 role: "complementary",
336 "data-shell-sheet": "",
337 "data-shell-sheet-state": (signals.sheet_snap)().as_str(),
338 {sheet_el}
339 }
340 }
341
342 if let Some(fab_el) = fab {
344 div {
345 "data-shell-fab": "",
346 {fab_el}
347 }
348 }
349
350 if let Some(action_bar_el) = action_bar {
352 div {
353 role: "toolbar",
354 aria_label: "Actions",
355 "data-shell-action-bar": "",
356 {action_bar_el}
357 }
358 }
359
360 if let Some(search_el) = search {
362 div {
363 role: "search",
364 "data-shell-search": "",
365 "data-shell-search-active": (signals.search_active)().to_string(),
366 {search_el}
367 }
368 }
369
370 if let Some(modal_el) = modal {
372 div {
373 role: "dialog",
374 "aria-modal": "true",
375 "data-shell-modal": "",
376 "data-shell-modal-state": if (signals.modal_open)() { "presented" } else { "dismissed" },
377 {modal_el}
378 }
379 }
380 }
381 }
382}
383
384#[component]
397pub fn MobileSidebarBackdrop(
398 #[props(default)]
400 class: Option<String>,
401) -> Element {
402 let ctx = use_shell_context();
403 if ctx.is_mobile() && (ctx.sidebar_mobile_open)() {
404 rsx! {
405 div {
406 "data-shell-backdrop": "",
407 class: class.unwrap_or_default(),
408 onclick: move |_| {
409 let mut open = ctx.sidebar_mobile_open;
410 open.set(false);
411 },
412 }
413 }
414 } else {
415 rsx! {}
416 }
417}