Skip to main content

dioxus_nox_shell/
shell.rs

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/// Selects the CSS layout mode applied via `data-shell-layout`.
10///
11/// The value is surfaced as a data attribute so consumers can write
12/// CSS selectors like `[data-shell-layout="horizontal"] { … }`.
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
14pub enum ShellLayout {
15    /// Side-by-side panes (default). Sidebar | Main | Preview.
16    #[default]
17    Horizontal,
18    /// Stacked panes. Main above, preview below.
19    Vertical,
20    /// Dedicated sidebar-first layout.
21    Sidebar,
22}
23
24impl ShellLayout {
25    /// Returns the lowercase string used as the `data-shell-layout` value.
26    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// ── Private signal bundle ──────────────────────────────────────────────────────
42
43#[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
58/// Composite hook — initialises all internal signals for `AppShell`.
59/// Must be called unconditionally from the component body.
60fn 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// ── AppShell component ────────────────────────────────────────────────────────
81
82/// Persistent application shell with named slots and responsive breakpoint awareness.
83///
84/// Provides [`ShellContext`] to all descendants via `use_context_provider`.
85/// All layout is CSS-driven through `data-shell*` attributes — zero inline styles.
86///
87/// On compact (mobile) viewports, the sidebar switches to a separate mobile tree
88/// controlled by the `mobile_sidebar` variant. On desktop, the sidebar stays in
89/// the DOM and CSS controls visibility via `data-shell-sidebar-visible`.
90///
91/// # Focus traps
92///
93/// `AppShell` does **not** manage focus traps. Consumers are responsible for
94/// implementing keyboard focus management (e.g., a focus-lock equivalent) within
95/// modal and search slot content.
96///
97/// # Example
98///
99/// ```rust,ignore
100/// AppShell {
101///     sidebar: rsx! { MySidebar {} },
102///     AppContent {}
103/// }
104/// ```
105#[component]
106pub fn AppShell(
107    /// The main content slot (always rendered).
108    children: Element,
109    /// Optional sidebar slot.
110    #[props(default)]
111    sidebar: Option<Element>,
112    /// Optional preview/detail pane slot.
113    #[props(default)]
114    preview: Option<Element>,
115    /// Optional footer slot.
116    #[props(default)]
117    footer: Option<Element>,
118    /// Initial layout mode. Defaults to [`ShellLayout::Horizontal`].
119    #[props(default)]
120    layout: ShellLayout,
121    /// Mobile sidebar variant. Defaults to [`MobileSidebar::Drawer`].
122    #[props(default)]
123    mobile_sidebar: MobileSidebar,
124    /// Desktop sidebar variant. Defaults to [`DesktopSidebar::Full`].
125    #[props(default)]
126    desktop_sidebar: DesktopSidebar,
127    /// Breakpoint thresholds for compact/expanded detection. Defaults to
128    /// `{ compact_below: 640.0, expanded_above: 1024.0 }`.
129    #[props(default)]
130    breakpoints: BreakpointConfig,
131    /// CSP-safe override: supply an external breakpoint signal (e.g., from SSR
132    /// or a `matchMedia` wrapper) instead of running the built-in eval.
133    /// When provided, the built-in JS eval still runs but its output is ignored.
134    #[props(default)]
135    external_breakpoint: Option<ReadSignal<ShellBreakpoint>>,
136    /// Extra CSS classes applied to the root element.
137    #[props(default)]
138    class: Option<String>,
139    /// ARIA role for the sidebar region. Defaults to `"complementary"`.
140    #[props(into, default = "complementary".to_string())]
141    sidebar_role: String,
142    /// `aria-label` for the preview region. Defaults to `"Preview"`.
143    #[props(into, default = "Preview".to_string())]
144    preview_label: String,
145    /// Optional bottom tab bar slot. Renders as `[data-shell-tabs]`.
146    #[props(default)]
147    tabs: Option<Element>,
148    /// Optional persistent bottom sheet slot. Renders as `[data-shell-sheet]`.
149    #[props(default)]
150    sheet: Option<Element>,
151    /// Optional full-screen modal slot. Renders as `[data-shell-modal][role="dialog"]`.
152    #[props(default)]
153    modal: Option<Element>,
154    /// Optional floating action button slot. Renders as `[data-shell-fab]`.
155    #[props(default)]
156    fab: Option<Element>,
157    /// Optional bottom action bar slot. Renders as `[data-shell-action-bar]`.
158    /// Typically used for mobile-only fixed bottom action patterns.
159    #[props(default)]
160    action_bar: Option<Element>,
161    /// Optional search overlay slot. Renders as `[data-shell-search]`.
162    #[props(default)]
163    search: Option<Element>,
164    /// Controlled modal state. When supplied, `AppShell` keeps its internal
165    /// `modal_open` signal in sync with this signal on every render.
166    #[props(default)]
167    modal_open: Option<ReadSignal<bool>>,
168    /// Callback fired when the shell changes modal open state.
169    #[props(default)]
170    on_modal_change: Option<EventHandler<bool>>,
171    /// Controlled search-active state. When supplied, `AppShell` keeps its
172    /// internal `search_active` signal in sync with this signal on every render.
173    #[props(default)]
174    search_active: Option<ReadSignal<bool>>,
175    /// Callback fired when the shell changes search active state.
176    #[props(default)]
177    on_search_change: Option<EventHandler<bool>>,
178    /// Additional HTML attributes spread onto the root `<div>`.
179    /// Useful for `data-testid`, custom ARIA annotations, etc.
180    #[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    // Sync mutable enum props → signals on every render.
189    // `peek()` reads without subscribing (no reactive dep → no re-render loop).
190    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    // Sync controlled props → internal signals.
200    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    // Store callbacks in signals so ShellContext methods can fire them.
214    // Set unconditionally — EventHandler is not PartialEq.
215    // Safe: AppShell never reads these signals reactively, so no re-render loop.
216    {
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            // Desktop sidebar: always in DOM when present; CSS controls width
270            // transitions via data-shell-sidebar-visible and data-shell-desktop-variant.
271            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            // Mobile sidebar: tree switches based on MobileSidebar variant.
286            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            // Bottom tab bar — persistent bottom navigation.
324            if let Some(tabs_el) = tabs {
325                div {
326                    role: "navigation",
327                    "data-shell-tabs": "",
328                    {tabs_el}
329                }
330            }
331
332            // Persistent bottom sheet — snap-point driven overlay.
333            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            // Floating action button.
343            if let Some(fab_el) = fab {
344                div {
345                    "data-shell-fab": "",
346                    {fab_el}
347                }
348            }
349
350            // Bottom action bar (mobile pattern).
351            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            // Search overlay.
361            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            // Full-screen modal — top layer, rendered last so it sits above all other regions.
371            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// ── MobileSidebarBackdrop ─────────────────────────────────────────────────────
385
386/// Scrim rendered behind the mobile sidebar drawer.
387///
388/// Place inside `AppShell` (requires [`ShellContext`]). Renders only when
389/// [`ShellContext::is_mobile`] is `true` and `sidebar_mobile_open` is open.
390/// Clicking closes the drawer.
391///
392/// Style via `[data-shell-backdrop]` in your CSS.
393///
394/// This is the preferred pattern (following Radix/Headless UI/Vaul precedent)
395/// over a hard-coded backdrop inside `AppShell`.
396#[component]
397pub fn MobileSidebarBackdrop(
398    /// Extra CSS classes applied to the backdrop element.
399    #[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}