Skip to main content

dioxus_docs_kit/components/
docs_layout.rs

1use dioxus::prelude::*;
2use dioxus_free_icons::Icon;
3use dioxus_free_icons::icons::ld_icons::LdMenu;
4
5use crate::DocsContext;
6use crate::registry::DocsRegistry;
7use dioxus_mdx::syntax_highlight_css;
8
9/// Layout offset values computed by `DocsLayout` and consumed by child components
10/// (e.g. `DocsPageContent`) via context.
11///
12/// The values are determined by `show_header` and whether tabs exist, so that the
13/// TOC sidebar and heading scroll targets align with the actual header height.
14#[derive(Clone, Debug)]
15pub struct LayoutOffsets {
16    /// Tailwind sticky top class for sidebars/TOC (e.g. `"top-[6.5rem]"`, `"top-16"`, `"top-0"`).
17    pub sticky_top: &'static str,
18    /// Tailwind scroll-margin-top class for heading anchors (e.g. `"scroll-mt-[6.5rem]"`).
19    pub scroll_mt: &'static str,
20    /// Tailwind height calc for sidebar (e.g. `"h-[calc(100vh-6.5rem)]"`).
21    pub sidebar_height: &'static str,
22}
23
24#[derive(Clone, Copy)]
25pub struct CurrentTheme(pub Signal<String>);
26
27/// Newtype wrapper for the drawer-open signal, so it can coexist with
28/// `Signal<bool>` (used for `search_open`) in the context system.
29///
30/// Consumers can provide this before rendering `DocsLayout` to control
31/// the mobile drawer from a custom header.
32#[derive(Clone, Copy)]
33pub struct DrawerOpen(pub Signal<bool>);
34
35use super::mobile_drawer::MobileDrawer;
36use super::search_modal::SearchModal;
37use super::sidebar::DocsSidebar;
38use super::theme_toggle::ThemeToggle;
39
40/// Documentation layout shell.
41///
42/// Renders the tab bar, sidebar, content area, search modal, and mobile drawer.
43/// The consumer wraps this in their own navbar via Dioxus route layouts.
44///
45/// # Context requirements
46///
47/// - `&'static DocsRegistry` — provided by consumer
48/// - `DocsContext` — provided by consumer
49///
50/// # Props
51///
52/// - `header`: Optional element to render inside the docs area header (e.g. branding + search button).
53///   If not provided, a default header with search button and hamburger is rendered.
54/// - `show_header`: Whether to render the internal header area (default header/custom header *and*
55///   tab bar). Defaults to `true`. Set to `false` when the consumer provides their own header
56///   and tab bar outside of `DocsLayout`.
57/// - `children`: The routed page content (from `Outlet` or explicit child).
58#[component]
59pub fn DocsLayout(
60    header: Option<Element>,
61    #[props(default = true)] show_header: bool,
62    children: Element,
63) -> Element {
64    let registry = use_context::<&'static DocsRegistry>();
65    let ctx = use_context::<DocsContext>();
66    let nav = &registry.nav;
67
68    // Check if consumer already provided context (lookups, not hooks)
69    let parent_search: Option<Signal<bool>> = try_use_context();
70    let parent_drawer: Option<DrawerOpen> = try_use_context();
71
72    // Always create local fallback signals unconditionally
73    let local_search = use_signal(|| false);
74    let local_drawer = use_signal(|| false);
75
76    // Use consumer-provided context if available, otherwise local
77    let mut search_open = parent_search.unwrap_or(local_search);
78    let mut drawer_open = parent_drawer.map(|d| d.0).unwrap_or(local_drawer);
79
80    // Always provide context for children (SearchModal, MobileDrawer, etc.)
81    use_context_provider(|| search_open);
82    use_context_provider(|| DrawerOpen(drawer_open));
83
84    // Theme state: hooks must be called unconditionally
85    let theme_default = registry
86        .theme
87        .as_ref()
88        .map(|t| t.default_theme.clone())
89        .unwrap_or_default();
90    let theme_storage_key = registry
91        .theme
92        .as_ref()
93        .map(|t| t.storage_key.clone())
94        .unwrap_or_default();
95    let has_theme = registry.theme.is_some();
96
97    let mut current_theme = use_signal(|| theme_default.clone());
98    use_context_provider(|| CurrentTheme(current_theme));
99
100    // On mount: read stored preference and apply data-theme
101    use_effect(move || {
102        if !has_theme {
103            return;
104        }
105        let key = theme_storage_key.clone();
106        let fallback = theme_default.clone();
107        spawn(async move {
108            let mut eval = document::eval(&format!(
109                r#"
110                let theme = null;
111                try {{ theme = localStorage.getItem('{key}'); }} catch(e) {{}}
112                theme = theme || '{fallback}';
113                document.documentElement.setAttribute('data-theme', theme);
114                dioxus.send(theme);
115                "#
116            ));
117            if let Ok(stored) = eval.recv::<String>().await {
118                current_theme.set(stored);
119            }
120        });
121    });
122
123    // Active tab state
124    let mut active_tab = use_signal(|| nav.tabs.first().cloned().unwrap_or_default());
125    use_context_provider(|| active_tab);
126
127    // Sync active tab from current path
128    let current_path = ctx.current_path;
129    let registry_for_effect = registry;
130    use_effect(move || {
131        let path = current_path();
132        if let Some(tab) = registry_for_effect.tab_for_path(&path) {
133            active_tab.set(tab);
134        }
135    });
136
137    // Keyboard shortcut: Cmd/Ctrl+K to toggle search
138    use_effect(move || {
139        spawn(async move {
140            let mut eval = document::eval(
141                r#"
142                document.addEventListener('keydown', (e) => {
143                    if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
144                        e.preventDefault();
145                        dioxus.send(true);
146                    }
147                });
148                while (true) { await new Promise(r => setTimeout(r, 1000000)); }
149                "#,
150            );
151            loop {
152                if (eval.recv::<bool>().await).is_ok() {
153                    search_open.toggle();
154                }
155            }
156        });
157    });
158
159    let has_tabs = nav.has_tabs();
160    let offsets = if !show_header {
161        LayoutOffsets {
162            sticky_top: "top-0",
163            scroll_mt: "scroll-mt-0",
164            sidebar_height: "h-screen",
165        }
166    } else if has_tabs {
167        LayoutOffsets {
168            sticky_top: "top-[6.5rem]",
169            scroll_mt: "scroll-mt-[6.5rem]",
170            sidebar_height: "h-[calc(100vh-6.5rem)]",
171        }
172    } else {
173        LayoutOffsets {
174            sticky_top: "top-16",
175            scroll_mt: "scroll-mt-16",
176            sidebar_height: "h-[calc(100vh-4rem)]",
177        }
178    };
179    use_context_provider(|| offsets.clone());
180
181    rsx! {
182        // Syntax highlighting theme-aware CSS (injected into <head> once)
183        SyntaxStyles {}
184
185        div { class: "min-h-screen bg-base-100",
186            // Top area
187            if show_header {
188                div { class: "sticky top-0 z-50",
189                    // Header (consumer-provided or default)
190                    if let Some(hdr) = header {
191                        {hdr}
192                    } else {
193                        // Default minimal header
194                        div { class: "navbar bg-base-200 border-b border-base-300 px-4 lg:px-8",
195                            div { class: "flex-1 gap-2",
196                                button {
197                                    class: "btn btn-ghost btn-sm btn-square lg:hidden",
198                                    onclick: move |_| drawer_open.toggle(),
199                                    Icon { class: "size-5", icon: LdMenu }
200                                }
201                            }
202                            div { class: "flex-none gap-1",
203                                SearchButton { search_open }
204                                ThemeToggle {}
205                            }
206                        }
207                    }
208
209                    // Tab bar (below header)
210                    if has_tabs {
211                        div { class: "bg-base-200/80 backdrop-blur border-b border-base-300 px-4 lg:px-8",
212                            div { class: "flex gap-6",
213                                for tab in nav.tabs.iter() {
214                                    {
215                                        let is_active = *tab == active_tab();
216                                        let tab_clone = tab.clone();
217                                        let style = if is_active {
218                                            "text-primary border-b-2 border-primary font-medium"
219                                        } else {
220                                            "text-base-content/60 hover:text-base-content border-b-2 border-transparent"
221                                        };
222                                        rsx! {
223                                            button {
224                                                class: "px-1 py-2.5 text-sm transition-colors -mb-px {style}",
225                                                onclick: move |_| {
226                                                    active_tab.set(tab_clone.clone());
227                                                    let groups = nav.groups_for_tab(&tab_clone);
228                                                    if let Some(first_page) = groups.first().and_then(|g| g.pages.first()) {
229                                                        (ctx.navigate)(first_page.clone());
230                                                    }
231                                                },
232                                                "{tab}"
233                                            }
234                                        }
235                                    }
236                                }
237                            }
238                        }
239                    }
240                }
241            }
242
243            // Main docs content with sidebar
244            div { class: "flex",
245                // Sidebar
246                aside { class: "w-64 shrink-0 border-r border-base-300 bg-base-200/30 hidden lg:block",
247                    div { class: "sticky {offsets.sticky_top} {offsets.sidebar_height} overflow-y-auto p-6",
248                        DocsSidebar {}
249                    }
250                }
251
252                // Main content area
253                div { class: "flex-1 min-w-0",
254                    {children}
255                }
256            }
257        }
258
259        // Overlays
260        MobileDrawer { open: drawer_open }
261        SearchModal {}
262    }
263}
264
265/// Injects syntax highlighting CSS into `<head>` exactly once.
266///
267/// Separated from `DocsLayout` so re-renders of the layout don't
268/// trigger the "Changing the props of Style {} is not supported" warning.
269#[component]
270fn SyntaxStyles() -> Element {
271    use std::sync::atomic::{AtomicBool, Ordering};
272    static INJECTED: AtomicBool = AtomicBool::new(false);
273
274    if INJECTED.swap(true, Ordering::Relaxed) {
275        return rsx! {};
276    }
277
278    let css = syntax_highlight_css();
279    rsx! { document::Style { {css} } }
280}
281
282/// Reusable search button component for headers.
283#[component]
284pub fn SearchButton(search_open: Signal<bool>) -> Element {
285    use dioxus_free_icons::icons::ld_icons::LdSearch;
286
287    rsx! {
288        button {
289            class: "btn btn-ghost btn-sm gap-2",
290            onclick: move |_| search_open.set(true),
291            Icon { class: "size-4", icon: LdSearch }
292            span { class: "hidden sm:inline text-base-content/60 text-sm", "Search" }
293            kbd { class: "kbd kbd-xs hidden sm:inline-flex", "\u{2318}K" }
294        }
295    }
296}