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